@haposoft/cafekit 0.16.0-rc.1 → 0.16.0-rc.3
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/bin/__tests__/codex-hooks.test.js +355 -12
- package/bin/__tests__/codex-native.test.js +1 -1
- package/bin/__tests__/develop-contract.test.js +1374 -78
- package/bin/__tests__/installer-safety.test.js +43 -29
- package/bin/__tests__/opencode-spec-gate.test.js +791 -0
- package/bin/__tests__/package-inventory.test.js +157 -0
- package/bin/__tests__/validator-grounding.test.js +707 -2
- package/package.json +1 -1
- package/src/claude/agents/code-auditor.md +8 -1
- package/src/claude/agents/spec-maker.md +107 -200
- package/src/claude/hooks/__tests__/completion-authority.test.js +720 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +511 -38
- package/src/claude/hooks/completion-authority-check.cjs +307 -0
- package/src/claude/hooks/completion-authority-state.cjs +525 -0
- package/src/claude/hooks/completion-authority.cjs +148 -0
- package/src/claude/hooks/session.cjs +33 -19
- package/src/claude/hooks/spec-gate.cjs +173 -61
- package/src/claude/hooks/spec-state.cjs +104 -45
- package/src/claude/migration-manifest.json +6 -1
- package/src/claude/scripts/provenance.cjs +315 -0
- package/src/claude/scripts/spec-resolver.cjs +450 -0
- package/src/claude/scripts/spec-scaffold.cjs +716 -41
- package/src/claude/scripts/validate-spec-output.cjs +355 -53
- package/src/claude/scripts/workflow-policy.cjs +1529 -162
- package/src/claude/settings/settings.json +8 -0
- package/src/claude/skills/backend-development/references/backend-security.md +52 -0
- package/src/claude/skills/code-review/SKILL.md +85 -66
- package/src/claude/skills/develop/SKILL.md +153 -285
- package/src/claude/skills/develop/references/quality-gate.md +112 -124
- package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
- package/src/claude/skills/specs/SKILL.md +170 -381
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +45 -46
- package/src/claude/skills/specs/rules/tasks-generation.md +49 -20
- package/src/claude/skills/specs/templates/spec-state.json +23 -2
- package/src/claude/skills/specs/templates/task.md +20 -0
- package/src/claude/skills/sync/SKILL.md +15 -8
- package/src/claude/skills/sync/references/sync-protocols.md +4 -4
- package/src/claude/skills/test/SKILL.md +141 -167
- package/src/codex/hooks/completion-authority-check.cjs +260 -0
- package/src/codex/hooks/completion-authority-state.cjs +525 -0
- package/src/codex/hooks/completion-authority.cjs +100 -0
- package/src/codex/hooks/lib/hook-context.cjs +42 -2
- package/src/codex/hooks/lib/spec-receipt.cjs +76 -43
- package/src/codex/hooks/lib/spec-utils.cjs +53 -27
- package/src/codex/hooks/session.cjs +2 -0
- package/src/codex/hooks/spec-gate.cjs +132 -20
- package/src/codex/hooks/spec-state.cjs +56 -10
- package/src/codex/hooks.json +10 -0
- package/src/opencode/AGENTS.md +4 -2
- package/src/opencode/plugins/spec-gate.ts +296 -122
- package/src/opencode/plugins/spec-state.ts +183 -116
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
|
+
const POLICY = require('./workflow-policy.cjs');
|
|
11
12
|
|
|
12
13
|
const TASK_PATH_RE = /^tasks\/task-R\d+-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/;
|
|
13
14
|
const REQUIRED_REGISTRY_KEYS = [
|
|
@@ -23,6 +24,7 @@ const REQUIRED_REGISTRY_KEYS = [
|
|
|
23
24
|
const APPROVAL_SCHEMA_VERSION = '2.0';
|
|
24
25
|
const LEGACY_APPROVAL_ERROR =
|
|
25
26
|
`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.`;
|
|
27
|
+
const FEATURE_RECEIPT_FILE = 'feature-receipt.md';
|
|
26
28
|
|
|
27
29
|
function usage() {
|
|
28
30
|
console.error('Usage: node .claude/scripts/validate-spec-output.cjs specs/<feature>');
|
|
@@ -50,15 +52,121 @@ function readJson(filePath, errors) {
|
|
|
50
52
|
}
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
function
|
|
54
|
-
const
|
|
55
|
-
|
|
55
|
+
function isPathInside(parent, child) {
|
|
56
|
+
const relative = path.relative(path.resolve(parent), path.resolve(child));
|
|
57
|
+
return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isMissingError(error) {
|
|
61
|
+
return error && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validateSpecRoot(specDir, errors) {
|
|
65
|
+
let rootStat;
|
|
66
|
+
try {
|
|
67
|
+
rootStat = fs.lstatSync(specDir);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
errors.push(`${specDir}: spec directory does not exist (${error.message})`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (rootStat.isSymbolicLink()) {
|
|
73
|
+
errors.push(`${specDir}: spec directory cannot be a symlink`);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (!rootStat.isDirectory()) {
|
|
77
|
+
errors.push(`${specDir}: spec path must be a directory`);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
return fs.realpathSync(specDir);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
errors.push(`${specDir}: spec directory canonicalization failed (${error.message})`);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function inspectSpecArtifact(specDir, canonicalSpecDir, relativePath, label, errors, { required = false, type = 'file' } = {}) {
|
|
89
|
+
const target = path.resolve(specDir, relativePath);
|
|
90
|
+
if (!isPathInside(specDir, target)) {
|
|
91
|
+
errors.push(`${label}: path must stay inside the spec directory`);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const relative = path.relative(path.resolve(specDir), target);
|
|
96
|
+
let current = path.resolve(specDir);
|
|
97
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
98
|
+
current = path.join(current, segment);
|
|
99
|
+
let stat;
|
|
100
|
+
try {
|
|
101
|
+
stat = fs.lstatSync(current);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (isMissingError(error)) {
|
|
104
|
+
if (required) errors.push(`${label}: missing`);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
errors.push(`${label}: cannot be inspected (${error.message})`);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
if (stat.isSymbolicLink()) {
|
|
111
|
+
errors.push(`${label}: symlink is not allowed (${current})`);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let stat;
|
|
117
|
+
try {
|
|
118
|
+
stat = fs.lstatSync(target);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (isMissingError(error)) {
|
|
121
|
+
if (required) errors.push(`${label}: missing`);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
errors.push(`${label}: cannot be inspected (${error.message})`);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const typeMatches = type === 'directory' ? stat.isDirectory() : stat.isFile();
|
|
128
|
+
if (!typeMatches) {
|
|
129
|
+
errors.push(`${label}: must be a regular ${type}`);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let canonicalTarget;
|
|
134
|
+
try {
|
|
135
|
+
canonicalTarget = fs.realpathSync(target);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
errors.push(`${label}: canonicalization failed (${error.message})`);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (!isPathInside(canonicalSpecDir, canonicalTarget)) {
|
|
141
|
+
errors.push(`${label}: canonical path escapes the spec directory`);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
return { path: target, canonicalPath: canonicalTarget, stat };
|
|
145
|
+
}
|
|
56
146
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
147
|
+
function listTaskFiles(specDir, canonicalSpecDir, errors) {
|
|
148
|
+
const tasks = inspectSpecArtifact(specDir, canonicalSpecDir, 'tasks', 'tasks directory', errors, {
|
|
149
|
+
type: 'directory',
|
|
150
|
+
});
|
|
151
|
+
if (!tasks) return [];
|
|
152
|
+
|
|
153
|
+
let entries;
|
|
154
|
+
try {
|
|
155
|
+
entries = fs.readdirSync(tasks.path, { withFileTypes: true });
|
|
156
|
+
} catch (error) {
|
|
157
|
+
errors.push(`tasks directory: cannot be read (${error.message})`);
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
const files = [];
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
const relative = path.join('tasks', entry.name).split(path.sep).join('/');
|
|
163
|
+
const artifact = inspectSpecArtifact(specDir, canonicalSpecDir, relative, relative, errors);
|
|
164
|
+
if (artifact && !entry.name.endsWith('.md')) {
|
|
165
|
+
errors.push(`${relative}: unexpected task artifact; only .md task files are allowed`);
|
|
166
|
+
}
|
|
167
|
+
if (artifact && entry.name.endsWith('.md')) files.push(relative);
|
|
168
|
+
}
|
|
169
|
+
return files.sort();
|
|
62
170
|
}
|
|
63
171
|
|
|
64
172
|
function hasHeading(content, heading) {
|
|
@@ -306,7 +414,150 @@ function validateRelatedFiles(taskPath, content, errors) {
|
|
|
306
414
|
}
|
|
307
415
|
}
|
|
308
416
|
|
|
309
|
-
function
|
|
417
|
+
function validateArtifactDeclaration(taskPath, entry, errors) {
|
|
418
|
+
if (!Object.prototype.hasOwnProperty.call(entry || {}, 'artifacts')) return;
|
|
419
|
+
const artifacts = entry.artifacts;
|
|
420
|
+
if (!Array.isArray(artifacts) || artifacts.length === 0) {
|
|
421
|
+
errors.push(`spec.json.task_registry.${taskPath}.artifacts: must be a non-empty array of safe relative paths`);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const seen = new Set();
|
|
426
|
+
for (const artifact of artifacts) {
|
|
427
|
+
const invalid = typeof artifact !== 'string'
|
|
428
|
+
|| artifact.trim() === ''
|
|
429
|
+
|| artifact !== artifact.trim()
|
|
430
|
+
|| /^\{\{[^}]+\}\}$/.test(artifact)
|
|
431
|
+
|| /^(?:TBD|TODO|N\/A|NA|NONE|UNKNOWN|PENDING|PLACEHOLDER|REPLACE_ME)$/i.test(artifact)
|
|
432
|
+
|| /^(?:[A-Za-z]:[\\/]|[\\/]|https?:\/\/)/.test(artifact)
|
|
433
|
+
|| artifact.split(/[\\/]+/).includes('..')
|
|
434
|
+
|| seen.has(artifact);
|
|
435
|
+
if (invalid) {
|
|
436
|
+
errors.push(`spec.json.task_registry.${taskPath}.artifacts: each entry must be a unique safe relative path (${String(artifact)})`);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
seen.add(artifact);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function workflowPolicyContext(spec, errors) {
|
|
444
|
+
if (Object.prototype.hasOwnProperty.call(spec, 'workflow_policy')) {
|
|
445
|
+
const result = POLICY.validateWorkflowPolicySnapshot(spec.workflow_policy);
|
|
446
|
+
if (!result.valid) {
|
|
447
|
+
for (const error of result.errors) errors.push(error);
|
|
448
|
+
return { explicit: true, policy: null, bounded: false, strict: false };
|
|
449
|
+
}
|
|
450
|
+
return {
|
|
451
|
+
explicit: true,
|
|
452
|
+
policy: spec.workflow_policy,
|
|
453
|
+
bounded: spec.workflow_policy.artifact_profile === 'bounded',
|
|
454
|
+
strict: spec.workflow_policy.artifact_profile === 'strict',
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const legacyTier = spec.design_context?.execution_tier;
|
|
459
|
+
errors.push(
|
|
460
|
+
legacyTier
|
|
461
|
+
? 'spec.json.workflow_policy: missing persisted snapshot; design_context.execution_tier is a read-only legacy adapter and cannot establish workflow authority or readiness'
|
|
462
|
+
: 'spec.json.workflow_policy: missing persisted snapshot; the current spec boundary requires an explicit workflow_policy',
|
|
463
|
+
);
|
|
464
|
+
return { explicit: false, policy: null, bounded: false, strict: false };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function validateFeatureReceipt(spec, context, errors, receiptArtifact) {
|
|
468
|
+
if (!context.explicit || !context.bounded) return;
|
|
469
|
+
if (!receiptArtifact) {
|
|
470
|
+
errors.push(`${FEATURE_RECEIPT_FILE}: required by bounded Standard workflow policy`);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
let body;
|
|
474
|
+
try {
|
|
475
|
+
body = fs.readFileSync(receiptArtifact.path, 'utf8');
|
|
476
|
+
} catch (error) {
|
|
477
|
+
errors.push(`${FEATURE_RECEIPT_FILE}: cannot be read (${error.message})`);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
// A spec-ready artifact is a design boundary, not execution closeout. Keep a
|
|
481
|
+
// pending receipt explicit. Feature receipts are informational and never own
|
|
482
|
+
// completion authority; task Evidence receipts are the only closeout source.
|
|
483
|
+
if (/^\s*Verification:\s*PENDING\s*$/im.test(body)) return;
|
|
484
|
+
errors.push(`${FEATURE_RECEIPT_FILE}: informational only; keep Verification: PENDING and use task Evidence for completion authority`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const SPEC_PLACEHOLDER_RE = /\{\{[^}\n]+\}\}|<[^>\n]+>|\b(?:TBD|TODO|N\/A|NA|NONE|UNKNOWN|PENDING|PLACEHOLDER|REPLACE_ME)\b|\.{3,}/i;
|
|
488
|
+
const SPEC_TEMPLATE_GUIDANCE_RE = /\b(?:to be filled|filled by (?:the )?(?:feature owner|user)|fill (?:this|in)|replace (?:this|with)|add (?:the )?real)\b/i;
|
|
489
|
+
|
|
490
|
+
function hasSpecPlaceholders(content) {
|
|
491
|
+
return SPEC_PLACEHOLDER_RE.test(content) || SPEC_TEMPLATE_GUIDANCE_RE.test(content);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function validateBoundedReadiness(spec, requirementsText, designText, errors) {
|
|
495
|
+
if (spec.ready_for_implementation !== true) return;
|
|
496
|
+
|
|
497
|
+
const source = spec.scope_lock?.source;
|
|
498
|
+
if (typeof source !== 'string' || source.trim() === '' || hasSpecPlaceholders(source)) {
|
|
499
|
+
errors.push('spec.json.scope_lock.source: Standard bounded readiness requires a concrete scope source');
|
|
500
|
+
}
|
|
501
|
+
if (hasSpecPlaceholders(requirementsText)) {
|
|
502
|
+
errors.push('requirements.md: Standard bounded readiness rejects placeholders');
|
|
503
|
+
}
|
|
504
|
+
if (hasSpecPlaceholders(designText)) {
|
|
505
|
+
errors.push('design.md: Standard bounded readiness rejects placeholders');
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function validateResearchPointer(specDir, canonicalSpecDir, spec, errors) {
|
|
510
|
+
if (!Object.prototype.hasOwnProperty.call(spec, 'research') || spec.research === null) return;
|
|
511
|
+
const pointer = spec.research && typeof spec.research === 'object' && !Array.isArray(spec.research)
|
|
512
|
+
? spec.research.path
|
|
513
|
+
: spec.research;
|
|
514
|
+
if (
|
|
515
|
+
typeof pointer !== 'string'
|
|
516
|
+
|| pointer.trim() === ''
|
|
517
|
+
|| pointer !== pointer.trim()
|
|
518
|
+
|| path.isAbsolute(pointer)
|
|
519
|
+
|| /^[A-Za-z]:[\\/]/.test(pointer)
|
|
520
|
+
|| pointer.split(/[\\/]+/).includes('..')
|
|
521
|
+
) {
|
|
522
|
+
errors.push('spec.research: must be a safe relative path inside the spec directory');
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
inspectSpecArtifact(specDir, canonicalSpecDir, pointer, 'spec.research path', errors);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function hasConcreteResearchEvidence(section) {
|
|
529
|
+
const placeholder = /\{\{[^}]+\}\}|<[^>]+>|\[[^\]]+\]|\b(?:TBD|TODO|N\/A|NA|NONE|UNKNOWN|PENDING|PLACEHOLDER|REPLACE_ME)\b/i;
|
|
530
|
+
const boilerplate = /^(?:this section is mandatory\b.*|(?:result or skip rationale|relevant files\/modules|existing patterns\/contracts|tests or checks affected|decision|why it fits the current codebase|why it fits current external constraints|task implication|test\/verification implication)\s*:?)$/i;
|
|
531
|
+
const generic = /^(?:finding|gap|alternative)\s+\d+\b|^(?:codebase scout|external \/ current research)\s*:\s*required\s*\/\s*skipped/i;
|
|
532
|
+
|
|
533
|
+
return section.split('\n').some((rawLine) => {
|
|
534
|
+
const line = rawLine
|
|
535
|
+
.trim()
|
|
536
|
+
.replace(/^[-*+]\s*/, '')
|
|
537
|
+
.replace(/^\||\|$/g, '')
|
|
538
|
+
.replace(/\*\*/g, '')
|
|
539
|
+
.replace(/`/g, '')
|
|
540
|
+
.trim();
|
|
541
|
+
if (!line || line.endsWith(':') || placeholder.test(line) || boilerplate.test(line) || generic.test(line)) return false;
|
|
542
|
+
return line.length >= 8;
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function validateResearchArtifact(researchArtifact, errors, requireConcrete = true) {
|
|
547
|
+
if (!researchArtifact) {
|
|
548
|
+
errors.push('research.md: missing Evidence Summary for non-trivial spec');
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const research = fs.readFileSync(researchArtifact.path, 'utf8');
|
|
552
|
+
const evidence = extractSection(research, 'Evidence Summary');
|
|
553
|
+
if (evidence === null) {
|
|
554
|
+
errors.push('research.md: missing ## Evidence Summary');
|
|
555
|
+
} else if (requireConcrete && !hasConcreteResearchEvidence(evidence)) {
|
|
556
|
+
errors.push('research.md: Evidence Summary must contain concrete evidence; empty or placeholder content is not sufficient');
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function validateStateTransitions(spec, errors, context = {}) {
|
|
310
561
|
// Approval schema v2: generated, agent_validated, user_approved independent.
|
|
311
562
|
// Legacy "approved" field is rejected fail-closed with migration guidance.
|
|
312
563
|
const approvals = spec.approvals || {};
|
|
@@ -349,7 +600,10 @@ function validateStateTransitions(spec, errors) {
|
|
|
349
600
|
if (hasLegacy) {
|
|
350
601
|
errors.push(`spec.json.ready_for_implementation: cannot be true with legacy approval fields — migrate to agent_validated/user_approved`);
|
|
351
602
|
}
|
|
352
|
-
|
|
603
|
+
const stages = context.bounded && !(Array.isArray(spec.task_files) && spec.task_files.length > 0)
|
|
604
|
+
? ['requirements', 'design']
|
|
605
|
+
: ['requirements', 'design', 'tasks'];
|
|
606
|
+
for (const stage of stages) {
|
|
353
607
|
const approval = spec.approvals?.[stage];
|
|
354
608
|
if (!approval || approval?.generated !== true || approval?.agent_validated !== true || approval?.user_approved !== true) {
|
|
355
609
|
errors.push(`spec.json.ready_for_implementation: requires generated and agent_validated and user_approved ${stage} evidence (v2)`);
|
|
@@ -486,39 +740,47 @@ function validateDependencyTopology(spec, taskFiles, registry, taskRecords, erro
|
|
|
486
740
|
function validateSpec(specDir) {
|
|
487
741
|
const errors = [];
|
|
488
742
|
const warnings = [];
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
if (!fs.existsSync(specDir)) {
|
|
492
|
-
errors.push(`${specDir}: spec directory does not exist`);
|
|
493
|
-
return { errors, warnings };
|
|
494
|
-
}
|
|
743
|
+
const canonicalSpecDir = validateSpecRoot(specDir, errors);
|
|
744
|
+
if (!canonicalSpecDir) return { errors, warnings };
|
|
495
745
|
|
|
496
746
|
for (const forbidden of ['init.json', 'spec-state.json', 'hydration.md']) {
|
|
497
|
-
|
|
747
|
+
const artifact = inspectSpecArtifact(specDir, canonicalSpecDir, forbidden, forbidden, errors);
|
|
748
|
+
if (artifact) {
|
|
498
749
|
errors.push(`${forbidden}: forbidden generated artifact`);
|
|
499
750
|
}
|
|
500
751
|
}
|
|
501
752
|
|
|
502
|
-
|
|
503
|
-
|
|
753
|
+
const specJsonArtifact = inspectSpecArtifact(specDir, canonicalSpecDir, 'spec.json', 'spec.json', errors, { required: true });
|
|
754
|
+
if (!specJsonArtifact) return { errors, warnings };
|
|
755
|
+
|
|
756
|
+
const spec = readJson(specJsonArtifact.path, errors);
|
|
757
|
+
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
758
|
+
if (errors.length === 0) errors.push('spec.json: must contain a JSON object');
|
|
504
759
|
return { errors, warnings };
|
|
505
760
|
}
|
|
506
761
|
|
|
507
|
-
const
|
|
508
|
-
if (
|
|
762
|
+
const policyContext = workflowPolicyContext(spec, errors);
|
|
763
|
+
if (policyContext.explicit && Object.prototype.hasOwnProperty.call(spec, 'override_receipt')) {
|
|
764
|
+
errors.push('spec.json.override_receipt: duplicate legacy field; persist only workflow_policy.override_receipt');
|
|
765
|
+
}
|
|
509
766
|
|
|
510
767
|
if (!spec.scope_lock || typeof spec.scope_lock !== 'object' || Array.isArray(spec.scope_lock)) {
|
|
511
768
|
errors.push('spec.json.scope_lock: must be an object, not a boolean or array');
|
|
512
769
|
}
|
|
513
770
|
|
|
514
771
|
validateTimestamps(spec, errors);
|
|
515
|
-
validateStateTransitions(spec, errors);
|
|
772
|
+
validateStateTransitions(spec, errors, policyContext);
|
|
516
773
|
|
|
517
|
-
const taskFiles = listTaskFiles(specDir);
|
|
774
|
+
const taskFiles = listTaskFiles(specDir, canonicalSpecDir, errors);
|
|
518
775
|
const taskFileSet = new Set(taskFiles);
|
|
776
|
+
const declaredTaskBundle = Array.isArray(spec.task_files) && spec.task_files.length > 0;
|
|
777
|
+
const requiresTaskBundle = taskFiles.length > 0 || declaredTaskBundle || policyContext.strict;
|
|
778
|
+
if (policyContext.strict && taskFiles.length === 0) {
|
|
779
|
+
errors.push('strict workflow policy requires at least one task file and task registry entry');
|
|
780
|
+
}
|
|
519
781
|
|
|
520
782
|
if (!Array.isArray(spec.task_files)) {
|
|
521
|
-
errors.push('spec.json.task_files: missing array');
|
|
783
|
+
if (requiresTaskBundle) errors.push('spec.json.task_files: missing array');
|
|
522
784
|
if (Array.isArray(spec.tasks)) {
|
|
523
785
|
errors.push('spec.json.tasks: legacy field detected; use task_files');
|
|
524
786
|
}
|
|
@@ -531,7 +793,7 @@ function validateSpec(specDir) {
|
|
|
531
793
|
}
|
|
532
794
|
|
|
533
795
|
if (!spec.task_registry || typeof spec.task_registry !== 'object' || Array.isArray(spec.task_registry)) {
|
|
534
|
-
errors.push('spec.json.task_registry: missing object keyed by task file path');
|
|
796
|
+
if (requiresTaskBundle) errors.push('spec.json.task_registry: missing object keyed by task file path');
|
|
535
797
|
} else {
|
|
536
798
|
const registryKeys = Object.keys(spec.task_registry).sort();
|
|
537
799
|
if (JSON.stringify(registryKeys) !== JSON.stringify(taskFiles)) {
|
|
@@ -547,6 +809,7 @@ function validateSpec(specDir) {
|
|
|
547
809
|
errors.push(`spec.json.task_registry.${registryPath}: missing ${key}`);
|
|
548
810
|
}
|
|
549
811
|
}
|
|
812
|
+
validateArtifactDeclaration(registryPath, entry, errors);
|
|
550
813
|
if (entry && !Array.isArray(entry.dependencies)) {
|
|
551
814
|
errors.push(`spec.json.task_registry.${registryPath}.dependencies: must be an array`);
|
|
552
815
|
}
|
|
@@ -590,48 +853,62 @@ function validateSpec(specDir) {
|
|
|
590
853
|
}
|
|
591
854
|
}
|
|
592
855
|
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
856
|
+
const requirementsArtifact = inspectSpecArtifact(
|
|
857
|
+
specDir,
|
|
858
|
+
canonicalSpecDir,
|
|
859
|
+
'requirements.md',
|
|
860
|
+
'requirements.md',
|
|
861
|
+
errors,
|
|
862
|
+
{ required: true },
|
|
863
|
+
);
|
|
864
|
+
const designArtifact = inspectSpecArtifact(
|
|
865
|
+
specDir,
|
|
866
|
+
canonicalSpecDir,
|
|
867
|
+
'design.md',
|
|
868
|
+
'design.md',
|
|
869
|
+
errors,
|
|
870
|
+
{ required: true },
|
|
871
|
+
);
|
|
872
|
+
const researchArtifact = inspectSpecArtifact(specDir, canonicalSpecDir, 'research.md', 'research.md', errors);
|
|
873
|
+
const receiptArtifact = inspectSpecArtifact(specDir, canonicalSpecDir, FEATURE_RECEIPT_FILE, FEATURE_RECEIPT_FILE, errors);
|
|
874
|
+
validateResearchPointer(specDir, canonicalSpecDir, spec, errors);
|
|
875
|
+
|
|
876
|
+
const requiresResearchArtifact = policyContext.explicit
|
|
877
|
+
&& Boolean(policyContext.policy?.proof_obligations?.includes('needsResearchGrounding'));
|
|
878
|
+
if (requiresResearchArtifact) {
|
|
879
|
+
validateResearchArtifact(researchArtifact, errors, policyContext.explicit);
|
|
609
880
|
}
|
|
610
881
|
|
|
882
|
+
validateFeatureReceipt(spec, policyContext, errors, receiptArtifact);
|
|
883
|
+
|
|
611
884
|
let requirementIds = [];
|
|
612
885
|
let subCriteriaIds = [];
|
|
613
|
-
|
|
614
|
-
|
|
886
|
+
let requirementsText = '';
|
|
887
|
+
let designText = '';
|
|
888
|
+
if (requirementsArtifact) {
|
|
889
|
+
requirementsText = fs.readFileSync(requirementsArtifact.path, 'utf8');
|
|
615
890
|
requirementIds = extractRequirementIds(requirementsText);
|
|
616
891
|
subCriteriaIds = extractSubCriteriaIds(requirementsText);
|
|
617
892
|
}
|
|
893
|
+
if (designArtifact) designText = fs.readFileSync(designArtifact.path, 'utf8');
|
|
894
|
+
validateBoundedReadiness(spec, requirementsText, designText, errors);
|
|
618
895
|
|
|
619
896
|
const coveredRequirementIds = new Set();
|
|
620
897
|
const coveredSubCriteriaIds = new Set();
|
|
898
|
+
const mappedRequirementReferences = [];
|
|
621
899
|
const taskRecords = new Map();
|
|
622
900
|
// Cross-layer contract defs (opt-in): empty unless design.md uses
|
|
623
901
|
// <!-- contract:NAME --> markers, so legacy specs are unaffected.
|
|
624
|
-
const contractDefs =
|
|
625
|
-
? extractContractDefs(fs.readFileSync(designPath, 'utf8'))
|
|
626
|
-
: new Map();
|
|
902
|
+
const contractDefs = designArtifact ? extractContractDefs(designText) : new Map();
|
|
627
903
|
if (taskFiles.length >= 5 && contractDefs.size === 0) {
|
|
628
904
|
errors.push(
|
|
629
905
|
'design.md: 5+ task spec requires contract blocks for BE/FE shared shapes (<!-- contract:NAME -->) — fail closed, not warning',
|
|
630
906
|
);
|
|
631
907
|
}
|
|
632
908
|
for (const taskFile of taskFiles) {
|
|
633
|
-
const
|
|
634
|
-
|
|
909
|
+
const taskArtifact = inspectSpecArtifact(specDir, canonicalSpecDir, taskFile, taskFile, errors);
|
|
910
|
+
if (!taskArtifact) continue;
|
|
911
|
+
const content = fs.readFileSync(taskArtifact.path, 'utf8');
|
|
635
912
|
validateTaskSections(taskFile, content, errors);
|
|
636
913
|
validateTaskPlaceholders(taskFile, content, errors, warnings);
|
|
637
914
|
validateRelatedFiles(taskFile, content, errors);
|
|
@@ -651,7 +928,15 @@ function validateSpec(specDir) {
|
|
|
651
928
|
for (const token of match[1].split(',')) {
|
|
652
929
|
const trimmed = token.trim();
|
|
653
930
|
const major = trimmed.match(/^(\d+)(?:\.\d+)?$/);
|
|
654
|
-
if (major)
|
|
931
|
+
if (major) {
|
|
932
|
+
const requirementId = `R${major[1]}`;
|
|
933
|
+
const sub = trimmed.match(/^(\d+\.\d+)$/);
|
|
934
|
+
const subId = sub ? `R${sub[1]}` : null;
|
|
935
|
+
coveredRequirementIds.add(requirementId);
|
|
936
|
+
if (policyContext.strict) mappedRequirementReferences.push({ taskFile, token: trimmed, requirementId, subId });
|
|
937
|
+
} else if (policyContext.strict && trimmed !== '') {
|
|
938
|
+
errors.push(`${taskFile}: requirement mapping "${trimmed}" must use numeric IDs such as 1 or 1.1`);
|
|
939
|
+
}
|
|
655
940
|
// Record the full sub-criterion (e.g. 3.4 -> R3.4) for per-criterion coverage.
|
|
656
941
|
const sub = trimmed.match(/^(\d+\.\d+)$/);
|
|
657
942
|
if (sub) coveredSubCriteriaIds.add(`R${sub[1]}`);
|
|
@@ -698,15 +983,32 @@ function validateSpec(specDir) {
|
|
|
698
983
|
|
|
699
984
|
validateDependencyTopology(spec, taskFiles, spec.task_registry, taskRecords, errors);
|
|
700
985
|
|
|
701
|
-
|
|
702
|
-
if (
|
|
703
|
-
errors.push(
|
|
986
|
+
if (policyContext.strict) {
|
|
987
|
+
if (requirementIds.length === 0) {
|
|
988
|
+
errors.push('requirements.md: strict workflow requires numeric requirement IDs (for example Requirement 1 or R1)');
|
|
989
|
+
}
|
|
990
|
+
const knownRequirements = new Set(requirementIds);
|
|
991
|
+
const knownSubCriteria = new Set(subCriteriaIds);
|
|
992
|
+
for (const reference of mappedRequirementReferences) {
|
|
993
|
+
if (!knownRequirements.has(reference.requirementId)) {
|
|
994
|
+
errors.push(`${reference.taskFile}: requirement mapping "${reference.token}" references unknown requirement ${reference.requirementId}`);
|
|
995
|
+
} else if (reference.subId && !knownSubCriteria.has(reference.subId)) {
|
|
996
|
+
errors.push(`${reference.taskFile}: requirement mapping "${reference.token}" references unknown acceptance criterion ${reference.subId}`);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (requiresTaskBundle) {
|
|
1002
|
+
for (const requirementId of requirementIds) {
|
|
1003
|
+
if (!coveredRequirementIds.has(requirementId)) {
|
|
1004
|
+
errors.push(`requirements.md:${requirementId}: not covered by any task`);
|
|
1005
|
+
}
|
|
704
1006
|
}
|
|
705
1007
|
}
|
|
706
1008
|
|
|
707
1009
|
// Per-criterion coverage: every explicit R{N}.{M} literal in requirements.md
|
|
708
1010
|
// must appear in a numeric `_Requirements: x.y_` task mapping.
|
|
709
|
-
if (subCriteriaIds.length > 0) {
|
|
1011
|
+
if (requiresTaskBundle && subCriteriaIds.length > 0) {
|
|
710
1012
|
for (const subId of subCriteriaIds) {
|
|
711
1013
|
if (!coveredSubCriteriaIds.has(subId)) {
|
|
712
1014
|
errors.push(`requirements.md:${subId}: acceptance criterion not covered by any task`);
|