@haposoft/cafekit 0.15.3 → 0.16.0-rc.2
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 +4 -2
- package/bin/__tests__/codex-hooks.test.js +140 -0
- package/bin/__tests__/codex-native.test.js +2 -2
- package/bin/__tests__/develop-contract.test.js +1120 -0
- package/bin/__tests__/installer-safety.test.js +510 -2
- package/bin/__tests__/package-inventory.test.js +258 -0
- package/bin/__tests__/secret-scanner.test.js +157 -0
- package/bin/__tests__/validator-grounding.test.js +713 -0
- package/bin/install.js +5 -1
- package/bin/lib/codex-install.js +21 -6
- package/bin/lib/context.js +17 -6
- package/bin/lib/copy-utils.js +31 -0
- package/bin/lib/instruction-blocks.js +85 -0
- package/bin/lib/managed-writer.js +4 -1
- package/bin/lib/opencode-install.js +75 -30
- package/bin/phases/claude-runtime.js +103 -4
- package/bin/phases/codex-runtime.js +9 -5
- package/bin/phases/post-install.js +29 -22
- package/bin/phases/root-config.js +89 -10
- package/package.json +14 -4
- package/src/claude/CLAUDE.md +6 -122
- package/src/claude/agents/code-auditor.md +28 -22
- package/src/claude/agents/debugger.md +3 -3
- package/src/claude/agents/deployer.md +1 -1
- package/src/claude/agents/{god-developer.md → implementer.md} +2 -2
- package/src/claude/agents/project-manager.md +1 -1
- package/src/claude/agents/spec-maker.md +2 -2
- package/src/claude/hooks/__tests__/spec-gate.test.js +356 -5
- package/src/claude/hooks/agent.cjs +7 -14
- package/src/claude/hooks/rules.cjs +33 -53
- package/src/claude/hooks/spec-gate.cjs +66 -21
- package/src/claude/hooks/spec-state.cjs +5 -0
- package/src/claude/migration-manifest.json +9 -2
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +1 -1
- package/src/claude/scripts/scan-staged-secrets.cjs +170 -0
- package/src/claude/scripts/spec-ground.cjs +189 -83
- package/src/claude/scripts/validate-spec-output.cjs +294 -30
- package/src/claude/scripts/workflow-policy.cjs +609 -0
- package/src/claude/skills/ai-multimodal/SKILL.md +2 -2
- package/src/claude/skills/ai-multimodal/references/audio-processing.md +6 -6
- package/src/claude/skills/ai-multimodal/references/video-analysis.md +1 -1
- package/src/claude/skills/backend-development/SKILL.md +4 -4
- package/src/claude/skills/backend-development/references/backend-architecture.md +1 -1
- package/src/claude/skills/backend-development/references/backend-devops.md +2 -2
- package/src/claude/skills/backend-development/references/backend-mindset.md +3 -3
- package/src/claude/skills/backend-development/references/backend-performance.md +5 -5
- package/src/claude/skills/backend-development/references/backend-security.md +55 -3
- package/src/claude/skills/backend-development/references/backend-technologies.md +5 -5
- package/src/claude/skills/backend-development/references/backend-testing.md +2 -2
- package/src/claude/skills/brainstorm/SKILL.md +2 -3
- package/src/claude/skills/code-review/SKILL.md +12 -8
- package/src/claude/skills/code-review/references/verification-gate.md +4 -4
- package/src/claude/skills/debug/SKILL.md +2 -0
- package/src/claude/skills/develop/SKILL.md +100 -52
- package/src/claude/skills/develop/references/parallel-waves.md +105 -26
- package/src/claude/skills/develop/references/quality-gate.md +78 -38
- package/src/claude/skills/develop/references/subagent-patterns.md +2 -2
- package/src/claude/skills/git/SKILL.md +44 -50
- package/src/claude/skills/hotfix/SKILL.md +10 -8
- package/src/claude/skills/hotfix/references/parallel-patterns.md +2 -2
- package/src/claude/skills/hotfix/references/review-cycle.md +26 -21
- package/src/claude/skills/inspect/SKILL.md +12 -30
- package/src/claude/skills/mobile-development/SKILL.md +12 -12
- package/src/claude/skills/mobile-development/references/mobile-android.md +7 -7
- package/src/claude/skills/mobile-development/references/mobile-best-practices.md +22 -23
- package/src/claude/skills/mobile-development/references/mobile-frameworks.md +13 -13
- package/src/claude/skills/mobile-development/references/mobile-ios.md +1 -1
- package/src/claude/skills/mobile-development/references/mobile-mindset.md +17 -19
- package/src/claude/skills/question/SKILL.md +13 -13
- package/src/claude/skills/research/SKILL.md +2 -2
- package/src/claude/skills/specs/SKILL.md +32 -15
- package/src/claude/skills/specs/references/review.md +3 -3
- package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
- package/src/claude/skills/specs/templates/spec-state.json +7 -3
- package/src/claude/skills/sync/SKILL.md +26 -1
- package/src/claude/skills/sync/references/sync-protocols.md +3 -3
- package/src/claude/skills/test/SKILL.md +12 -4
- package/src/claude/skills/test/references/failure-triage.md +17 -17
- package/src/claude/skills/test/references/test-memory.md +2 -2
- package/src/codex/AGENTS.md +16 -24
- package/src/codex/hooks/lib/spec-receipt.cjs +57 -4
- package/src/codex/hooks/rules.cjs +27 -26
- package/src/codex/hooks/spec-gate.cjs +19 -8
- package/src/codex/hooks/spec-state.cjs +10 -1
- package/src/common/AGENTS.md +43 -0
- package/src/opencode/AGENTS.md +21 -121
- package/src/opencode/plugins/rules.ts +26 -34
- package/src/opencode/plugins/spec-gate.ts +283 -0
- package/src/claude/skills/inspect/references/external-gemini-inspection.md +0 -169
|
@@ -20,6 +20,9 @@ const REQUIRED_REGISTRY_KEYS = [
|
|
|
20
20
|
'completed_at',
|
|
21
21
|
'last_updated_at',
|
|
22
22
|
];
|
|
23
|
+
const APPROVAL_SCHEMA_VERSION = '2.0';
|
|
24
|
+
const LEGACY_APPROVAL_ERROR =
|
|
25
|
+
`Legacy approval field "approved" detected. Migration required: replace "approved" with "agent_validated" and "user_approved" per schema v${APPROVAL_SCHEMA_VERSION} (schema_version: "${APPROVAL_SCHEMA_VERSION}"). See spec-state.json template. Refusing to infer user approval.`;
|
|
23
26
|
|
|
24
27
|
function usage() {
|
|
25
28
|
console.error('Usage: node .claude/scripts/validate-spec-output.cjs specs/<feature>');
|
|
@@ -63,6 +66,17 @@ function hasHeading(content, heading) {
|
|
|
63
66
|
return new RegExp(`^##\\s+${escaped}\\s*$`, 'm').test(content);
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
function extractSection(content, heading) {
|
|
70
|
+
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
71
|
+
const re = new RegExp(`^##\\s+${escaped}\\s*$`, 'im');
|
|
72
|
+
const match = content.match(re);
|
|
73
|
+
if (!match) return null;
|
|
74
|
+
const start = match.index + match[0].length;
|
|
75
|
+
const after = content.slice(start);
|
|
76
|
+
const next = after.match(/^##\s+/m);
|
|
77
|
+
return next ? after.slice(0, next.index) : after;
|
|
78
|
+
}
|
|
79
|
+
|
|
66
80
|
function extractRequirementIds(requirementsText) {
|
|
67
81
|
const ids = new Set();
|
|
68
82
|
const headingRe = /^#{2,4}\s+(?:(?:Requirement)\s+)?((?:REQ-\d+)|(?:R\d+))\b/gim;
|
|
@@ -88,10 +102,8 @@ function extractRequirementIds(requirementsText) {
|
|
|
88
102
|
/**
|
|
89
103
|
* Extract sub-criteria IDs (e.g. R3.4) ONLY when requirements.md declares them
|
|
90
104
|
* as explicit literals — bold `**R3.4**` or a line-leading `R3.4`. Specs that
|
|
91
|
-
* write acceptance criteria as a plain numbered list
|
|
92
|
-
*
|
|
93
|
-
* for them (no behaviour change). This is a progressive check: it rewards an
|
|
94
|
-
* unambiguous format, it never penalises the legacy one.
|
|
105
|
+
* write acceptance criteria as a plain numbered list declare no such literals.
|
|
106
|
+
* Every declared literal must have an explicit numeric task mapping.
|
|
95
107
|
*/
|
|
96
108
|
function extractSubCriteriaIds(requirementsText) {
|
|
97
109
|
const ids = new Set();
|
|
@@ -125,11 +137,39 @@ function validateTaskSections(taskPath, content, errors) {
|
|
|
125
137
|
if (!hasSteps) errors.push(`${taskPath}: missing Steps/Implementation Steps`);
|
|
126
138
|
if (!hasRequirements) errors.push(`${taskPath}: missing Requirements mapping`);
|
|
127
139
|
if (!hasRelatedFiles) errors.push(`${taskPath}: missing Related Files`);
|
|
140
|
+
else if (relatedFilesSection(content).rows.length === 0) errors.push(`${taskPath}: Related Files section must not be empty`);
|
|
128
141
|
if (!hasCompletionCriteria) errors.push(`${taskPath}: missing Completion Criteria`);
|
|
129
142
|
if (!hasEvidence) errors.push(`${taskPath}: missing Evidence or task test plan`);
|
|
130
143
|
if (!hasRiskAssessment) errors.push(`${taskPath}: missing Risk Assessment`);
|
|
131
|
-
if (hasEvidence
|
|
132
|
-
|
|
144
|
+
if (hasEvidence) {
|
|
145
|
+
const evidenceSection = extractSection(content, 'Evidence')
|
|
146
|
+
|| extractSection(content, 'Task Test Plan & Verification Evidence')
|
|
147
|
+
|| extractSection(content, 'Verification & Evidence')
|
|
148
|
+
|| '';
|
|
149
|
+
if (!/Runtime reachability verification/i.test(evidenceSection)) {
|
|
150
|
+
errors.push(`${taskPath}: missing Runtime reachability verification`);
|
|
151
|
+
} else {
|
|
152
|
+
// Reachability must prove a concrete path/anchor, not just the phrase.
|
|
153
|
+
// Require at least one of: backtick file path, Entrypoint/caller line, or anchor.
|
|
154
|
+
const hasConcreteAnchor =
|
|
155
|
+
/`[^`]*\.[a-z]{1,4}`/i.test(evidenceSection) ||
|
|
156
|
+
/Entrypoint\/caller\s*:/i.test(evidenceSection) ||
|
|
157
|
+
/Route is registered|import.*from|anchor:\s*`/i.test(evidenceSection);
|
|
158
|
+
if (!hasConcreteAnchor) {
|
|
159
|
+
errors.push(`${taskPath}: Runtime reachability verification must reference a concrete file path or anchor (e.g. \`src/...\` or Entrypoint/caller)`);
|
|
160
|
+
} else {
|
|
161
|
+
// Cross-check against declared Related Files paths
|
|
162
|
+
const relatedPaths = new Set(relatedFilesSection(content).rows.map((r) => r.path));
|
|
163
|
+
const referencedPaths = [...evidenceSection.matchAll(/`([^`]*\.[a-z0-9]{1,4})`/gi)].map((m) => m[1].replace(/^\.\//, ''));
|
|
164
|
+
// At least one referenced path should correspond to a declared Related Files entry (or be clearly external)
|
|
165
|
+
if (referencedPaths.length > 0 && relatedPaths.size > 0) {
|
|
166
|
+
const overlaps = referencedPaths.some((p) => [...relatedPaths].some((rp) => p === rp || p.endsWith(rp) || rp.endsWith(p) || p.includes(rp.split('/').pop())));
|
|
167
|
+
if (!overlaps && !/Entrypoint\/caller/i.test(evidenceSection)) {
|
|
168
|
+
errors.push(`${taskPath}: Runtime reachability verification references no Related Files path — must anchor to a declared file`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
133
173
|
}
|
|
134
174
|
}
|
|
135
175
|
|
|
@@ -227,6 +267,222 @@ function extractTaskContracts(taskText) {
|
|
|
227
267
|
return { names, blocks, firstBlock };
|
|
228
268
|
}
|
|
229
269
|
|
|
270
|
+
const RELATED_FILE_ACTIONS = new Set(['create', 'modify', 'delete', 'read']);
|
|
271
|
+
|
|
272
|
+
function relatedFilesSection(content) {
|
|
273
|
+
const lines = content.split('\n');
|
|
274
|
+
const headingIndex = lines.findIndex((line) => /^##+\s+Related Files\s*$/i.test(line));
|
|
275
|
+
if (headingIndex < 0) return { present: false, rows: [] };
|
|
276
|
+
const rows = [];
|
|
277
|
+
for (let index = headingIndex + 1; index < lines.length; index += 1) {
|
|
278
|
+
const line = lines[index];
|
|
279
|
+
if (/^##+\s+/.test(line)) break;
|
|
280
|
+
if (!line.trim().startsWith('|')) continue;
|
|
281
|
+
const cells = line.split('|').map((cell) => cell.trim());
|
|
282
|
+
const filePath = (cells[1] || '').replace(/`/g, '').trim();
|
|
283
|
+
const action = (cells[2] || '').replace(/`/g, '').trim().toLowerCase();
|
|
284
|
+
if (!filePath || filePath.toLowerCase() === 'path' || /^-+$/.test(filePath)) continue;
|
|
285
|
+
rows.push({ path: filePath, action });
|
|
286
|
+
}
|
|
287
|
+
return { present: true, rows };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function unsafeRelatedPath(filePath) {
|
|
291
|
+
return path.isAbsolute(filePath)
|
|
292
|
+
|| /^[A-Za-z]:[\\/]/.test(filePath)
|
|
293
|
+
|| filePath.split(/[\\/]+/).includes('..');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function validateRelatedFiles(taskPath, content, errors) {
|
|
297
|
+
const section = relatedFilesSection(content);
|
|
298
|
+
if (!section.present || section.rows.length === 0) return;
|
|
299
|
+
for (const row of section.rows) {
|
|
300
|
+
if (!RELATED_FILE_ACTIONS.has(row.action)) {
|
|
301
|
+
errors.push(`${taskPath}: unsupported Related Files action "${row.action}" for ${row.path}`);
|
|
302
|
+
}
|
|
303
|
+
if (unsafeRelatedPath(row.path)) {
|
|
304
|
+
errors.push(`${taskPath}: Related Files path must be relative and stay within work root: ${row.path}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function validateStateTransitions(spec, errors) {
|
|
310
|
+
// Approval schema v2: generated, agent_validated, user_approved independent.
|
|
311
|
+
// Legacy "approved" field is rejected fail-closed with migration guidance.
|
|
312
|
+
const approvals = spec.approvals || {};
|
|
313
|
+
let hasLegacy = false;
|
|
314
|
+
for (const [stage, approval] of Object.entries(approvals)) {
|
|
315
|
+
if (!approval || typeof approval !== 'object') {
|
|
316
|
+
errors.push(`spec.json.approvals.${stage}: must be an object`);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if ('approved' in approval) {
|
|
320
|
+
errors.push(`spec.json.approvals.${stage}: ${LEGACY_APPROVAL_ERROR}`);
|
|
321
|
+
hasLegacy = true;
|
|
322
|
+
}
|
|
323
|
+
// Unsupported/ambiguous: if schema_version missing when approvals use v2 shape, warn but not fail? B2 says fail closed.
|
|
324
|
+
// We fail closed when version is unsupported or ambiguous and ready_for_implementation is true.
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Schema version validation
|
|
328
|
+
if (spec.schema_version !== undefined && spec.schema_version !== APPROVAL_SCHEMA_VERSION) {
|
|
329
|
+
errors.push(`spec.json.schema_version: unsupported "${spec.schema_version}", expected "${APPROVAL_SCHEMA_VERSION}" — migration required`);
|
|
330
|
+
}
|
|
331
|
+
if (spec.approval_schema_version !== undefined && spec.approval_schema_version !== APPROVAL_SCHEMA_VERSION) {
|
|
332
|
+
errors.push(`spec.json.approval_schema_version: unsupported "${spec.approval_schema_version}", expected "${APPROVAL_SCHEMA_VERSION}"`);
|
|
333
|
+
}
|
|
334
|
+
// If approvals use v2 fields but no schema_version, treat as ambiguous — fail closed when ready flag is set
|
|
335
|
+
const usesV2 = Object.values(approvals).some((a) => a && typeof a === 'object' && ('agent_validated' in a || 'user_approved' in a));
|
|
336
|
+
if (usesV2 && spec.schema_version === undefined && spec.approval_schema_version === undefined) {
|
|
337
|
+
// Only warn now; will be error if ready_for_implementation is true (checked below)
|
|
338
|
+
// Provide guidance
|
|
339
|
+
if (spec.ready_for_implementation === true) {
|
|
340
|
+
errors.push(`spec.json.schema_version: missing — spec uses v2 approvals (agent_validated/user_approved) but has no schema_version "${APPROVAL_SCHEMA_VERSION}"`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (spec.validation?.status === 'completed' && !spec.timestamps?.validation_done) {
|
|
345
|
+
errors.push('spec.json.validation: completed transition requires timestamps.validation_done');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (spec.ready_for_implementation === true) {
|
|
349
|
+
if (hasLegacy) {
|
|
350
|
+
errors.push(`spec.json.ready_for_implementation: cannot be true with legacy approval fields — migrate to agent_validated/user_approved`);
|
|
351
|
+
}
|
|
352
|
+
for (const stage of ['requirements', 'design', 'tasks']) {
|
|
353
|
+
const approval = spec.approvals?.[stage];
|
|
354
|
+
if (!approval || approval?.generated !== true || approval?.agent_validated !== true || approval?.user_approved !== true) {
|
|
355
|
+
errors.push(`spec.json.ready_for_implementation: requires generated and agent_validated and user_approved ${stage} evidence (v2)`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (spec.validation?.status === 'in_progress' || spec.validation?.status === 'not-run') {
|
|
359
|
+
errors.push('spec.json.ready_for_implementation: cannot be true while validation evidence is incomplete');
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function validateDependencyTopology(spec, taskFiles, registry, taskRecords, errors) {
|
|
365
|
+
const dependencies = new Map(taskFiles.map((taskFile) => [taskFile, new Set(registry?.[taskFile]?.dependencies || [])]));
|
|
366
|
+
if (dependencies.size === 0) return;
|
|
367
|
+
|
|
368
|
+
const visiting = new Set();
|
|
369
|
+
const visited = new Set();
|
|
370
|
+
function visit(taskFile, stack = []) {
|
|
371
|
+
if (visiting.has(taskFile)) {
|
|
372
|
+
const cycleStart = stack.indexOf(taskFile);
|
|
373
|
+
errors.push(`spec.json.task_registry.dependencies: dependency cycle detected (${stack.slice(cycleStart).concat(taskFile).join(' -> ')})`);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (visited.has(taskFile)) return;
|
|
377
|
+
visiting.add(taskFile);
|
|
378
|
+
for (const dependency of dependencies.get(taskFile) || []) visit(dependency, [...stack, taskFile]);
|
|
379
|
+
visiting.delete(taskFile);
|
|
380
|
+
visited.add(taskFile);
|
|
381
|
+
}
|
|
382
|
+
for (const taskFile of taskFiles) visit(taskFile);
|
|
383
|
+
|
|
384
|
+
const roots = taskFiles.filter((taskFile) => (dependencies.get(taskFile) || new Set()).size === 0);
|
|
385
|
+
const reachable = new Set();
|
|
386
|
+
const dependents = new Map(taskFiles.map((taskFile) => [taskFile, []]));
|
|
387
|
+
for (const [taskFile, deps] of dependencies) {
|
|
388
|
+
for (const dependency of deps) dependents.get(dependency)?.push(taskFile);
|
|
389
|
+
}
|
|
390
|
+
const queue = [...roots];
|
|
391
|
+
while (queue.length > 0) {
|
|
392
|
+
const taskFile = queue.shift();
|
|
393
|
+
if (reachable.has(taskFile)) continue;
|
|
394
|
+
reachable.add(taskFile);
|
|
395
|
+
queue.push(...(dependents.get(taskFile) || []));
|
|
396
|
+
}
|
|
397
|
+
for (const taskFile of taskFiles) {
|
|
398
|
+
if (!reachable.has(taskFile)) errors.push(`spec.json.task_registry.${taskFile}: orphan or unreachable task in dependency graph`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function dependsOn(taskFile, ancestor, seen = new Set()) {
|
|
402
|
+
if (taskFile === ancestor) return true;
|
|
403
|
+
if (seen.has(taskFile)) return false;
|
|
404
|
+
seen.add(taskFile);
|
|
405
|
+
return [...(dependencies.get(taskFile) || [])].some((dependency) => dependsOn(dependency, ancestor, seen));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const producers = new Map();
|
|
409
|
+
for (const [taskFile, record] of taskRecords) {
|
|
410
|
+
for (const [index, row] of record.rows.entries()) {
|
|
411
|
+
if (row.action !== 'create') continue;
|
|
412
|
+
const prior = producers.get(row.path);
|
|
413
|
+
if (prior && prior.taskFile !== taskFile) {
|
|
414
|
+
errors.push(`${taskFile}: Related Files path ${row.path} is created by multiple tasks (${prior.taskFile}, ${taskFile})`);
|
|
415
|
+
} else {
|
|
416
|
+
producers.set(row.path, { taskFile, index });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// Lifecycle ordering: Create must precede Modify/Delete/Read within task for same path
|
|
421
|
+
for (const [taskFile, record] of taskRecords) {
|
|
422
|
+
const createIndexByPath = new Map();
|
|
423
|
+
for (const [index, row] of record.rows.entries()) {
|
|
424
|
+
if (row.action === 'create' && !createIndexByPath.has(row.path)) {
|
|
425
|
+
createIndexByPath.set(row.path, index);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
for (const [index, row] of record.rows.entries()) {
|
|
429
|
+
if (!['modify', 'delete', 'read'].includes(row.action)) continue;
|
|
430
|
+
if (!createIndexByPath.has(row.path)) continue;
|
|
431
|
+
const createIdx = createIndexByPath.get(row.path);
|
|
432
|
+
if (createIdx > index) {
|
|
433
|
+
errors.push(`${taskFile}: Create must precede ${row.action} for ${row.path} within Related Files order`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// Cross-task lifecycle: Modify/Delete/Read of a path created by another task must depend on creator
|
|
438
|
+
for (const [taskFile, record] of taskRecords) {
|
|
439
|
+
for (const [index, row] of record.rows.entries()) {
|
|
440
|
+
if (!['modify', 'delete', 'read'].includes(row.action)) continue;
|
|
441
|
+
const producer = producers.get(row.path);
|
|
442
|
+
if (!producer) continue;
|
|
443
|
+
if (producer.taskFile === taskFile) continue;
|
|
444
|
+
if (!dependsOn(taskFile, producer.taskFile)) {
|
|
445
|
+
errors.push(`${taskFile}: ${row.action} of ${row.path} must depend on creator ${producer.taskFile}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Existing implementation: Modify/Delete on non-produced paths must have a Read before them.
|
|
451
|
+
// This ensures lifecycle Read is not skipped for existing files.
|
|
452
|
+
for (const [taskFile, record] of taskRecords) {
|
|
453
|
+
for (const [index, row] of record.rows.entries()) {
|
|
454
|
+
if (!['modify', 'delete'].includes(row.action)) continue;
|
|
455
|
+
if (producers.has(row.path)) continue; // produced by spec, not existing
|
|
456
|
+
// Check if there's a Read for same path preceding this Modify/Delete
|
|
457
|
+
let hasPrecedingRead = false;
|
|
458
|
+
// Same task earlier row
|
|
459
|
+
for (let i = 0; i < index; i += 1) {
|
|
460
|
+
const r = record.rows[i];
|
|
461
|
+
if (r.action === 'read' && r.path === row.path) { hasPrecedingRead = true; break; }
|
|
462
|
+
}
|
|
463
|
+
if (hasPrecedingRead) continue;
|
|
464
|
+
// In any dependency ancestor
|
|
465
|
+
const deps = dependencies.get(taskFile) || new Set();
|
|
466
|
+
// Walk transitive deps for Read
|
|
467
|
+
const stack = [...deps];
|
|
468
|
+
const seen = new Set();
|
|
469
|
+
while (stack.length > 0) {
|
|
470
|
+
const dep = stack.pop();
|
|
471
|
+
if (seen.has(dep)) continue;
|
|
472
|
+
seen.add(dep);
|
|
473
|
+
const depRecord = taskRecords.get(dep);
|
|
474
|
+
if (depRecord && depRecord.rows.some((r) => r.action === 'read' && r.path === row.path)) {
|
|
475
|
+
hasPrecedingRead = true; break;
|
|
476
|
+
}
|
|
477
|
+
for (const d of dependencies.get(dep) || []) stack.push(d);
|
|
478
|
+
}
|
|
479
|
+
if (!hasPrecedingRead) {
|
|
480
|
+
errors.push(`${taskFile}: ${row.action} of ${row.path} targets existing implementation but has no preceding Read — add a Read for ${row.path} before modifying it (lifecycle ordering)`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
230
486
|
function validateSpec(specDir) {
|
|
231
487
|
const errors = [];
|
|
232
488
|
const warnings = [];
|
|
@@ -256,6 +512,7 @@ function validateSpec(specDir) {
|
|
|
256
512
|
}
|
|
257
513
|
|
|
258
514
|
validateTimestamps(spec, errors);
|
|
515
|
+
validateStateTransitions(spec, errors);
|
|
259
516
|
|
|
260
517
|
const taskFiles = listTaskFiles(specDir);
|
|
261
518
|
const taskFileSet = new Set(taskFiles);
|
|
@@ -361,14 +618,15 @@ function validateSpec(specDir) {
|
|
|
361
618
|
|
|
362
619
|
const coveredRequirementIds = new Set();
|
|
363
620
|
const coveredSubCriteriaIds = new Set();
|
|
621
|
+
const taskRecords = new Map();
|
|
364
622
|
// Cross-layer contract defs (opt-in): empty unless design.md uses
|
|
365
623
|
// <!-- contract:NAME --> markers, so legacy specs are unaffected.
|
|
366
624
|
const contractDefs = fs.existsSync(designPath)
|
|
367
625
|
? extractContractDefs(fs.readFileSync(designPath, 'utf8'))
|
|
368
626
|
: new Map();
|
|
369
627
|
if (taskFiles.length >= 5 && contractDefs.size === 0) {
|
|
370
|
-
|
|
371
|
-
'design.md
|
|
628
|
+
errors.push(
|
|
629
|
+
'design.md: 5+ task spec requires contract blocks for BE/FE shared shapes (<!-- contract:NAME -->) — fail closed, not warning',
|
|
372
630
|
);
|
|
373
631
|
}
|
|
374
632
|
for (const taskFile of taskFiles) {
|
|
@@ -376,16 +634,20 @@ function validateSpec(specDir) {
|
|
|
376
634
|
const content = fs.readFileSync(fullPath, 'utf8');
|
|
377
635
|
validateTaskSections(taskFile, content, errors);
|
|
378
636
|
validateTaskPlaceholders(taskFile, content, errors, warnings);
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
637
|
+
validateRelatedFiles(taskFile, content, errors);
|
|
638
|
+
taskRecords.set(taskFile, { rows: relatedFilesSection(content).rows });
|
|
639
|
+
|
|
640
|
+
// Requirement traceability: only structured _Requirements: ..._ mappings inside Steps/Requirements sections count.
|
|
641
|
+
// Incidental mentions elsewhere (prose Context, Constraints) must not be counted.
|
|
642
|
+
const stepsSection = extractSection(content, 'Steps') || extractSection(content, 'Implementation Steps') || '';
|
|
643
|
+
const reqSectionInTask = extractSection(content, 'Requirements') || '';
|
|
644
|
+
const mappingSource = `${stepsSection}\n${reqSectionInTask}`;
|
|
645
|
+
// Also consider inline _Requirements: inside Steps bullet lines that may be outside section extraction edge cases,
|
|
646
|
+
// so fallback to whole content scanning only if sections are null (e.g., malformed heading). But prefer scoped.
|
|
647
|
+
const effectiveMappingSource = mappingSource.trim() ? mappingSource : content;
|
|
387
648
|
const numericMappingRe = /_Requirements:\s*([^_\n]+)_/gi;
|
|
388
|
-
|
|
649
|
+
let match;
|
|
650
|
+
while ((match = numericMappingRe.exec(effectiveMappingSource)) !== null) {
|
|
389
651
|
for (const token of match[1].split(',')) {
|
|
390
652
|
const trimmed = token.trim();
|
|
391
653
|
const major = trimmed.match(/^(\d+)(?:\.\d+)?$/);
|
|
@@ -397,12 +659,16 @@ function validateSpec(specDir) {
|
|
|
397
659
|
}
|
|
398
660
|
|
|
399
661
|
// Cross-layer contract check (opt-in via design.md markers). When a task
|
|
400
|
-
// claims `Contracts: NAME`,
|
|
401
|
-
// local copy
|
|
402
|
-
//
|
|
403
|
-
//
|
|
662
|
+
// claims `Contracts: NAME`, design.md must define the name and the task's
|
|
663
|
+
// local copy must match the canonical definition byte-for-byte (after
|
|
664
|
+
// whitespace normalization). This catches BE/FE drift like user_name vs
|
|
665
|
+
// userName before integration, and rejects declarations with no canonical
|
|
666
|
+
// source instead of silently accepting an orphan contract.
|
|
667
|
+
const { names, blocks, firstBlock } = extractTaskContracts(content);
|
|
668
|
+
if (names.length > 0 && contractDefs.size === 0) {
|
|
669
|
+
errors.push(`${taskFile}: declares contract(s) but design.md defines no canonical contract blocks`);
|
|
670
|
+
}
|
|
404
671
|
if (contractDefs.size > 0) {
|
|
405
|
-
const { names, blocks, firstBlock } = extractTaskContracts(content);
|
|
406
672
|
for (const name of names) {
|
|
407
673
|
if (!contractDefs.has(name)) {
|
|
408
674
|
errors.push(`${taskFile}: declares unknown contract "${name}" (not defined in design.md)`);
|
|
@@ -414,9 +680,7 @@ function validateSpec(specDir) {
|
|
|
414
680
|
? blocks.get(name)
|
|
415
681
|
: (names.length === 1 ? firstBlock : null);
|
|
416
682
|
if (localCopy === null) {
|
|
417
|
-
|
|
418
|
-
errors.push(`${taskFile}: contract "${name}" has no <!-- contract:${name} --> tagged block (multi-contract tasks must tag each copy)`);
|
|
419
|
-
}
|
|
683
|
+
errors.push(`${taskFile}: contract "${name}" is missing a copied canonical block`);
|
|
420
684
|
continue;
|
|
421
685
|
}
|
|
422
686
|
if (localCopy !== contractDefs.get(name)) {
|
|
@@ -432,17 +696,17 @@ function validateSpec(specDir) {
|
|
|
432
696
|
}
|
|
433
697
|
}
|
|
434
698
|
|
|
699
|
+
validateDependencyTopology(spec, taskFiles, spec.task_registry, taskRecords, errors);
|
|
700
|
+
|
|
435
701
|
for (const requirementId of requirementIds) {
|
|
436
702
|
if (!coveredRequirementIds.has(requirementId)) {
|
|
437
703
|
errors.push(`requirements.md:${requirementId}: not covered by any task`);
|
|
438
704
|
}
|
|
439
705
|
}
|
|
440
706
|
|
|
441
|
-
// Per-criterion coverage:
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
// legacy coarse format (major-only) — skip silently to avoid false failures.
|
|
445
|
-
if (subCriteriaIds.length > 0 && coveredSubCriteriaIds.size > 0) {
|
|
707
|
+
// Per-criterion coverage: every explicit R{N}.{M} literal in requirements.md
|
|
708
|
+
// must appear in a numeric `_Requirements: x.y_` task mapping.
|
|
709
|
+
if (subCriteriaIds.length > 0) {
|
|
446
710
|
for (const subId of subCriteriaIds) {
|
|
447
711
|
if (!coveredSubCriteriaIds.has(subId)) {
|
|
448
712
|
errors.push(`requirements.md:${subId}: acceptance criterion not covered by any task`);
|