@guilhermefsousa/open-spec-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +57 -0
  2. package/bin/open-spec-kit.js +39 -0
  3. package/package.json +51 -0
  4. package/src/commands/doctor.js +324 -0
  5. package/src/commands/init.js +981 -0
  6. package/src/commands/update.js +168 -0
  7. package/src/commands/validate.js +599 -0
  8. package/src/parsers/markdown-sections.js +271 -0
  9. package/src/schemas/projects.schema.js +111 -0
  10. package/src/schemas/spec.schema.js +643 -0
  11. package/templates/agents/agents/spec-hub.agent.md +99 -0
  12. package/templates/agents/rules/hub_structure.instructions.md +49 -0
  13. package/templates/agents/rules/ownership.instructions.md +138 -0
  14. package/templates/agents/scripts/notify-gchat.ps1 +99 -0
  15. package/templates/agents/scripts/notify-gchat.sh +131 -0
  16. package/templates/agents/skills/dev-orchestrator/SKILL.md +573 -0
  17. package/templates/agents/skills/discovery/SKILL.md +406 -0
  18. package/templates/agents/skills/setup-project/SKILL.md +452 -0
  19. package/templates/agents/skills/specifying-features/SKILL.md +378 -0
  20. package/templates/github/agents/spec-hub.agent.md +75 -0
  21. package/templates/github/copilot-instructions.md +102 -0
  22. package/templates/github/instructions/hub_structure.instructions.md +33 -0
  23. package/templates/github/instructions/ownership.instructions.md +45 -0
  24. package/templates/github/prompts/dev.prompt.md +19 -0
  25. package/templates/github/prompts/discovery.prompt.md +20 -0
  26. package/templates/github/prompts/nova-feature.prompt.md +19 -0
  27. package/templates/github/prompts/setup.prompt.md +18 -0
  28. package/templates/github/skills/dev-orchestrator/SKILL.md +9 -0
  29. package/templates/github/skills/discovery/SKILL.md +9 -0
  30. package/templates/github/skills/setup-project/SKILL.md +9 -0
  31. package/templates/github/skills/specifying-features/SKILL.md +9 -0
