@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.
- package/README.md +57 -0
- package/bin/open-spec-kit.js +39 -0
- package/package.json +51 -0
- package/src/commands/doctor.js +324 -0
- package/src/commands/init.js +981 -0
- package/src/commands/update.js +168 -0
- package/src/commands/validate.js +599 -0
- package/src/parsers/markdown-sections.js +271 -0
- package/src/schemas/projects.schema.js +111 -0
- package/src/schemas/spec.schema.js +643 -0
- package/templates/agents/agents/spec-hub.agent.md +99 -0
- package/templates/agents/rules/hub_structure.instructions.md +49 -0
- package/templates/agents/rules/ownership.instructions.md +138 -0
- package/templates/agents/scripts/notify-gchat.ps1 +99 -0
- package/templates/agents/scripts/notify-gchat.sh +131 -0
- package/templates/agents/skills/dev-orchestrator/SKILL.md +573 -0
- package/templates/agents/skills/discovery/SKILL.md +406 -0
- package/templates/agents/skills/setup-project/SKILL.md +452 -0
- package/templates/agents/skills/specifying-features/SKILL.md +378 -0
- package/templates/github/agents/spec-hub.agent.md +75 -0
- package/templates/github/copilot-instructions.md +102 -0
- package/templates/github/instructions/hub_structure.instructions.md +33 -0
- package/templates/github/instructions/ownership.instructions.md +45 -0
- package/templates/github/prompts/dev.prompt.md +19 -0
- package/templates/github/prompts/discovery.prompt.md +20 -0
- package/templates/github/prompts/nova-feature.prompt.md +19 -0
- package/templates/github/prompts/setup.prompt.md +18 -0
- package/templates/github/skills/dev-orchestrator/SKILL.md +9 -0
- package/templates/github/skills/discovery/SKILL.md +9 -0
- package/templates/github/skills/setup-project/SKILL.md +9 -0
- 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
|
+
}
|