@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,643 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schemas + 32 validation rules for spec artifacts.
|
|
3
|
+
*
|
|
4
|
+
* Each rule is a pure function:
|
|
5
|
+
* Input: parsed data from markdown-sections parser
|
|
6
|
+
* Output: { rule: number, pass: boolean, severity: 'ERROR'|'WARNING', message: string, details?: string[] }
|
|
7
|
+
*
|
|
8
|
+
* Rules 1-24, 28-32: per-spec
|
|
9
|
+
* Rules 25-27: cross-spec (implemented in validate.js)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// --- Result helper ---
|
|
13
|
+
|
|
14
|
+
function result(rule, pass, severity, message, details) {
|
|
15
|
+
return { rule, pass, severity, message, ...(details ? { details } : {}) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// RULES 1-7: Structure
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
const PRESET_ARTIFACTS = {
|
|
23
|
+
lean: ['brief.md', 'tasks.md'],
|
|
24
|
+
standard: ['brief.md', 'scenarios.md', 'contracts.md', 'tasks.md', 'links.md'],
|
|
25
|
+
enterprise: ['brief.md', 'scenarios.md', 'contracts.md', 'tasks.md', 'links.md'],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Rule 1: Required files exist per preset.
|
|
30
|
+
* @param {string[]} existingFiles - filenames that exist in the spec dir
|
|
31
|
+
* @param {string} preset
|
|
32
|
+
*/
|
|
33
|
+
export function rule01_requiredFiles(existingFiles, preset) {
|
|
34
|
+
const required = PRESET_ARTIFACTS[preset] || PRESET_ARTIFACTS.standard;
|
|
35
|
+
const missing = required.filter(f => !existingFiles.includes(f));
|
|
36
|
+
return result(1, missing.length === 0, 'ERROR',
|
|
37
|
+
missing.length === 0
|
|
38
|
+
? `All ${required.length} required files present`
|
|
39
|
+
: `Missing files: ${missing.join(', ')}`,
|
|
40
|
+
missing.length > 0 ? missing : undefined,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Rule 2: Brief max 80 lines.
|
|
46
|
+
* @param {{ lineCount: number }} brief
|
|
47
|
+
*/
|
|
48
|
+
export function rule02_briefMaxLines(brief) {
|
|
49
|
+
return result(2, brief.lineCount <= 80, 'WARNING',
|
|
50
|
+
brief.lineCount <= 80
|
|
51
|
+
? `Brief has ${brief.lineCount} lines (max 80)`
|
|
52
|
+
: `Brief has ${brief.lineCount} lines (max recommended: 80)`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Rule 3: Brief has "Problema" section.
|
|
58
|
+
* @param {{ hasProblema: boolean }} brief
|
|
59
|
+
*/
|
|
60
|
+
export function rule03_briefHasProblema(brief) {
|
|
61
|
+
return result(3, brief.hasProblema, 'ERROR',
|
|
62
|
+
brief.hasProblema
|
|
63
|
+
? 'Brief has "Problema" section'
|
|
64
|
+
: 'Brief missing "Problema" section — brief must start with the problem',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Rule 4: Brief has "Fora de Escopo" section.
|
|
70
|
+
* @param {{ hasForaDeEscopo: boolean }} brief
|
|
71
|
+
*/
|
|
72
|
+
export function rule04_briefHasForaDeEscopo(brief) {
|
|
73
|
+
return result(4, brief.hasForaDeEscopo, 'WARNING',
|
|
74
|
+
brief.hasForaDeEscopo
|
|
75
|
+
? 'Brief has "Fora de Escopo" section'
|
|
76
|
+
: 'Brief missing "Fora de Escopo" section — recommended to define scope boundaries',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Rule 5: REQ-NNN format valid.
|
|
82
|
+
* @param {{ reqIds: string[] }} brief
|
|
83
|
+
*/
|
|
84
|
+
export function rule05_reqIdFormat(brief) {
|
|
85
|
+
const invalid = brief.reqIds.filter(id => !/^REQ-\d{3}$/.test(id));
|
|
86
|
+
return result(5, invalid.length === 0, 'ERROR',
|
|
87
|
+
invalid.length === 0
|
|
88
|
+
? `All ${brief.reqIds.length} REQ IDs have valid format`
|
|
89
|
+
: `Invalid REQ ID format: ${invalid.join(', ')} (expected 3-digit zero-padded: REQ-001, REQ-011, NOT REQ-1 or REQ-11)`,
|
|
90
|
+
invalid.length > 0 ? invalid : undefined,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Rule 6: No duplicate REQ IDs in brief.
|
|
96
|
+
* @param {{ reqIds: string[] }} brief
|
|
97
|
+
*/
|
|
98
|
+
export function rule06_noDuplicateReqIds(brief) {
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
const duplicates = [];
|
|
101
|
+
for (const id of brief.reqIds) {
|
|
102
|
+
if (seen.has(id)) duplicates.push(id);
|
|
103
|
+
seen.add(id);
|
|
104
|
+
}
|
|
105
|
+
return result(6, duplicates.length === 0, 'ERROR',
|
|
106
|
+
duplicates.length === 0
|
|
107
|
+
? `No duplicate REQ IDs`
|
|
108
|
+
: `Duplicate REQ IDs: ${[...new Set(duplicates)].join(', ')}`,
|
|
109
|
+
duplicates.length > 0 ? [...new Set(duplicates)] : undefined,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Rule 7: CT-NNN-XX format valid on scenario headings.
|
|
115
|
+
* @param {{ allCtIds: string[] }} scenarios
|
|
116
|
+
*/
|
|
117
|
+
export function rule07_ctIdFormat(scenarios) {
|
|
118
|
+
const invalid = scenarios.allCtIds.filter(id => !/^CT-\d{3}-\d{2}$/.test(id));
|
|
119
|
+
return result(7, invalid.length === 0, 'ERROR',
|
|
120
|
+
invalid.length === 0
|
|
121
|
+
? `All ${scenarios.allCtIds.length} CT IDs have valid format`
|
|
122
|
+
: `Invalid CT ID format: ${invalid.join(', ')} (expected CT-NNN-XX)`,
|
|
123
|
+
invalid.length > 0 ? invalid : undefined,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// =============================================================================
|
|
128
|
+
// RULES 8-11: Traceability
|
|
129
|
+
// =============================================================================
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Rule 8: Every REQ in brief has >= 1 CT in scenarios.
|
|
133
|
+
* REQs that are cross-spec references (contain "Usa", "Utiliza", "Ver contracts", etc.)
|
|
134
|
+
* are reported as WARNING instead of ERROR — they don't need their own scenarios.
|
|
135
|
+
*/
|
|
136
|
+
const REF_REQ_RE = /\b(usa\b|utiliza\b|referencia|reutiliza|ver contracts|definid[ao] em|já defini)/i;
|
|
137
|
+
|
|
138
|
+
export function rule08_everyReqHasCt(brief, scenarios) {
|
|
139
|
+
const orphanReqs = brief.reqIds.filter(r => !scenarios.allReqRefs.includes(r));
|
|
140
|
+
if (orphanReqs.length === 0) {
|
|
141
|
+
return result(8, true, 'ERROR', `All ${brief.reqIds.length} REQs have scenarios`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Check if orphan REQs are cross-spec references (not behavioral)
|
|
145
|
+
const refOrphans = [];
|
|
146
|
+
const realOrphans = [];
|
|
147
|
+
for (const reqId of orphanReqs) {
|
|
148
|
+
const reqLineRe = new RegExp(`${reqId}[:\\s|]+(.+)`, 'i');
|
|
149
|
+
const match = (brief.rawContent || '').match(reqLineRe);
|
|
150
|
+
if (match && REF_REQ_RE.test(match[1])) {
|
|
151
|
+
refOrphans.push(reqId);
|
|
152
|
+
} else {
|
|
153
|
+
realOrphans.push(reqId);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (realOrphans.length === 0 && refOrphans.length > 0) {
|
|
158
|
+
return result(8, true, 'WARNING',
|
|
159
|
+
`All behavioral REQs have scenarios (${refOrphans.length} cross-spec reference REQ(s) skipped: ${refOrphans.join(', ')})`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return result(8, false, 'ERROR',
|
|
164
|
+
`REQs without scenarios: ${realOrphans.join(', ')}`,
|
|
165
|
+
realOrphans.length > 0 ? realOrphans : undefined,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Rule 9: Every CT references existing REQ from brief.
|
|
171
|
+
*/
|
|
172
|
+
export function rule09_everyCtRefsValidReq(brief, scenarios) {
|
|
173
|
+
const orphanRefs = scenarios.allReqRefs.filter(r => !brief.reqIds.includes(r));
|
|
174
|
+
const uniqueOrphans = [...new Set(orphanRefs)];
|
|
175
|
+
return result(9, uniqueOrphans.length === 0, 'ERROR',
|
|
176
|
+
uniqueOrphans.length === 0
|
|
177
|
+
? `All CT references point to valid REQs`
|
|
178
|
+
: `CTs reference non-existent REQs: ${uniqueOrphans.join(', ')}`,
|
|
179
|
+
uniqueOrphans.length > 0 ? uniqueOrphans : undefined,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Rule 10: Every REQ in tasks has CT in scenarios.
|
|
185
|
+
*/
|
|
186
|
+
export function rule10_everyTaskReqHasCt(tasks, scenarios) {
|
|
187
|
+
const taskReqsWithoutCt = tasks.reqRefs.filter(r => !scenarios.allReqRefs.includes(r));
|
|
188
|
+
const unique = [...new Set(taskReqsWithoutCt)];
|
|
189
|
+
return result(10, unique.length === 0, 'WARNING',
|
|
190
|
+
unique.length === 0
|
|
191
|
+
? `All task REQ references have corresponding CTs`
|
|
192
|
+
: `Task REQs without CT coverage: ${unique.join(', ')}`,
|
|
193
|
+
unique.length > 0 ? unique : undefined,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Rule 11: Every CT has >= 1 task referencing the REQ.
|
|
199
|
+
*/
|
|
200
|
+
export function rule11_everyCtHasTask(scenarios, tasks) {
|
|
201
|
+
const ctReqsWithoutTask = scenarios.allReqRefs.filter(r => !tasks.reqRefs.includes(r));
|
|
202
|
+
const unique = [...new Set(ctReqsWithoutTask)];
|
|
203
|
+
return result(11, unique.length === 0, 'WARNING',
|
|
204
|
+
unique.length === 0
|
|
205
|
+
? `All CTs have tasks referencing their REQs`
|
|
206
|
+
: `CT REQs without tasks: ${unique.join(', ')}`,
|
|
207
|
+
unique.length > 0 ? unique : undefined,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// =============================================================================
|
|
212
|
+
// RULES 12-15: Scenarios
|
|
213
|
+
// =============================================================================
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Rule 12: Every scenario has Given.
|
|
217
|
+
* @param {{ scenarios: Array<{ id: string, hasGiven: boolean }> }} parsed
|
|
218
|
+
*/
|
|
219
|
+
export function rule12_scenarioHasGiven(parsed) {
|
|
220
|
+
const missing = parsed.scenarios.filter(s => !s.hasGiven).map(s => s.id);
|
|
221
|
+
return result(12, missing.length === 0, 'ERROR',
|
|
222
|
+
missing.length === 0
|
|
223
|
+
? `All scenarios have Given`
|
|
224
|
+
: `Scenarios missing Given: ${missing.join(', ')}`,
|
|
225
|
+
missing.length > 0 ? missing : undefined,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Rule 13: Every scenario has When.
|
|
231
|
+
*/
|
|
232
|
+
export function rule13_scenarioHasWhen(parsed) {
|
|
233
|
+
const missing = parsed.scenarios.filter(s => !s.hasWhen).map(s => s.id);
|
|
234
|
+
return result(13, missing.length === 0, 'ERROR',
|
|
235
|
+
missing.length === 0
|
|
236
|
+
? `All scenarios have When`
|
|
237
|
+
: `Scenarios missing When: ${missing.join(', ')}`,
|
|
238
|
+
missing.length > 0 ? missing : undefined,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Rule 14: Every scenario has Then.
|
|
244
|
+
*/
|
|
245
|
+
export function rule14_scenarioHasThen(parsed) {
|
|
246
|
+
const missing = parsed.scenarios.filter(s => !s.hasThen).map(s => s.id);
|
|
247
|
+
return result(14, missing.length === 0, 'ERROR',
|
|
248
|
+
missing.length === 0
|
|
249
|
+
? `All scenarios have Then`
|
|
250
|
+
: `Scenarios missing Then: ${missing.join(', ')}`,
|
|
251
|
+
missing.length > 0 ? missing : undefined,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Rule 15: At least 1 scenario per REQ.
|
|
257
|
+
* @param {{ requirements: Array<{ id: string, scenarios: any[] }> }} parsed
|
|
258
|
+
*/
|
|
259
|
+
export function rule15_minOneScenarioPerReq(parsed) {
|
|
260
|
+
const empty = parsed.requirements.filter(r => r.scenarios.length === 0).map(r => r.id);
|
|
261
|
+
return result(15, empty.length === 0, 'ERROR',
|
|
262
|
+
empty.length === 0
|
|
263
|
+
? `All REQs have at least 1 scenario`
|
|
264
|
+
: `REQs with 0 scenarios: ${empty.join(', ')}`,
|
|
265
|
+
empty.length > 0 ? empty : undefined,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// =============================================================================
|
|
270
|
+
// RULES 16-20: Contracts
|
|
271
|
+
// =============================================================================
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Rule 16: Contracts has content (endpoint OR entity OR event).
|
|
275
|
+
*/
|
|
276
|
+
export function rule16_contractsHasContent(contracts) {
|
|
277
|
+
const has = contracts.endpoints.length > 0 || contracts.entities.length > 0 || contracts.events.length > 0;
|
|
278
|
+
return result(16, has, 'ERROR',
|
|
279
|
+
has
|
|
280
|
+
? `Contracts has ${contracts.endpoints.length} endpoints, ${contracts.entities.length} entities, ${contracts.events.length} events`
|
|
281
|
+
: `Contracts is empty — must have at least 1 endpoint, entity, or event`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Rule 17: Contracts exist for each stack in projects.yml.
|
|
287
|
+
* @param {{ stackSections: string[] }} contracts - detected stack section headings
|
|
288
|
+
* @param {string[]} stacks - unique stacks from projects.yml repos
|
|
289
|
+
*/
|
|
290
|
+
const STACK_ALIASES = {
|
|
291
|
+
dotnet: ['c#', '.net', 'csharp', 'dotnet'],
|
|
292
|
+
nodejs: ['typescript', 'node', 'javascript', 'react', 'vue', 'angular', 'nodejs'],
|
|
293
|
+
java: ['java', 'spring', 'kotlin'],
|
|
294
|
+
python: ['python', 'fastapi', 'django', 'flask'],
|
|
295
|
+
react: ['react', 'typescript', 'frontend'],
|
|
296
|
+
go: ['go', 'golang'],
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
export function rule17_multiStackContracts(contracts, stacks) {
|
|
300
|
+
if (stacks.length <= 1) {
|
|
301
|
+
return result(17, true, 'WARNING', `Single stack — multi-stack check not applicable`);
|
|
302
|
+
}
|
|
303
|
+
const missing = stacks.filter(s => {
|
|
304
|
+
const aliases = STACK_ALIASES[s.toLowerCase()] || [s.toLowerCase()];
|
|
305
|
+
return !contracts.stackSections.some(sec =>
|
|
306
|
+
aliases.some(alias => sec.toLowerCase().includes(alias)),
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
return result(17, missing.length === 0, 'WARNING',
|
|
310
|
+
missing.length === 0
|
|
311
|
+
? `Contracts cover all ${stacks.length} stacks`
|
|
312
|
+
: `Missing contracts for stacks: ${missing.join(', ')}`,
|
|
313
|
+
missing.length > 0 ? missing : undefined,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Rule 18: Editable entity has createdAt + updatedAt.
|
|
319
|
+
* Only checks entities that have PUT/PATCH endpoints (editable).
|
|
320
|
+
* Junction tables, logs, snapshots, and append-only entities are skipped.
|
|
321
|
+
*/
|
|
322
|
+
export function rule18_entityHasTimestamps(contracts) {
|
|
323
|
+
const CREATED_RE = /created_?at|criado_?em|data_?cria[cç][aã]o|date_?created/i;
|
|
324
|
+
const UPDATED_RE = /updated_?at|atualizado_?em|data_?atualiza[cç][aã]o|date_?updated|modified_?at/i;
|
|
325
|
+
|
|
326
|
+
// Build set of editable entity patterns from PUT/PATCH endpoints
|
|
327
|
+
const editablePatterns = contracts.endpoints
|
|
328
|
+
.filter(e => /put|patch/i.test(e.metodo || e.method || e['método'] || ''))
|
|
329
|
+
.map(e => (e.rota || e.route || '').replace(/`/g, '').replace(/\{[^}]+\}/g, ''))
|
|
330
|
+
.map(route => {
|
|
331
|
+
const match = route.match(/\/api\/([a-z-]+)/i);
|
|
332
|
+
return match ? match[1].toLowerCase().replace(/-/g, '').replace(/s$/, '') : null;
|
|
333
|
+
})
|
|
334
|
+
.filter(Boolean);
|
|
335
|
+
|
|
336
|
+
const failures = [];
|
|
337
|
+
const skipped = [];
|
|
338
|
+
|
|
339
|
+
for (const entity of contracts.entities) {
|
|
340
|
+
const entityNameNorm = entity.name.toLowerCase().replace(/[^a-z]/g, '');
|
|
341
|
+
|
|
342
|
+
// Check if entity is editable (has PUT/PATCH endpoint)
|
|
343
|
+
// Only check forward direction: entity name contains route pattern
|
|
344
|
+
// e.g., "planomodalidade" includes "plano" → editable (defensible)
|
|
345
|
+
// Reverse direction removed: "log" inside "logistica" = false positive
|
|
346
|
+
const isEditable = editablePatterns.some(p => entityNameNorm.includes(p));
|
|
347
|
+
|
|
348
|
+
if (!isEditable) {
|
|
349
|
+
skipped.push(entity.name);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const fieldNames = entity.fields.map(f => f.campo);
|
|
354
|
+
const fullContent = entity.fullContent || '';
|
|
355
|
+
const hasCreated = fieldNames.some(n => CREATED_RE.test(n)) || CREATED_RE.test(fullContent);
|
|
356
|
+
const hasUpdated = fieldNames.some(n => UPDATED_RE.test(n)) || UPDATED_RE.test(fullContent);
|
|
357
|
+
if (!hasCreated || !hasUpdated) {
|
|
358
|
+
const missing = [];
|
|
359
|
+
if (!hasCreated) missing.push('createdAt/CriadoEm');
|
|
360
|
+
if (!hasUpdated) missing.push('updatedAt/AtualizadoEm');
|
|
361
|
+
failures.push(`${entity.name}: missing ${missing.join(', ')}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const checkedCount = contracts.entities.length - skipped.length;
|
|
366
|
+
const skippedMsg = skipped.length > 0 ? ` (${skipped.length} non-editable skipped: ${skipped.join(', ')})` : '';
|
|
367
|
+
|
|
368
|
+
return result(18, failures.length === 0, 'ERROR',
|
|
369
|
+
failures.length === 0
|
|
370
|
+
? checkedCount === 0
|
|
371
|
+
? `No editable entities (no PUT/PATCH endpoints) — rule not applicable${skippedMsg}`
|
|
372
|
+
: `All ${checkedCount} editable entities have timestamp fields${skippedMsg}`
|
|
373
|
+
: `Editable entities missing audit fields: ${failures.join('; ')}${skippedMsg}`,
|
|
374
|
+
failures.length > 0 ? failures : undefined,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Rule 19: Auth 401 scenario exists if authenticated endpoint.
|
|
380
|
+
*/
|
|
381
|
+
export function rule19_auth401Scenario(contracts, scenariosRaw) {
|
|
382
|
+
const authEndpoints = contracts.endpoints.filter(e => {
|
|
383
|
+
// Flexible column name: autenticado, auth, authenticated, etc.
|
|
384
|
+
const authKey = Object.keys(e).find(k => /autenticad|auth/i.test(k));
|
|
385
|
+
const authValue = e.autenticado || e.auth || e.authenticated || e['requer auth']
|
|
386
|
+
|| (authKey ? e[authKey] : '') || '';
|
|
387
|
+
return /sim|yes|true|✓|✅|x/i.test(String(authValue));
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
if (authEndpoints.length === 0) {
|
|
391
|
+
return result(19, true, 'ERROR', `No authenticated endpoints — rule not applicable`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const has401 = /401|NAO_AUTORIZADO|UNAUTHORIZED|sem token|without token/i.test(scenariosRaw);
|
|
395
|
+
return result(19, has401, 'ERROR',
|
|
396
|
+
has401
|
|
397
|
+
? `401 scenario found for ${authEndpoints.length} authenticated endpoints`
|
|
398
|
+
: `${authEndpoints.length} authenticated endpoints but no 401/unauthorized scenario found`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Rule 20: Pagination scenarios if PaginatedResponse.
|
|
404
|
+
*/
|
|
405
|
+
export function rule20_paginationScenarios(contractsRaw, scenariosRaw, contracts) {
|
|
406
|
+
// Check if any endpoint USES pagination (not just defines the type as shared)
|
|
407
|
+
const endpointDescriptions = (contracts?.endpoints || []).map(e =>
|
|
408
|
+
`${e.descricao || e['descrição'] || ''} ${e.rota || e.route || ''} ${e.retorno || e.response || ''}`,
|
|
409
|
+
).join(' ');
|
|
410
|
+
const hasPaginatedEndpoint = /PaginatedResponse|pageSize|page_size|PagedResult|paginad|listagem/i.test(endpointDescriptions);
|
|
411
|
+
const hasPagination = hasPaginatedEndpoint || /PaginatedResponse|pageSize|page_size|PagedResult/i.test(contractsRaw);
|
|
412
|
+
|
|
413
|
+
if (!hasPaginatedEndpoint && !hasPagination) {
|
|
414
|
+
return result(20, true, 'WARNING', `No pagination types found — rule not applicable`);
|
|
415
|
+
}
|
|
416
|
+
if (hasPagination && !hasPaginatedEndpoint) {
|
|
417
|
+
return result(20, true, 'WARNING', `PaginatedResponse defined as shared type but no paginated endpoints in this spec`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const hasPaginationScenario = /pagina|pagination|page|default.*page|last.*page|invalid.*page/i.test(scenariosRaw);
|
|
421
|
+
return result(20, hasPaginationScenario, 'WARNING',
|
|
422
|
+
hasPaginationScenario
|
|
423
|
+
? `Pagination scenarios found`
|
|
424
|
+
: `PaginatedResponse in contracts but no pagination scenarios found`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// =============================================================================
|
|
429
|
+
// RULES 21-24: Tasks & Links
|
|
430
|
+
// =============================================================================
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Rule 21: Task ## headings reference repos from projects.yml.
|
|
434
|
+
*/
|
|
435
|
+
export function rule21_taskReposMatchProjects(tasks, repoNames) {
|
|
436
|
+
if (tasks.repoHeadings.length === 0) {
|
|
437
|
+
return result(21, false, 'WARNING', `No repo headings (##) found in tasks.md`);
|
|
438
|
+
}
|
|
439
|
+
if (repoNames.length === 0) {
|
|
440
|
+
return result(21, true, 'WARNING', `projects.yml has no repos — cannot validate task headings`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const unknown = tasks.repoHeadings.filter(h => !repoNames.some(rn => h.includes(rn)));
|
|
444
|
+
return result(21, unknown.length === 0, 'WARNING',
|
|
445
|
+
unknown.length === 0
|
|
446
|
+
? `All ${tasks.repoHeadings.length} task repo headings match projects.yml`
|
|
447
|
+
: `Task repos not in projects.yml: ${unknown.join(', ')}`,
|
|
448
|
+
unknown.length > 0 ? unknown : undefined,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Rule 22: Audit report has no failures (if exists).
|
|
454
|
+
* @param {string|null} auditContent - null if file doesn't exist
|
|
455
|
+
*/
|
|
456
|
+
export function rule22_auditReportClean(auditContent) {
|
|
457
|
+
if (auditContent === null) {
|
|
458
|
+
return result(22, true, 'WARNING', `audit-report.md not found (generated by /spec)`);
|
|
459
|
+
}
|
|
460
|
+
const hasFail = auditContent.includes('❌');
|
|
461
|
+
return result(22, !hasFail, 'ERROR',
|
|
462
|
+
hasFail
|
|
463
|
+
? `audit-report.md contains failures (❌)`
|
|
464
|
+
: `audit-report.md is clean`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Rule 23: links.md contains Confluence URL.
|
|
470
|
+
*/
|
|
471
|
+
export function rule23_linksHasUrl(linksContent) {
|
|
472
|
+
const hasUrl = /https?:\/\//.test(linksContent);
|
|
473
|
+
return result(23, hasUrl, 'WARNING',
|
|
474
|
+
hasUrl
|
|
475
|
+
? `links.md contains URL reference`
|
|
476
|
+
: `links.md has no URL — should link to Confluence feature page`,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Rule 24: links.md has PR table with correct headers.
|
|
482
|
+
*/
|
|
483
|
+
export function rule24_linksPrTableHeaders(linksContent) {
|
|
484
|
+
// Check that a table header line contains Repo, PR/MR (any variant), and Status
|
|
485
|
+
const headerLine = linksContent.split('\n').find(l => /\|\s*Repo\b/i.test(l));
|
|
486
|
+
const hasPrTable = headerLine
|
|
487
|
+
? (/\b(MR\/PR|PR\/MR|PR|MR)\b/i.test(headerLine) && /\bStatus\b/i.test(headerLine))
|
|
488
|
+
: false;
|
|
489
|
+
return result(24, hasPrTable, 'WARNING',
|
|
490
|
+
hasPrTable
|
|
491
|
+
? `links.md has PR tracking table`
|
|
492
|
+
: `links.md missing PR tracking table (expected: Repo | PR/MR | Status)`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// =============================================================================
|
|
497
|
+
// RULES 28-32: Spec Hygiene
|
|
498
|
+
// =============================================================================
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Rule 28: No unresolved markers in spec artifacts.
|
|
502
|
+
* @param {string[]} contents - raw content of brief, scenarios, contracts
|
|
503
|
+
*/
|
|
504
|
+
export function rule28_noUnresolvedMarkers(contents) {
|
|
505
|
+
// TODO separado sem flag i — evita falso positivo com "Método" (é é \W em JS, \b casa entre é e t)
|
|
506
|
+
const MARKER_RE = /\b(MOCKADO|A CONFIRMAR|TBD|FIXME|PLACEHOLDER|TKTK)\b/gi;
|
|
507
|
+
const TODO_RE = /\bTODO\b/g;
|
|
508
|
+
const allMarkers = [];
|
|
509
|
+
for (const content of contents) {
|
|
510
|
+
const matches = content.match(MARKER_RE) || [];
|
|
511
|
+
const todoMatches = content.match(TODO_RE) || [];
|
|
512
|
+
allMarkers.push(...matches, ...todoMatches);
|
|
513
|
+
}
|
|
514
|
+
const unique = [...new Set(allMarkers.map(m => m.toUpperCase()))];
|
|
515
|
+
return result(28, unique.length === 0, 'ERROR',
|
|
516
|
+
unique.length === 0
|
|
517
|
+
? `No unresolved markers found`
|
|
518
|
+
: `Unresolved markers found: ${unique.join(', ')} — resolve before /dev`,
|
|
519
|
+
unique.length > 0 ? unique : undefined,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Rule 29: Spec directory follows NNN-kebab-case.
|
|
525
|
+
* @param {string} dirName
|
|
526
|
+
*/
|
|
527
|
+
export function rule29_specDirNaming(dirName) {
|
|
528
|
+
// Accept NNN-kebab-case (e.g. 001-consumer-shipment) OR SIGLA-NNN[-suffix] (e.g. TD-001, PROJ-002-auth)
|
|
529
|
+
const valid = /^\d{3}-[a-z0-9]+(-[a-z0-9]+)*$/.test(dirName)
|
|
530
|
+
|| /^[A-Z]+-\d{3}(-[a-zA-Z0-9]+)*$/.test(dirName);
|
|
531
|
+
return result(29, valid, 'ERROR',
|
|
532
|
+
valid
|
|
533
|
+
? `Spec directory "${dirName}" follows naming convention`
|
|
534
|
+
: `Spec directory "${dirName}" invalid — must be NNN-kebab-case (e.g., 001-consumer-shipment) or SIGLA-NNN (e.g., TD-001)`,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Rule 30: Spec numbers are unique (no duplicates).
|
|
540
|
+
* @param {string[]} allDirNames - all spec directory names
|
|
541
|
+
*/
|
|
542
|
+
export function rule30_specNumbersUnique(allDirNames) {
|
|
543
|
+
const numbers = allDirNames.map(d => d.match(/^(\d{3})/)?.[1]).filter(Boolean);
|
|
544
|
+
const seen = new Set();
|
|
545
|
+
const duplicates = [];
|
|
546
|
+
for (const n of numbers) {
|
|
547
|
+
if (seen.has(n)) duplicates.push(n);
|
|
548
|
+
seen.add(n);
|
|
549
|
+
}
|
|
550
|
+
const unique = [...new Set(duplicates)];
|
|
551
|
+
return result(30, unique.length === 0, 'ERROR',
|
|
552
|
+
unique.length === 0
|
|
553
|
+
? `All spec numbers are unique`
|
|
554
|
+
: `Duplicate spec numbers: ${unique.join(', ')}`,
|
|
555
|
+
unique.length > 0 ? unique : undefined,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Rule 31: Every endpoint in table has response example in contracts.
|
|
561
|
+
*/
|
|
562
|
+
export function rule31_endpointsHaveResponseExample(contracts) {
|
|
563
|
+
if (contracts.endpoints.length === 0) {
|
|
564
|
+
return result(31, true, 'WARNING', `No endpoints — rule not applicable`);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const missing = [];
|
|
568
|
+
for (const ep of contracts.endpoints) {
|
|
569
|
+
const route = (ep.rota || ep.route || '').replace(/`/g, '').trim();
|
|
570
|
+
if (!route) continue;
|
|
571
|
+
// Check if the route path appears in a code block or example section
|
|
572
|
+
const routeEscaped = route.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
573
|
+
const routeRe = new RegExp(routeEscaped, 'i');
|
|
574
|
+
// Look beyond the endpoint table itself — in code blocks or example sections
|
|
575
|
+
if (!routeRe.test(contracts.examplesContent || '')) {
|
|
576
|
+
missing.push(route);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return result(31, missing.length === 0, 'WARNING',
|
|
581
|
+
missing.length === 0
|
|
582
|
+
? `All ${contracts.endpoints.length} endpoints have response examples`
|
|
583
|
+
: `Endpoints without response examples: ${missing.join(', ')}`,
|
|
584
|
+
missing.length > 0 ? missing : undefined,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Rule 32: No duplicate scenario headings in same spec.
|
|
590
|
+
*/
|
|
591
|
+
export function rule32_noDuplicateScenarioHeadings(scenarios) {
|
|
592
|
+
const titles = scenarios.scenarios.map(s => s.title);
|
|
593
|
+
const seen = new Set();
|
|
594
|
+
const duplicates = [];
|
|
595
|
+
for (const t of titles) {
|
|
596
|
+
if (seen.has(t)) duplicates.push(t);
|
|
597
|
+
seen.add(t);
|
|
598
|
+
}
|
|
599
|
+
const unique = [...new Set(duplicates)];
|
|
600
|
+
return result(32, unique.length === 0, 'ERROR',
|
|
601
|
+
unique.length === 0
|
|
602
|
+
? `No duplicate scenario headings`
|
|
603
|
+
: `Duplicate scenario headings: ${unique.join(', ')}`,
|
|
604
|
+
unique.length > 0 ? unique : undefined,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// =============================================================================
|
|
609
|
+
// RULE 33: Exit gate — open decisions in architecture.md
|
|
610
|
+
// =============================================================================
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Rule 33: No blocking open decisions in architecture.md.
|
|
614
|
+
* @param {string|null} architectureContent - null if file doesn't exist
|
|
615
|
+
*/
|
|
616
|
+
export function rule33_openDecisions(architectureContent) {
|
|
617
|
+
if (!architectureContent) {
|
|
618
|
+
return result(33, true, 'WARNING', `docs/architecture.md not found — skipping decision check`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const lines = architectureContent.split('\n');
|
|
622
|
+
const blocking = [];
|
|
623
|
+
|
|
624
|
+
for (const line of lines) {
|
|
625
|
+
if (/\|\s*aberta\s*\|/i.test(line) && /\/spec|\/dev/i.test(line)) {
|
|
626
|
+
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
|
|
627
|
+
blocking.push(cells[1] || line.trim());
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return result(33, blocking.length === 0, 'WARNING',
|
|
632
|
+
blocking.length === 0
|
|
633
|
+
? `No blocking open decisions in architecture.md`
|
|
634
|
+
: `${blocking.length} open decision(s) in architecture.md. Review before proceeding — some may block spec or dev: ${blocking.join('; ')}`,
|
|
635
|
+
blocking.length > 0 ? blocking : undefined,
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// =============================================================================
|
|
640
|
+
// EXPORTS: Preset artifacts config (used by validate.js)
|
|
641
|
+
// =============================================================================
|
|
642
|
+
|
|
643
|
+
export { PRESET_ARTIFACTS };
|