@@ -0,0 +1,599 @@
1
+ /**
2
+ * osk validate — Spec consistency validator
3
+ *
4
+ * 32 deterministic rules, 3 output modes (chalk, json, trace).
5
+ * See docs/plans/validate-turbo-plan.md for rule reference.
6
+ */
7
+
8
+ import chalk from 'chalk';
9
+ import ora from 'ora';
10
+ import { readFile, readdir, access } from 'fs/promises';
11
+ import { join, basename } from 'path';
12
+ import { execSync } from 'child_process';
13
+ import {
14
+ parseSections, findSection, findAllSections,
15
+ parseTable, extractUniqueMatches, getSectionFullContent,
16
+ } from '../parsers/markdown-sections.js';
17
+ import { parseAndValidateProjects } from '../schemas/projects.schema.js';
18
+ import * as rules from '../schemas/spec.schema.js';
19
+
20
+ // =============================================================================
21
+ // ARTIFACT PARSERS — transform markdown into structured objects for Zod rules
22
+ // =============================================================================
23
+
24
+ function parseBrief(content) {
25
+ const sections = parseSections(content);
26
+ return {
27
+ lineCount: content.split('\n').length,
28
+ hasProblema: !!findSection(sections, /problema|contexto|visão\s*geral|overview|background|objetivo\s+do\s+produto|situação\s*atual|descri[çc][aã]o|identifica[çc][aã]o/i),
29
+ hasForaDeEscopo: !!findSection(sections, /fora\s+de\s+escopo/i),
30
+ reqIds: extractUniqueMatches(content, /REQ-\w+/g),
31
+ rawContent: content,
32
+ };
33
+ }
34
+
35
+ function parseScenarios(content) {
36
+ const sections = parseSections(content);
37
+ const CT_HEADING_RE = /^CT-(\d{3}-\d{2}):\s*(.+?)(?:\s*\(REQ-(\d{3})\))?$/;
38
+ const CT_ID_RE = /CT-\d{3}-\d{2}/g;
39
+ const REQ_REF_RE = /REQ-\d{3}/g;
40
+
41
+ const allCtIds = [];
42
+ const allReqRefs = [];
43
+ const scenarioList = [];
44
+ const requirementList = [];
45
+
46
+ // REQ sections are ## level
47
+ const reqSections = findAllSections(sections, /^REQ-\d{3}/);
48
+
49
+ for (const reqSection of reqSections) {
50
+ const reqIdMatch = reqSection.title.match(/REQ-(\d{3})/);
51
+ const reqId = reqIdMatch ? `REQ-${reqIdMatch[1]}` : reqSection.title;
52
+
53
+ const reqScenarios = [];
54
+
55
+ for (const child of reqSection.children) {
56
+ const ctMatch = child.title.match(CT_HEADING_RE);
57
+ const fullContent = getSectionFullContent(child);
58
+ const GIVEN_RE = /\b(Given|Dado)\b/i;
59
+ const WHEN_RE = /\b(When|Quando)\b/i;
60
+ const THEN_RE = /\b(Then|Ent[aã]o)\b/i;
61
+
62
+ const scenario = {
63
+ id: ctMatch ? `CT-${ctMatch[1]}` : child.title,
64
+ title: ctMatch ? ctMatch[2].trim() : child.title,
65
+ reqRef: ctMatch && ctMatch[3] ? `REQ-${ctMatch[3]}` : reqId,
66
+ hasGiven: GIVEN_RE.test(fullContent),
67
+ hasWhen: WHEN_RE.test(fullContent),
68
+ hasThen: THEN_RE.test(fullContent),
69
+ };
70
+
71
+ scenarioList.push(scenario);
72
+ reqScenarios.push(scenario);
73
+
74
+ // Collect IDs
75
+ const ctIds = extractUniqueMatches(child.title, CT_ID_RE);
76
+ allCtIds.push(...ctIds);
77
+ const refs = extractUniqueMatches(child.title, REQ_REF_RE);
78
+ allReqRefs.push(...refs);
79
+ }
80
+
81
+ requirementList.push({ id: reqId, title: reqSection.title, scenarios: reqScenarios });
82
+ }
83
+
84
+ // Also extract from full content for backwards compat (headings without CT IDs)
85
+ const globalCtIds = extractUniqueMatches(content, CT_ID_RE);
86
+ const globalReqRefs = extractUniqueMatches(content, REQ_REF_RE);
87
+
88
+ return {
89
+ requirements: requirementList,
90
+ scenarios: scenarioList,
91
+ allCtIds: [...new Set([...allCtIds, ...globalCtIds])],
92
+ allReqRefs: [...new Set([...allReqRefs, ...globalReqRefs])],
93
+ rawContent: content,
94
+ };
95
+ }
96
+
97
+ function parseContracts(content) {
98
+ const sections = parseSections(content);
99
+
100
+ // Extract endpoint table — look for explicit "Endpoints/Rotas" section first (anchored, avoids matching
101
+ // the spec title like "# TD-001 — API Contracts" which contains "api" but has no table).
102
+ // Falls back to detecting individual endpoint sections (## METHOD /path/).
103
+ const endpointTableSection = findSection(sections, /^(endpoints?|rotas?)\b/i);
104
+ let endpoints = endpointTableSection ? parseTable(endpointTableSection.content) : [];
105
+
106
+ if (endpoints.length === 0) {
107
+ // Individual endpoint sections: ## GET /api/..., ## POST /api/..., etc.
108
+ const HTTP_METHOD_RE = /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+\//i;
109
+ const individualSections = findAllSections(sections, HTTP_METHOD_RE);
110
+ if (individualSections.length > 0) {
111
+ endpoints = individualSections.map(s => {
112
+ const parts = s.title.split(/\s+/);
113
+ return { method: parts[0], metodo: parts[0], route: parts[1] || '', rota: parts[1] || '' };
114
+ });
115
+ }
116
+ }
117
+
118
+ if (endpoints.length === 0) {
119
+ // Prefixed format: ## Endpoint: GET /path or ## Rota: POST /api/...
120
+ const ENDPOINT_PREFIX_RE = /^(endpoint|rota):\s*(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+\//i;
121
+ const prefixedSections = findAllSections(sections, ENDPOINT_PREFIX_RE);
122
+ if (prefixedSections.length > 0) {
123
+ endpoints = prefixedSections.map(s => {
124
+ const m = s.title.match(/(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\/\S*)/i);
125
+ return { method: m?.[1] || '', metodo: m?.[1] || '', route: m?.[2] || '', rota: m?.[2] || '' };
126
+ });
127
+ }
128
+ }
129
+
130
+ // Extract entities (## Entidade: Name, ## Entity: Name, ## Modelo: Name, ## Tipo: Name, etc.)
131
+ const entitySections = findAllSections(sections, /^(entidades?|entit(?:y|ies)|modelos?|models?|tipos?|types?)\b/i);
132
+ const entities = entitySections.map(es => {
133
+ const fullContent = getSectionFullContent(es);
134
+ const fields = parseTable(fullContent);
135
+ return { name: es.title.replace(/^(Entidades?|Entity|Entities|Modelos?|Models?|Tipos?|Types?):?\s*[—-]?\s*/i, '').trim(), fields, fullContent };
136
+ });
137
+
138
+ // Extract events (## Evento: Name or ## Event: Name)
139
+ const eventSections = findAllSections(sections, /^evento|^event/i);
140
+ const events = eventSections.map(es => ({
141
+ name: es.title.replace(/^(Evento|Event):?\s*/i, '').trim(),
142
+ }));
143
+
144
+ // Detect stack-specific sections (## Contratos C#, ## Contratos TypeScript, etc.)
145
+ const stackSections = findAllSections(sections, /^contratos?\s/i).map(s => s.title);
146
+
147
+ // Content for response example checks — code blocks and example sections ONLY
148
+ // Exclude the endpoint table itself to avoid self-matching route strings
149
+ const exampleSections = findAllSections(sections, /exemplo|example|payload|response|c#|typescript|java|python|json/i);
150
+ const examplesContent = exampleSections.length > 0
151
+ ? exampleSections.map(s => getSectionFullContent(s)).join('\n')
152
+ : '';
153
+
154
+ return {
155
+ endpoints,
156
+ entities,
157
+ events,
158
+ stackSections,
159
+ examplesContent,
160
+ rawContent: content,
161
+ };
162
+ }
163
+
164
+ function parseTasks(content) {
165
+ const headingMatches = content.match(/^##\s+(.+)$/gm) || [];
166
+ const repoHeadings = headingMatches.map(m => m.replace(/^##\s+/, '').trim());
167
+ const reqRefs = extractUniqueMatches(content, /REQ-\d{3}/g);
168
+
169
+ return { repoHeadings, reqRefs, rawContent: content };
170
+ }
171
+
172
+ // =============================================================================
173
+ // CROSS-SPEC RULES (25-27)
174
+ // =============================================================================
175
+
176
+ function crossSpec25_typeDuplication(allContracts) {
177
+ // Map entity name → list of spec dirs that define it
178
+ const entityMap = new Map();
179
+ for (const { specDir, contracts } of allContracts) {
180
+ for (const entity of contracts.entities) {
181
+ if (!entityMap.has(entity.name)) entityMap.set(entity.name, []);
182
+ entityMap.get(entity.name).push(specDir);
183
+ }
184
+ }
185
+
186
+ const duplicates = [];
187
+ for (const [name, dirs] of entityMap) {
188
+ if (dirs.length > 1) {
189
+ // Check if the SPECIFIC contracts that define this entity reference each other
190
+ const relevantContracts = allContracts.filter(({ specDir }) => dirs.includes(specDir));
191
+ const hasReference = relevantContracts.some(({ contracts }) =>
192
+ /ver contracts\.md da feature/i.test(contracts.rawContent),
193
+ );
194
+ if (!hasReference) {
195
+ duplicates.push(`${name} defined in: ${dirs.join(', ')}`);
196
+ }
197
+ }
198
+ }
199
+
200
+ return {
201
+ rule: 25, pass: duplicates.length === 0, severity: 'WARNING',
202
+ message: duplicates.length === 0
203
+ ? `No cross-spec type duplication`
204
+ : `Type/entity redefinition across specs: ${duplicates.join('; ')}`,
205
+ ...(duplicates.length > 0 ? { details: duplicates } : {}),
206
+ };
207
+ }
208
+
209
+ function crossSpec26_staleness(specPaths) {
210
+ const stale = [];
211
+ for (const { specDir, specPath } of specPaths) {
212
+ const conformancePath = join(specPath, 'conformance-report.json');
213
+ const contractsPath = join(specPath, 'contracts.md');
214
+ try {
215
+ const conformanceStat = execSync(`git log -1 --format=%ct -- "${conformancePath}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
216
+ const contractsStat = execSync(`git log -1 --format=%ct -- "${contractsPath}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
217
+
218
+ if (conformanceStat && contractsStat && parseInt(contractsStat) > parseInt(conformanceStat)) {
219
+ stale.push(specDir);
220
+ }
221
+ } catch {
222
+ // git not available or files not tracked — skip
223
+ }
224
+ }
225
+
226
+ return {
227
+ rule: 26, pass: stale.length === 0, severity: 'WARNING',
228
+ message: stale.length === 0
229
+ ? `All conformance reports are fresh`
230
+ : `Stale conformance reports (contracts changed after last check): ${stale.join(', ')}`,
231
+ ...(stale.length > 0 ? { details: stale } : {}),
232
+ };
233
+ }
234
+
235
+ function crossSpec27_refValidation(allSpecs) {
236
+ const specNumbers = new Set(allSpecs.map(s => s.specDir.match(/^(\d{3})/)?.[1]).filter(Boolean));
237
+
238
+ const errors = [];
239
+ const collisions = new Map(); // entity name → set of spec dirs
240
+
241
+ for (const { specDir, briefContent, contractsContent } of allSpecs) {
242
+ const combined = (briefContent || '') + '\n' + (contractsContent || '');
243
+
244
+ // Check feature references
245
+ let match;
246
+ const refRe = /(?:feature|spec|specs\/)\s*(\d{3})/gi;
247
+ while ((match = refRe.exec(combined)) !== null) {
248
+ const refNum = match[1];
249
+ if (refNum !== specDir.match(/^(\d{3})/)?.[1] && !specNumbers.has(refNum)) {
250
+ errors.push(`${specDir}: references feature ${refNum} which doesn't exist`);
251
+ }
252
+ }
253
+
254
+ // Check entity references and track collisions
255
+ const entRefRe = /entidade\s+(\w+)\s+(?:da|de|defined in)\s+(?:feature|spec)?\s*(\d{3})/gi;
256
+ while ((match = entRefRe.exec(combined)) !== null) {
257
+ const entityName = match[1];
258
+ const refNum = match[2];
259
+
260
+ if (!collisions.has(entityName)) collisions.set(entityName, new Set());
261
+ collisions.get(entityName).add(specDir);
262
+
263
+ // Validate the referenced spec has that entity
264
+ const targetSpec = allSpecs.find(s => s.specDir.startsWith(refNum));
265
+ if (targetSpec && targetSpec.contracts) {
266
+ const hasEntity = targetSpec.contracts.entities.some(e =>
267
+ e.name.toLowerCase() === entityName.toLowerCase(),
268
+ );
269
+ if (!hasEntity) {
270
+ errors.push(`${specDir}: references entity "${entityName}" in feature ${refNum}, but it doesn't exist there`);
271
+ }
272
+ }
273
+ }
274
+ }
275
+
276
+ // Generate collision warnings
277
+ const collisionWarnings = [];
278
+ for (const [name, dirs] of collisions) {
279
+ if (dirs.size > 1) {
280
+ collisionWarnings.push(`Entity "${name}" referenced by: ${[...dirs].join(', ')}`);
281
+ }
282
+ }
283
+
284
+ const allIssues = [...errors, ...collisionWarnings];
285
+ return {
286
+ rule: 27,
287
+ pass: errors.length === 0,
288
+ severity: errors.length > 0 ? 'ERROR' : (collisionWarnings.length > 0 ? 'WARNING' : 'ERROR'),
289
+ message: allIssues.length === 0
290
+ ? `All cross-spec references are valid`
291
+ : errors.length > 0
292
+ ? `Invalid cross-spec references: ${errors.join('; ')}`
293
+ : `Cross-spec entity collisions detected (not errors, but review): ${collisionWarnings.join('; ')}`,
294
+ ...(allIssues.length > 0 ? { details: allIssues } : {}),
295
+ };
296
+ }
297
+
298
+ // =============================================================================
299
+ // OUTPUT FORMATTERS
300
+ // =============================================================================
301
+
302
+ function formatChalk(specDir, results) {
303
+ console.log(chalk.bold(`\n specs/${specDir}/`));
304
+ for (const r of results) {
305
+ const icon = r.pass ? chalk.green('✓') : (r.severity === 'ERROR' ? chalk.red('✗') : chalk.yellow('⚠'));
306
+ const label = `[${r.rule}]`;
307
+ console.log(` ${icon} ${chalk.dim(label)} ${r.message}`);
308
+ }
309
+ }
310
+
311
+ function formatTrace(specDir, brief, scenarios) {
312
+ console.log(chalk.bold(`\n Traceability: specs/${specDir}/\n`));
313
+ for (const reqId of brief.reqIds) {
314
+ const cts = scenarios.scenarios.filter(s => s.reqRef === reqId).map(s => s.id);
315
+ const icon = cts.length > 0 ? chalk.green('✓') : chalk.red('✗');
316
+ console.log(` ${icon} ${reqId} → ${cts.length > 0 ? cts.join(', ') : 'NO SCENARIOS'}`);
317
+ }
318
+
319
+ // Orphan CTs (referencing REQs not in brief)
320
+ const briefReqSet = new Set(brief.reqIds);
321
+ const orphanCts = scenarios.scenarios.filter(s => !briefReqSet.has(s.reqRef));
322
+ if (orphanCts.length > 0) {
323
+ console.log(chalk.yellow(`\n Orphan CTs (reference non-existent REQs):`));
324
+ for (const ct of orphanCts) {
325
+ console.log(chalk.yellow(` ⚠ ${ct.id} → ${ct.reqRef} (not in brief)`));
326
+ }
327
+ }
328
+
329
+ const coverage = brief.reqIds.length > 0
330
+ ? Math.round((brief.reqIds.filter(r => scenarios.allReqRefs.includes(r)).length / brief.reqIds.length) * 100)
331
+ : 0;
332
+ console.log(`\n Coverage: ${coverage}% (${brief.reqIds.length} REQs)\n`);
333
+ }
334
+
335
+ // =============================================================================
336
+ // MAIN COMMAND
337
+ // =============================================================================
338
+
339
+ export async function validateCommand(options = {}) {
340
+ const isJson = options.json || false;
341
+ const isTrace = options.trace || false;
342
+ const specFilter = options.spec || null;
343
+
344
+ if (!isJson) {
345
+ console.log(chalk.bold('\n open-spec-kit validate\n'));
346
+ }
347
+
348
+ const cwd = process.cwd();
349
+ const spinner = isJson ? null : ora('Reading configuration...').start();
350
+
351
+ try {
352
+ // --- Read projects.yml ---
353
+ let preset = 'standard';
354
+ let repoNames = [];
355
+ let repoStacks = new Map();
356
+ try {
357
+ const projectsRaw = await readFile(join(cwd, 'projects.yml'), 'utf-8');
358
+ const parsed = parseAndValidateProjects(projectsRaw);
359
+ preset = parsed.preset;
360
+ repoNames = parsed.repoNames;
361
+ repoStacks = parsed.repoStacks;
362
+ } catch { /* no projects.yml */ }
363
+
364
+ // --- Discover specs ---
365
+ if (spinner) spinner.text = 'Scanning specs...';
366
+ const specsDir = join(cwd, 'specs');
367
+ let specDirs;
368
+ try {
369
+ const entries = await readdir(specsDir, { withFileTypes: true });
370
+ specDirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
371
+ } catch {
372
+ if (spinner) spinner.fail('Directory specs/ not found.');
373
+ if (isJson) console.log(JSON.stringify({ version: 1, specs: [], summary: { pass: 0, warn: 0, fail: 0 } }));
374
+ return;
375
+ }
376
+
377
+ if (specFilter) {
378
+ specDirs = specDirs.filter(d => d.startsWith(specFilter));
379
+ if (specDirs.length === 0) {
380
+ if (spinner) spinner.fail(`No spec matching "${specFilter}" found.`);
381
+ return;
382
+ }
383
+ }
384
+
385
+ if (specDirs.length === 0) {
386
+ if (spinner) spinner.info('No specs found in specs/.');
387
+ return;
388
+ }
389
+
390
+ if (spinner) spinner.succeed(`${specDirs.length} spec(s) found — preset: ${preset}`);
391
+
392
+ // --- Global rules (30 only — rule 29 is per-spec) ---
393
+ const globalResults = [];
394
+ globalResults.push(rules.rule30_specNumbersUnique(specDirs));
395
+
396
+ // --- Per-spec validation ---
397
+ const allSpecData = [];
398
+ const allResults = new Map(); // specDir → results[]
399
+
400
+ for (const specDir of specDirs) {
401
+ const specPath = join(specsDir, specDir);
402
+ const results = [];
403
+
404
+ // List existing files
405
+ const files = await readdir(specPath).catch(() => []);
406
+ const fileNames = Array.isArray(files) ? (files.map ? files.map(f => typeof f === 'string' ? f : f.name) : []) : [];
407
+
408
+ // Rule 29: Directory naming (per-spec)
409
+ results.push(rules.rule29_specDirNaming(specDir));
410
+
411
+ // Rule 1: Required files
412
+ results.push(rules.rule01_requiredFiles(fileNames, preset));
413
+
414
+ // Read artifacts
415
+ const readSafe = async (name) => {
416
+ try { return await readFile(join(specPath, name), 'utf-8'); } catch { return null; }
417
+ };
418
+
419
+ const briefRaw = await readSafe('brief.md');
420
+ const scenariosRaw = await readSafe('scenarios.md');
421
+ const contractsRaw = await readSafe('contracts.md');
422
+ const tasksRaw = await readSafe('tasks.md');
423
+ const linksRaw = await readSafe('links.md');
424
+ const auditRaw = await readSafe('audit-report.md');
425
+
426
+ // Parse artifacts
427
+ const brief = briefRaw ? parseBrief(briefRaw) : null;
428
+ const scenarios = scenariosRaw ? parseScenarios(scenariosRaw) : null;
429
+ const contracts = contractsRaw ? parseContracts(contractsRaw) : null;
430
+ const tasks = tasksRaw ? parseTasks(tasksRaw) : null;
431
+
432
+ // Enrich brief with REQs from scenarios (union) — covers specs where brief doesn't list REQs
433
+ if (brief && scenarios) {
434
+ brief.reqIds = [...new Set([...brief.reqIds, ...scenarios.allReqRefs])];
435
+ }
436
+
437
+ // Rules 2-6: Brief
438
+ if (brief) {
439
+ results.push(rules.rule02_briefMaxLines(brief));
440
+ results.push(rules.rule03_briefHasProblema(brief));
441
+ results.push(rules.rule04_briefHasForaDeEscopo(brief));
442
+ results.push(rules.rule05_reqIdFormat(brief));
443
+ results.push(rules.rule06_noDuplicateReqIds(brief));
444
+ }
445
+
446
+ // Rule 7: CT IDs
447
+ if (scenarios) {
448
+ results.push(rules.rule07_ctIdFormat(scenarios));
449
+ }
450
+
451
+ // Rules 8-11: Traceability
452
+ if (brief && scenarios) {
453
+ results.push(rules.rule08_everyReqHasCt(brief, scenarios));
454
+ results.push(rules.rule09_everyCtRefsValidReq(brief, scenarios));
455
+ }
456
+ if (tasks && scenarios) {
457
+ results.push(rules.rule10_everyTaskReqHasCt(tasks, scenarios));
458
+ results.push(rules.rule11_everyCtHasTask(scenarios, tasks));
459
+ }
460
+
461
+ // Rules 12-15: Scenarios
462
+ if (scenarios) {
463
+ results.push(rules.rule12_scenarioHasGiven(scenarios));
464
+ results.push(rules.rule13_scenarioHasWhen(scenarios));
465
+ results.push(rules.rule14_scenarioHasThen(scenarios));
466
+ results.push(rules.rule15_minOneScenarioPerReq(scenarios));
467
+ }
468
+
469
+ // Rules 16-20: Contracts
470
+ if (contracts) {
471
+ results.push(rules.rule16_contractsHasContent(contracts));
472
+ const uniqueStacks = [...new Set(repoNames.map(r => repoStacks.get(r)).filter(Boolean))];
473
+ results.push(rules.rule17_multiStackContracts(contracts, uniqueStacks));
474
+ results.push(rules.rule18_entityHasTimestamps(contracts));
475
+ results.push(rules.rule19_auth401Scenario(contracts, scenariosRaw || ''));
476
+ results.push(rules.rule20_paginationScenarios(contractsRaw || '', scenariosRaw || '', contracts));
477
+ }
478
+
479
+ // Rules 21-24: Tasks & Links
480
+ if (tasks) {
481
+ results.push(rules.rule21_taskReposMatchProjects(tasks, repoNames));
482
+ }
483
+ results.push(rules.rule22_auditReportClean(auditRaw));
484
+ if (linksRaw) {
485
+ results.push(rules.rule23_linksHasUrl(linksRaw));
486
+ results.push(rules.rule24_linksPrTableHeaders(linksRaw));
487
+ }
488
+
489
+ // Rule 28: Unresolved markers
490
+ const markerContents = [briefRaw, scenariosRaw, contractsRaw].filter(Boolean);
491
+ if (markerContents.length > 0) {
492
+ results.push(rules.rule28_noUnresolvedMarkers(markerContents));
493
+ }
494
+
495
+ // Rule 31: Endpoint response examples
496
+ if (contracts) {
497
+ results.push(rules.rule31_endpointsHaveResponseExample(contracts));
498
+ }
499
+
500
+ // Rule 32: Duplicate scenario headings
501
+ if (scenarios) {
502
+ results.push(rules.rule32_noDuplicateScenarioHeadings(scenarios));
503
+ }
504
+
505
+ allResults.set(specDir, results);
506
+
507
+ // Store for cross-spec checks
508
+ allSpecData.push({
509
+ specDir,
510
+ specPath,
511
+ contracts,
512
+ briefContent: briefRaw,
513
+ contractsContent: contractsRaw,
514
+ });
515
+
516
+ // Output per-spec results
517
+ if (!isJson) {
518
+ if (isTrace && brief && scenarios) {
519
+ formatTrace(specDir, brief, scenarios);
520
+ }
521
+ formatChalk(specDir, results);
522
+ }
523
+ }
524
+
525
+ // --- Cross-spec rules (25-27) ---
526
+ const crossSpecResults = [];
527
+ if (allSpecData.length > 1) {
528
+ const contractData = allSpecData.filter(s => s.contracts).map(s => ({
529
+ specDir: s.specDir,
530
+ contracts: s.contracts,
531
+ }));
532
+ if (contractData.length > 1) {
533
+ crossSpecResults.push(crossSpec25_typeDuplication(contractData));
534
+ }
535
+ crossSpecResults.push(crossSpec26_staleness(allSpecData));
536
+ crossSpecResults.push(crossSpec27_refValidation(allSpecData));
537
+ }
538
+
539
+ // Rule 33: open decisions in architecture.md
540
+ let architectureContent = null;
541
+ try {
542
+ architectureContent = await readFile(join(cwd, 'docs', 'architecture.md'), 'utf-8');
543
+ } catch { /* file not found — ok */ }
544
+ const rule33Result = rules.rule33_openDecisions(architectureContent);
545
+ crossSpecResults.push(rule33Result);
546
+
547
+ if (!isJson && crossSpecResults.length > 0) {
548
+ console.log(chalk.bold('\n Cross-spec checks'));
549
+ for (const r of crossSpecResults) {
550
+ const icon = r.pass ? chalk.green('✓') : (r.severity === 'ERROR' ? chalk.red('✗') : chalk.yellow('⚠'));
551
+ console.log(` ${icon} [${r.rule}] ${r.message}`);
552
+ }
553
+ }
554
+
555
+ // Global results (rule 30 only — rule 29 is per-spec)
556
+ if (!isJson && globalResults.length > 0) {
557
+ console.log(chalk.bold('\n Directory checks'));
558
+ for (const r of globalResults) {
559
+ const icon = r.pass ? chalk.green('✓') : (r.severity === 'ERROR' ? chalk.red('✗') : chalk.yellow('⚠'));
560
+ console.log(` ${icon} [${r.rule}] ${r.message}`);
561
+ }
562
+ }
563
+
564
+ // --- Summary ---
565
+ const allRuleResults = [
566
+ ...globalResults,
567
+ ...[...allResults.values()].flat(),
568
+ ...crossSpecResults,
569
+ ];
570
+
571
+ const totalPass = allRuleResults.filter(r => r.pass).length;
572
+ const totalWarn = allRuleResults.filter(r => !r.pass && r.severity === 'WARNING').length;
573
+ const totalFail = allRuleResults.filter(r => !r.pass && r.severity === 'ERROR').length;
574
+
575
+ if (isJson) {
576
+ const output = {
577
+ version: 1,
578
+ specs: [...allResults.entries()].map(([dir, res]) => ({ spec: dir, rules: res })),
579
+ crossSpec: crossSpecResults,
580
+ global: globalResults,
581
+ summary: { pass: totalPass, warn: totalWarn, fail: totalFail, total: allRuleResults.length },
582
+ };
583
+ console.log(JSON.stringify(output, null, 2));
584
+ } else {
585
+ console.log(chalk.bold('\n Summary:\n'));
586
+ console.log(` ${chalk.green('✓')} ${totalPass} passed`);
587
+ if (totalWarn > 0) console.log(` ${chalk.yellow('⚠')} ${totalWarn} warnings`);
588
+ if (totalFail > 0) console.log(` ${chalk.red('✗')} ${totalFail} failed`);
589
+ console.log(` Total: ${allRuleResults.length} checks\n`);
590
+ }
591
+
592
+ if (totalFail > 0) process.exit(1);
593
+
594
+ } catch (err) {
595
+ if (spinner) spinner.fail(`Error: ${err.message}`);
596
+ if (isJson) console.log(JSON.stringify({ version: 1, error: err.message }));
597
+ process.exit(1);
598
+ }
599
+ }