BMAD-METHOD/.claude/tools/renderers/bmad-render.mjs

375 lines
12 KiB
JavaScript

#!/usr/bin/env node
/**
* BMAD Renderer: JSON-first → Markdown
*
* Usage:
* node .claude/tools/renderers/bmad-render.mjs <type> <path/to/input.json>
*
* Types:
* - project-brief -> .claude/schemas/project_brief.schema.json
* - prd -> .claude/schemas/product_requirements.schema.json
* - architecture -> .claude/schemas/system_architecture.schema.json
* - ux-spec -> .claude/schemas/ux_spec.schema.json
* - test-plan -> .claude/schemas/test_plan.schema.json
*/
import fs from 'fs';
import path from 'path';
const CWD = process.cwd();
const ROOT = CWD; // Run from repo root for relative paths
const schemaMap = {
'project-brief': '.claude/schemas/project_brief.schema.json',
prd: '.claude/schemas/product_requirements.schema.json',
architecture: '.claude/schemas/system_architecture.schema.json',
'ux-spec': '.claude/schemas/ux_spec.schema.json',
'test-plan': '.claude/schemas/test_plan.schema.json',
story: '.claude/schemas/user_story.schema.json',
epic: '.claude/schemas/epic.schema.json',
backlog: '.claude/schemas/backlog.schema.json',
'review-notes': '.claude/schemas/review_notes.schema.json',
'enhancement-classification': '.claude/schemas/enhancement_classification.schema.json'
};
function loadJSON(p) {
return JSON.parse(fs.readFileSync(p, 'utf8'));
}
function typeOf(v) {
if (v === null) return 'null';
if (Array.isArray(v)) return 'array';
return typeof v;
}
function validate(schema, data, pathSeg = '$') {
const errors = [];
const t = schema.type;
// Handle union types like ["string", "null"] (not used here; keep simple)
const expectedTypes = Array.isArray(t) ? t : [t];
const dataType = typeOf(data);
if (t && !expectedTypes.includes(dataType)) {
errors.push(`${pathSeg}: expected type ${expectedTypes.join('|')}, got ${dataType}`);
return errors;
}
if (schema.enum && !schema.enum.includes(data)) {
errors.push(`${pathSeg}: value must be one of ${schema.enum.join(', ')}`);
}
if (dataType === 'object') {
const props = schema.properties || {};
const required = schema.required || [];
for (const r of required) {
if (!(r in data)) errors.push(`${pathSeg}.${r}: is required`);
}
if (schema.additionalProperties === false) {
for (const k of Object.keys(data)) {
if (!(k in props)) errors.push(`${pathSeg}.${k}: additional property not allowed`);
}
}
for (const [k, subSchema] of Object.entries(props)) {
if (k in data) {
errors.push(...validate(subSchema, data[k], `${pathSeg}.${k}`));
}
}
}
if (dataType === 'array') {
const itemsSchema = schema.items;
const minItems = schema.minItems || 0;
if (data.length < minItems) errors.push(`${pathSeg}: must have at least ${minItems} items`);
if (itemsSchema) {
data.forEach((item, i) => {
errors.push(...validate(itemsSchema, item, `${pathSeg}[${i}]`));
});
}
}
if (dataType === 'string') {
if (schema.minLength && data.length < schema.minLength) {
errors.push(`${pathSeg}: string shorter than minLength ${schema.minLength}`);
}
if (schema.pattern) {
const re = new RegExp(schema.pattern);
if (!re.test(data)) errors.push(`${pathSeg}: does not match pattern ${schema.pattern}`);
}
}
if (dataType === 'number') {
if (schema.minimum !== undefined && data < schema.minimum) {
errors.push(`${pathSeg}: number below minimum ${schema.minimum}`);
}
if (schema.maximum !== undefined && data > schema.maximum) {
errors.push(`${pathSeg}: number above maximum ${schema.maximum}`);
}
}
return errors;
}
function h(level, text) { return `${'#'.repeat(level)} ${text}`; }
function bullet(items) { return items.map(i => `- ${i}`).join('\n'); }
function kv(key, val) { return `- ${key}: ${val}`; }
const renderers = {
'project-brief': (d) => {
return [
h(1, d.project_name),
'',
h(2, 'Summary'),
d.summary,
'',
h(2, 'Goals'),
bullet(d.goals),
'',
h(2, 'Target Users'),
bullet(d.target_users),
'',
h(2, 'Success Metrics'),
bullet(d.success_metrics),
'',
h(2, 'Scope'),
kv('In scope', d.scope.in_scope.join(', ')),
kv('Out of scope', d.scope.out_of_scope.join(', '))
].join('\n');
},
prd: (d) => {
const personas = d.personas.map(p => `- ${p.name}: goals: ${p.goals.join('; ')}; pain points: ${p.pain_points.join('; ')}`).join('\n');
const features = d.features.map(f => [
`- ${f.id} ${f.title}`,
` - Description: ${f.description}`,
` - Priority: ${f.priority}`,
` - Acceptance Criteria:`,
...f.acceptance_criteria.map(ac => ` - ${ac}`),
f.dependencies && f.dependencies.length ? ` - Dependencies: ${f.dependencies.join(', ')}` : ''
].filter(Boolean).join('\n')).join('\n');
return [
h(1, d.title),
'',
h(2, 'Overview'),
d.overview,
'',
h(2, 'Personas'),
personas,
'',
h(2, 'Features'),
features,
'',
h(2, 'Non-functional Requirements'),
bullet(d.non_functional_requirements),
'',
h(2, 'Constraints'),
bullet(d.constraints)
].join('\n');
},
architecture: (d) => {
const adrs = d.decisions.map(a => `- ${a.id} [${a.status}]: ${a.decision}${a.rationale}`).join('\n');
const comps = d.components.map(c => `- ${c.name} (${c.technology}): ${c.responsibilities.join(', ')}`).join('\n');
const entities = d.data_model.entities.map(e => [
`- ${e.name}`,
...e.attributes.map(at => ` - ${at.name}: ${at.type}${at.description ? `${at.description}` : ''}`),
e.relationships && e.relationships.length ? ` - Relationships: ${e.relationships.join('; ')}` : ''
].filter(Boolean).join('\n')).join('\n');
const integrations = d.integration.map(i => `- ${i.with} (${i.direction}, ${i.protocol})${i.notes ? `${i.notes}` : ''}`).join('\n');
const perf = d.performance.targets.map(t => `- ${t.metric}: ${t.target}`).join('\n');
return [
h(1, 'System Architecture'),
'',
h(2, 'Context'),
d.context,
'',
h(2, 'Architecture Decisions'),
adrs,
'',
h(2, 'Components'),
comps,
'',
h(2, 'Data Model'),
entities,
'',
h(2, 'Integration'),
integrations,
'',
h(2, 'Deployment'),
bullet([`Strategy: ${d.deployment.strategy}`, d.deployment.diagram ? `Diagram: ${d.deployment.diagram}` : null].filter(Boolean)),
'',
h(2, 'Security'),
bullet([`Threats: ${d.security.threats.join(', ')}`, `Controls: ${d.security.controls.join(', ')}`]),
'',
h(2, 'Performance Targets'),
perf
].join('\n');
},
'ux-spec': (d) => {
const screens = d.screens.map(s => [
`- ${s.name}: ${s.purpose}`,
...s.components.map(c => ` - ${c.id} (${c.type})${c.label ? `${c.label}` : ''}`)
].join('\n')).join('\n');
const flows = d.flows.map(f => `- ${f.name}: ${f.steps.join(' → ')}`).join('\n');
return [
h(1, 'UX Specification'),
'',
h(2, 'Design Principles'),
bullet(d.design_principles),
'',
h(2, 'Information Architecture'),
d.information_architecture.map(i => `- ${i.section}: ${i.content}`).join('\n'),
'',
h(2, 'Screens'),
screens,
'',
h(2, 'Flows'),
flows,
'',
h(2, 'Accessibility'),
bullet(d.accessibility.guidelines)
].join('\n');
},
'test-plan': (d) => {
const levels = d.test_levels.map(l => `- ${l.level}: ${l.tools.join(', ')}`).join('\n');
const cases = d.test_cases.map(tc => [
`- ${tc.id} ${tc.title} [${tc.priority}]`,
` - Preconditions: ${tc.preconditions.join('; ')}`,
` - Steps:`,
...tc.steps.map(s => ` - ${s.action}${s.expected}`),
tc.tags && tc.tags.length ? ` - Tags: ${tc.tags.join(', ')}` : ''
].filter(Boolean).join('\n')).join('\n');
return [
h(1, 'Test Plan'),
'',
h(2, 'Strategy'),
d.strategy,
'',
h(2, 'Scope'),
bullet([`In scope: ${d.scope.in_scope.join(', ')}`, `Out of scope: ${d.scope.out_of_scope.join(', ')}`]),
'',
h(2, 'Test Levels'),
levels,
'',
h(2, 'Test Cases'),
cases,
'',
h(2, 'Coverage Targets'),
bullet([`Statements: ${d.coverage_targets.statements}%`, `Branches: ${d.coverage_targets.branches}%`, `Functions: ${d.coverage_targets.functions}%`, `Lines: ${d.coverage_targets.lines}%`]),
'',
h(2, 'Environments'),
bullet(d.environments),
'',
h(2, 'Reporting'),
bullet([`Tools: ${d.reporting.tools.join(', ')}`, `Cadence: ${d.reporting.cadence}`])
].join('\n');
}
,
story: (d) => {
const deps = d.depends_on && d.depends_on.length ? `\n${h(3, 'Dependencies')}\n${bullet(d.depends_on)}` : '';
const links = d.prd_refs && d.prd_refs.length ? `\n${h(3, 'PRD References')}\n${bullet(d.prd_refs)}` : '';
return [
h(1, `${d.id} ${d.title}`),
'',
h(2, 'Description'),
d.description,
'',
h(2, 'Acceptance Criteria'),
bullet(d.acceptance_criteria),
'',
h(2, 'Priority & Estimate'),
bullet([`Priority: ${d.priority}`, d.estimate_points !== undefined ? `Estimate: ${d.estimate_points}pt` : null].filter(Boolean)),
deps,
links
].join('\n');
},
epic: (d) => {
return [
h(1, `${d.id} ${d.title}`),
'',
h(2, 'Description'),
d.description,
'',
h(2, 'Business Value'),
String(d.business_value ?? ''),
'',
h(2, 'Stories'),
bullet(d.stories)
].join('\n');
},
backlog: (d) => {
const items = d.items.map(i => `- [${i.type}] ${i.id}${i.priority}${i.status}${i.sprint ? `${i.sprint}` : ''}`).join('\n');
return [
h(1, 'Backlog'),
'',
h(2, 'Items'),
items
].join('\n');
},
'review-notes': (d) => {
const findings = d.findings.map(f => [
`- ${f.area}`,
...f.issues.map(is => ` - ${is}`)
].join('\n')).join('\n');
return [
h(1, 'Implementation Review Notes'),
'',
h(2, 'Summary'),
d.summary,
'',
h(2, 'Findings'),
findings,
'',
h(2, 'Recommendations'),
bullet(d.recommendations)
].join('\n');
},
'enhancement-classification': (d) => {
return [
h(1, 'Enhancement Classification'),
'',
h(2, 'Level'),
d.level,
'',
h(2, 'Recommended Workflow'),
d.recommended_workflow,
'',
h(2, 'Rationale'),
d.rationale,
'',
h(2, 'Indicators'),
d.indicators && d.indicators.length ? bullet(d.indicators) : '-'
].join('\n');
}
};
function die(msg) { console.error(msg); process.exit(1); }
function main() {
const [,, type, jsonPath] = process.argv;
if (!type || !jsonPath) {
die('Usage: node .claude/tools/renderers/bmad-render.mjs <type> <path/to/input.json>');
}
const schemaPath = schemaMap[type];
if (!schemaPath) die(`Unknown type: ${type}`);
const absSchema = path.resolve(ROOT, schemaPath);
const absInput = path.resolve(ROOT, jsonPath);
if (!fs.existsSync(absSchema)) die(`Schema not found: ${absSchema}`);
if (!fs.existsSync(absInput)) die(`Input not found: ${absInput}`);
const schema = loadJSON(absSchema);
const data = loadJSON(absInput);
const errors = validate(schema, data);
if (errors.length) {
die('Schema validation failed:\n' + errors.map(e => `- ${e}`).join('\n'));
}
const renderer = renderers[type];
if (!renderer) die(`No renderer for type: ${type}`);
const md = renderer(data);
process.stdout.write(md + '\n');
}
main();