@haposoft/cafekit 0.18.0 → 0.18.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 +12 -1
- package/bin/__tests__/claude-settings.test.js +123 -0
- package/bin/__tests__/codex-ask-guard.test.js +123 -0
- package/bin/__tests__/codex-ask-projection.test.js +51 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
- package/bin/__tests__/codex-hooks.test.js +938 -237
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +222 -149
- package/bin/__tests__/codex-projection-parity.test.js +314 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
- package/bin/__tests__/codex-research-projection.test.js +70 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +102 -1362
- package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
- package/bin/__tests__/inspect-paths.test.js +183 -0
- package/bin/__tests__/installer-safety.test.js +335 -0
- package/bin/__tests__/legacy-authority-decoupled.test.js +14 -19
- package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
- package/bin/__tests__/omp-bridge.test.js +66 -12
- package/bin/__tests__/omp-hooks.test.js +11 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/package-inventory.test.js +1 -395
- package/bin/__tests__/privacy-paths.test.js +317 -0
- package/bin/__tests__/spec-narrowing.test.js +30 -134
- package/bin/__tests__/specs-v2-execution-closeout.test.js +0 -372
- package/bin/__tests__/test-runtime-dependency-closure.cjs +1 -2
- package/bin/__tests__/usage-hook-retired.test.js +5 -3
- package/bin/__tests__/validator-grounding.test.js +17 -2929
- package/bin/install.js +25 -0
- package/bin/lib/codex-install.js +134 -12
- package/bin/lib/context.js +1 -1
- package/bin/lib/managed-writer.js +2 -3
- package/bin/phases/claude-runtime.js +91 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +96 -33
- package/bin/phases/codex-runtime.js +45 -49
- package/bin/phases/copy-payload.js +11 -28
- package/bin/phases/omp-runtime.js +6 -3
- package/package.json +2 -3
- package/src/claude/CLAUDE.md +4 -0
- package/src/claude/agents/code-auditor.md +9 -6
- package/src/claude/agents/debugger.md +3 -9
- package/src/claude/agents/docs-keeper.md +4 -7
- package/src/claude/agents/git-ops.md +2 -4
- package/src/claude/agents/implementer.md +6 -7
- package/src/claude/agents/inspector.md +2 -0
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/spec-maker.md +4 -4
- package/src/claude/agents/test-runner.md +8 -11
- package/src/claude/agents/ui-ux-designer.md +0 -1
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/precompact.test.js +206 -10
- package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +311 -340
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +230 -8
- package/src/claude/hooks/__tests__/state.test.js +207 -7
- package/src/claude/hooks/__tests__/update-check.test.js +88 -0
- package/src/claude/hooks/agent.cjs +7 -6
- package/src/claude/hooks/inspect-block.cjs +15 -63
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
- package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
- package/src/claude/hooks/lib/project-root.cjs +15 -0
- package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
- package/src/claude/hooks/lib/spec-touch.cjs +67 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +23 -205
- package/src/claude/hooks/rules.cjs +6 -28
- package/src/claude/hooks/session.cjs +34 -117
- package/src/claude/hooks/spec-gate.cjs +17 -89
- package/src/claude/hooks/spec-state.cjs +89 -96
- package/src/claude/hooks/state.cjs +96 -51
- package/src/claude/migration-manifest.json +20 -8
- package/src/claude/output-styles/coding-level-0-eli5.md +9 -9
- package/src/claude/output-styles/coding-level-1-junior.md +3 -3
- package/src/claude/output-styles/coding-level-2-mid.md +3 -3
- package/src/claude/output-styles/coding-level-3-senior.md +2 -2
- package/src/claude/output-styles/coding-level-4-lead.md +5 -5
- package/src/claude/output-styles/coding-level-5-god.md +6 -6
- package/src/claude/rules/ai-dev-rules.md +1 -2
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
- package/src/claude/scripts/provenance.cjs +14 -0
- package/src/claude/scripts/spec-receipt.cjs +0 -114
- package/src/claude/scripts/spec-resolver.cjs +41 -457
- package/src/claude/scripts/workflow-policy.cjs +3 -1801
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/agent-browser/SKILL.md +1 -1
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
- package/src/claude/skills/chrome-devtools/SKILL.md +14 -31
- package/src/claude/skills/code-review/SKILL.md +25 -108
- package/src/claude/skills/code-review/references/spec-compliance-review.md +4 -7
- package/src/claude/skills/develop/SKILL.md +22 -70
- package/src/claude/skills/develop/references/subagent-patterns.md +0 -6
- package/src/claude/skills/git/SKILL.md +1 -1
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/claude/skills/research/SKILL.md +24 -55
- package/src/claude/skills/sync/SKILL.md +12 -34
- package/src/claude/skills/sync/references/rebind-and-audit.md +2 -2
- package/src/claude/skills/test/SKILL.md +39 -123
- package/src/claude/skills/test/references/execution-strategy.md +11 -22
- package/src/claude/skills/ui-ux/SKILL.md +1 -1
- package/src/claude/skills/ui-ux-pro-max/SKILL.md +24 -79
- package/src/claude/skills/web-testing/SKILL.md +2 -20
- package/src/codex/AGENTS.md +8 -0
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +11 -7
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +98 -16
- package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
- package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +2 -30
- package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
- package/src/codex/hooks/lib/spec-touch.cjs +3 -0
- package/src/codex/hooks/lib/spec-utils.cjs +6 -45
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +39 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +9 -215
- package/src/codex/hooks/rules.cjs +4 -30
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +47 -11
- package/src/codex/hooks/spec-gate.cjs +32 -74
- package/src/codex/hooks/spec-state.cjs +68 -46
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/common/AGENTS.md +4 -10
- package/src/omp/extensions/cafekit-bridge.mjs +12 -4
- package/src/omp/hooks/privacy-block.cjs +24 -206
- package/bin/__tests__/change-firewall.semantic-firewall.test.js +0 -2133
- package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +0 -342
- package/bin/__tests__/release-preflight.semantic-firewall.test.js +0 -586
- package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +0 -378
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +0 -665
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +0 -1132
- package/src/claude/hooks/lib/context.cjs +0 -527
- package/src/claude/hooks/lib/detect.cjs +0 -474
- package/src/claude/scripts/change-firewall.cjs +0 -1748
- package/src/claude/scripts/spec-authoring-digest.cjs +0 -151
- package/src/claude/scripts/spec-authoring-validation.cjs +0 -126
- package/src/claude/scripts/spec-final-state.cjs +0 -290
- package/src/claude/scripts/spec-ground.cjs +0 -650
- package/src/claude/scripts/spec-readiness.cjs +0 -425
- package/src/claude/scripts/spec-scaffold.cjs +0 -2317
- package/src/claude/scripts/spec-semantic-model.cjs +0 -396
- package/src/claude/scripts/validate-spec-output.cjs +0 -3914
- package/src/claude/skills/code-review/references/verification-gate.md +0 -41
- package/src/claude/skills/specs/templates/design.md +0 -82
- package/src/claude/skills/specs/templates/requirements-init.md +0 -8
- package/src/claude/skills/specs/templates/requirements.md +0 -42
- package/src/claude/skills/specs/templates/spec-state.json +0 -56
- package/src/claude/skills/specs/templates/task.md +0 -47
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -1,3914 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* CafeKit spec artifact validator.
|
|
4
|
-
*
|
|
5
|
-
* This is intentionally deterministic. Prompt rules can drift; this script is
|
|
6
|
-
* the hard backstop before a spec is marked ready for implementation.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
const crypto = require('crypto');
|
|
10
|
-
const fs = require('fs');
|
|
11
|
-
const path = require('path');
|
|
12
|
-
const POLICY = require('./workflow-policy.cjs');
|
|
13
|
-
const SEMANTIC = require('./spec-semantic-model.cjs');
|
|
14
|
-
const AUTHORING_DIGEST = require('./spec-authoring-digest.cjs');
|
|
15
|
-
const {
|
|
16
|
-
canonicalProjectRoot,
|
|
17
|
-
groundSpec,
|
|
18
|
-
parseCanonicalAnchors,
|
|
19
|
-
parseVerificationDefinitions,
|
|
20
|
-
} = require('./spec-ground.cjs');
|
|
21
|
-
|
|
22
|
-
const TASK_PATH_RE = /^tasks\/task-R\d+-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/;
|
|
23
|
-
const TASK_IDENTITY_RE = /^tasks\/task-(R\d+-\d{2})-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/;
|
|
24
|
-
const REQUIRED_REGISTRY_KEYS = [
|
|
25
|
-
'id',
|
|
26
|
-
'title',
|
|
27
|
-
'status',
|
|
28
|
-
'dependencies',
|
|
29
|
-
'blocker',
|
|
30
|
-
'started_at',
|
|
31
|
-
'completed_at',
|
|
32
|
-
'last_updated_at',
|
|
33
|
-
];
|
|
34
|
-
const APPROVAL_SCHEMA_VERSION = '2.0';
|
|
35
|
-
const LEGACY_APPROVAL_ERROR =
|
|
36
|
-
`Legacy approval field "approved" detected. Migration required: replace "approved" with "agent_validated" per schema v${APPROVAL_SCHEMA_VERSION} (schema_version: "${APPROVAL_SCHEMA_VERSION}"). See spec-state.json template.`;
|
|
37
|
-
const FEATURE_RECEIPT_FILE = 'feature-receipt.md';
|
|
38
|
-
const SPEC_STATUS_VALUES = new Set(['in_progress', 'in-progress', 'paused', 'blocked', 'done']);
|
|
39
|
-
const TASK_STATUS_VALUES = new Set(['pending', 'in_progress', 'blocked', 'done']);
|
|
40
|
-
const VALIDATION_STATUS_VALUES = new Set(['not-run', 'in_progress', 'completed']);
|
|
41
|
-
const ANCHOR_TYPES = new Set([
|
|
42
|
-
'file', 'symbol', 'command', 'route', 'schema', 'contract', 'artifact', 'external',
|
|
43
|
-
]);
|
|
44
|
-
const PHASE_FIELDS = ['id', 'task_ids', 'entry_condition', 'exit_condition', 'owner_boundary'];
|
|
45
|
-
const TASK_TRIGGER_VALUES = new Set([
|
|
46
|
-
'distinct_ownership',
|
|
47
|
-
'real_dependency',
|
|
48
|
-
'durable_transition',
|
|
49
|
-
'separate_proof',
|
|
50
|
-
'parallel_coordination',
|
|
51
|
-
]);
|
|
52
|
-
const SEMANTIC_REVIEW_FIELDS = [
|
|
53
|
-
'status',
|
|
54
|
-
'reviewed_artifact_digest',
|
|
55
|
-
'reviewed_criteria',
|
|
56
|
-
'counterexamples',
|
|
57
|
-
];
|
|
58
|
-
const SEMANTIC_REVIEW_STATUSES = new Set(['not-run', 'completed']);
|
|
59
|
-
const SHA256_RE = /^sha256:[a-f0-9]{64}$/;
|
|
60
|
-
const SCHEMA_21 = '2.1';
|
|
61
|
-
const TASK_21_SECTIONS = [
|
|
62
|
-
'Outcome', 'Scope', 'Anchors and Ownership', 'Changes', 'Acceptance', 'Dependencies', 'Verification Plan',
|
|
63
|
-
];
|
|
64
|
-
const BOUNDARY_21_FIELDS = Object.freeze({
|
|
65
|
-
ownership: ['id', 'type', 'tasks', 'write_sets'],
|
|
66
|
-
dependency: ['id', 'type', 'producer', 'consumer', 'deliverable'],
|
|
67
|
-
transition: ['id', 'type', 'design_ref', 'owner', 'consumers', 'precondition', 'postcondition', 'failure', 'recovery'],
|
|
68
|
-
proof: ['id', 'type', 'subject', 'verifier', 'verification_ref', 'artifact_anchor'],
|
|
69
|
-
parallel: ['id', 'type', 'tasks', 'resources'],
|
|
70
|
-
});
|
|
71
|
-
const SPEC_21_REQUIRED_FIELDS = Object.freeze([
|
|
72
|
-
'schema_version', 'feature_name', 'created_at', 'updated_at', 'language', 'status',
|
|
73
|
-
'scope_lock', 'authoring', 'coordination', 'validation', 'semantic_model',
|
|
74
|
-
'ready_for_implementation', 'workflow_policy',
|
|
75
|
-
]);
|
|
76
|
-
const SPEC_21_OPTIONAL_FIELDS = Object.freeze(['research', 'task_files', 'task_registry', 'decisions']);
|
|
77
|
-
|
|
78
|
-
// C2 SemanticReviewReceipt (R1-01, frozen) — separate reviewer verdict from
|
|
79
|
-
// lifecycle_disposition; repair_round is the finalizer-derived C13 attempt_index.
|
|
80
|
-
// This is the sole canonical field-list authority for C2's exact key set;
|
|
81
|
-
// every other consumer (e.g. run-skill-self-tests.mjs's template contract
|
|
82
|
-
// check) imports this constant rather than maintaining its own literal copy.
|
|
83
|
-
const C2_FIELDS = Object.freeze([
|
|
84
|
-
'status', 'semantic_digest', 'verdict', 'lifecycle_disposition', 'findings',
|
|
85
|
-
'unresolved_decisions', 'graph_coverage', 'repair_round', 'reviewed_criteria',
|
|
86
|
-
'counterexamples', 'reviewer_evidence',
|
|
87
|
-
]);
|
|
88
|
-
const C2_VERDICTS = new Set(['PASS', 'FAIL']);
|
|
89
|
-
const C2_LIFECYCLE_DISPOSITIONS = new Set(['CONTINUE', 'BLOCKED']);
|
|
90
|
-
const C2_FINDING_FIELDS = ['id', 'severity', 'location', 'summary', 'blocking', 'disposition'];
|
|
91
|
-
const C2_FINDING_SEVERITIES = new Set(['Critical', 'High', 'Medium', 'Low']);
|
|
92
|
-
const C2_FINDING_DISPOSITIONS = new Set(['open', 'accepted', 'mitigated']);
|
|
93
|
-
const C2_UNRESOLVED_DECISION_FIELDS = ['id', 'summary', 'blocking'];
|
|
94
|
-
const C2_GRAPH_COVERAGE_FIELDS = ['surface', 'covered', 'notes'];
|
|
95
|
-
const C2_GRAPH_COVERAGE_SURFACES = [
|
|
96
|
-
'criterion_local', 'cross_criterion', 'runtime_path', 'assumption_provenance', 'compatibility_migration',
|
|
97
|
-
];
|
|
98
|
-
const C2_REVIEWER_EVIDENCE_FIELDS = ['assurance', 'summary'];
|
|
99
|
-
const C2_REVIEWER_EVIDENCE_ASSURANCES = new Set(['Routine', 'Elevated', 'Strict']);
|
|
100
|
-
// C13 SemanticReviewHistory (R1-01, frozen) — durable, append-only, whole-lineage-deduped.
|
|
101
|
-
const C13_HISTORY_FIELDS = ['lineage_id', 'entries'];
|
|
102
|
-
const C13_ENTRY_FIELDS = [
|
|
103
|
-
'sequence', 'semantic_digest', 'review_receipt_digest', 'verdict',
|
|
104
|
-
'lifecycle_disposition', 'blocking_count', 'attempt_index', 'review_epoch',
|
|
105
|
-
];
|
|
106
|
-
// C16 AuthoringValidationReceipt (R1-01, frozen) — sole freshness evidence for
|
|
107
|
-
// an authoring stage's `validated` reading (I20); computed via the shared,
|
|
108
|
-
// R0-01-owned spec-authoring-digest.cjs primitive (never reimplemented here).
|
|
109
|
-
const C16_FIELDS = ['schema_version', 'requirements', 'design', 'research', 'tasks'];
|
|
110
|
-
const C16_STAGE_FIELDS = ['digest', 'validated_at'];
|
|
111
|
-
const VALIDATION_21_FIELDS = ['status', 'semantic_review', 'semantic_review_history', 'authoring_validation'];
|
|
112
|
-
|
|
113
|
-
function usage() {
|
|
114
|
-
console.error('Usage: node .claude/scripts/validate-spec-output.cjs specs/<feature> [--semantic-digest]');
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function resolveSpecDir(input) {
|
|
118
|
-
if (!input) return null;
|
|
119
|
-
|
|
120
|
-
const cwd = process.cwd();
|
|
121
|
-
const direct = path.resolve(cwd, input);
|
|
122
|
-
if (fs.existsSync(direct)) return direct;
|
|
123
|
-
|
|
124
|
-
const viaSpecs = path.resolve(cwd, 'specs', input);
|
|
125
|
-
if (fs.existsSync(viaSpecs)) return viaSpecs;
|
|
126
|
-
|
|
127
|
-
return direct;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function readJson(filePath, errors) {
|
|
131
|
-
try {
|
|
132
|
-
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
133
|
-
} catch (error) {
|
|
134
|
-
errors.push(`${filePath}: invalid JSON (${error.message})`);
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function isPlainObject(value) {
|
|
140
|
-
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function isPathInside(parent, child) {
|
|
144
|
-
const relative = path.relative(path.resolve(parent), path.resolve(child));
|
|
145
|
-
return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function isMissingError(error) {
|
|
149
|
-
return error && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function validateSpecRoot(specDir, errors) {
|
|
153
|
-
let rootStat;
|
|
154
|
-
try {
|
|
155
|
-
rootStat = fs.lstatSync(specDir);
|
|
156
|
-
} catch (error) {
|
|
157
|
-
errors.push(`${specDir}: spec directory does not exist (${error.message})`);
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
160
|
-
if (rootStat.isSymbolicLink()) {
|
|
161
|
-
errors.push(`${specDir}: spec directory cannot be a symlink`);
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
164
|
-
if (!rootStat.isDirectory()) {
|
|
165
|
-
errors.push(`${specDir}: spec path must be a directory`);
|
|
166
|
-
return null;
|
|
167
|
-
}
|
|
168
|
-
try {
|
|
169
|
-
return fs.realpathSync(specDir);
|
|
170
|
-
} catch (error) {
|
|
171
|
-
errors.push(`${specDir}: spec directory canonicalization failed (${error.message})`);
|
|
172
|
-
return null;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function inspectSpecArtifact(specDir, canonicalSpecDir, relativePath, label, errors, { required = false, type = 'file' } = {}) {
|
|
177
|
-
const target = path.resolve(specDir, relativePath);
|
|
178
|
-
if (!isPathInside(specDir, target)) {
|
|
179
|
-
errors.push(`${label}: path must stay inside the spec directory`);
|
|
180
|
-
return null;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const relative = path.relative(path.resolve(specDir), target);
|
|
184
|
-
let current = path.resolve(specDir);
|
|
185
|
-
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
186
|
-
current = path.join(current, segment);
|
|
187
|
-
let stat;
|
|
188
|
-
try {
|
|
189
|
-
stat = fs.lstatSync(current);
|
|
190
|
-
} catch (error) {
|
|
191
|
-
if (isMissingError(error)) {
|
|
192
|
-
if (required) errors.push(`${label}: missing`);
|
|
193
|
-
return null;
|
|
194
|
-
}
|
|
195
|
-
errors.push(`${label}: cannot be inspected (${error.message})`);
|
|
196
|
-
return null;
|
|
197
|
-
}
|
|
198
|
-
if (stat.isSymbolicLink()) {
|
|
199
|
-
errors.push(`${label}: symlink is not allowed (${current})`);
|
|
200
|
-
return null;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
let stat;
|
|
205
|
-
try {
|
|
206
|
-
stat = fs.lstatSync(target);
|
|
207
|
-
} catch (error) {
|
|
208
|
-
if (isMissingError(error)) {
|
|
209
|
-
if (required) errors.push(`${label}: missing`);
|
|
210
|
-
return null;
|
|
211
|
-
}
|
|
212
|
-
errors.push(`${label}: cannot be inspected (${error.message})`);
|
|
213
|
-
return null;
|
|
214
|
-
}
|
|
215
|
-
const typeMatches = type === 'directory' ? stat.isDirectory() : stat.isFile();
|
|
216
|
-
if (!typeMatches) {
|
|
217
|
-
errors.push(`${label}: must be a regular ${type}`);
|
|
218
|
-
return null;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
let canonicalTarget;
|
|
222
|
-
try {
|
|
223
|
-
canonicalTarget = fs.realpathSync(target);
|
|
224
|
-
} catch (error) {
|
|
225
|
-
errors.push(`${label}: canonicalization failed (${error.message})`);
|
|
226
|
-
return null;
|
|
227
|
-
}
|
|
228
|
-
if (!isPathInside(canonicalSpecDir, canonicalTarget)) {
|
|
229
|
-
errors.push(`${label}: canonical path escapes the spec directory`);
|
|
230
|
-
return null;
|
|
231
|
-
}
|
|
232
|
-
return { path: target, canonicalPath: canonicalTarget, stat };
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function listTaskFiles(specDir, canonicalSpecDir, errors) {
|
|
236
|
-
const tasks = inspectSpecArtifact(specDir, canonicalSpecDir, 'tasks', 'tasks directory', errors, {
|
|
237
|
-
type: 'directory',
|
|
238
|
-
});
|
|
239
|
-
if (!tasks) return [];
|
|
240
|
-
|
|
241
|
-
let entries;
|
|
242
|
-
try {
|
|
243
|
-
entries = fs.readdirSync(tasks.path, { withFileTypes: true });
|
|
244
|
-
} catch (error) {
|
|
245
|
-
errors.push(`tasks directory: cannot be read (${error.message})`);
|
|
246
|
-
return [];
|
|
247
|
-
}
|
|
248
|
-
const files = [];
|
|
249
|
-
for (const entry of entries) {
|
|
250
|
-
const relative = path.join('tasks', entry.name).split(path.sep).join('/');
|
|
251
|
-
const artifact = inspectSpecArtifact(specDir, canonicalSpecDir, relative, relative, errors);
|
|
252
|
-
if (artifact && !entry.name.endsWith('.md')) {
|
|
253
|
-
errors.push(`${relative}: unexpected task artifact; only .md task files are allowed`);
|
|
254
|
-
}
|
|
255
|
-
if (artifact && entry.name.endsWith('.md')) files.push(relative);
|
|
256
|
-
}
|
|
257
|
-
return files.sort();
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function hasHeading(content, heading) {
|
|
261
|
-
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
262
|
-
return new RegExp(`^##\\s+${escaped}\\s*$`, 'm').test(content);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function headingCount(content, heading) {
|
|
266
|
-
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
267
|
-
return [...content.matchAll(new RegExp(`^##\\s+${escaped}\\s*$`, 'gim'))].length;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function validateUniqueSectionGroups(label, content, groups, errors) {
|
|
271
|
-
for (const group of groups) {
|
|
272
|
-
const count = group.reduce((total, heading) => total + headingCount(content, heading), 0);
|
|
273
|
-
if (count > 1) {
|
|
274
|
-
errors.push(`${label}: machine-read section ${group.join('/')} must appear at most once`);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function extractSection(content, heading) {
|
|
280
|
-
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
281
|
-
const re = new RegExp(`^##\\s+${escaped}\\s*$`, 'im');
|
|
282
|
-
const match = content.match(re);
|
|
283
|
-
if (!match) return null;
|
|
284
|
-
const start = match.index + match[0].length;
|
|
285
|
-
const after = content.slice(start);
|
|
286
|
-
const next = after.match(/^##\s+/m);
|
|
287
|
-
return next ? after.slice(0, next.index) : after;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function semanticMarkdown(content) {
|
|
291
|
-
const withoutComments = String(content).replace(/<!--[\s\S]*?-->/g, '');
|
|
292
|
-
let fence = null;
|
|
293
|
-
return withoutComments.split('\n').map((line) => {
|
|
294
|
-
const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/);
|
|
295
|
-
if (marker) {
|
|
296
|
-
const character = marker[1][0];
|
|
297
|
-
const length = marker[1].length;
|
|
298
|
-
if (fence === null) fence = { character, length };
|
|
299
|
-
else if (character === fence.character && length >= fence.length) fence = null;
|
|
300
|
-
return '';
|
|
301
|
-
}
|
|
302
|
-
return fence === null ? line : '';
|
|
303
|
-
}).join('\n');
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function requirementHeadingDeclarations(requirementsText) {
|
|
307
|
-
const semanticText = semanticMarkdown(requirementsText);
|
|
308
|
-
const declarations = [];
|
|
309
|
-
const invalid = [];
|
|
310
|
-
const headingRe = /^#{2,4}\s+(.+?)\s*$/gm;
|
|
311
|
-
let match;
|
|
312
|
-
while ((match = headingRe.exec(semanticText)) !== null) {
|
|
313
|
-
const heading = match[1].trim();
|
|
314
|
-
const explicit = heading.match(/^Requirement\s+([^\s:.-]+)(?=[:.\s-]|$)/i);
|
|
315
|
-
const prefixed = heading.match(/^((?:R|REQ|NFR|SEC|PERF)[A-Za-z0-9_.-]*\d[A-Za-z0-9_.-]*)(?=[:\s]|$)/i);
|
|
316
|
-
const numeric = heading.match(/^(\d+)(?=[:.\s-]|$)/);
|
|
317
|
-
const bracketed = heading.match(/^\[(R\d+)\](?=[:.\s-]|$)/i);
|
|
318
|
-
let token = null;
|
|
319
|
-
let canonical = null;
|
|
320
|
-
if (explicit) {
|
|
321
|
-
token = explicit[1];
|
|
322
|
-
canonical = /^\d+$/.test(token)
|
|
323
|
-
? `R${token}`
|
|
324
|
-
: (/^R\d+$/i.test(token) ? token.toUpperCase() : null);
|
|
325
|
-
} else if (prefixed) {
|
|
326
|
-
token = prefixed[1];
|
|
327
|
-
canonical = /^R\d+$/i.test(token) ? token.toUpperCase() : null;
|
|
328
|
-
} else if (numeric) {
|
|
329
|
-
token = numeric[1];
|
|
330
|
-
} else if (bracketed) {
|
|
331
|
-
token = bracketed[1];
|
|
332
|
-
}
|
|
333
|
-
if (canonical) declarations.push({ id: canonical, heading });
|
|
334
|
-
else if (token !== null) invalid.push({ token, heading });
|
|
335
|
-
}
|
|
336
|
-
return { declarations, invalid };
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
function acceptanceCriterionDeclarations(requirementsText) {
|
|
340
|
-
const semanticText = semanticMarkdown(requirementsText);
|
|
341
|
-
const canonical = [];
|
|
342
|
-
const invalid = [];
|
|
343
|
-
const re = /^\s*(?:[-*+]\s+)?\*{0,2}([^\s*]+)\*{0,2}(?:\s*[:—-])?\s+(.+?)\s*$/gm;
|
|
344
|
-
let match;
|
|
345
|
-
while ((match = re.exec(semanticText)) !== null) {
|
|
346
|
-
const token = match[1];
|
|
347
|
-
const looksStructured = /^(?:R|REQ|NFR|SEC|PERF|AC)[A-Za-z0-9_.-]*\d[A-Za-z0-9_.-]*$/i.test(token)
|
|
348
|
-
|| /^R\d+[.-][A-Za-z0-9]+$/i.test(token);
|
|
349
|
-
if (!looksStructured) continue;
|
|
350
|
-
if (/^R\d+\.\d+$/i.test(token)) {
|
|
351
|
-
canonical.push({ id: token.toUpperCase(), text: match[2].trim() });
|
|
352
|
-
} else {
|
|
353
|
-
invalid.push({ token, text: match[2].trim() });
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
return { canonical, invalid };
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function validateRequirementInventory(requirementsText, errors) {
|
|
360
|
-
const headings = requirementHeadingDeclarations(requirementsText);
|
|
361
|
-
const criteria = acceptanceCriterionDeclarations(requirementsText);
|
|
362
|
-
for (const declaration of headings.invalid) {
|
|
363
|
-
if (hasSpecPlaceholders(declaration.token)) continue;
|
|
364
|
-
errors.push(
|
|
365
|
-
`requirements.md: unsupported requirement id ${declaration.token} in requirement heading; ` +
|
|
366
|
-
'use Requirement N or RN',
|
|
367
|
-
);
|
|
368
|
-
}
|
|
369
|
-
for (const declaration of criteria.invalid) {
|
|
370
|
-
if (hasSpecPlaceholders(declaration.token)) continue;
|
|
371
|
-
errors.push(
|
|
372
|
-
`requirements.md: unsupported acceptance criterion id ${declaration.token}; use RN.M`,
|
|
373
|
-
);
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
const seenRequirements = new Set();
|
|
377
|
-
for (const declaration of headings.declarations) {
|
|
378
|
-
if (seenRequirements.has(declaration.id)) {
|
|
379
|
-
errors.push(`requirements.md: duplicate canonical requirement id ${declaration.id}`);
|
|
380
|
-
}
|
|
381
|
-
seenRequirements.add(declaration.id);
|
|
382
|
-
}
|
|
383
|
-
const seenCriteria = new Set();
|
|
384
|
-
for (const criterion of criteria.canonical) {
|
|
385
|
-
if (seenCriteria.has(criterion.id)) {
|
|
386
|
-
errors.push(`requirements.md: duplicate canonical acceptance criterion id ${criterion.id}`);
|
|
387
|
-
}
|
|
388
|
-
seenCriteria.add(criterion.id);
|
|
389
|
-
const requirementId = criterion.id.split('.')[0];
|
|
390
|
-
if (seenRequirements.size > 0 && !seenRequirements.has(requirementId)) {
|
|
391
|
-
errors.push(`requirements.md: acceptance criterion ${criterion.id} has no matching ${requirementId} heading`);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
function extractRequirementIds(requirementsText) {
|
|
397
|
-
return [...new Set(requirementHeadingDeclarations(requirementsText).declarations
|
|
398
|
-
.map(({ id }) => id)
|
|
399
|
-
.filter((id) => id !== 'R0'))].sort();
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function extractAcceptanceCriteria(requirementsText) {
|
|
403
|
-
return acceptanceCriterionDeclarations(requirementsText).canonical;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
function hasTestableAcceptanceCriterion(requirementsText) {
|
|
407
|
-
const assertion = /\b(?:shall|must|returns?|rejects?|emits?|writes?|persists?|creates?|deletes?|renders?|displays?|records?|exits?|fails?|equals?|contains?|remains?|transitions?)\b/i;
|
|
408
|
-
const observable = /`[^`]+`|\b(?:exit code|http|status|file|path|record|event|response|result|state|field|value|message|artifact|directory|request|output|error)\b|\d/i;
|
|
409
|
-
return extractAcceptanceCriteria(requirementsText).some(({ text }) => (
|
|
410
|
-
text.length >= 24
|
|
411
|
-
&& !hasSpecPlaceholders(text)
|
|
412
|
-
&& assertion.test(text)
|
|
413
|
-
&& observable.test(text)
|
|
414
|
-
));
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
function hasConcreteDesignBoundary(designText) {
|
|
418
|
-
const sections = [
|
|
419
|
-
'Boundary',
|
|
420
|
-
'Architecture',
|
|
421
|
-
'Components and Interfaces',
|
|
422
|
-
'Canonical Contracts & Invariants',
|
|
423
|
-
'Implementation Boundary',
|
|
424
|
-
].map((heading) => extractSection(designText, heading)).filter(Boolean);
|
|
425
|
-
const boundary = sections.join('\n');
|
|
426
|
-
if (boundary.length < 24 || hasSpecPlaceholders(boundary)) return false;
|
|
427
|
-
const genericIdentifiers = new Set([
|
|
428
|
-
'module', 'service', 'component', 'entrypoint', 'interface', 'function',
|
|
429
|
-
'class', 'route', 'command', 'database', 'table', 'process', 'boundary', 'owner',
|
|
430
|
-
]);
|
|
431
|
-
const fencedReferences = [...boundary.matchAll(/`([^`]+)`/g)]
|
|
432
|
-
.map((match) => match[1].trim())
|
|
433
|
-
.filter((reference) => (
|
|
434
|
-
reference.length >= 3
|
|
435
|
-
&& !genericIdentifiers.has(reference.toLowerCase())
|
|
436
|
-
&& (/[/\\.]/.test(reference) || /^[A-Za-z_$][A-Za-z0-9_$.:-]*$/.test(reference))
|
|
437
|
-
));
|
|
438
|
-
const pathReference = /(?:^|\s)(?:src|packages|apps|lib|cmd|internal|tests?|\.claude|\.codex)\/[A-Za-z0-9_.\/-]+/im.test(boundary);
|
|
439
|
-
const ownedBehavior = /\b(?:owns?|reads?|writes?|calls?|invokes?|imports?|registers?|routes?|persists?|updates?|creates?|deletes?|validates?|returns?|emits?|handles?|maps?|touches?|modifies?|implements?|consumes?|produces?)\b/i.test(boundary);
|
|
440
|
-
return ownedBehavior && (fencedReferences.length > 0 || pathReference);
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
function taskFormat(content) {
|
|
444
|
-
const conciseHeadings = [
|
|
445
|
-
'Outcome', 'Scope and Typed Anchors', 'Changes', 'Acceptance', 'Dependencies',
|
|
446
|
-
'Verification Plan',
|
|
447
|
-
];
|
|
448
|
-
return conciseHeadings.some((heading) => hasHeading(content, heading)) ? 'v2' : 'legacy';
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function sectionFieldValues(section, labels) {
|
|
452
|
-
return evidenceFieldValues(section || '', labels);
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function validateVerificationPlan(taskPath, content, errors) {
|
|
456
|
-
const section = extractSection(content, 'Verification Plan') || '';
|
|
457
|
-
const commands = sectionFieldValues(section, ['Command', 'Commands', 'Command(s)']);
|
|
458
|
-
if (!commands.some((value) => isExecutableCommand(value) || isJustifiedNotApplicable(value))) {
|
|
459
|
-
errors.push(`${taskPath}: Verification Plan requires a command-shaped invocation or justified N/A`);
|
|
460
|
-
}
|
|
461
|
-
if (!sectionFieldValues(section, ['Expected', 'Expected result', 'Expected proof']).some(hasObservableProof)) {
|
|
462
|
-
errors.push(`${taskPath}: Verification Plan requires an observable Expected result`);
|
|
463
|
-
}
|
|
464
|
-
const negative = sectionFieldValues(section, ['Negative path', 'Negative-path']);
|
|
465
|
-
if (!negative.some((value) => meaningfulMarkdownLines(value).length > 0 || isJustifiedNotApplicable(value))) {
|
|
466
|
-
errors.push(`${taskPath}: Verification Plan requires a negative-path disposition`);
|
|
467
|
-
}
|
|
468
|
-
const reachability = sectionFieldValues(section, ['Reachability']);
|
|
469
|
-
if (!reachability.some((value) => isConcreteReachabilityAnchor(value) || isJustifiedNotApplicable(value))) {
|
|
470
|
-
errors.push(`${taskPath}: Verification Plan requires a concrete reachability target or justified N/A`);
|
|
471
|
-
}
|
|
472
|
-
if (/^\s*(?:[-*+]\s*)?(?:\*\*)?(?:Observed|Actual|Verdict|Verification)(?:\*\*)?\s*:/im.test(section)
|
|
473
|
-
|| /^\s*(?:Base|Head)\s*:/im.test(section)
|
|
474
|
-
|| /\b(?:PASS|PASS_WITH_WARNINGS|FAIL|BLOCKED)\b/.test(section)) {
|
|
475
|
-
errors.push(`${taskPath}: Verification Plan is planned proof only and must not contain execution receipt fields or verdicts`);
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/**
|
|
480
|
-
* Extract sub-criteria IDs (e.g. R3.4) only from canonical criterion declaration
|
|
481
|
-
* positions. Incidental prose such as "see R7.2" is not an inventory entry.
|
|
482
|
-
*/
|
|
483
|
-
function extractSubCriteriaIds(requirementsText) {
|
|
484
|
-
return [...new Set(extractAcceptanceCriteria(requirementsText)
|
|
485
|
-
.map(({ id }) => id)
|
|
486
|
-
.filter((id) => !id.startsWith('R0.')))].sort();
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
function meaningfulMarkdownLines(section) {
|
|
490
|
-
if (typeof section !== 'string') return [];
|
|
491
|
-
return section.split('\n').map((rawLine) => rawLine
|
|
492
|
-
.trim()
|
|
493
|
-
.replace(/^[-*+]\s*/, '')
|
|
494
|
-
.replace(/^\[[ xX]\]\s*/, '')
|
|
495
|
-
.replace(/\*\*/g, '')
|
|
496
|
-
.trim())
|
|
497
|
-
.filter((line) => (
|
|
498
|
-
line.length >= 8
|
|
499
|
-
&& !/^\|?\s*:?-{3,}/.test(line)
|
|
500
|
-
&& !hasSpecPlaceholders(line)
|
|
501
|
-
));
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
function hasSubstantiveSection(content, headings) {
|
|
505
|
-
const section = headings.map((heading) => extractSection(content, heading)).find((value) => value !== null);
|
|
506
|
-
return meaningfulMarkdownLines(section).length > 0;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
function hasActionableTaskStep(section) {
|
|
510
|
-
if (typeof section !== 'string') return false;
|
|
511
|
-
return section.split('\n').some((line) => {
|
|
512
|
-
const match = line.match(/^\s*[-*+]\s+\[[ xX]\]\s+(?:\d+(?:\.\d+)?\s+)?(?:\(P\)\s+)?(.+?)\s*$/i);
|
|
513
|
-
if (!match) return false;
|
|
514
|
-
const action = match[1].replace(/\*\*/g, '').trim();
|
|
515
|
-
return action.length >= 4
|
|
516
|
-
&& !hasSpecPlaceholders(action)
|
|
517
|
-
&& !/^_?Requirements?\s*:/i.test(action);
|
|
518
|
-
});
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
function hasSubstantiveTaskRequirement(section) {
|
|
522
|
-
return meaningfulMarkdownLines(section).some((line) => {
|
|
523
|
-
if (/^_?Requirements?\s*:/i.test(line)) return false;
|
|
524
|
-
const match = line.match(/^(?:R)?\d+(?:\.\d+)?\s*(?:[-—:]\s*)?(.+)$/i);
|
|
525
|
-
return Boolean(match && match[1].trim().length >= 4 && !hasSpecPlaceholders(match[1]));
|
|
526
|
-
});
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
function evidenceFieldValues(section, labels) {
|
|
530
|
-
const alternation = labels
|
|
531
|
-
.map((label) => label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
532
|
-
.join('|');
|
|
533
|
-
const re = new RegExp(
|
|
534
|
-
`^\\s*(?:[-*+]\\s*)?(?:\\*\\*)?(?:${alternation})(?:\\*\\*)?\\s*:\\s*(.*?)\\s*$`,
|
|
535
|
-
'gim',
|
|
536
|
-
);
|
|
537
|
-
return [...section.matchAll(re)].map((match) => match[1].trim());
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
function isJustifiedNotApplicable(value) {
|
|
541
|
-
const match = typeof value === 'string' && value.match(/^N\/A\s*(?:[-—:]\s*)(.+)$/i);
|
|
542
|
-
return Boolean(match && match[1].trim().length >= 12 && !hasSpecPlaceholders(match[1]));
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
function isExecutableCommand(value) {
|
|
546
|
-
// Static validation can prove command shape only. Project command/script
|
|
547
|
-
// existence is runtime evidence owned by the execution receipt; resolving it
|
|
548
|
-
// here would require forbidden project inspection or command execution.
|
|
549
|
-
if (typeof value !== 'string' || hasSpecPlaceholders(value)) return false;
|
|
550
|
-
const fenced = [...value.matchAll(/`([^`]+)`/g)].map((match) => match[1].trim());
|
|
551
|
-
const candidates = fenced.length > 0 ? fenced : [value.trim()];
|
|
552
|
-
return candidates.some((command) => {
|
|
553
|
-
if (command.length < 4 || /^(?:run|execute|verify|check)\s+(?:tests?|checks?|build)$/i.test(command)) return false;
|
|
554
|
-
const first = command.split(/\s+/)[0];
|
|
555
|
-
if (!/^(?:[A-Za-z0-9_@.-]+|\.\.?\/[A-Za-z0-9_./-]+)$/.test(first)) return false;
|
|
556
|
-
return command.includes(' ')
|
|
557
|
-
|| /[/.]/.test(first)
|
|
558
|
-
|| /^(?:make|pytest|jest|vitest)$/i.test(first);
|
|
559
|
-
});
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
function hasObservableProof(value) {
|
|
563
|
-
if (typeof value !== 'string') return false;
|
|
564
|
-
const text = value.trim();
|
|
565
|
-
return text.length >= 12
|
|
566
|
-
&& !hasSpecPlaceholders(text)
|
|
567
|
-
&& /`[^`]+`|(?:^|\s)(?:src|packages|apps|lib|cmd|internal|tests?)\/|\b(?:exit(?:\s+code)?|status|error|output|result|artifact|route|record|state|field|value|response|file|path|persisted|rendered|registered|imported|mounted|invoked)\b|\d/i.test(text);
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
function hasObservableCompletionCriterion(section) {
|
|
571
|
-
return meaningfulMarkdownLines(section).some((line) => (
|
|
572
|
-
line.length >= 16
|
|
573
|
-
&& /\b(?:returns?|rejects?|writes?|persists?|creates?|deletes?|renders?|displays?|records?|exits?|fails?|equals?|contains?|remains?|transitions?|is imported|is mounted|is registered|is invoked)\b/i.test(line)
|
|
574
|
-
&& /`[^`]+`|(?:^|\s)(?:src|packages|apps|lib|cmd|internal|tests?)\/|\b(?:exit(?:\s+code)?|status|error|output|result|artifact|route|record|state|field|value|response|file|path|caller|component|service|command)\b|\d/i.test(line)
|
|
575
|
-
));
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
function isConcreteReachabilityAnchor(value) {
|
|
579
|
-
if (typeof value !== 'string') return false;
|
|
580
|
-
const anchor = value.replace(/`/g, '').trim();
|
|
581
|
-
return anchor.length >= 3
|
|
582
|
-
&& !hasSpecPlaceholders(anchor)
|
|
583
|
-
&& !/^(?:N\/A|none|unknown|pending)$/i.test(anchor)
|
|
584
|
-
&& (/[/\\.]/.test(anchor) || /^[A-Za-z_$][A-Za-z0-9_$:-]*(?:\s+[A-Za-z0-9_./:-]+)*$/.test(anchor));
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
function validateTaskSections(taskPath, content, errors) {
|
|
588
|
-
if (taskFormat(content) === 'v2') {
|
|
589
|
-
const headings = [
|
|
590
|
-
'Outcome', 'Scope and Typed Anchors', 'Changes', 'Acceptance', 'Dependencies',
|
|
591
|
-
'Verification Plan',
|
|
592
|
-
];
|
|
593
|
-
validateUniqueSectionGroups(taskPath, content, headings.map((heading) => [heading]), errors);
|
|
594
|
-
for (const heading of headings) {
|
|
595
|
-
if (!hasHeading(content, heading)) errors.push(`${taskPath}: missing ${heading}`);
|
|
596
|
-
else if (heading !== 'Dependencies' && !hasSubstantiveSection(content, [heading])) errors.push(`${taskPath}: ${heading} must be substantive`);
|
|
597
|
-
}
|
|
598
|
-
const changes = extractSection(content, 'Changes') || '';
|
|
599
|
-
if (!hasActionableTaskStep(changes)) {
|
|
600
|
-
errors.push(`${taskPath}: Changes must contain at least one substantive actionable checkbox`);
|
|
601
|
-
}
|
|
602
|
-
const acceptance = extractSection(content, 'Acceptance') || '';
|
|
603
|
-
if (!/\bR?\d+\.\d+\b/.test(acceptance)) {
|
|
604
|
-
errors.push(`${taskPath}: Acceptance must map at least one canonical RN.M criterion`);
|
|
605
|
-
}
|
|
606
|
-
validateVerificationPlan(taskPath, content, errors);
|
|
607
|
-
return;
|
|
608
|
-
}
|
|
609
|
-
validateUniqueSectionGroups(taskPath, content, [
|
|
610
|
-
['Context'],
|
|
611
|
-
['Constraints'],
|
|
612
|
-
['Steps', 'Implementation Steps'],
|
|
613
|
-
['Requirements'],
|
|
614
|
-
['Related Files'],
|
|
615
|
-
['Completion Criteria'],
|
|
616
|
-
['Evidence', 'Task Test Plan & Verification Evidence', 'Verification & Evidence'],
|
|
617
|
-
['Risk Assessment'],
|
|
618
|
-
['Execution Closure'],
|
|
619
|
-
], errors);
|
|
620
|
-
const hasContext = hasHeading(content, 'Context');
|
|
621
|
-
const hasConstraints = hasHeading(content, 'Constraints');
|
|
622
|
-
const hasSteps =
|
|
623
|
-
hasHeading(content, 'Steps') || hasHeading(content, 'Implementation Steps');
|
|
624
|
-
const hasRequirements =
|
|
625
|
-
hasHeading(content, 'Requirements') || /_Requirements:\s*[^_\n]+_/i.test(content);
|
|
626
|
-
const hasRelatedFiles = hasHeading(content, 'Related Files');
|
|
627
|
-
const hasCompletionCriteria = hasHeading(content, 'Completion Criteria');
|
|
628
|
-
// legacy heading aliases: read-compat only, no longer advertised
|
|
629
|
-
const hasEvidence =
|
|
630
|
-
hasHeading(content, 'Evidence') ||
|
|
631
|
-
hasHeading(content, 'Task Test Plan & Verification Evidence') ||
|
|
632
|
-
hasHeading(content, 'Verification & Evidence');
|
|
633
|
-
const hasRiskAssessment = hasHeading(content, 'Risk Assessment');
|
|
634
|
-
const stepsSection = extractSection(content, 'Steps')
|
|
635
|
-
|| extractSection(content, 'Implementation Steps')
|
|
636
|
-
|| '';
|
|
637
|
-
const requirementsSection = extractSection(content, 'Requirements') || '';
|
|
638
|
-
|
|
639
|
-
if (!hasContext) errors.push(`${taskPath}: missing Context`);
|
|
640
|
-
else if (!hasSubstantiveSection(content, ['Context'])) errors.push(`${taskPath}: Context section must contain substantive task context`);
|
|
641
|
-
if (!hasConstraints) errors.push(`${taskPath}: missing Constraints`);
|
|
642
|
-
else if (!hasSubstantiveSection(content, ['Constraints'])) errors.push(`${taskPath}: Constraints section must contain substantive scope or implementation constraints`);
|
|
643
|
-
if (!hasSteps) errors.push(`${taskPath}: missing Steps/Implementation Steps`);
|
|
644
|
-
else if (!hasActionableTaskStep(stepsSection)) errors.push(`${taskPath}: Steps must contain at least one substantive actionable checkbox`);
|
|
645
|
-
if (!hasRequirements) errors.push(`${taskPath}: missing Requirements mapping`);
|
|
646
|
-
else if (!hasSubstantiveTaskRequirement(requirementsSection)) errors.push(`${taskPath}: Requirements section must contain a substantive mapped requirement description`);
|
|
647
|
-
if (!hasRelatedFiles) errors.push(`${taskPath}: missing Related Files`);
|
|
648
|
-
else if (relatedFilesSection(content).rows.length === 0) errors.push(`${taskPath}: Related Files section must not be empty`);
|
|
649
|
-
if (!hasCompletionCriteria) errors.push(`${taskPath}: missing Completion Criteria`);
|
|
650
|
-
else {
|
|
651
|
-
const completion = extractSection(content, 'Completion Criteria') || '';
|
|
652
|
-
if (!hasObservableCompletionCriterion(completion)) {
|
|
653
|
-
errors.push(`${taskPath}: Completion Criteria must contain at least one observable, testable outcome`);
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
if (!hasEvidence) errors.push(`${taskPath}: missing Evidence or task test plan`);
|
|
657
|
-
if (!hasRiskAssessment) errors.push(`${taskPath}: missing Risk Assessment`);
|
|
658
|
-
if (hasEvidence) {
|
|
659
|
-
const evidenceSection = extractSection(content, 'Evidence')
|
|
660
|
-
|| extractSection(content, 'Task Test Plan & Verification Evidence')
|
|
661
|
-
|| extractSection(content, 'Verification & Evidence')
|
|
662
|
-
|| '';
|
|
663
|
-
const commandValues = evidenceFieldValues(evidenceSection, ['Command', 'Commands', 'Command(s)']);
|
|
664
|
-
if (!commandValues.some((value) => isExecutableCommand(value) || isJustifiedNotApplicable(value))) {
|
|
665
|
-
errors.push(`${taskPath}: Evidence requires an executable Command(s) value or explicit justified N/A`);
|
|
666
|
-
}
|
|
667
|
-
const expectedValues = evidenceFieldValues(evidenceSection, ['Expected proof', 'Expected result', 'Expected', 'Expect']);
|
|
668
|
-
if (!expectedValues.some(hasObservableProof)) {
|
|
669
|
-
errors.push(`${taskPath}: Evidence requires an observable expected result`);
|
|
670
|
-
}
|
|
671
|
-
const artifactValues = evidenceFieldValues(
|
|
672
|
-
evidenceSection,
|
|
673
|
-
['Inspect', 'Artifact/runtime proof', 'Artifact / runtime proof', 'Artifact proof', 'Runtime proof'],
|
|
674
|
-
);
|
|
675
|
-
if (!artifactValues.some((value) => isConcreteReachabilityAnchor(value) || hasObservableProof(value))) {
|
|
676
|
-
errors.push(`${taskPath}: Evidence requires concrete artifact or runtime proof`);
|
|
677
|
-
}
|
|
678
|
-
if (!/Runtime reachability verification/i.test(evidenceSection)) {
|
|
679
|
-
errors.push(`${taskPath}: missing Runtime reachability verification`);
|
|
680
|
-
} else {
|
|
681
|
-
const anchorValues = evidenceFieldValues(evidenceSection, ['Entrypoint/caller', 'Anchor']);
|
|
682
|
-
const concreteAnchors = anchorValues.filter(isConcreteReachabilityAnchor);
|
|
683
|
-
if (concreteAnchors.length === 0) {
|
|
684
|
-
errors.push(`${taskPath}: Runtime reachability verification must reference a concrete file path or anchor (e.g. \`src/...\` or Entrypoint/caller)`);
|
|
685
|
-
} else {
|
|
686
|
-
// Cross-check against declared Related Files paths
|
|
687
|
-
const relatedPaths = new Set(relatedFilesSection(content).rows.map((r) => r.path));
|
|
688
|
-
const referencedPaths = concreteAnchors
|
|
689
|
-
.map((anchor) => anchor.replace(/`/g, '').replace(/^\.\//, ''))
|
|
690
|
-
.filter((anchor) => /[/\\.]|^[A-Za-z0-9_-]+\.[A-Za-z0-9]+$/.test(anchor));
|
|
691
|
-
// At least one referenced path should correspond to a declared Related Files entry (or be clearly external)
|
|
692
|
-
if (referencedPaths.length > 0 && relatedPaths.size > 0) {
|
|
693
|
-
const overlaps = referencedPaths.some((p) => [...relatedPaths].some((rp) => p === rp || p.endsWith(rp) || rp.endsWith(p) || p.includes(rp.split('/').pop())));
|
|
694
|
-
if (!overlaps && !/\b(?:external|later integration task)\b/i.test(evidenceSection)) {
|
|
695
|
-
errors.push(`${taskPath}: Runtime reachability verification references no Related Files path — must anchor to a declared file`);
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
function taskDeclaresParallel(content, taskPath, errors) {
|
|
704
|
-
const steps = extractSection(content, 'Changes')
|
|
705
|
-
|| extractSection(content, 'Steps')
|
|
706
|
-
|| extractSection(content, 'Implementation Steps')
|
|
707
|
-
|| '';
|
|
708
|
-
const actionable = steps.split('\n').filter((line) => /^\s*[-*+]\s+\[[ xX]\]\s+/i.test(line));
|
|
709
|
-
const markedIndexes = [];
|
|
710
|
-
actionable.forEach((line, index) => {
|
|
711
|
-
if (/\(P\)/i.test(line)) markedIndexes.push(index);
|
|
712
|
-
});
|
|
713
|
-
if (markedIndexes.length > 1) {
|
|
714
|
-
errors.push(`${taskPath}: task-level (P) marker must appear exactly once`);
|
|
715
|
-
}
|
|
716
|
-
if (markedIndexes.some((index) => index !== 0)) {
|
|
717
|
-
errors.push(`${taskPath}: task-level (P) marker must be on the first actionable Steps checkbox`);
|
|
718
|
-
}
|
|
719
|
-
if (markedIndexes.length > 0
|
|
720
|
-
&& !/^\s*[-*+]\s+\[[ xX]\]\s+(?:\d+(?:\.\d+)?\s+)?\(P\)\s+\S/i.test(actionable[markedIndexes[0]])) {
|
|
721
|
-
errors.push(`${taskPath}: (P) must follow the optional step number on an actionable checkbox`);
|
|
722
|
-
}
|
|
723
|
-
return markedIndexes.length > 0;
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
/**
|
|
727
|
-
* Task files are usually created from the scaffold template, so a task can start as
|
|
728
|
-
* a stub full of `{{...}}` placeholders, and nothing guarantees the model FILLED it.
|
|
729
|
-
* An unfilled `{{...}}` is an incomplete task — SKILL.md: "Leave NO {{...}} placeholder ... fails DoCT" —
|
|
730
|
-
* so it is a hard error here. A `.../` path fragment is a not-yet-resolved path
|
|
731
|
-
* placeholder; it is only a warning, because it usually survives in prose Steps
|
|
732
|
-
* while the Related Files table (which spec-ground.cjs does verify) is already
|
|
733
|
-
* concrete. Matching `\.\.\.\/` (three dots + slash) avoids flagging a relative
|
|
734
|
-
* `../` path or a prose ellipsis.
|
|
735
|
-
*/
|
|
736
|
-
function validateTaskPlaceholders(taskPath, content, errors, warnings) {
|
|
737
|
-
const stub = content.match(/\{\{[^}\n]+\}\}/);
|
|
738
|
-
if (stub) {
|
|
739
|
-
errors.push(`${taskPath}: unfilled scaffold placeholder ${stub[0]} — task stub was not completed`);
|
|
740
|
-
}
|
|
741
|
-
const bracket = content.match(SPEC_BRACKET_PLACEHOLDER_RE);
|
|
742
|
-
if (bracket) {
|
|
743
|
-
errors.push(`${taskPath}: unresolved square-bracket placeholder ${bracket[0]}`);
|
|
744
|
-
}
|
|
745
|
-
if (/\.\.\.\//.test(content)) {
|
|
746
|
-
warnings.push(`${taskPath}: contains a '.../' path placeholder — replace with a concrete path`);
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
function markdownHeaders(content, name) {
|
|
751
|
-
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
752
|
-
return [...content.matchAll(new RegExp(`^\\*\\*${escaped}:\\*\\*\\s*(.*?)\\s*$`, 'gim'))]
|
|
753
|
-
.map((match) => match[1].trim());
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
function expectedTaskId(taskPath) {
|
|
757
|
-
return taskPath.match(TASK_IDENTITY_RE)?.[1] || null;
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
function validateTaskIdentity(taskPath, content, entry, errors) {
|
|
761
|
-
const expected = expectedTaskId(taskPath);
|
|
762
|
-
if (!expected) return;
|
|
763
|
-
if (entry?.id !== expected) {
|
|
764
|
-
errors.push(`${taskPath}: task_registry id must match path-derived id ${expected} (got ${String(entry?.id)})`);
|
|
765
|
-
}
|
|
766
|
-
const headings = [...content.matchAll(/^#\s+Task\s+(R\d+-\d{2})\s*:/gim)].map((match) => match[1].toUpperCase());
|
|
767
|
-
if (headings.length !== 1) {
|
|
768
|
-
errors.push(`${taskPath}: canonical task heading must appear exactly once`);
|
|
769
|
-
} else if (headings[0] !== expected) {
|
|
770
|
-
errors.push(`${taskPath}: Markdown heading id ${headings[0]} must match path-derived id ${expected}`);
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
function isIsoTimestamp(value) {
|
|
775
|
-
return typeof value === 'string'
|
|
776
|
-
&& /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
|
|
777
|
-
&& Number.isFinite(Date.parse(value));
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
function validateTaskLifecycle(taskPath, entry, errors) {
|
|
781
|
-
if (!isPlainObject(entry) || !TASK_STATUS_VALUES.has(entry.status)) return;
|
|
782
|
-
const label = `spec.json.task_registry.${taskPath}`;
|
|
783
|
-
const blocker = entry.blocker;
|
|
784
|
-
if (blocker !== null && (typeof blocker !== 'string' || blocker.trim() === '')) {
|
|
785
|
-
errors.push(`${label}.blocker: must be null or a non-empty string`);
|
|
786
|
-
}
|
|
787
|
-
for (const field of ['started_at', 'completed_at', 'last_updated_at']) {
|
|
788
|
-
if (entry[field] !== null && !isIsoTimestamp(entry[field])) {
|
|
789
|
-
errors.push(`${label}.${field}: must be null or an ISO 8601 timestamp`);
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
if (entry.status === 'pending') {
|
|
794
|
-
if (blocker !== null) errors.push(`${label}: pending status requires blocker null`);
|
|
795
|
-
if (entry.started_at !== null || entry.completed_at !== null) {
|
|
796
|
-
errors.push(`${label}: pending status requires started_at and completed_at null`);
|
|
797
|
-
}
|
|
798
|
-
} else if (entry.status === 'in_progress') {
|
|
799
|
-
if (blocker !== null) errors.push(`${label}: in_progress status requires blocker null`);
|
|
800
|
-
if (!isIsoTimestamp(entry.started_at) || !isIsoTimestamp(entry.last_updated_at)) {
|
|
801
|
-
errors.push(`${label}: in_progress status requires started_at and last_updated_at timestamps`);
|
|
802
|
-
} else if (Date.parse(entry.last_updated_at) < Date.parse(entry.started_at)) {
|
|
803
|
-
errors.push(`${label}: in_progress timestamps must satisfy started_at <= last_updated_at`);
|
|
804
|
-
}
|
|
805
|
-
if (entry.completed_at !== null) errors.push(`${label}: in_progress status requires completed_at null`);
|
|
806
|
-
} else if (entry.status === 'blocked') {
|
|
807
|
-
if (typeof blocker !== 'string' || blocker.trim() === '') {
|
|
808
|
-
errors.push(`${label}: blocked status requires a non-empty blocker`);
|
|
809
|
-
}
|
|
810
|
-
if (!isIsoTimestamp(entry.last_updated_at)) {
|
|
811
|
-
errors.push(`${label}: blocked status requires last_updated_at timestamp`);
|
|
812
|
-
} else if (isIsoTimestamp(entry.started_at)
|
|
813
|
-
&& Date.parse(entry.last_updated_at) < Date.parse(entry.started_at)) {
|
|
814
|
-
errors.push(`${label}: started blocked timestamps must satisfy started_at <= last_updated_at`);
|
|
815
|
-
}
|
|
816
|
-
if (entry.completed_at !== null) errors.push(`${label}: blocked status requires completed_at null`);
|
|
817
|
-
} else if (entry.status === 'done') {
|
|
818
|
-
if (blocker !== null) errors.push(`${label}: done status requires blocker null`);
|
|
819
|
-
if (![entry.started_at, entry.completed_at, entry.last_updated_at].every(isIsoTimestamp)) {
|
|
820
|
-
errors.push(`${label}: done status requires started_at, completed_at, and last_updated_at timestamps`);
|
|
821
|
-
} else if (
|
|
822
|
-
Date.parse(entry.completed_at) < Date.parse(entry.started_at)
|
|
823
|
-
|| Date.parse(entry.last_updated_at) < Date.parse(entry.completed_at)
|
|
824
|
-
) {
|
|
825
|
-
errors.push(`${label}: done timestamps must satisfy started_at <= completed_at <= last_updated_at`);
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
function validateTaskDependencyHeader(taskPath, content, entry, specReady, errors) {
|
|
831
|
-
const registryDependencies = Array.isArray(entry?.dependencies) ? entry.dependencies : [];
|
|
832
|
-
const headers = markdownHeaders(content, 'Dependencies');
|
|
833
|
-
if (headers.length > 1) {
|
|
834
|
-
errors.push(`${taskPath}: Dependencies header must appear exactly once`);
|
|
835
|
-
}
|
|
836
|
-
let raw = headers[0];
|
|
837
|
-
if (taskFormat(content) === 'v2') {
|
|
838
|
-
const section = extractSection(content, 'Dependencies') || '';
|
|
839
|
-
const values = section.split('\n')
|
|
840
|
-
.map((line) => line.trim().replace(/^[-*+]\s*/, '').replace(/^`|`$/g, '').trim())
|
|
841
|
-
.filter(Boolean);
|
|
842
|
-
raw = values.length === 1 && /^(?:none|n\/a)$/i.test(values[0])
|
|
843
|
-
? values[0]
|
|
844
|
-
: values.join(',');
|
|
845
|
-
}
|
|
846
|
-
if (raw === undefined) {
|
|
847
|
-
if (registryDependencies.length > 0 || specReady) {
|
|
848
|
-
errors.push(`${taskPath}: missing Dependencies header; required for dependency parity before readiness`);
|
|
849
|
-
}
|
|
850
|
-
return;
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
let markdownDependencies = [];
|
|
854
|
-
if (!/^(?:none|n\/a)$/i.test(raw)) {
|
|
855
|
-
markdownDependencies = raw.split(',').map((dependency) => dependency.trim().replace(/^`|`$/g, ''));
|
|
856
|
-
const seen = new Set();
|
|
857
|
-
for (const dependency of markdownDependencies) {
|
|
858
|
-
if (!TASK_PATH_RE.test(dependency)) {
|
|
859
|
-
errors.push(`${taskPath}: Dependencies header contains invalid task path ${dependency || '(empty)'}`);
|
|
860
|
-
} else if (dependency === taskPath) {
|
|
861
|
-
errors.push(`${taskPath}: Dependencies header cannot contain itself`);
|
|
862
|
-
} else if (seen.has(dependency)) {
|
|
863
|
-
errors.push(`${taskPath}: Dependencies header contains duplicate dependency ${dependency}`);
|
|
864
|
-
}
|
|
865
|
-
seen.add(dependency);
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
const registrySorted = [...registryDependencies].sort();
|
|
870
|
-
const markdownSorted = [...markdownDependencies].sort();
|
|
871
|
-
if (JSON.stringify(registrySorted) !== JSON.stringify(markdownSorted)) {
|
|
872
|
-
errors.push(
|
|
873
|
-
`${taskPath}: dependency drift between spec.json task_registry ${JSON.stringify(registrySorted)} ` +
|
|
874
|
-
`and Markdown Dependencies header ${JSON.stringify(markdownSorted)}`,
|
|
875
|
-
);
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
function validateTaskStatusHeader(taskPath, content, entry, specReady, errors) {
|
|
880
|
-
const headers = markdownHeaders(content, 'Status');
|
|
881
|
-
if (headers.length > 1) {
|
|
882
|
-
errors.push(`${taskPath}: Status header must appear exactly once`);
|
|
883
|
-
}
|
|
884
|
-
const status = headers[0];
|
|
885
|
-
if (status === undefined) {
|
|
886
|
-
errors.push(`${taskPath}: missing Status header required for registry parity`);
|
|
887
|
-
return;
|
|
888
|
-
}
|
|
889
|
-
if (!TASK_STATUS_VALUES.has(status)) {
|
|
890
|
-
errors.push(`${taskPath}: Status must be one of ${[...TASK_STATUS_VALUES].join(', ')} (got ${status})`);
|
|
891
|
-
}
|
|
892
|
-
if (specReady && status === 'blocked') {
|
|
893
|
-
errors.push(`${taskPath}: Status cannot be blocked while ready_for_implementation is true`);
|
|
894
|
-
}
|
|
895
|
-
if (typeof entry?.status === 'string' && status !== entry.status) {
|
|
896
|
-
errors.push(
|
|
897
|
-
`${taskPath}: status drift between spec.json task_registry ${entry.status} ` +
|
|
898
|
-
`and Markdown Status header ${status}`,
|
|
899
|
-
);
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
/**
|
|
904
|
-
* Each phase completion must carry its own timestamp. Reusing `timestamps.init`
|
|
905
|
-
* for a later phase is forbidden (SKILL.md spec.json Update Rules). This used to
|
|
906
|
-
* be a prompt-only rule the model had to remember; here it is a hard backstop.
|
|
907
|
-
*/
|
|
908
|
-
function validateTimestamps(spec, errors) {
|
|
909
|
-
const ts = spec.timestamps;
|
|
910
|
-
for (const field of ['created_at', 'updated_at']) {
|
|
911
|
-
if (Object.prototype.hasOwnProperty.call(spec, field) && !isIsoTimestamp(spec[field])) {
|
|
912
|
-
errors.push(`spec.json.${field}: must be an ISO 8601 timestamp`);
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
if (ts === undefined) return;
|
|
916
|
-
if (!isPlainObject(ts)) {
|
|
917
|
-
errors.push('spec.json.timestamps: must be an object when present');
|
|
918
|
-
return;
|
|
919
|
-
}
|
|
920
|
-
for (const [field, value] of Object.entries(ts)) {
|
|
921
|
-
if (value !== null && !isIsoTimestamp(value)) {
|
|
922
|
-
errors.push(`spec.json.timestamps.${field}: must be null or an ISO 8601 timestamp`);
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
const init = ts.init;
|
|
926
|
-
if (!isIsoTimestamp(init)) return;
|
|
927
|
-
|
|
928
|
-
for (const phase of ['requirements_done', 'design_done', 'tasks_done']) {
|
|
929
|
-
if (ts[phase] && ts[phase] === init) {
|
|
930
|
-
errors.push(
|
|
931
|
-
`spec.json.timestamps.${phase}: reuses init timestamp (${init}); ` +
|
|
932
|
-
'each phase must stamp its own completion time',
|
|
933
|
-
);
|
|
934
|
-
}
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
function validateFinalLifecycleTimestamps(spec, taskFiles, context, errors) {
|
|
939
|
-
if (spec.ready_for_implementation !== true) return;
|
|
940
|
-
const ts = isPlainObject(spec.timestamps) ? spec.timestamps : {};
|
|
941
|
-
const required = ['init', 'requirements_done', 'design_done', 'validation_done'];
|
|
942
|
-
if (taskFiles.length > 0) required.push('tasks_done');
|
|
943
|
-
if (explicitResearchPath(spec) === 'research.md') required.push('research_done');
|
|
944
|
-
for (const field of required) {
|
|
945
|
-
if (!isIsoTimestamp(ts[field])) {
|
|
946
|
-
errors.push(`spec.json.timestamps.${field}: ready handoff requires an ISO 8601 timestamp`);
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
for (const field of ['created_at', 'updated_at']) {
|
|
950
|
-
if (!isIsoTimestamp(spec[field])) {
|
|
951
|
-
errors.push(`spec.json.${field}: ready handoff requires an ISO 8601 timestamp`);
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
if (![spec.created_at, ts.init, ts.requirements_done, ts.design_done, ts.validation_done, spec.updated_at]
|
|
956
|
-
.every(isIsoTimestamp)) return;
|
|
957
|
-
const ordered = [
|
|
958
|
-
['created_at', spec.created_at],
|
|
959
|
-
['timestamps.init', ts.init],
|
|
960
|
-
['timestamps.requirements_done', ts.requirements_done],
|
|
961
|
-
...(isIsoTimestamp(ts.research_done) ? [['timestamps.research_done', ts.research_done]] : []),
|
|
962
|
-
['timestamps.design_done', ts.design_done],
|
|
963
|
-
...(isIsoTimestamp(ts.tasks_done) ? [['timestamps.tasks_done', ts.tasks_done]] : []),
|
|
964
|
-
...(isIsoTimestamp(ts.review_done) ? [['timestamps.review_done', ts.review_done]] : []),
|
|
965
|
-
['timestamps.validation_done', ts.validation_done],
|
|
966
|
-
];
|
|
967
|
-
for (let index = 1; index < ordered.length; index += 1) {
|
|
968
|
-
if (Date.parse(ordered[index][1]) < Date.parse(ordered[index - 1][1])) {
|
|
969
|
-
errors.push(
|
|
970
|
-
`spec.json final lifecycle timestamps must be ordered; ${ordered[index][0]} precedes ${ordered[index - 1][0]}`,
|
|
971
|
-
);
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
if (Date.parse(spec.updated_at) !== Date.parse(ts.validation_done)) {
|
|
975
|
-
errors.push('spec.json.updated_at must describe the same final state as timestamps.validation_done');
|
|
976
|
-
}
|
|
977
|
-
const lastValidatedAt = spec.validation?.last_validated_at;
|
|
978
|
-
if (lastValidatedAt !== undefined && lastValidatedAt !== null) {
|
|
979
|
-
if (!isIsoTimestamp(lastValidatedAt)) {
|
|
980
|
-
errors.push('spec.json.validation.last_validated_at: must be null or an ISO 8601 timestamp');
|
|
981
|
-
} else if (Date.parse(lastValidatedAt) !== Date.parse(ts.validation_done)) {
|
|
982
|
-
errors.push('spec.json.validation.last_validated_at must describe the same final state as timestamps.validation_done');
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
/** Normalize a fenced code block body for byte-comparison: trim + collapse
|
|
988
|
-
* trailing whitespace per line + drop blank edges. Keeps inner structure so a
|
|
989
|
-
* real field rename (user_name vs userName) still differs. */
|
|
990
|
-
function normalizeBlock(body) {
|
|
991
|
-
return body
|
|
992
|
-
.split('\n')
|
|
993
|
-
.map((line) => line.replace(/\s+$/, ''))
|
|
994
|
-
.join('\n')
|
|
995
|
-
.replace(/^\n+|\n+$/g, '');
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
/**
|
|
999
|
-
* Parse canonical contract definitions from design.md. A definition is an HTML
|
|
1000
|
-
* marker `<!-- contract:NAME -->` immediately followed by a fenced code block.
|
|
1001
|
-
* Returns a Map<name, normalizedBody>. Empty when the spec uses no markers —
|
|
1002
|
-
* which makes the whole cross-layer check opt-in (no effect on legacy specs).
|
|
1003
|
-
*/
|
|
1004
|
-
function extractContractDefs(designText, label, errors) {
|
|
1005
|
-
const defs = new Map();
|
|
1006
|
-
const re = /<!--\s*contract:([A-Za-z0-9_.-]+)\s*-->\s*\n```[^\n]*\n([\s\S]*?)\n```/g;
|
|
1007
|
-
let match;
|
|
1008
|
-
while ((match = re.exec(designText)) !== null) {
|
|
1009
|
-
if (defs.has(match[1])) {
|
|
1010
|
-
errors.push(`${label}: duplicate canonical contract definition "${match[1]}"`);
|
|
1011
|
-
continue;
|
|
1012
|
-
}
|
|
1013
|
-
defs.set(match[1], normalizeBlock(match[2]));
|
|
1014
|
-
}
|
|
1015
|
-
return defs;
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
/**
|
|
1019
|
-
* From a task body, return the contracts it claims plus its local contract
|
|
1020
|
-
* copies. Shape: { names: string[], blocks: Map<name, string>, firstBlock: string|null }
|
|
1021
|
-
* `names` come from a `Contracts: A, B` line. `blocks` maps every
|
|
1022
|
-
* `<!-- contract:NAME -->` marker in the task to its fenced block, so a task
|
|
1023
|
-
* carrying multiple contracts has EACH copy verified (not just the first).
|
|
1024
|
-
* `firstBlock` keeps the legacy fallback for single-contract tasks that copy
|
|
1025
|
-
* the block without repeating the marker.
|
|
1026
|
-
*/
|
|
1027
|
-
function extractTaskContracts(taskText, taskPath, errors) {
|
|
1028
|
-
const names = [];
|
|
1029
|
-
const nameLines = [...taskText.matchAll(/^\s*Contracts:\s*([^\n]+)$/gim)];
|
|
1030
|
-
if (nameLines.length > 1) {
|
|
1031
|
-
errors.push(`${taskPath}: Contracts declaration must appear at most once`);
|
|
1032
|
-
}
|
|
1033
|
-
const seenNames = new Set();
|
|
1034
|
-
for (const nameLine of nameLines) {
|
|
1035
|
-
for (const token of nameLine[1].split(',')) {
|
|
1036
|
-
const name = token.trim();
|
|
1037
|
-
if (!name) continue;
|
|
1038
|
-
if (seenNames.has(name)) {
|
|
1039
|
-
errors.push(`${taskPath}: Contracts declaration contains duplicate name "${name}"`);
|
|
1040
|
-
continue;
|
|
1041
|
-
}
|
|
1042
|
-
seenNames.add(name);
|
|
1043
|
-
names.push(name);
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
const blocks = extractContractDefs(taskText, taskPath, errors); // same marker+fence grammar as design.md
|
|
1047
|
-
const blockMatch = taskText.match(/```[^\n]*\n([\s\S]*?)\n```/);
|
|
1048
|
-
const firstBlock = blockMatch ? normalizeBlock(blockMatch[1]) : null;
|
|
1049
|
-
return { names, blocks, firstBlock };
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
function implementationObligationClosure(content, label, errors) {
|
|
1053
|
-
const allIds = [];
|
|
1054
|
-
const re = /<!--\s*implementation-obligation:([a-z0-9]+(?:[._-][a-z0-9]+)*)\s*-->/gi;
|
|
1055
|
-
let match;
|
|
1056
|
-
while ((match = re.exec(content)) !== null) {
|
|
1057
|
-
allIds.push(match[1].toLowerCase());
|
|
1058
|
-
}
|
|
1059
|
-
const counts = new Map();
|
|
1060
|
-
for (const id of allIds) counts.set(id, (counts.get(id) || 0) + 1);
|
|
1061
|
-
for (const [id, count] of counts) {
|
|
1062
|
-
if (count > 1) errors.push(`${label}: duplicate implementation obligation marker ${id}`);
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
const closureCount = headingCount(content, 'Execution Closure');
|
|
1066
|
-
const section = closureCount === 1 ? (extractSection(content, 'Execution Closure') || '') : '';
|
|
1067
|
-
const closureIds = [];
|
|
1068
|
-
const blocks = new Map();
|
|
1069
|
-
const closureRe = /<!--\s*implementation-obligation:([a-z0-9]+(?:[._-][a-z0-9]+)*)\s*-->/gi;
|
|
1070
|
-
const markers = [...section.matchAll(closureRe)];
|
|
1071
|
-
for (const [index, marker] of markers.entries()) {
|
|
1072
|
-
const id = marker[1].toLowerCase();
|
|
1073
|
-
closureIds.push(id);
|
|
1074
|
-
const blockStart = marker.index + marker[0].length;
|
|
1075
|
-
const blockEnd = markers[index + 1]?.index ?? section.length;
|
|
1076
|
-
if (!blocks.has(id)) blocks.set(id, section.slice(blockStart, blockEnd).trim());
|
|
1077
|
-
}
|
|
1078
|
-
if (allIds.length > 0 && closureCount !== 1) {
|
|
1079
|
-
errors.push(`${label}: implementation obligation markers require exactly one Execution Closure section`);
|
|
1080
|
-
} else if (allIds.length > closureIds.length) {
|
|
1081
|
-
const closureCounts = new Map();
|
|
1082
|
-
for (const id of closureIds) closureCounts.set(id, (closureCounts.get(id) || 0) + 1);
|
|
1083
|
-
for (const [id, count] of counts) {
|
|
1084
|
-
if ((closureCounts.get(id) || 0) < count) {
|
|
1085
|
-
errors.push(`${label}: implementation obligation marker ${id} must be inside Execution Closure`);
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
const notApplicable = closureCount === 1
|
|
1090
|
-
&& closureIds.length === 0
|
|
1091
|
-
&& isJustifiedNotApplicable(section.trim());
|
|
1092
|
-
return {
|
|
1093
|
-
ids: [...new Set(closureIds)],
|
|
1094
|
-
section,
|
|
1095
|
-
headingCount: closureCount,
|
|
1096
|
-
blocks,
|
|
1097
|
-
notApplicable,
|
|
1098
|
-
};
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
const RELATED_FILE_ACTIONS = new Set(['create', 'modify', 'delete', 'read']);
|
|
1102
|
-
|
|
1103
|
-
function relatedFilesSection(content) {
|
|
1104
|
-
const lines = content.split('\n');
|
|
1105
|
-
const headingIndex = lines.findIndex((line) => /^##+\s+Related Files\s*$/i.test(line));
|
|
1106
|
-
if (headingIndex < 0) return { present: false, rows: [] };
|
|
1107
|
-
const rows = [];
|
|
1108
|
-
for (let index = headingIndex + 1; index < lines.length; index += 1) {
|
|
1109
|
-
const line = lines[index];
|
|
1110
|
-
if (/^##+\s+/.test(line)) break;
|
|
1111
|
-
if (!line.trim().startsWith('|')) continue;
|
|
1112
|
-
const cells = line.split('|').map((cell) => cell.trim());
|
|
1113
|
-
const filePath = (cells[1] || '').replace(/`/g, '').trim();
|
|
1114
|
-
const action = (cells[2] || '').replace(/`/g, '').trim().toLowerCase();
|
|
1115
|
-
if (!filePath || filePath.toLowerCase() === 'path' || /^-+$/.test(filePath)) continue;
|
|
1116
|
-
rows.push({ path: filePath, action });
|
|
1117
|
-
}
|
|
1118
|
-
return { present: true, rows };
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
function unsafeRelatedPath(filePath) {
|
|
1122
|
-
return path.isAbsolute(filePath)
|
|
1123
|
-
|| /^[A-Za-z]:[\\/]/.test(filePath)
|
|
1124
|
-
|| filePath.split(/[\\/]+/).includes('..');
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
function validateRelatedFiles(taskPath, content, errors) {
|
|
1128
|
-
const section = relatedFilesSection(content);
|
|
1129
|
-
if (!section.present || section.rows.length === 0) return;
|
|
1130
|
-
for (const row of section.rows) {
|
|
1131
|
-
if (!RELATED_FILE_ACTIONS.has(row.action)) {
|
|
1132
|
-
errors.push(`${taskPath}: unsupported Related Files action "${row.action}" for ${row.path}`);
|
|
1133
|
-
}
|
|
1134
|
-
if (unsafeRelatedPath(row.path)) {
|
|
1135
|
-
errors.push(`${taskPath}: Related Files path must be relative and stay within work root: ${row.path}`);
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
function parseMarkdownTable(section, expectedHeaders) {
|
|
1141
|
-
if (typeof section !== 'string') return [];
|
|
1142
|
-
const lines = section.split('\n');
|
|
1143
|
-
for (let index = 0; index + 1 < lines.length; index += 1) {
|
|
1144
|
-
if (!/^\s*\|/.test(lines[index]) || !/^\s*\|(?:\s*:?-{3,}:?\s*\|)+\s*$/.test(lines[index + 1])) continue;
|
|
1145
|
-
const headers = lines[index].split('|').slice(1, -1).map((cell) => cell.trim().toLowerCase());
|
|
1146
|
-
if (JSON.stringify(headers) !== JSON.stringify(expectedHeaders.map((header) => header.toLowerCase()))) continue;
|
|
1147
|
-
const rows = [];
|
|
1148
|
-
for (let rowIndex = index + 2; rowIndex < lines.length && /^\s*\|/.test(lines[rowIndex]); rowIndex += 1) {
|
|
1149
|
-
const cells = lines[rowIndex].split('|').slice(1, -1)
|
|
1150
|
-
.map((cell) => cell.trim().replace(/^`|`$/g, '').trim());
|
|
1151
|
-
if (cells.some(Boolean)) rows.push(cells);
|
|
1152
|
-
}
|
|
1153
|
-
return rows;
|
|
1154
|
-
}
|
|
1155
|
-
return [];
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
function typedAnchors(content, label, namespace, errors) {
|
|
1159
|
-
const section = extractSection(content, namespace === 'design' ? 'Typed Anchors' : 'Scope and Typed Anchors') || '';
|
|
1160
|
-
const rows = parseMarkdownTable(section, ['ID', 'Type', 'Target', 'Role']);
|
|
1161
|
-
if (rows.length === 0) {
|
|
1162
|
-
const consumesDesign = namespace === 'task'
|
|
1163
|
-
&& sectionFieldValues(section, ['Canonical design anchors consumed']).join(' ').match(/\bA-D-\d{2}\b/);
|
|
1164
|
-
if (!consumesDesign) errors.push(`${label}: typed anchor table ID/Type/Target/Role is required unless the task only consumes canonical design anchors`);
|
|
1165
|
-
return [];
|
|
1166
|
-
}
|
|
1167
|
-
const taskId = namespace === 'task' ? expectedTaskId(label) : null;
|
|
1168
|
-
const namespacePattern = namespace === 'design'
|
|
1169
|
-
? /^A-D-\d{2}$/
|
|
1170
|
-
: new RegExp(`^A-${taskId || 'INVALID'}-\\d{2}$`);
|
|
1171
|
-
return rows.map(([id = '', rawType = '', target = '', role = '']) => {
|
|
1172
|
-
const type = rawType.toLowerCase();
|
|
1173
|
-
if (!ANCHOR_TYPES.has(type)) {
|
|
1174
|
-
errors.push(`${label}: typed anchor ${id || '(missing id)'} has unknown Type ${rawType || '(empty)'}`);
|
|
1175
|
-
}
|
|
1176
|
-
if (!namespacePattern.test(id)) {
|
|
1177
|
-
errors.push(`${label}: typed anchor ${id || '(missing id)'} has invalid ${namespace} namespace`);
|
|
1178
|
-
}
|
|
1179
|
-
if (!target || hasSpecPlaceholders(target)) {
|
|
1180
|
-
errors.push(`${label}: typed anchor ${id || '(missing id)'} requires a concrete Target`);
|
|
1181
|
-
}
|
|
1182
|
-
if (!role || hasSpecPlaceholders(role)) {
|
|
1183
|
-
errors.push(`${label}: typed anchor ${id || '(missing id)'} requires a concrete Role`);
|
|
1184
|
-
}
|
|
1185
|
-
return { id, type, target, role, label, namespace };
|
|
1186
|
-
});
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
function validateTypedAnchorInventory(designText, taskContents, errors) {
|
|
1190
|
-
const designAnchors = typedAnchors(designText, 'design.md', 'design', errors);
|
|
1191
|
-
const allAnchors = [...designAnchors];
|
|
1192
|
-
for (const [taskPath, content] of taskContents) {
|
|
1193
|
-
if (taskFormat(content) === 'v2') allAnchors.push(...typedAnchors(content, taskPath, 'task', errors));
|
|
1194
|
-
}
|
|
1195
|
-
const byId = new Map();
|
|
1196
|
-
const designByTarget = new Map(designAnchors.map((anchor) => [`${anchor.type}\0${anchor.target}`, anchor]));
|
|
1197
|
-
for (const anchor of allAnchors) {
|
|
1198
|
-
if (byId.has(anchor.id)) {
|
|
1199
|
-
errors.push(`${anchor.label}: duplicate typed anchor ID ${anchor.id}; first declared in ${byId.get(anchor.id).label}`);
|
|
1200
|
-
} else byId.set(anchor.id, anchor);
|
|
1201
|
-
if (anchor.namespace === 'task') {
|
|
1202
|
-
const canonical = designByTarget.get(`${anchor.type}\0${anchor.target}`);
|
|
1203
|
-
if (canonical) {
|
|
1204
|
-
errors.push(`${anchor.label}: typed anchor ${anchor.id} duplicates canonical design target ${canonical.id}; reference the design anchor ID instead`);
|
|
1205
|
-
}
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
for (const [taskPath, content] of taskContents) {
|
|
1209
|
-
if (taskFormat(content) !== 'v2') continue;
|
|
1210
|
-
for (const reference of content.match(/\bA-D-\d{2}\b/g) || []) {
|
|
1211
|
-
if (!byId.has(reference)) errors.push(`${taskPath}: dangling canonical design anchor reference ${reference}`);
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
|
-
for (const reference of designText.match(/\bA-D-\d{2}\b/g) || []) {
|
|
1215
|
-
if (!byId.has(reference)) errors.push(`design.md: dangling canonical design anchor reference ${reference}`);
|
|
1216
|
-
}
|
|
1217
|
-
return { anchors: allAnchors, designAnchors, byId };
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
function validateArtifactDeclaration(taskPath, entry, errors) {
|
|
1221
|
-
if (!Object.prototype.hasOwnProperty.call(entry || {}, 'artifacts')) return;
|
|
1222
|
-
const artifacts = entry.artifacts;
|
|
1223
|
-
if (!Array.isArray(artifacts) || artifacts.length === 0) {
|
|
1224
|
-
errors.push(`spec.json.task_registry.${taskPath}.artifacts: must be a non-empty array of safe relative paths`);
|
|
1225
|
-
return;
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
const seen = new Set();
|
|
1229
|
-
for (const artifact of artifacts) {
|
|
1230
|
-
const invalid = typeof artifact !== 'string'
|
|
1231
|
-
|| artifact.trim() === ''
|
|
1232
|
-
|| artifact !== artifact.trim()
|
|
1233
|
-
|| /^\{\{[^}]+\}\}$/.test(artifact)
|
|
1234
|
-
|| /^(?:TBD|TODO|N\/A|NA|NONE|UNKNOWN|PENDING|PLACEHOLDER|REPLACE_ME)$/i.test(artifact)
|
|
1235
|
-
|| /^(?:[A-Za-z]:[\\/]|[\\/]|https?:\/\/)/.test(artifact)
|
|
1236
|
-
|| artifact.split(/[\\/]+/).includes('..')
|
|
1237
|
-
|| seen.has(artifact);
|
|
1238
|
-
if (invalid) {
|
|
1239
|
-
errors.push(`spec.json.task_registry.${taskPath}.artifacts: each entry must be a unique safe relative path (${String(artifact)})`);
|
|
1240
|
-
continue;
|
|
1241
|
-
}
|
|
1242
|
-
seen.add(artifact);
|
|
1243
|
-
}
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
function workflowPolicyContext(spec, errors) {
|
|
1247
|
-
if (Object.prototype.hasOwnProperty.call(spec, 'workflow_policy')) {
|
|
1248
|
-
const result = POLICY.validateWorkflowPolicySnapshot(spec.workflow_policy);
|
|
1249
|
-
if (!result.valid) {
|
|
1250
|
-
for (const error of result.errors) errors.push(error);
|
|
1251
|
-
return { explicit: true, policy: null, legacy: false, taskRequired: false, strict: false };
|
|
1252
|
-
}
|
|
1253
|
-
let policy;
|
|
1254
|
-
try {
|
|
1255
|
-
policy = POLICY.readWorkflowPolicySnapshot(spec);
|
|
1256
|
-
} catch (error) {
|
|
1257
|
-
errors.push(`spec.json.workflow_policy: shared policy adapter failed (${error.message})`);
|
|
1258
|
-
return { explicit: true, policy: null, legacy: false, taskRequired: false, strict: false };
|
|
1259
|
-
}
|
|
1260
|
-
return {
|
|
1261
|
-
explicit: true,
|
|
1262
|
-
policy,
|
|
1263
|
-
legacy: spec.workflow_policy.version === '1',
|
|
1264
|
-
taskRequired: spec.coordination?.tasks_required === true,
|
|
1265
|
-
strict: policy.assurance_level === 'Strict',
|
|
1266
|
-
};
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
const legacyTier = spec.design_context?.execution_tier;
|
|
1270
|
-
errors.push(
|
|
1271
|
-
legacyTier
|
|
1272
|
-
? 'spec.json.workflow_policy: missing persisted snapshot; design_context.execution_tier is a read-only legacy adapter and cannot establish workflow authority or readiness'
|
|
1273
|
-
: 'spec.json.workflow_policy: missing persisted snapshot; the current spec boundary requires an explicit workflow_policy',
|
|
1274
|
-
);
|
|
1275
|
-
return { explicit: false, policy: null, legacy: false, taskRequired: false, strict: false };
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
function validateFeatureReceipt(spec, context, errors, warnings, receiptArtifact) {
|
|
1279
|
-
if (!receiptArtifact) return;
|
|
1280
|
-
let body;
|
|
1281
|
-
try {
|
|
1282
|
-
body = fs.readFileSync(receiptArtifact.path, 'utf8');
|
|
1283
|
-
} catch (error) {
|
|
1284
|
-
errors.push(`${FEATURE_RECEIPT_FILE}: cannot be read (${error.message})`);
|
|
1285
|
-
return;
|
|
1286
|
-
}
|
|
1287
|
-
if (/^\s*Verification:\s*PENDING\s*$/im.test(body)) {
|
|
1288
|
-
if (context.legacy) {
|
|
1289
|
-
warnings.push(`${FEATURE_RECEIPT_FILE}: legacy v1 PENDING receipt is read-compatible only and is not readiness proof`);
|
|
1290
|
-
return;
|
|
1291
|
-
}
|
|
1292
|
-
errors.push(`${FEATURE_RECEIPT_FILE}: premature execution receipt is forbidden in a Specs v2 pre-implementation artifact`);
|
|
1293
|
-
return;
|
|
1294
|
-
}
|
|
1295
|
-
if (spec.status !== 'done') {
|
|
1296
|
-
errors.push(`${FEATURE_RECEIPT_FILE}: completed execution receipt is valid only on terminal status done, never for readiness`);
|
|
1297
|
-
return;
|
|
1298
|
-
}
|
|
1299
|
-
if (!/^\s*Verification:\s*PASS\s*$/im.test(body)) {
|
|
1300
|
-
errors.push(`${FEATURE_RECEIPT_FILE}: completed taskless proof requires literal Verification: PASS`);
|
|
1301
|
-
}
|
|
1302
|
-
if (!/^\s*Status:\s*done\s*$/im.test(body)) {
|
|
1303
|
-
errors.push(`${FEATURE_RECEIPT_FILE}: completed proof and spec.json must both use status done`);
|
|
1304
|
-
}
|
|
1305
|
-
const commands = evidenceFieldValues(body, ['Command', 'Commands', 'Command(s)']);
|
|
1306
|
-
if (!commands.some(isExecutableCommand)) {
|
|
1307
|
-
errors.push(`${FEATURE_RECEIPT_FILE}: completed proof requires an executable Command`);
|
|
1308
|
-
}
|
|
1309
|
-
const expected = evidenceFieldValues(body, ['Expected result', 'Expected proof', 'Expected']);
|
|
1310
|
-
if (!expected.some(hasObservableProof)) {
|
|
1311
|
-
errors.push(`${FEATURE_RECEIPT_FILE}: completed proof requires an observable expected result`);
|
|
1312
|
-
}
|
|
1313
|
-
const observed = evidenceFieldValues(body, ['Observed result', 'Result', 'Observed proof']);
|
|
1314
|
-
if (!observed.some(hasObservableProof)) {
|
|
1315
|
-
errors.push(`${FEATURE_RECEIPT_FILE}: completed proof requires an observable result`);
|
|
1316
|
-
}
|
|
1317
|
-
const artifacts = evidenceFieldValues(
|
|
1318
|
-
body,
|
|
1319
|
-
['Artifact/runtime proof', 'Artifact / runtime proof', 'Artifact proof', 'Runtime proof', 'Inspect'],
|
|
1320
|
-
);
|
|
1321
|
-
if (!artifacts.some((value) => isConcreteReachabilityAnchor(value) || hasObservableProof(value))) {
|
|
1322
|
-
errors.push(`${FEATURE_RECEIPT_FILE}: completed proof requires concrete artifact or runtime proof`);
|
|
1323
|
-
}
|
|
1324
|
-
const bindingPattern = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
|
|
1325
|
-
const base = evidenceFieldValues(body, ['Base'])[0];
|
|
1326
|
-
const head = evidenceFieldValues(body, ['Head'])[0];
|
|
1327
|
-
if (!bindingPattern.test(base || '') || !bindingPattern.test(head || '') || base === head) {
|
|
1328
|
-
errors.push(`${FEATURE_RECEIPT_FILE}: completed proof requires distinct 40- or 64-hex Base and Head bindings`);
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
const SPEC_PLACEHOLDER_RE = /\{\{[^}\n]+\}\}|<[^>\n]+>|(?<![A-Za-z0-9_./-])(?:TBD|TODO|N\/A|NA|NONE|UNKNOWN|PENDING|PLACEHOLDER|REPLACE_ME)(?![A-Za-z0-9_./-])|\.{3,}/i;
|
|
1333
|
-
const SPEC_BRACKET_PLACEHOLDER_RE = /\[(?:specific(?:\s+[a-z][a-z0-9 /_-]{0,60})?|target\s+users?|component\s+name)\](?!\s*\()/i;
|
|
1334
|
-
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;
|
|
1335
|
-
|
|
1336
|
-
function hasSpecPlaceholders(content) {
|
|
1337
|
-
return SPEC_PLACEHOLDER_RE.test(content)
|
|
1338
|
-
|| SPEC_BRACKET_PLACEHOLDER_RE.test(content)
|
|
1339
|
-
|| SPEC_TEMPLATE_GUIDANCE_RE.test(content);
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
function validateRequirementDialect(requirementsText, errors) {
|
|
1343
|
-
const legacy = requirementsText.match(/\bREQ-\d+\b/i);
|
|
1344
|
-
if (legacy) {
|
|
1345
|
-
errors.push(
|
|
1346
|
-
`requirements.md: ${legacy[0].toUpperCase()} is not a canonical requirement id; ` +
|
|
1347
|
-
'use Requirement N/RN with acceptance criteria RN.M (task mappings use N.M)',
|
|
1348
|
-
);
|
|
1349
|
-
}
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
function validateSemanticReadiness(spec, requirementsText, designText, context, errors) {
|
|
1353
|
-
if (
|
|
1354
|
-
spec.ready_for_implementation !== true
|
|
1355
|
-
|| !context.explicit
|
|
1356
|
-
|| context.policy?.lane === 'Direct'
|
|
1357
|
-
) return;
|
|
1358
|
-
const profile = context.policy?.lane || 'non-Direct';
|
|
1359
|
-
|
|
1360
|
-
const source = spec.scope_lock?.source;
|
|
1361
|
-
if (typeof source !== 'string' || source.trim() === '' || hasSpecPlaceholders(source)) {
|
|
1362
|
-
errors.push(`spec.json.scope_lock.source: ${profile} readiness requires a concrete scope source`);
|
|
1363
|
-
}
|
|
1364
|
-
if (hasSpecPlaceholders(requirementsText)) {
|
|
1365
|
-
errors.push(`requirements.md: ${profile} readiness rejects placeholders`);
|
|
1366
|
-
}
|
|
1367
|
-
let placeholderDesignText = designText;
|
|
1368
|
-
if (context.policy?.lane === 'Critical') {
|
|
1369
|
-
const closure = extractSection(designText, 'Execution Closure');
|
|
1370
|
-
if (closure !== null && isJustifiedNotApplicable(closure.trim())) {
|
|
1371
|
-
placeholderDesignText = designText.replace(closure, '');
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
if (hasSpecPlaceholders(placeholderDesignText)) {
|
|
1375
|
-
errors.push(`design.md: ${profile} readiness rejects placeholders`);
|
|
1376
|
-
}
|
|
1377
|
-
if (extractRequirementIds(requirementsText).length === 0) {
|
|
1378
|
-
errors.push(`requirements.md: ${profile} readiness requires at least one canonical numeric requirement id (Requirement N or RN)`);
|
|
1379
|
-
}
|
|
1380
|
-
if (extractSubCriteriaIds(requirementsText).length === 0 || !hasTestableAcceptanceCriterion(requirementsText)) {
|
|
1381
|
-
errors.push(
|
|
1382
|
-
`requirements.md: ${profile} readiness requires at least one testable acceptance criterion using RN.M`,
|
|
1383
|
-
);
|
|
1384
|
-
}
|
|
1385
|
-
if (!hasConcreteDesignBoundary(designText)) {
|
|
1386
|
-
errors.push(
|
|
1387
|
-
`design.md: ${profile} readiness requires a grounded path/code identifier plus owned or touched behavior in Architecture, Components and Interfaces, Canonical Contracts & Invariants, or Implementation Boundary`,
|
|
1388
|
-
);
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
function validateResearchPointer(specDir, canonicalSpecDir, spec, errors) {
|
|
1393
|
-
if (!Object.prototype.hasOwnProperty.call(spec, 'research') || spec.research === null) return;
|
|
1394
|
-
const pointer = spec.research && typeof spec.research === 'object' && !Array.isArray(spec.research)
|
|
1395
|
-
? spec.research.path
|
|
1396
|
-
: spec.research;
|
|
1397
|
-
if (
|
|
1398
|
-
typeof pointer !== 'string'
|
|
1399
|
-
|| pointer.trim() === ''
|
|
1400
|
-
|| pointer !== pointer.trim()
|
|
1401
|
-
|| path.isAbsolute(pointer)
|
|
1402
|
-
|| /^[A-Za-z]:[\\/]/.test(pointer)
|
|
1403
|
-
|| pointer.split(/[\\/]+/).includes('..')
|
|
1404
|
-
) {
|
|
1405
|
-
errors.push('spec.research: must be a safe relative path inside the spec directory');
|
|
1406
|
-
return;
|
|
1407
|
-
}
|
|
1408
|
-
inspectSpecArtifact(specDir, canonicalSpecDir, pointer, 'spec.research path', errors);
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
function hasConcreteResearchEvidence(section) {
|
|
1412
|
-
const placeholder = /\{\{[^}]+\}\}|<[^>]+>|\[[^\]]+\]|\b(?:TBD|TODO|N\/A|NA|NONE|UNKNOWN|PENDING|PLACEHOLDER|REPLACE_ME)\b/i;
|
|
1413
|
-
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;
|
|
1414
|
-
const generic = /^(?:finding|gap|alternative)\s+\d+\b|^(?:codebase scout|external \/ current research)\s*:\s*required\s*\/\s*skipped/i;
|
|
1415
|
-
|
|
1416
|
-
return section.split('\n').some((rawLine) => {
|
|
1417
|
-
const line = rawLine
|
|
1418
|
-
.trim()
|
|
1419
|
-
.replace(/^[-*+]\s*/, '')
|
|
1420
|
-
.replace(/^\||\|$/g, '')
|
|
1421
|
-
.replace(/\*\*/g, '')
|
|
1422
|
-
.replace(/`/g, '')
|
|
1423
|
-
.trim();
|
|
1424
|
-
if (!line || line.endsWith(':') || placeholder.test(line) || boilerplate.test(line) || generic.test(line)) return false;
|
|
1425
|
-
return line.length >= 8;
|
|
1426
|
-
});
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
function validateResearchArtifact(researchArtifact, errors, requireConcrete = true) {
|
|
1430
|
-
if (!researchArtifact) {
|
|
1431
|
-
errors.push('research.md: missing Evidence Summary for non-trivial spec');
|
|
1432
|
-
return;
|
|
1433
|
-
}
|
|
1434
|
-
const research = fs.readFileSync(researchArtifact.path, 'utf8');
|
|
1435
|
-
validateUniqueSectionGroups('research.md', research, [['Evidence Summary']], errors);
|
|
1436
|
-
const evidence = extractSection(research, 'Evidence Summary');
|
|
1437
|
-
if (evidence === null) {
|
|
1438
|
-
errors.push('research.md: missing ## Evidence Summary');
|
|
1439
|
-
} else if (requireConcrete && !hasConcreteResearchEvidence(evidence)) {
|
|
1440
|
-
errors.push('research.md: Evidence Summary must contain concrete evidence; empty or placeholder content is not sufficient');
|
|
1441
|
-
}
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
function groundedResearchEvidence(section, canonicalSpecDir) {
|
|
1445
|
-
if (/https:\/\/[^\s)>]+/i.test(section)) return true;
|
|
1446
|
-
const projectRoot = path.dirname(path.dirname(canonicalSpecDir));
|
|
1447
|
-
for (const match of section.matchAll(/`([^`]+)`/g)) {
|
|
1448
|
-
let target = match[1].trim();
|
|
1449
|
-
if (!target || /\s/.test(target) || path.isAbsolute(target) || /^[A-Za-z]:[\\/]/.test(target)) continue;
|
|
1450
|
-
target = target.split('#')[0].replace(/:\d+(?::\d+)?$/, '');
|
|
1451
|
-
if (unsafeRelatedPath(target)) continue;
|
|
1452
|
-
const resolved = path.resolve(projectRoot, target);
|
|
1453
|
-
if (isPathInside(projectRoot, resolved) && fs.existsSync(resolved)) return true;
|
|
1454
|
-
}
|
|
1455
|
-
return false;
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
function validateResearchArtifact21(researchArtifact, canonicalSpecDir, errors) {
|
|
1459
|
-
const research = fs.readFileSync(researchArtifact.path, 'utf8');
|
|
1460
|
-
const headings = ['Uncertainty', 'Evidence Summary', 'Decision', 'Remaining Gaps'];
|
|
1461
|
-
for (const heading of headings) {
|
|
1462
|
-
const count = headingCount(research, heading);
|
|
1463
|
-
if (count !== 1) errors.push(`research.md: requires exactly one ## ${heading} section`);
|
|
1464
|
-
}
|
|
1465
|
-
const sections = Object.fromEntries(headings.map((heading) => [heading, extractSection(research, heading)]));
|
|
1466
|
-
for (const heading of ['Uncertainty', 'Evidence Summary', 'Decision']) {
|
|
1467
|
-
if (sections[heading] !== null && !hasConcreteResearchEvidence(sections[heading])) {
|
|
1468
|
-
errors.push(`research.md: ${heading} must contain concrete non-placeholder content`);
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
if (sections['Evidence Summary'] !== null
|
|
1472
|
-
&& hasConcreteResearchEvidence(sections['Evidence Summary'])
|
|
1473
|
-
&& !groundedResearchEvidence(sections['Evidence Summary'], canonicalSpecDir)) {
|
|
1474
|
-
errors.push('research.md: Evidence Summary must ground at least one existing repository target or https primary source');
|
|
1475
|
-
}
|
|
1476
|
-
const gaps = sections['Remaining Gaps'];
|
|
1477
|
-
if (gaps !== null) {
|
|
1478
|
-
const semantic = semanticMarkdown(gaps).replace(/^\s*[-*+]\s*/gm, '').trim();
|
|
1479
|
-
const none = /^none\b/i.test(semantic);
|
|
1480
|
-
if (none) {
|
|
1481
|
-
const rationale = semantic.replace(/^none\b\s*(?:[-—:]\s*)?/i, '').trim();
|
|
1482
|
-
if (rationale.length < 12 || !hasConcreteResearchEvidence(rationale)) {
|
|
1483
|
-
errors.push('research.md: Remaining Gaps None requires concrete rationale or evidence');
|
|
1484
|
-
}
|
|
1485
|
-
} else if (!hasConcreteResearchEvidence(gaps)) {
|
|
1486
|
-
errors.push('research.md: Remaining Gaps must contain a concrete gap or None with rationale or evidence');
|
|
1487
|
-
}
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
function validateStateTransitions(spec, errors, context = {}) {
|
|
1492
|
-
// Canonical approval authority is technical only. rc.3 user_approved is readable but inert.
|
|
1493
|
-
// Legacy "approved" field is rejected fail-closed with migration guidance.
|
|
1494
|
-
const approvals = spec.approvals || {};
|
|
1495
|
-
let hasLegacy = false;
|
|
1496
|
-
for (const [stage, approval] of Object.entries(approvals)) {
|
|
1497
|
-
if (!approval || typeof approval !== 'object') {
|
|
1498
|
-
errors.push(`spec.json.approvals.${stage}: must be an object`);
|
|
1499
|
-
continue;
|
|
1500
|
-
}
|
|
1501
|
-
if ('approved' in approval) {
|
|
1502
|
-
errors.push(`spec.json.approvals.${stage}: ${LEGACY_APPROVAL_ERROR}`);
|
|
1503
|
-
hasLegacy = true;
|
|
1504
|
-
}
|
|
1505
|
-
const keys = Object.keys(approval).sort();
|
|
1506
|
-
const canonical = ['agent_validated', 'generated'];
|
|
1507
|
-
const rc3 = ['agent_validated', 'generated', 'user_approved'];
|
|
1508
|
-
if (JSON.stringify(keys) !== JSON.stringify(canonical) && JSON.stringify(keys) !== JSON.stringify(rc3)) {
|
|
1509
|
-
errors.push(`spec.json.approvals.${stage}: fields must be generated and agent_validated; legacy user_approved is readable but has zero authority`);
|
|
1510
|
-
}
|
|
1511
|
-
for (const key of keys) {
|
|
1512
|
-
if (typeof approval[key] !== 'boolean') errors.push(`spec.json.approvals.${stage}.${key}: must be boolean`);
|
|
1513
|
-
}
|
|
1514
|
-
// Unsupported/ambiguous: if schema_version missing when approvals use v2 shape, warn but not fail? B2 says fail closed.
|
|
1515
|
-
// We fail closed when version is unsupported or ambiguous and ready_for_implementation is true.
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
|
-
// Schema version validation
|
|
1519
|
-
if (spec.schema_version !== undefined && spec.schema_version !== APPROVAL_SCHEMA_VERSION) {
|
|
1520
|
-
errors.push(`spec.json.schema_version: unsupported "${spec.schema_version}", expected "${APPROVAL_SCHEMA_VERSION}" — migration required`);
|
|
1521
|
-
}
|
|
1522
|
-
if (spec.approval_schema_version !== undefined && spec.approval_schema_version !== APPROVAL_SCHEMA_VERSION) {
|
|
1523
|
-
errors.push(`spec.json.approval_schema_version: unsupported "${spec.approval_schema_version}", expected "${APPROVAL_SCHEMA_VERSION}"`);
|
|
1524
|
-
}
|
|
1525
|
-
// If approvals use v2 fields but no schema_version, treat as ambiguous — fail closed when ready flag is set
|
|
1526
|
-
const usesV2 = Object.values(approvals).some((a) => a && typeof a === 'object' && 'agent_validated' in a);
|
|
1527
|
-
if (usesV2 && spec.schema_version === undefined && spec.approval_schema_version === undefined) {
|
|
1528
|
-
// Only warn now; will be error if ready_for_implementation is true (checked below)
|
|
1529
|
-
// Provide guidance
|
|
1530
|
-
if (spec.ready_for_implementation === true) {
|
|
1531
|
-
errors.push(`spec.json.schema_version: missing — spec uses v2 approvals but has no schema_version "${APPROVAL_SCHEMA_VERSION}"`);
|
|
1532
|
-
}
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
if (spec.validation?.status === 'completed' && !spec.timestamps?.validation_done) {
|
|
1536
|
-
errors.push('spec.json.validation: completed transition requires timestamps.validation_done');
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
if (spec.ready_for_implementation === true) {
|
|
1540
|
-
if (hasLegacy) {
|
|
1541
|
-
errors.push('spec.json.ready_for_implementation: cannot be true with legacy approved fields');
|
|
1542
|
-
}
|
|
1543
|
-
const stages = context.taskRequired
|
|
1544
|
-
? ['requirements', 'design', 'tasks']
|
|
1545
|
-
: ['requirements', 'design'];
|
|
1546
|
-
for (const stage of stages) {
|
|
1547
|
-
const approval = spec.approvals?.[stage];
|
|
1548
|
-
if (!approval || approval?.generated !== true || approval?.agent_validated !== true) {
|
|
1549
|
-
errors.push(`spec.json.ready_for_implementation: requires generated and agent_validated ${stage} evidence (v2)`);
|
|
1550
|
-
}
|
|
1551
|
-
}
|
|
1552
|
-
if (spec.validation?.status === 'in_progress' || spec.validation?.status === 'not-run') {
|
|
1553
|
-
errors.push('spec.json.ready_for_implementation: cannot be true while validation evidence is incomplete');
|
|
1554
|
-
}
|
|
1555
|
-
}
|
|
1556
|
-
}
|
|
1557
|
-
|
|
1558
|
-
function validateReadinessStatusValues(spec, errors) {
|
|
1559
|
-
if (typeof spec.ready_for_implementation !== 'boolean') {
|
|
1560
|
-
errors.push('spec.json.ready_for_implementation: must be a boolean');
|
|
1561
|
-
}
|
|
1562
|
-
if (typeof spec.status !== 'string' || !SPEC_STATUS_VALUES.has(spec.status)) {
|
|
1563
|
-
errors.push(`spec.json.status: must be one of ${[...SPEC_STATUS_VALUES].join(', ')}`);
|
|
1564
|
-
}
|
|
1565
|
-
if (spec.validation !== undefined && !isPlainObject(spec.validation)) {
|
|
1566
|
-
errors.push('spec.json.validation: must be an object when present');
|
|
1567
|
-
}
|
|
1568
|
-
const validationStatus = isPlainObject(spec.validation) ? spec.validation.status : undefined;
|
|
1569
|
-
if (validationStatus !== undefined && !VALIDATION_STATUS_VALUES.has(validationStatus)) {
|
|
1570
|
-
errors.push(`spec.json.validation.status: must be one of ${[...VALIDATION_STATUS_VALUES].join(', ')}`);
|
|
1571
|
-
}
|
|
1572
|
-
if (spec.ready_for_implementation !== true) return;
|
|
1573
|
-
if (spec.status === 'blocked' || spec.status === 'paused') {
|
|
1574
|
-
errors.push(`spec.json.status: cannot be ${spec.status} while ready_for_implementation is true`);
|
|
1575
|
-
}
|
|
1576
|
-
if (typeof spec.blocker === 'string' && spec.blocker.trim() !== '') {
|
|
1577
|
-
errors.push('spec.json.blocker: must be empty while ready_for_implementation is true');
|
|
1578
|
-
}
|
|
1579
|
-
if (validationStatus !== 'completed') {
|
|
1580
|
-
errors.push('spec.json.ready_for_implementation: requires validation.status completed');
|
|
1581
|
-
}
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
function validateCoreMachineState(spec, taskFiles, errors, { schema21 = false } = {}) {
|
|
1585
|
-
if (!isPlainObject(spec.scope_lock)) {
|
|
1586
|
-
errors.push('spec.json.scope_lock: must be an object, not a boolean or array');
|
|
1587
|
-
}
|
|
1588
|
-
validateTimestamps(spec, errors);
|
|
1589
|
-
validateReadinessStatusValues(spec, errors);
|
|
1590
|
-
if (isIsoTimestamp(spec.created_at) && isIsoTimestamp(spec.updated_at)
|
|
1591
|
-
&& Date.parse(spec.updated_at) < Date.parse(spec.created_at)) {
|
|
1592
|
-
errors.push('spec.json lifecycle timestamps must satisfy created_at <= updated_at');
|
|
1593
|
-
}
|
|
1594
|
-
if (schema21 || spec.schema_version === APPROVAL_SCHEMA_VERSION) {
|
|
1595
|
-
const required = [
|
|
1596
|
-
'schema_version', 'feature_name', 'created_at', 'updated_at', 'status',
|
|
1597
|
-
'scope_lock', 'authoring', 'coordination', 'validation', 'ready_for_implementation', 'workflow_policy',
|
|
1598
|
-
];
|
|
1599
|
-
if (!schema21) required.splice(required.indexOf('authoring'), 1);
|
|
1600
|
-
else required.push('language');
|
|
1601
|
-
for (const field of required) if (!Object.prototype.hasOwnProperty.call(spec, field)) {
|
|
1602
|
-
errors.push(`spec.json.${field}: required by schema ${spec.schema_version} machine state`);
|
|
1603
|
-
}
|
|
1604
|
-
if (!isPlainObject(spec.validation)
|
|
1605
|
-
|| !Object.prototype.hasOwnProperty.call(spec.validation, 'status')
|
|
1606
|
-
|| !Object.prototype.hasOwnProperty.call(spec.validation, 'semantic_review')) {
|
|
1607
|
-
errors.push(`spec.json.validation: schema ${spec.schema_version} requires status and semantic_review`);
|
|
1608
|
-
}
|
|
1609
|
-
}
|
|
1610
|
-
|
|
1611
|
-
if (taskFiles.length === 0) return;
|
|
1612
|
-
const taskFileSet = new Set(taskFiles);
|
|
1613
|
-
if (!Array.isArray(spec.task_files)) errors.push('spec.json.task_files: physical tasks require an array');
|
|
1614
|
-
if (!isPlainObject(spec.task_registry)) {
|
|
1615
|
-
errors.push('spec.json.task_registry: physical tasks require an object keyed by task file path');
|
|
1616
|
-
return;
|
|
1617
|
-
}
|
|
1618
|
-
for (const taskPath of taskFiles) {
|
|
1619
|
-
const entry = spec.task_registry[taskPath];
|
|
1620
|
-
const label = `spec.json.task_registry.${taskPath}`;
|
|
1621
|
-
if (!isPlainObject(entry)) {
|
|
1622
|
-
errors.push(`${label}: must be an object`);
|
|
1623
|
-
continue;
|
|
1624
|
-
}
|
|
1625
|
-
for (const key of REQUIRED_REGISTRY_KEYS) if (!Object.prototype.hasOwnProperty.call(entry, key)) {
|
|
1626
|
-
errors.push(`${label}: missing ${key}`);
|
|
1627
|
-
}
|
|
1628
|
-
if (entry.id !== expectedTaskId(taskPath)) {
|
|
1629
|
-
errors.push(`${label}: id must match path-derived id ${expectedTaskId(taskPath)} (got ${String(entry.id)})`);
|
|
1630
|
-
}
|
|
1631
|
-
if (typeof entry.title !== 'string' || entry.title.trim() === '' || hasSpecPlaceholders(entry.title)) {
|
|
1632
|
-
errors.push(`${label}.title: must be a concrete non-empty string`);
|
|
1633
|
-
}
|
|
1634
|
-
if (!TASK_STATUS_VALUES.has(entry.status)) {
|
|
1635
|
-
errors.push(`${label}.status: must be one of ${[...TASK_STATUS_VALUES].join(', ')}`);
|
|
1636
|
-
}
|
|
1637
|
-
validateTaskLifecycle(taskPath, entry, errors);
|
|
1638
|
-
if (spec.ready_for_implementation === true && entry.status === 'blocked') {
|
|
1639
|
-
errors.push(`${label}.status: cannot be blocked while ready_for_implementation is true`);
|
|
1640
|
-
}
|
|
1641
|
-
if (!Array.isArray(entry.dependencies)) {
|
|
1642
|
-
errors.push(`${label}.dependencies: must be an array`);
|
|
1643
|
-
continue;
|
|
1644
|
-
}
|
|
1645
|
-
const seen = new Set();
|
|
1646
|
-
for (const dependency of entry.dependencies) {
|
|
1647
|
-
if (!taskFileSet.has(dependency)) errors.push(`${label}.dependencies: unknown dependency ${dependency}`);
|
|
1648
|
-
if (dependency === taskPath) errors.push(`${label}.dependencies: cannot contain itself`);
|
|
1649
|
-
if (seen.has(dependency)) errors.push(`${label}.dependencies: duplicate dependency ${dependency}`);
|
|
1650
|
-
seen.add(dependency);
|
|
1651
|
-
}
|
|
1652
|
-
}
|
|
1653
|
-
}
|
|
1654
|
-
|
|
1655
|
-
function meaningfulObligationValue(value) {
|
|
1656
|
-
if (typeof value !== 'string') return false;
|
|
1657
|
-
const text = value.trim();
|
|
1658
|
-
return text.length >= 8
|
|
1659
|
-
&& !hasSpecPlaceholders(text)
|
|
1660
|
-
&& !/^(?:works?|done|check|verify|test|proof|evidence|state|authority|recovery)$/i.test(text);
|
|
1661
|
-
}
|
|
1662
|
-
|
|
1663
|
-
function normalizeClosureText(value) {
|
|
1664
|
-
return String(value)
|
|
1665
|
-
.toLowerCase()
|
|
1666
|
-
.replace(/[|`*_[\]{}()]/g, ' ')
|
|
1667
|
-
.replace(/\s+/g, ' ')
|
|
1668
|
-
.trim();
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
function closureFieldName(value) {
|
|
1672
|
-
const name = normalizeClosureText(value).replace(/\s*\/\s*/g, ' / ');
|
|
1673
|
-
if (/^(?:obligation ids? \/ )?requirements?$/.test(name)
|
|
1674
|
-
|| /^obligation id \/ requirements$/.test(name)) return ['requirements'];
|
|
1675
|
-
if (/^canonical state$/.test(name)) return ['canonical_state'];
|
|
1676
|
-
if (/^authority$/.test(name)) return ['authority'];
|
|
1677
|
-
if (/^(?:crash \/ retry )?recovery$/.test(name)) return ['recovery'];
|
|
1678
|
-
if (/^owner task$/.test(name)) return ['owner_task'];
|
|
1679
|
-
if (/^(?:consumer tasks?|consumers?)(?: \/ dependency order)?$/.test(name)) return ['consumer_tasks'];
|
|
1680
|
-
if (/^verification$/.test(name)) return ['verification'];
|
|
1681
|
-
if (/^evidence$/.test(name)) return ['evidence'];
|
|
1682
|
-
if (/^verification \/ evidence$/.test(name)) return ['verification', 'evidence'];
|
|
1683
|
-
return [];
|
|
1684
|
-
}
|
|
1685
|
-
|
|
1686
|
-
function appendClosureField(fields, label, value) {
|
|
1687
|
-
for (const key of closureFieldName(label)) {
|
|
1688
|
-
if (!fields.has(key)) fields.set(key, []);
|
|
1689
|
-
fields.get(key).push(String(value).trim());
|
|
1690
|
-
}
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
function parseClosureFields(block) {
|
|
1694
|
-
const fields = new Map();
|
|
1695
|
-
const lines = String(block).split('\n');
|
|
1696
|
-
for (const line of lines) {
|
|
1697
|
-
const candidate = line
|
|
1698
|
-
.trim()
|
|
1699
|
-
.replace(/^[-*+]\s*/, '')
|
|
1700
|
-
.replace(/^\*\*/, '');
|
|
1701
|
-
const field = candidate.match(/^([^:]+?)(?:\*\*)?\s*:\s*(.*?)\s*$/);
|
|
1702
|
-
if (field) appendClosureField(fields, field[1], field[2]);
|
|
1703
|
-
}
|
|
1704
|
-
|
|
1705
|
-
for (let index = 0; index + 2 < lines.length; index += 1) {
|
|
1706
|
-
if (!/^\s*\|/.test(lines[index]) || !/^\s*\|(?:\s*:?-{3,}:?\s*\|)+\s*$/.test(lines[index + 1])) {
|
|
1707
|
-
continue;
|
|
1708
|
-
}
|
|
1709
|
-
const headers = lines[index].split('|').slice(1, -1).map((cell) => cell.trim());
|
|
1710
|
-
for (let rowIndex = index + 2; rowIndex < lines.length && /^\s*\|/.test(lines[rowIndex]); rowIndex += 1) {
|
|
1711
|
-
const cells = lines[rowIndex].split('|').slice(1, -1).map((cell) => cell.trim());
|
|
1712
|
-
headers.forEach((header, cellIndex) => appendClosureField(fields, header, cells[cellIndex] || ''));
|
|
1713
|
-
}
|
|
1714
|
-
break;
|
|
1715
|
-
}
|
|
1716
|
-
return fields;
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
function closureField(fields, key) {
|
|
1720
|
-
return (fields.get(key) || []).join(' ');
|
|
1721
|
-
}
|
|
1722
|
-
|
|
1723
|
-
function normalizedStringSet(values) {
|
|
1724
|
-
return [...new Set(values.map((value) => String(value).toUpperCase()))].sort();
|
|
1725
|
-
}
|
|
1726
|
-
|
|
1727
|
-
function validateClosureCorrespondence(label, closure, obligationId, obligation, errors) {
|
|
1728
|
-
const block = closure.blocks?.get(obligationId);
|
|
1729
|
-
if (typeof block !== 'string' || block.trim() === '') {
|
|
1730
|
-
errors.push(`${label}: Execution Closure marker ${obligationId} requires a marker-local field block`);
|
|
1731
|
-
return;
|
|
1732
|
-
}
|
|
1733
|
-
const parsed = parseClosureFields(block);
|
|
1734
|
-
const fields = [
|
|
1735
|
-
['canonical_state', obligation.canonical_state],
|
|
1736
|
-
['authority', obligation.authority],
|
|
1737
|
-
['recovery', obligation.recovery],
|
|
1738
|
-
['owner_task', obligation.owner_task],
|
|
1739
|
-
['verification', obligation.verification],
|
|
1740
|
-
['evidence', obligation.evidence],
|
|
1741
|
-
];
|
|
1742
|
-
for (const [field, value] of fields) {
|
|
1743
|
-
if (typeof value !== 'string' || value.trim() === '') continue;
|
|
1744
|
-
const localValue = closureField(parsed, field);
|
|
1745
|
-
if (!localValue || !normalizeClosureText(localValue).includes(normalizeClosureText(value))) {
|
|
1746
|
-
errors.push(
|
|
1747
|
-
`${label}: Execution Closure for ${obligationId} must mirror spec.json ${field} in its marker-local field`,
|
|
1748
|
-
);
|
|
1749
|
-
}
|
|
1750
|
-
}
|
|
1751
|
-
|
|
1752
|
-
const expectedRequirements = normalizedStringSet(
|
|
1753
|
-
Array.isArray(obligation.requirements) ? obligation.requirements : [],
|
|
1754
|
-
);
|
|
1755
|
-
const actualRequirements = normalizedStringSet(
|
|
1756
|
-
closureField(parsed, 'requirements').match(/\bR\d+(?:\.\d+)?\b/gi) || [],
|
|
1757
|
-
);
|
|
1758
|
-
if (JSON.stringify(actualRequirements) !== JSON.stringify(expectedRequirements)) {
|
|
1759
|
-
errors.push(
|
|
1760
|
-
`${label}: Execution Closure for ${obligationId} must mirror spec.json requirements in its marker-local field`,
|
|
1761
|
-
);
|
|
1762
|
-
}
|
|
1763
|
-
|
|
1764
|
-
const consumers = Array.isArray(obligation.consumer_tasks) ? obligation.consumer_tasks : [];
|
|
1765
|
-
const consumerValue = closureField(parsed, 'consumer_tasks');
|
|
1766
|
-
if (consumers.length === 0) {
|
|
1767
|
-
if (!/^\s*(?:none|n\/a)\b/i.test(consumerValue)) {
|
|
1768
|
-
errors.push(`${label}: Execution Closure for ${obligationId} must state that consumer_tasks are none`);
|
|
1769
|
-
}
|
|
1770
|
-
} else {
|
|
1771
|
-
const actualConsumers = [...new Set(
|
|
1772
|
-
consumerValue.match(/tasks\/task-R\d+-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.md/gi) || [],
|
|
1773
|
-
)].sort();
|
|
1774
|
-
const expectedConsumers = [...new Set(consumers)].sort();
|
|
1775
|
-
if (JSON.stringify(actualConsumers) !== JSON.stringify(expectedConsumers)) {
|
|
1776
|
-
errors.push(
|
|
1777
|
-
`${label}: Execution Closure for ${obligationId} must mirror spec.json consumer_tasks in its marker-local field`,
|
|
1778
|
-
);
|
|
1779
|
-
}
|
|
1780
|
-
}
|
|
1781
|
-
}
|
|
1782
|
-
|
|
1783
|
-
function validateImplementationObligations(
|
|
1784
|
-
spec,
|
|
1785
|
-
context,
|
|
1786
|
-
requirementIds,
|
|
1787
|
-
subCriteriaIds,
|
|
1788
|
-
taskFiles,
|
|
1789
|
-
registry,
|
|
1790
|
-
designClosure,
|
|
1791
|
-
taskClosures,
|
|
1792
|
-
errors,
|
|
1793
|
-
) {
|
|
1794
|
-
if (!Object.prototype.hasOwnProperty.call(spec, 'implementation_obligations')) {
|
|
1795
|
-
const taskClosurePresent = [...taskClosures.values()].some((closure) => closure.headingCount > 0);
|
|
1796
|
-
if (designClosure.ids.length > 0 || [...taskClosures.values()].some((closure) => closure.ids.length > 0)) {
|
|
1797
|
-
errors.push(
|
|
1798
|
-
'implementation obligation markers require a declared spec.json.implementation_obligations array',
|
|
1799
|
-
);
|
|
1800
|
-
}
|
|
1801
|
-
if (context.policy?.lane === 'Critical') {
|
|
1802
|
-
if (designClosure.headingCount !== 1 || !designClosure.notApplicable) {
|
|
1803
|
-
errors.push(
|
|
1804
|
-
'design.md: Critical scope must declare non-empty spec.json.implementation_obligations or one justified N/A in Execution Closure',
|
|
1805
|
-
);
|
|
1806
|
-
}
|
|
1807
|
-
} else if (designClosure.headingCount > 0) {
|
|
1808
|
-
errors.push('design.md: Execution Closure is allowed only for a declared Critical obligation decision');
|
|
1809
|
-
}
|
|
1810
|
-
if (taskClosurePresent) {
|
|
1811
|
-
errors.push('tasks/: Execution Closure is allowed only for tasks assigned to declared implementation obligations');
|
|
1812
|
-
}
|
|
1813
|
-
return;
|
|
1814
|
-
}
|
|
1815
|
-
const obligations = spec.implementation_obligations;
|
|
1816
|
-
if (context.policy?.lane !== 'Critical') {
|
|
1817
|
-
errors.push('spec.json.implementation_obligations: allowed only for Critical workflow policy');
|
|
1818
|
-
}
|
|
1819
|
-
if (!Array.isArray(obligations) || obligations.length === 0) {
|
|
1820
|
-
errors.push('spec.json.implementation_obligations: must be a non-empty array when present');
|
|
1821
|
-
return;
|
|
1822
|
-
}
|
|
1823
|
-
if (designClosure.notApplicable) {
|
|
1824
|
-
errors.push('design.md: justified N/A cannot coexist with declared implementation obligations');
|
|
1825
|
-
}
|
|
1826
|
-
|
|
1827
|
-
const knownRequirements = new Set([...requirementIds, ...subCriteriaIds]);
|
|
1828
|
-
const knownTasks = new Set(taskFiles);
|
|
1829
|
-
const dependencies = new Map(taskFiles.map((taskFile) => [
|
|
1830
|
-
taskFile,
|
|
1831
|
-
new Set(Array.isArray(registry?.[taskFile]?.dependencies) ? registry[taskFile].dependencies : []),
|
|
1832
|
-
]));
|
|
1833
|
-
function dependsOn(taskFile, ownerTask, seen = new Set()) {
|
|
1834
|
-
if (taskFile === ownerTask) return true;
|
|
1835
|
-
if (seen.has(taskFile)) return false;
|
|
1836
|
-
seen.add(taskFile);
|
|
1837
|
-
return [...(dependencies.get(taskFile) || [])].some((dependency) => (
|
|
1838
|
-
dependsOn(dependency, ownerTask, seen)
|
|
1839
|
-
));
|
|
1840
|
-
}
|
|
1841
|
-
|
|
1842
|
-
const ids = new Set();
|
|
1843
|
-
const obligationsById = new Map();
|
|
1844
|
-
for (const [index, obligation] of obligations.entries()) {
|
|
1845
|
-
const label = `spec.json.implementation_obligations[${index}]`;
|
|
1846
|
-
if (!isPlainObject(obligation)) {
|
|
1847
|
-
errors.push(`${label}: must be an object`);
|
|
1848
|
-
continue;
|
|
1849
|
-
}
|
|
1850
|
-
const obligationId = typeof obligation.id === 'string' ? obligation.id.trim().toLowerCase() : '';
|
|
1851
|
-
if (!/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(obligationId)) {
|
|
1852
|
-
errors.push(`${label}.id: must be a stable lowercase marker id`);
|
|
1853
|
-
} else if (obligation.id !== obligationId) {
|
|
1854
|
-
errors.push(`${label}.id: must be lowercase without surrounding whitespace`);
|
|
1855
|
-
} else if (ids.has(obligationId)) {
|
|
1856
|
-
errors.push(`${label}.id: duplicate obligation id ${obligationId}`);
|
|
1857
|
-
} else {
|
|
1858
|
-
ids.add(obligationId);
|
|
1859
|
-
obligationsById.set(obligationId, obligation);
|
|
1860
|
-
}
|
|
1861
|
-
|
|
1862
|
-
if (!Array.isArray(obligation.requirements) || obligation.requirements.length === 0) {
|
|
1863
|
-
errors.push(`${label}.requirements: must be a non-empty array of requirement references`);
|
|
1864
|
-
} else {
|
|
1865
|
-
const seenRequirements = new Set();
|
|
1866
|
-
for (const reference of obligation.requirements) {
|
|
1867
|
-
if (typeof reference !== 'string' || !/^R\d+(?:\.\d+)?$/i.test(reference)) {
|
|
1868
|
-
errors.push(`${label}.requirements: invalid requirement reference ${String(reference)}`);
|
|
1869
|
-
continue;
|
|
1870
|
-
}
|
|
1871
|
-
const normalized = reference.toUpperCase();
|
|
1872
|
-
if (seenRequirements.has(normalized)) {
|
|
1873
|
-
errors.push(`${label}.requirements: duplicate requirement reference ${normalized}`);
|
|
1874
|
-
} else if (!knownRequirements.has(normalized)) {
|
|
1875
|
-
errors.push(`${label}.requirements: unknown requirement reference ${normalized}`);
|
|
1876
|
-
}
|
|
1877
|
-
seenRequirements.add(normalized);
|
|
1878
|
-
}
|
|
1879
|
-
}
|
|
1880
|
-
|
|
1881
|
-
const ownerTask = obligation.owner_task;
|
|
1882
|
-
const ownerValid = typeof ownerTask === 'string' && knownTasks.has(ownerTask);
|
|
1883
|
-
if (!ownerValid) {
|
|
1884
|
-
errors.push(`${label}.owner_task: must name exactly one registered task`);
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
if (!Array.isArray(obligation.consumer_tasks)) {
|
|
1888
|
-
errors.push(`${label}.consumer_tasks: must be an array of registered tasks`);
|
|
1889
|
-
} else {
|
|
1890
|
-
const seenConsumers = new Set();
|
|
1891
|
-
for (const consumerTask of obligation.consumer_tasks) {
|
|
1892
|
-
if (typeof consumerTask !== 'string' || !knownTasks.has(consumerTask)) {
|
|
1893
|
-
errors.push(`${label}.consumer_tasks: unknown task reference ${String(consumerTask)}`);
|
|
1894
|
-
continue;
|
|
1895
|
-
}
|
|
1896
|
-
if (consumerTask === ownerTask) {
|
|
1897
|
-
errors.push(`${label}.consumer_tasks: owner task cannot also be a consumer`);
|
|
1898
|
-
} else if (seenConsumers.has(consumerTask)) {
|
|
1899
|
-
errors.push(`${label}.consumer_tasks: duplicate task reference ${consumerTask}`);
|
|
1900
|
-
} else if (ownerValid && !dependsOn(consumerTask, ownerTask)) {
|
|
1901
|
-
errors.push(`${label}.consumer_tasks: ${consumerTask} must depend on owner ${ownerTask}`);
|
|
1902
|
-
}
|
|
1903
|
-
seenConsumers.add(consumerTask);
|
|
1904
|
-
}
|
|
1905
|
-
}
|
|
1906
|
-
|
|
1907
|
-
for (const field of ['canonical_state', 'authority', 'recovery']) {
|
|
1908
|
-
if (!meaningfulObligationValue(obligation[field])) {
|
|
1909
|
-
errors.push(`${label}.${field}: must be concrete, non-placeholder implementation semantics`);
|
|
1910
|
-
}
|
|
1911
|
-
}
|
|
1912
|
-
if (!isExecutableCommand(obligation.verification)) {
|
|
1913
|
-
errors.push(`${label}.verification: must be an executable command`);
|
|
1914
|
-
}
|
|
1915
|
-
if (!hasObservableProof(obligation.evidence)) {
|
|
1916
|
-
errors.push(`${label}.evidence: must name an observable result, state, artifact, or exit outcome`);
|
|
1917
|
-
}
|
|
1918
|
-
}
|
|
1919
|
-
|
|
1920
|
-
if (ids.size > 0 && designClosure.headingCount !== 1) {
|
|
1921
|
-
errors.push('design.md: declared implementation obligations require exactly one Execution Closure section');
|
|
1922
|
-
}
|
|
1923
|
-
const designSet = new Set(designClosure.ids);
|
|
1924
|
-
for (const designId of designSet) {
|
|
1925
|
-
if (!ids.has(designId)) {
|
|
1926
|
-
errors.push(`design.md: implementation obligation marker ${designId} is not declared in spec.json`);
|
|
1927
|
-
}
|
|
1928
|
-
}
|
|
1929
|
-
for (const obligationId of ids) {
|
|
1930
|
-
if (!designSet.has(obligationId)) {
|
|
1931
|
-
errors.push(`design.md: missing implementation obligation marker ${obligationId}`);
|
|
1932
|
-
}
|
|
1933
|
-
}
|
|
1934
|
-
|
|
1935
|
-
for (const [taskFile, closure] of taskClosures) {
|
|
1936
|
-
if (closure.headingCount > 0 && closure.ids.length === 0) {
|
|
1937
|
-
errors.push(`${taskFile}: Execution Closure requires at least one assigned implementation obligation marker`);
|
|
1938
|
-
}
|
|
1939
|
-
for (const marker of closure.ids) {
|
|
1940
|
-
const obligation = obligationsById.get(marker);
|
|
1941
|
-
if (!obligation) {
|
|
1942
|
-
errors.push(`${taskFile}: implementation obligation marker ${marker} is not declared in spec.json`);
|
|
1943
|
-
continue;
|
|
1944
|
-
}
|
|
1945
|
-
const participants = new Set([
|
|
1946
|
-
obligation.owner_task,
|
|
1947
|
-
...(Array.isArray(obligation.consumer_tasks) ? obligation.consumer_tasks : []),
|
|
1948
|
-
]);
|
|
1949
|
-
if (!participants.has(taskFile)) {
|
|
1950
|
-
errors.push(`${taskFile}: marker ${marker} is not assigned to this owner/consumer task`);
|
|
1951
|
-
}
|
|
1952
|
-
}
|
|
1953
|
-
}
|
|
1954
|
-
|
|
1955
|
-
for (const [obligationId, obligation] of obligationsById) {
|
|
1956
|
-
if (designSet.has(obligationId)) {
|
|
1957
|
-
validateClosureCorrespondence('design.md', designClosure, obligationId, obligation, errors);
|
|
1958
|
-
}
|
|
1959
|
-
const participants = [
|
|
1960
|
-
obligation.owner_task,
|
|
1961
|
-
...(Array.isArray(obligation.consumer_tasks) ? obligation.consumer_tasks : []),
|
|
1962
|
-
].filter((taskFile) => typeof taskFile === 'string' && knownTasks.has(taskFile));
|
|
1963
|
-
for (const taskFile of participants) {
|
|
1964
|
-
const closure = taskClosures.get(taskFile)
|
|
1965
|
-
|| { ids: [], section: '', headingCount: 0, blocks: new Map(), notApplicable: false };
|
|
1966
|
-
if (!closure.ids.includes(obligationId)) {
|
|
1967
|
-
errors.push(`${taskFile}: missing implementation obligation marker ${obligationId}`);
|
|
1968
|
-
} else {
|
|
1969
|
-
if (closure.headingCount !== 1) {
|
|
1970
|
-
errors.push(`${taskFile}: declared implementation obligations require exactly one Execution Closure section`);
|
|
1971
|
-
}
|
|
1972
|
-
validateClosureCorrespondence(taskFile, closure, obligationId, obligation, errors);
|
|
1973
|
-
}
|
|
1974
|
-
}
|
|
1975
|
-
}
|
|
1976
|
-
}
|
|
1977
|
-
|
|
1978
|
-
function validateDependencyTopology(spec, taskFiles, registry, taskRecords, errors) {
|
|
1979
|
-
const dependencies = new Map(taskFiles.map((taskFile) => [taskFile, new Set(registry?.[taskFile]?.dependencies || [])]));
|
|
1980
|
-
if (dependencies.size === 0) return;
|
|
1981
|
-
|
|
1982
|
-
const visiting = new Set();
|
|
1983
|
-
const visited = new Set();
|
|
1984
|
-
function visit(taskFile, stack = []) {
|
|
1985
|
-
if (visiting.has(taskFile)) {
|
|
1986
|
-
const cycleStart = stack.indexOf(taskFile);
|
|
1987
|
-
errors.push(`spec.json.task_registry.dependencies: dependency cycle detected (${stack.slice(cycleStart).concat(taskFile).join(' -> ')})`);
|
|
1988
|
-
return;
|
|
1989
|
-
}
|
|
1990
|
-
if (visited.has(taskFile)) return;
|
|
1991
|
-
visiting.add(taskFile);
|
|
1992
|
-
for (const dependency of dependencies.get(taskFile) || []) visit(dependency, [...stack, taskFile]);
|
|
1993
|
-
visiting.delete(taskFile);
|
|
1994
|
-
visited.add(taskFile);
|
|
1995
|
-
}
|
|
1996
|
-
for (const taskFile of taskFiles) visit(taskFile);
|
|
1997
|
-
|
|
1998
|
-
const roots = taskFiles.filter((taskFile) => (dependencies.get(taskFile) || new Set()).size === 0);
|
|
1999
|
-
const reachable = new Set();
|
|
2000
|
-
const dependents = new Map(taskFiles.map((taskFile) => [taskFile, []]));
|
|
2001
|
-
for (const [taskFile, deps] of dependencies) {
|
|
2002
|
-
for (const dependency of deps) dependents.get(dependency)?.push(taskFile);
|
|
2003
|
-
}
|
|
2004
|
-
const queue = [...roots];
|
|
2005
|
-
while (queue.length > 0) {
|
|
2006
|
-
const taskFile = queue.shift();
|
|
2007
|
-
if (reachable.has(taskFile)) continue;
|
|
2008
|
-
reachable.add(taskFile);
|
|
2009
|
-
queue.push(...(dependents.get(taskFile) || []));
|
|
2010
|
-
}
|
|
2011
|
-
for (const taskFile of taskFiles) {
|
|
2012
|
-
if (!reachable.has(taskFile)) errors.push(`spec.json.task_registry.${taskFile}: orphan or unreachable task in dependency graph`);
|
|
2013
|
-
}
|
|
2014
|
-
|
|
2015
|
-
function dependsOn(taskFile, ancestor, seen = new Set()) {
|
|
2016
|
-
if (taskFile === ancestor) return true;
|
|
2017
|
-
if (seen.has(taskFile)) return false;
|
|
2018
|
-
seen.add(taskFile);
|
|
2019
|
-
return [...(dependencies.get(taskFile) || [])].some((dependency) => dependsOn(dependency, ancestor, seen));
|
|
2020
|
-
}
|
|
2021
|
-
|
|
2022
|
-
const producers = new Map();
|
|
2023
|
-
for (const [taskFile, record] of taskRecords) {
|
|
2024
|
-
for (const [index, row] of record.rows.entries()) {
|
|
2025
|
-
if (row.action !== 'create') continue;
|
|
2026
|
-
const prior = producers.get(row.path);
|
|
2027
|
-
if (prior && prior.taskFile !== taskFile) {
|
|
2028
|
-
errors.push(`${taskFile}: Related Files path ${row.path} is created by multiple tasks (${prior.taskFile}, ${taskFile})`);
|
|
2029
|
-
} else {
|
|
2030
|
-
producers.set(row.path, { taskFile, index });
|
|
2031
|
-
}
|
|
2032
|
-
}
|
|
2033
|
-
}
|
|
2034
|
-
// Lifecycle ordering: Create must precede Modify/Delete/Read within task for same path
|
|
2035
|
-
for (const [taskFile, record] of taskRecords) {
|
|
2036
|
-
const createIndexByPath = new Map();
|
|
2037
|
-
for (const [index, row] of record.rows.entries()) {
|
|
2038
|
-
if (row.action === 'create' && !createIndexByPath.has(row.path)) {
|
|
2039
|
-
createIndexByPath.set(row.path, index);
|
|
2040
|
-
}
|
|
2041
|
-
}
|
|
2042
|
-
for (const [index, row] of record.rows.entries()) {
|
|
2043
|
-
if (!['modify', 'delete', 'read'].includes(row.action)) continue;
|
|
2044
|
-
if (!createIndexByPath.has(row.path)) continue;
|
|
2045
|
-
const createIdx = createIndexByPath.get(row.path);
|
|
2046
|
-
if (createIdx > index) {
|
|
2047
|
-
errors.push(`${taskFile}: Create must precede ${row.action} for ${row.path} within Related Files order`);
|
|
2048
|
-
}
|
|
2049
|
-
}
|
|
2050
|
-
}
|
|
2051
|
-
// Cross-task lifecycle: Modify/Delete/Read of a path created by another task must depend on creator
|
|
2052
|
-
for (const [taskFile, record] of taskRecords) {
|
|
2053
|
-
for (const [index, row] of record.rows.entries()) {
|
|
2054
|
-
if (!['modify', 'delete', 'read'].includes(row.action)) continue;
|
|
2055
|
-
const producer = producers.get(row.path);
|
|
2056
|
-
if (!producer) continue;
|
|
2057
|
-
if (producer.taskFile === taskFile) continue;
|
|
2058
|
-
if (!dependsOn(taskFile, producer.taskFile)) {
|
|
2059
|
-
errors.push(`${taskFile}: ${row.action} of ${row.path} must depend on creator ${producer.taskFile}`);
|
|
2060
|
-
}
|
|
2061
|
-
}
|
|
2062
|
-
}
|
|
2063
|
-
|
|
2064
|
-
// Existing implementation: Modify/Delete on non-produced paths must have a Read before them.
|
|
2065
|
-
// This ensures lifecycle Read is not skipped for existing files.
|
|
2066
|
-
for (const [taskFile, record] of taskRecords) {
|
|
2067
|
-
for (const [index, row] of record.rows.entries()) {
|
|
2068
|
-
if (!['modify', 'delete'].includes(row.action)) continue;
|
|
2069
|
-
if (producers.has(row.path)) continue; // produced by spec, not existing
|
|
2070
|
-
// Check if there's a Read for same path preceding this Modify/Delete
|
|
2071
|
-
let hasPrecedingRead = false;
|
|
2072
|
-
// Same task earlier row
|
|
2073
|
-
for (let i = 0; i < index; i += 1) {
|
|
2074
|
-
const r = record.rows[i];
|
|
2075
|
-
if (r.action === 'read' && r.path === row.path) { hasPrecedingRead = true; break; }
|
|
2076
|
-
}
|
|
2077
|
-
if (hasPrecedingRead) continue;
|
|
2078
|
-
// In any dependency ancestor
|
|
2079
|
-
const deps = dependencies.get(taskFile) || new Set();
|
|
2080
|
-
// Walk transitive deps for Read
|
|
2081
|
-
const stack = [...deps];
|
|
2082
|
-
const seen = new Set();
|
|
2083
|
-
while (stack.length > 0) {
|
|
2084
|
-
const dep = stack.pop();
|
|
2085
|
-
if (seen.has(dep)) continue;
|
|
2086
|
-
seen.add(dep);
|
|
2087
|
-
const depRecord = taskRecords.get(dep);
|
|
2088
|
-
if (depRecord && depRecord.rows.some((r) => r.action === 'read' && r.path === row.path)) {
|
|
2089
|
-
hasPrecedingRead = true; break;
|
|
2090
|
-
}
|
|
2091
|
-
for (const d of dependencies.get(dep) || []) stack.push(d);
|
|
2092
|
-
}
|
|
2093
|
-
if (!hasPrecedingRead) {
|
|
2094
|
-
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)`);
|
|
2095
|
-
}
|
|
2096
|
-
}
|
|
2097
|
-
}
|
|
2098
|
-
}
|
|
2099
|
-
|
|
2100
|
-
function validateParallelEligibility(taskFiles, registry, taskRecords, errors) {
|
|
2101
|
-
const parallelTasks = taskFiles.filter((taskFile) => taskRecords.get(taskFile)?.parallel === true);
|
|
2102
|
-
for (const taskFile of parallelTasks) {
|
|
2103
|
-
const dependencies = Array.isArray(registry?.[taskFile]?.dependencies)
|
|
2104
|
-
? registry[taskFile].dependencies
|
|
2105
|
-
: [];
|
|
2106
|
-
if (dependencies.length > 0) {
|
|
2107
|
-
errors.push(`${taskFile}: task-level (P) cannot declare sibling dependencies`);
|
|
2108
|
-
}
|
|
2109
|
-
|
|
2110
|
-
const ownRows = taskRecords.get(taskFile)?.rows || [];
|
|
2111
|
-
for (const sibling of taskFiles) {
|
|
2112
|
-
if (sibling === taskFile) continue;
|
|
2113
|
-
const siblingRows = taskRecords.get(sibling)?.rows || [];
|
|
2114
|
-
const conflicts = new Set();
|
|
2115
|
-
for (const ownRow of ownRows) {
|
|
2116
|
-
for (const siblingRow of siblingRows) {
|
|
2117
|
-
if (ownRow.path === siblingRow.path) {
|
|
2118
|
-
conflicts.add(ownRow.path);
|
|
2119
|
-
}
|
|
2120
|
-
}
|
|
2121
|
-
}
|
|
2122
|
-
for (const conflict of conflicts) {
|
|
2123
|
-
errors.push(
|
|
2124
|
-
`${taskFile}: task-level (P) has Related Files contention on ${conflict} with ${sibling}`,
|
|
2125
|
-
);
|
|
2126
|
-
}
|
|
2127
|
-
}
|
|
2128
|
-
}
|
|
2129
|
-
}
|
|
2130
|
-
|
|
2131
|
-
function explicitResearchPath(spec) {
|
|
2132
|
-
if (!Object.prototype.hasOwnProperty.call(spec, 'research') || spec.research === null) return null;
|
|
2133
|
-
return isPlainObject(spec.research) ? spec.research.path : spec.research;
|
|
2134
|
-
}
|
|
2135
|
-
|
|
2136
|
-
function validateCoordinationTopology(spec, context, taskFiles, specDir, errors) {
|
|
2137
|
-
const depth = context.policy?.planning_depth;
|
|
2138
|
-
if (depth === 'None') {
|
|
2139
|
-
errors.push('spec.json.workflow_policy.planning_depth: None cannot have a durable spec artifact');
|
|
2140
|
-
}
|
|
2141
|
-
const coordination = spec.coordination;
|
|
2142
|
-
if (!isPlainObject(coordination)) {
|
|
2143
|
-
if (!context.legacy) errors.push('spec.json.coordination: Specs v2 requires an object');
|
|
2144
|
-
return { tasksRequired: context.legacy && taskFiles.length > 0, phases: [] };
|
|
2145
|
-
}
|
|
2146
|
-
if (typeof coordination.tasks_required !== 'boolean') {
|
|
2147
|
-
errors.push('spec.json.coordination.tasks_required: must be a boolean');
|
|
2148
|
-
}
|
|
2149
|
-
if (typeof coordination.phases_required !== 'boolean') {
|
|
2150
|
-
errors.push('spec.json.coordination.phases_required: must be a boolean');
|
|
2151
|
-
}
|
|
2152
|
-
const tasksRequired = coordination.tasks_required === true;
|
|
2153
|
-
if (tasksRequired && taskFiles.length === 0) {
|
|
2154
|
-
errors.push('spec.json.coordination.tasks_required: true requires physical task files and matching registry');
|
|
2155
|
-
}
|
|
2156
|
-
if (!tasksRequired && taskFiles.length > 0) {
|
|
2157
|
-
errors.push('tasks/: physical task files exist while coordination.tasks_required is false');
|
|
2158
|
-
}
|
|
2159
|
-
let taskTriggers = [];
|
|
2160
|
-
if (!context.legacy && tasksRequired) {
|
|
2161
|
-
if (coordination.reason !== 'task_topology') {
|
|
2162
|
-
errors.push('spec.json.coordination.reason: Specs v2 task bundles require canonical task_topology reason');
|
|
2163
|
-
}
|
|
2164
|
-
if (!Array.isArray(coordination.task_triggers) || coordination.task_triggers.length === 0) {
|
|
2165
|
-
errors.push('spec.json.coordination.task_triggers: Specs v2 task bundles require a non-empty auditable trigger list');
|
|
2166
|
-
} else {
|
|
2167
|
-
taskTriggers = coordination.task_triggers;
|
|
2168
|
-
const seen = new Set();
|
|
2169
|
-
for (const trigger of taskTriggers) {
|
|
2170
|
-
if (!TASK_TRIGGER_VALUES.has(trigger)) {
|
|
2171
|
-
errors.push(`spec.json.coordination.task_triggers: unknown trigger ${String(trigger)}`);
|
|
2172
|
-
} else if (seen.has(trigger)) {
|
|
2173
|
-
errors.push(`spec.json.coordination.task_triggers: duplicate trigger ${trigger}`);
|
|
2174
|
-
}
|
|
2175
|
-
seen.add(trigger);
|
|
2176
|
-
}
|
|
2177
|
-
}
|
|
2178
|
-
} else if (!context.legacy && Object.prototype.hasOwnProperty.call(coordination, 'task_triggers')) {
|
|
2179
|
-
errors.push('spec.json.coordination.task_triggers: taskless Specs v2 must omit task triggers');
|
|
2180
|
-
}
|
|
2181
|
-
const phases = coordination.phases;
|
|
2182
|
-
if (phases !== undefined && !Array.isArray(phases)) {
|
|
2183
|
-
errors.push('spec.json.coordination.phases: must be an array when present');
|
|
2184
|
-
return { tasksRequired, phases: [] };
|
|
2185
|
-
}
|
|
2186
|
-
const phaseList = Array.isArray(phases) ? phases : [];
|
|
2187
|
-
if (coordination.phases_required !== (phaseList.length > 0)) {
|
|
2188
|
-
errors.push('spec.json.coordination.phases_required: must match whether structured phases are present');
|
|
2189
|
-
}
|
|
2190
|
-
if (phaseList.length > 0 && (depth !== 'Full' || !tasksRequired)) {
|
|
2191
|
-
errors.push('spec.json.coordination.phases: structured phases are allowed only for a Full task graph');
|
|
2192
|
-
}
|
|
2193
|
-
const taskIds = new Set(taskFiles.map(expectedTaskId));
|
|
2194
|
-
const covered = new Set();
|
|
2195
|
-
const phaseIds = new Set();
|
|
2196
|
-
for (const [index, phase] of phaseList.entries()) {
|
|
2197
|
-
const label = `spec.json.coordination.phases[${index}]`;
|
|
2198
|
-
if (!isPlainObject(phase)
|
|
2199
|
-
|| JSON.stringify(Object.keys(phase).sort()) !== JSON.stringify([...PHASE_FIELDS].sort())) {
|
|
2200
|
-
errors.push(`${label}: must contain exactly ${PHASE_FIELDS.join(', ')}`);
|
|
2201
|
-
continue;
|
|
2202
|
-
}
|
|
2203
|
-
if (typeof phase.id !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(phase.id)) {
|
|
2204
|
-
errors.push(`${label}.id: must be a stable kebab-case id`);
|
|
2205
|
-
} else if (phaseIds.has(phase.id)) errors.push(`${label}.id: duplicate phase id ${phase.id}`);
|
|
2206
|
-
phaseIds.add(phase.id);
|
|
2207
|
-
if (!Array.isArray(phase.task_ids) || phase.task_ids.length === 0) {
|
|
2208
|
-
errors.push(`${label}.task_ids: must be a non-empty array`);
|
|
2209
|
-
} else {
|
|
2210
|
-
for (const taskId of phase.task_ids) {
|
|
2211
|
-
if (!taskIds.has(taskId)) errors.push(`${label}.task_ids: unknown task id ${String(taskId)}`);
|
|
2212
|
-
else if (covered.has(taskId)) errors.push(`${label}.task_ids: task ${taskId} is grouped more than once`);
|
|
2213
|
-
covered.add(taskId);
|
|
2214
|
-
}
|
|
2215
|
-
}
|
|
2216
|
-
for (const field of ['entry_condition', 'exit_condition', 'owner_boundary']) {
|
|
2217
|
-
if (typeof phase[field] !== 'string' || phase[field].trim().length < 8 || hasSpecPlaceholders(phase[field])) {
|
|
2218
|
-
errors.push(`${label}.${field}: must be concrete and non-placeholder`);
|
|
2219
|
-
}
|
|
2220
|
-
}
|
|
2221
|
-
}
|
|
2222
|
-
if (phaseList.length > 0) {
|
|
2223
|
-
for (const taskId of taskIds) {
|
|
2224
|
-
if (!covered.has(taskId)) errors.push(`spec.json.coordination.phases: task ${taskId} is not grouped exactly once`);
|
|
2225
|
-
}
|
|
2226
|
-
}
|
|
2227
|
-
try {
|
|
2228
|
-
for (const entry of fs.readdirSync(specDir, { withFileTypes: true })) {
|
|
2229
|
-
if (entry.isFile() && /^phase(?:-|_).*\.md$/i.test(entry.name)) {
|
|
2230
|
-
errors.push(`${entry.name}: phase files are forbidden; use compact spec.json.coordination.phases metadata`);
|
|
2231
|
-
}
|
|
2232
|
-
}
|
|
2233
|
-
} catch (_) {}
|
|
2234
|
-
return { tasksRequired, phases: phaseList, taskTriggers };
|
|
2235
|
-
}
|
|
2236
|
-
|
|
2237
|
-
function validateTaskTriggerEvidence(spec, topology, taskFiles, taskRecords, errors) {
|
|
2238
|
-
if (!Array.isArray(topology.taskTriggers) || topology.taskTriggers.length === 0) return;
|
|
2239
|
-
const triggers = new Set(topology.taskTriggers);
|
|
2240
|
-
const dependencyCount = Object.values(spec.task_registry || {}).reduce((count, entry) => (
|
|
2241
|
-
count + (Array.isArray(entry?.dependencies) ? entry.dependencies.length : 0)
|
|
2242
|
-
), 0);
|
|
2243
|
-
const artifactTaskCount = Object.values(spec.task_registry || {}).filter((entry) => (
|
|
2244
|
-
Array.isArray(entry?.artifacts) && entry.artifacts.length > 0
|
|
2245
|
-
)).length;
|
|
2246
|
-
const parallelTaskCount = taskFiles.filter((taskFile) => taskRecords.get(taskFile)?.parallel === true).length;
|
|
2247
|
-
const writeOwners = new Map();
|
|
2248
|
-
let tasksWithWriteOwnership = 0;
|
|
2249
|
-
for (const taskFile of taskFiles) {
|
|
2250
|
-
const writePaths = new Set((taskRecords.get(taskFile)?.rows || [])
|
|
2251
|
-
.filter((row) => ['create', 'modify', 'delete'].includes(row.action))
|
|
2252
|
-
.map((row) => path.normalize(row.path).split(path.sep).join('/')));
|
|
2253
|
-
if (writePaths.size > 0) tasksWithWriteOwnership += 1;
|
|
2254
|
-
for (const filePath of writePaths) {
|
|
2255
|
-
if (!writeOwners.has(filePath)) writeOwners.set(filePath, []);
|
|
2256
|
-
writeOwners.get(filePath).push(taskFile);
|
|
2257
|
-
}
|
|
2258
|
-
}
|
|
2259
|
-
const overlappingWrite = [...writeOwners.entries()]
|
|
2260
|
-
.find(([, owners]) => owners.length > 1);
|
|
2261
|
-
const hasDistinctOwnership = taskFiles.length >= 2
|
|
2262
|
-
&& tasksWithWriteOwnership >= 2
|
|
2263
|
-
&& overlappingWrite === undefined;
|
|
2264
|
-
const evidence = {
|
|
2265
|
-
distinct_ownership: hasDistinctOwnership,
|
|
2266
|
-
real_dependency: dependencyCount > 0,
|
|
2267
|
-
durable_transition: topology.phases.length > 0,
|
|
2268
|
-
separate_proof: artifactTaskCount > 0,
|
|
2269
|
-
parallel_coordination: parallelTaskCount > 0,
|
|
2270
|
-
};
|
|
2271
|
-
for (const trigger of triggers) {
|
|
2272
|
-
if (TASK_TRIGGER_VALUES.has(trigger) && evidence[trigger] !== true) {
|
|
2273
|
-
if (trigger === 'distinct_ownership') {
|
|
2274
|
-
const overlap = overlappingWrite
|
|
2275
|
-
? `; shared write path ${overlappingWrite[0]} is declared by ${overlappingWrite[1].join(', ')}`
|
|
2276
|
-
: '';
|
|
2277
|
-
errors.push(
|
|
2278
|
-
'spec.json.coordination.task_triggers: distinct_ownership requires at least 2 tasks with distinct, ' +
|
|
2279
|
-
`non-overlapping Related Files Create/Modify/Delete boundaries${overlap}`,
|
|
2280
|
-
);
|
|
2281
|
-
} else {
|
|
2282
|
-
errors.push(`spec.json.coordination.task_triggers: ${trigger} has no matching physical task inventory or graph evidence`);
|
|
2283
|
-
}
|
|
2284
|
-
}
|
|
2285
|
-
}
|
|
2286
|
-
for (const [trigger, present] of Object.entries(evidence)) {
|
|
2287
|
-
if (present && trigger !== 'distinct_ownership' && !triggers.has(trigger)) {
|
|
2288
|
-
errors.push(`spec.json.coordination.task_triggers: physical task inventory or graph requires ${trigger}`);
|
|
2289
|
-
}
|
|
2290
|
-
}
|
|
2291
|
-
}
|
|
2292
|
-
|
|
2293
|
-
function criterionReferences(content) {
|
|
2294
|
-
const ids = new Set();
|
|
2295
|
-
for (const match of semanticMarkdown(content).matchAll(/\bR?(\d+\.\d+)\b/gi)) ids.add(`R${match[1]}`);
|
|
2296
|
-
return ids;
|
|
2297
|
-
}
|
|
2298
|
-
|
|
2299
|
-
function namedContractDefinitions(designText, errors) {
|
|
2300
|
-
const ids = new Set();
|
|
2301
|
-
for (const match of semanticMarkdown(designText).matchAll(/^#{3,6}\s+(C\d+)\s+(?:—|-)\s+.+$/gim)) {
|
|
2302
|
-
const id = match[1].toUpperCase();
|
|
2303
|
-
if (ids.has(id)) errors.push(`design.md: duplicate named contract ID ${id}`);
|
|
2304
|
-
ids.add(id);
|
|
2305
|
-
}
|
|
2306
|
-
return ids;
|
|
2307
|
-
}
|
|
2308
|
-
|
|
2309
|
-
function validateNamedContractReferences(designText, taskContents, errors) {
|
|
2310
|
-
const definitions = namedContractDefinitions(designText, errors);
|
|
2311
|
-
for (const [taskPath, content] of taskContents) {
|
|
2312
|
-
if (taskFormat(content) !== 'v2') continue;
|
|
2313
|
-
const scope = extractSection(content, 'Scope and Typed Anchors') || '';
|
|
2314
|
-
const declared = sectionFieldValues(scope, ['Contracts/Invariants', 'Contracts', 'Invariants']).join(' ');
|
|
2315
|
-
for (const id of declared.match(/\bC\d+\b/gi) || []) {
|
|
2316
|
-
if (!definitions.has(id.toUpperCase())) errors.push(`${taskPath}: references unknown named contract ${id.toUpperCase()}`);
|
|
2317
|
-
}
|
|
2318
|
-
if (/<!--\s*contract:[A-Za-z0-9_.-]+\s*-->/.test(content)) {
|
|
2319
|
-
errors.push(`${taskPath}: Specs v2 tasks reference named contract IDs and must not copy canonical contract blocks`);
|
|
2320
|
-
}
|
|
2321
|
-
}
|
|
2322
|
-
}
|
|
2323
|
-
|
|
2324
|
-
function stableJson(value) {
|
|
2325
|
-
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
|
2326
|
-
if (isPlainObject(value)) {
|
|
2327
|
-
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
|
|
2328
|
-
}
|
|
2329
|
-
return JSON.stringify(value);
|
|
2330
|
-
}
|
|
2331
|
-
|
|
2332
|
-
function semanticDigestState(spec) {
|
|
2333
|
-
const taskRegistry = isPlainObject(spec.task_registry)
|
|
2334
|
-
? Object.fromEntries(Object.entries(spec.task_registry).map(([taskPath, entry]) => [taskPath, {
|
|
2335
|
-
id: entry?.id ?? null,
|
|
2336
|
-
dependencies: entry?.dependencies ?? null,
|
|
2337
|
-
...(Object.prototype.hasOwnProperty.call(entry || {}, 'artifacts') ? { artifacts: entry.artifacts } : {}),
|
|
2338
|
-
}]))
|
|
2339
|
-
: spec.task_registry ?? null;
|
|
2340
|
-
return {
|
|
2341
|
-
scope_lock: spec.scope_lock ?? null,
|
|
2342
|
-
task_files: spec.task_files ?? null,
|
|
2343
|
-
task_registry: taskRegistry,
|
|
2344
|
-
coordination: spec.coordination ?? null,
|
|
2345
|
-
semantic_model: spec.semantic_model ?? null,
|
|
2346
|
-
decisions: spec.decisions ?? null,
|
|
2347
|
-
workflow_policy: spec.workflow_policy ?? null,
|
|
2348
|
-
};
|
|
2349
|
-
}
|
|
2350
|
-
|
|
2351
|
-
function semanticTaskMarkdown(content) {
|
|
2352
|
-
let fence = null;
|
|
2353
|
-
let inComment = false;
|
|
2354
|
-
return String(content).split(/(\r?\n)/).map((line) => {
|
|
2355
|
-
if (/^\r?\n$/.test(line)) return line;
|
|
2356
|
-
if (inComment) {
|
|
2357
|
-
if (line.includes('-->')) inComment = false;
|
|
2358
|
-
return line;
|
|
2359
|
-
}
|
|
2360
|
-
const commentStart = line.indexOf('<!--');
|
|
2361
|
-
if (commentStart >= 0) {
|
|
2362
|
-
if (line.indexOf('-->', commentStart + 4) < 0) inComment = true;
|
|
2363
|
-
return line;
|
|
2364
|
-
}
|
|
2365
|
-
const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/);
|
|
2366
|
-
if (marker) {
|
|
2367
|
-
const character = marker[1][0];
|
|
2368
|
-
const length = marker[1].length;
|
|
2369
|
-
if (fence === null) fence = { character, length };
|
|
2370
|
-
else if (character === fence.character && length >= fence.length) fence = null;
|
|
2371
|
-
return line;
|
|
2372
|
-
}
|
|
2373
|
-
if (fence !== null) return line;
|
|
2374
|
-
return line
|
|
2375
|
-
.replace(/^(\*\*Status:\*\*[ \t]*)(?:pending|in_progress|blocked|done)([ \t]*)$/, '$1<lifecycle-status>$2')
|
|
2376
|
-
.replace(/^(\s*[-*+]\s+\[)[ xX](\])/, '$1 $2');
|
|
2377
|
-
}).join('');
|
|
2378
|
-
}
|
|
2379
|
-
|
|
2380
|
-
function computeSemanticDigest(specDir) {
|
|
2381
|
-
const errors = [];
|
|
2382
|
-
const canonicalSpecDir = validateSpecRoot(specDir, errors);
|
|
2383
|
-
if (!canonicalSpecDir) return { errors, digest: null };
|
|
2384
|
-
const specArtifact = inspectSpecArtifact(
|
|
2385
|
-
specDir, canonicalSpecDir, 'spec.json', 'spec.json', errors, { required: true },
|
|
2386
|
-
);
|
|
2387
|
-
const spec = specArtifact ? readJson(specArtifact.path, errors) : null;
|
|
2388
|
-
const artifacts = [];
|
|
2389
|
-
for (const relativePath of ['requirements.md', 'design.md']) {
|
|
2390
|
-
const artifact = inspectSpecArtifact(
|
|
2391
|
-
specDir, canonicalSpecDir, relativePath, relativePath, errors, { required: true },
|
|
2392
|
-
);
|
|
2393
|
-
if (artifact) artifacts.push([relativePath, fs.readFileSync(artifact.path)]);
|
|
2394
|
-
}
|
|
2395
|
-
const research = inspectSpecArtifact(specDir, canonicalSpecDir, 'research.md', 'research.md', errors);
|
|
2396
|
-
if (research) artifacts.push(['research.md', fs.readFileSync(research.path)]);
|
|
2397
|
-
for (const taskFile of listTaskFiles(specDir, canonicalSpecDir, errors)) {
|
|
2398
|
-
const artifact = inspectSpecArtifact(specDir, canonicalSpecDir, taskFile, taskFile, errors, { required: true });
|
|
2399
|
-
if (artifact) artifacts.push([taskFile, Buffer.from(semanticTaskMarkdown(fs.readFileSync(artifact.path, 'utf8')), 'utf8')]);
|
|
2400
|
-
}
|
|
2401
|
-
if (!isPlainObject(spec) || errors.length > 0) return { errors, digest: null };
|
|
2402
|
-
const hash = crypto.createHash('sha256');
|
|
2403
|
-
for (const [relativePath, bytes] of artifacts) {
|
|
2404
|
-
hash.update(relativePath, 'utf8');
|
|
2405
|
-
hash.update('\0');
|
|
2406
|
-
hash.update(String(bytes.length), 'utf8');
|
|
2407
|
-
hash.update('\0');
|
|
2408
|
-
hash.update(bytes);
|
|
2409
|
-
hash.update('\0');
|
|
2410
|
-
}
|
|
2411
|
-
const state = Buffer.from(stableJson(semanticDigestState(spec)), 'utf8');
|
|
2412
|
-
hash.update('spec.json#semantic-state\0', 'utf8');
|
|
2413
|
-
hash.update(String(state.length), 'utf8');
|
|
2414
|
-
hash.update('\0');
|
|
2415
|
-
hash.update(state);
|
|
2416
|
-
return { errors, digest: `sha256:${hash.digest('hex')}` };
|
|
2417
|
-
}
|
|
2418
|
-
|
|
2419
|
-
function designSemanticReviewIds(designText) {
|
|
2420
|
-
return new Set([...semanticMarkdown(designText).matchAll(/^#{3,6}\s+([DIC]\d+)\s+(?:—|-)\s+.+$/gim)]
|
|
2421
|
-
.map((match) => match[1].toUpperCase()));
|
|
2422
|
-
}
|
|
2423
|
-
|
|
2424
|
-
function concreteSemanticReviewText(value) {
|
|
2425
|
-
return typeof value === 'string'
|
|
2426
|
-
&& value.trim() === value
|
|
2427
|
-
&& meaningfulObligationValue(value)
|
|
2428
|
-
&& !hasSpecPlaceholders(value);
|
|
2429
|
-
}
|
|
2430
|
-
|
|
2431
|
-
function validateSemanticReview(specDir, spec, context, subCriteriaIds, designText, errors) {
|
|
2432
|
-
const review = spec.validation?.semantic_review;
|
|
2433
|
-
if (!isPlainObject(review)) {
|
|
2434
|
-
if (!context.legacy) errors.push('spec.json.validation.semantic_review: Specs v2 requires the canonical object');
|
|
2435
|
-
return;
|
|
2436
|
-
}
|
|
2437
|
-
const fields = Object.keys(review).sort();
|
|
2438
|
-
if (JSON.stringify(fields) !== JSON.stringify([...SEMANTIC_REVIEW_FIELDS].sort())) {
|
|
2439
|
-
errors.push(`spec.json.validation.semantic_review: fields must be exactly ${SEMANTIC_REVIEW_FIELDS.join(', ')}`);
|
|
2440
|
-
}
|
|
2441
|
-
if (!SEMANTIC_REVIEW_STATUSES.has(review.status)) {
|
|
2442
|
-
errors.push('spec.json.validation.semantic_review.status: must be not-run or completed');
|
|
2443
|
-
return;
|
|
2444
|
-
}
|
|
2445
|
-
if (!Array.isArray(review.reviewed_criteria)) {
|
|
2446
|
-
errors.push('spec.json.validation.semantic_review.reviewed_criteria: must be an array');
|
|
2447
|
-
}
|
|
2448
|
-
if (!Array.isArray(review.counterexamples)) {
|
|
2449
|
-
errors.push('spec.json.validation.semantic_review.counterexamples: must be an array');
|
|
2450
|
-
}
|
|
2451
|
-
if (review.status === 'not-run') {
|
|
2452
|
-
if (spec.ready_for_implementation === true) {
|
|
2453
|
-
errors.push('spec.json.ready_for_implementation: requires validation.semantic_review.status completed');
|
|
2454
|
-
}
|
|
2455
|
-
if (review.reviewed_artifact_digest !== null || review.reviewed_criteria?.length > 0
|
|
2456
|
-
|| review.counterexamples?.length > 0) {
|
|
2457
|
-
errors.push('spec.json.validation.semantic_review: not-run state must keep receipt fields null or empty');
|
|
2458
|
-
}
|
|
2459
|
-
return;
|
|
2460
|
-
}
|
|
2461
|
-
|
|
2462
|
-
if (!SHA256_RE.test(review.reviewed_artifact_digest || '')) {
|
|
2463
|
-
errors.push('spec.json.validation.semantic_review.reviewed_artifact_digest: must be sha256:<64 lowercase hex>');
|
|
2464
|
-
} else {
|
|
2465
|
-
const computed = computeSemanticDigest(specDir);
|
|
2466
|
-
if (computed.errors.length > 0) {
|
|
2467
|
-
errors.push(`spec.json.validation.semantic_review.reviewed_artifact_digest: cannot recompute (${computed.errors.join('; ')})`);
|
|
2468
|
-
} else if (review.reviewed_artifact_digest !== computed.digest) {
|
|
2469
|
-
errors.push('spec.json.validation.semantic_review.reviewed_artifact_digest: stale or does not match final artifacts and canonical state');
|
|
2470
|
-
}
|
|
2471
|
-
}
|
|
2472
|
-
if (context.policy?.assurance_level === 'Strict') errors.push('spec.json.validation.semantic_review: Strict assurance is no longer supported (its reviewer attestation hook was removed); move the packet to plan.md with flat task files');
|
|
2473
|
-
|
|
2474
|
-
const criteria = Array.isArray(review.reviewed_criteria) ? review.reviewed_criteria : [];
|
|
2475
|
-
const criterionSet = new Set(criteria);
|
|
2476
|
-
if (criterionSet.size !== criteria.length) {
|
|
2477
|
-
errors.push('spec.json.validation.semantic_review.reviewed_criteria: must not contain duplicates');
|
|
2478
|
-
}
|
|
2479
|
-
for (const criterion of criteria) {
|
|
2480
|
-
if (!subCriteriaIds.includes(criterion)) {
|
|
2481
|
-
errors.push(`spec.json.validation.semantic_review.reviewed_criteria: unknown criterion ${String(criterion)}`);
|
|
2482
|
-
}
|
|
2483
|
-
}
|
|
2484
|
-
for (const criterion of subCriteriaIds) {
|
|
2485
|
-
if (!criterionSet.has(criterion)) {
|
|
2486
|
-
errors.push(`spec.json.validation.semantic_review.reviewed_criteria: missing criterion ${criterion}`);
|
|
2487
|
-
}
|
|
2488
|
-
}
|
|
2489
|
-
|
|
2490
|
-
const designIds = designSemanticReviewIds(designText);
|
|
2491
|
-
const coveredCriteria = new Set();
|
|
2492
|
-
for (const [index, counterexample] of (Array.isArray(review.counterexamples) ? review.counterexamples : []).entries()) {
|
|
2493
|
-
const label = `spec.json.validation.semantic_review.counterexamples[${index}]`;
|
|
2494
|
-
const expectedFields = ['criterion', 'scenario', 'expected', 'design_reference'];
|
|
2495
|
-
if (!isPlainObject(counterexample)
|
|
2496
|
-
|| JSON.stringify(Object.keys(counterexample).sort()) !== JSON.stringify(expectedFields.sort())) {
|
|
2497
|
-
errors.push(`${label}: must contain exactly criterion, scenario, expected, design_reference`);
|
|
2498
|
-
continue;
|
|
2499
|
-
}
|
|
2500
|
-
if (!subCriteriaIds.includes(counterexample.criterion)) {
|
|
2501
|
-
errors.push(`${label}.criterion: unknown criterion ${String(counterexample.criterion)}`);
|
|
2502
|
-
} else if (coveredCriteria.has(counterexample.criterion)) {
|
|
2503
|
-
errors.push(`${label}.criterion: duplicate counterexample for ${counterexample.criterion}`);
|
|
2504
|
-
} else {
|
|
2505
|
-
coveredCriteria.add(counterexample.criterion);
|
|
2506
|
-
}
|
|
2507
|
-
for (const field of ['scenario', 'expected']) {
|
|
2508
|
-
if (!concreteSemanticReviewText(counterexample[field])) {
|
|
2509
|
-
errors.push(`${label}.${field}: must be concrete and non-placeholder`);
|
|
2510
|
-
}
|
|
2511
|
-
}
|
|
2512
|
-
if (typeof counterexample.design_reference !== 'string'
|
|
2513
|
-
|| !designIds.has(counterexample.design_reference.toUpperCase())) {
|
|
2514
|
-
errors.push(`${label}.design_reference: must reference a real D/I/C ID in design.md`);
|
|
2515
|
-
}
|
|
2516
|
-
}
|
|
2517
|
-
for (const criterionId of subCriteriaIds) {
|
|
2518
|
-
if (!coveredCriteria.has(criterionId)) {
|
|
2519
|
-
errors.push(`spec.json.validation.semantic_review.counterexamples: missing concrete counterexample for ${criterionId}`);
|
|
2520
|
-
}
|
|
2521
|
-
}
|
|
2522
|
-
}
|
|
2523
|
-
|
|
2524
|
-
function exactTaskTarget(value) {
|
|
2525
|
-
return typeof value === 'string' && value.trim() === value && value !== ''
|
|
2526
|
-
&& !unsafeRelatedPath(value) && !/[*?{}[\]]/.test(value) && !/[\\/]$/.test(value);
|
|
2527
|
-
}
|
|
2528
|
-
|
|
2529
|
-
function targetsContend(left, right) {
|
|
2530
|
-
const a = left.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
2531
|
-
const b = right.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
2532
|
-
return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
2533
|
-
}
|
|
2534
|
-
|
|
2535
|
-
function task21Anchors(taskPath, content, errors) {
|
|
2536
|
-
const section = extractSection(content, 'Anchors and Ownership') || '';
|
|
2537
|
-
const tableCount = (section.match(/^\s*\|\s*ID\s*\|\s*Type\s*\|\s*Target\s*\|\s*Role\s*\|\s*Access\s*\|\s*Action\s*\|\s*$/gim) || []).length;
|
|
2538
|
-
if (tableCount !== 1) {
|
|
2539
|
-
errors.push(`${taskPath}: Anchors and Ownership requires one table ID | Type | Target | Role | Access | Action`);
|
|
2540
|
-
return [];
|
|
2541
|
-
}
|
|
2542
|
-
if (hasHeading(content, 'Related Files')) {
|
|
2543
|
-
errors.push(`${taskPath}: Related Files is not canonical in schema 2.1; use Anchors and Ownership only`);
|
|
2544
|
-
}
|
|
2545
|
-
return parseCanonicalAnchors(content, { label: taskPath, design: false }, errors);
|
|
2546
|
-
}
|
|
2547
|
-
|
|
2548
|
-
function canonicalTaskCriterionIds(section) {
|
|
2549
|
-
return [...new Set([...String(section || '').matchAll(/^\s*[-*+]\s+(?:\*\*)?(R\d+\.\d+)(?:\*\*)?\s*:/gim)]
|
|
2550
|
-
.map((match) => match[1].toUpperCase()))].sort();
|
|
2551
|
-
}
|
|
2552
|
-
|
|
2553
|
-
function canonicalTaskChangeIds(section) {
|
|
2554
|
-
const ids = [];
|
|
2555
|
-
for (const match of String(section || '').matchAll(/_Requirements:\s*([^_\n]+)_/gi)) {
|
|
2556
|
-
for (const token of match[1].split(',')) {
|
|
2557
|
-
const id = token.trim().match(/^(?:R)?(\d+\.\d+)$/i);
|
|
2558
|
-
if (id) ids.push(`R${id[1]}`);
|
|
2559
|
-
}
|
|
2560
|
-
}
|
|
2561
|
-
return [...new Set(ids)].sort();
|
|
2562
|
-
}
|
|
2563
|
-
|
|
2564
|
-
function hasConcreteTaskProse(section) {
|
|
2565
|
-
return meaningfulMarkdownLines(section).some((line) => (
|
|
2566
|
-
line.length >= 16
|
|
2567
|
-
&& !/^(?:implement|update|handle|verify|deliver|support|works?|done|task outcome|in scope|out of scope)\.?$/i.test(line)
|
|
2568
|
-
&& !/\b(?:as needed|appropriate behavior|expected behavior|the feature|this task|relevant files?)\b/i.test(line)
|
|
2569
|
-
));
|
|
2570
|
-
}
|
|
2571
|
-
|
|
2572
|
-
function validateTask21(taskPath, content, entry, specReady, errors) {
|
|
2573
|
-
for (const section of TASK_21_SECTIONS) {
|
|
2574
|
-
if (headingCount(content, section) !== 1) errors.push(`${taskPath}: requires exactly one ## ${section} section`);
|
|
2575
|
-
}
|
|
2576
|
-
const h2 = [...content.matchAll(/^##\s+(.+?)\s*$/gm)].map((match) => match[1]);
|
|
2577
|
-
for (const heading of h2) if (!TASK_21_SECTIONS.includes(heading)) {
|
|
2578
|
-
errors.push(`${taskPath}: schema 2.1 task has non-canonical section ## ${heading}`);
|
|
2579
|
-
}
|
|
2580
|
-
validateTaskIdentity(taskPath, content, entry, errors);
|
|
2581
|
-
validateTaskDependencyHeader(taskPath, content, entry, true, errors);
|
|
2582
|
-
validateTaskStatusHeader(taskPath, content, entry, specReady, errors);
|
|
2583
|
-
validateVerificationPlan(taskPath, content, errors);
|
|
2584
|
-
validateTaskPlaceholders(taskPath, content, errors, []);
|
|
2585
|
-
const outcome = extractSection(content, 'Outcome') || '';
|
|
2586
|
-
const scope = extractSection(content, 'Scope') || '';
|
|
2587
|
-
const changes = extractSection(content, 'Changes') || '';
|
|
2588
|
-
const acceptance = extractSection(content, 'Acceptance') || '';
|
|
2589
|
-
if (!hasConcreteTaskProse(outcome)) errors.push(`${taskPath}: Outcome must be concrete and non-generic`);
|
|
2590
|
-
if (!hasConcreteTaskProse(scope)
|
|
2591
|
-
|| !/^\s*[-*+]\s+(?:\*\*)?In scope(?:\*\*)?\s*:/im.test(scope)
|
|
2592
|
-
|| !/^\s*[-*+]\s+(?:\*\*)?Out of scope(?:\*\*)?\s*:/im.test(scope)) {
|
|
2593
|
-
errors.push(`${taskPath}: Scope requires concrete In scope and Out of scope boundaries`);
|
|
2594
|
-
}
|
|
2595
|
-
if (!hasActionableTaskStep(changes)) errors.push(`${taskPath}: Changes requires an actionable checkbox`);
|
|
2596
|
-
const changeCriteria = canonicalTaskChangeIds(changes);
|
|
2597
|
-
const acceptanceCriteria = canonicalTaskCriterionIds(acceptance);
|
|
2598
|
-
const rawChangeCriteria = [...changes.matchAll(/_Requirements:\s*([^_\n]+)_/gi)]
|
|
2599
|
-
.flatMap((match) => match[1].split(','))
|
|
2600
|
-
.map((token) => token.trim().replace(/^R/i, ''))
|
|
2601
|
-
.filter((token) => /^\d+\.\d+$/.test(token));
|
|
2602
|
-
const rawAcceptanceCriteria = [...acceptance.matchAll(/^\s*[-*+]\s+(?:\*\*)?(R\d+\.\d+)(?:\*\*)?\s*:/gim)]
|
|
2603
|
-
.map((match) => match[1].toUpperCase());
|
|
2604
|
-
if (new Set(rawChangeCriteria).size !== rawChangeCriteria.length) errors.push(`${taskPath}: Changes contains duplicate RN.M authority`);
|
|
2605
|
-
if (new Set(rawAcceptanceCriteria).size !== rawAcceptanceCriteria.length) errors.push(`${taskPath}: Acceptance contains duplicate RN.M authority`);
|
|
2606
|
-
if (!hasConcreteTaskProse(acceptance)) errors.push(`${taskPath}: Acceptance must declare a concrete implementation or verification outcome`);
|
|
2607
|
-
if (JSON.stringify(changeCriteria) !== JSON.stringify(acceptanceCriteria)) {
|
|
2608
|
-
errors.push(`${taskPath}: Changes and Acceptance must trace the same exact RN.M criteria`);
|
|
2609
|
-
}
|
|
2610
|
-
const verificationRefs = sectionFieldValues(extractSection(content, 'Verification Plan') || '', ['Verification ref'])
|
|
2611
|
-
.map((value) => String(value).toUpperCase().match(/\bV\d+(?:\.\d+)?\b/)?.[0])
|
|
2612
|
-
.filter(Boolean);
|
|
2613
|
-
if (verificationRefs.length !== 1 || !/^V\d+(?:\.\d+)?$/.test(verificationRefs[0])) {
|
|
2614
|
-
errors.push(`${taskPath}: Verification Plan requires exactly one canonical V reference`);
|
|
2615
|
-
}
|
|
2616
|
-
const taskRoles = sectionFieldValues(extractSection(content, 'Verification Plan') || '', ['Task role'])
|
|
2617
|
-
.map((value) => String(value).toLowerCase().match(/\b(subject|verifier)\b/)?.[1])
|
|
2618
|
-
.filter(Boolean);
|
|
2619
|
-
if (taskRoles.length !== 1) errors.push(`${taskPath}: Verification Plan requires exactly one Task role: subject or verifier`);
|
|
2620
|
-
return {
|
|
2621
|
-
anchors: task21Anchors(taskPath, content, errors),
|
|
2622
|
-
criteria: acceptanceCriteria,
|
|
2623
|
-
verificationRefs,
|
|
2624
|
-
taskRole: taskRoles[0] || null,
|
|
2625
|
-
};
|
|
2626
|
-
}
|
|
2627
|
-
|
|
2628
|
-
function semanticDigest21(specDir, spec, taskFiles) {
|
|
2629
|
-
const policy = spec.workflow_policy;
|
|
2630
|
-
const semanticState = {
|
|
2631
|
-
schema_version: spec.schema_version,
|
|
2632
|
-
feature_name: spec.feature_name,
|
|
2633
|
-
scope_lock: spec.scope_lock,
|
|
2634
|
-
authoring: spec.authoring,
|
|
2635
|
-
research: spec.research ?? null,
|
|
2636
|
-
workflow_policy: policy,
|
|
2637
|
-
coordination: spec.coordination,
|
|
2638
|
-
semantic_model: spec.semantic_model,
|
|
2639
|
-
task_files: spec.task_files,
|
|
2640
|
-
task_registry: Object.fromEntries(taskFiles.map((taskPath) => {
|
|
2641
|
-
const entry = spec.task_registry?.[taskPath] || {};
|
|
2642
|
-
return [taskPath, { id: entry.id, title: entry.title, dependencies: entry.dependencies, artifacts: entry.artifacts }];
|
|
2643
|
-
})),
|
|
2644
|
-
};
|
|
2645
|
-
const artifacts = {
|
|
2646
|
-
state: semanticState,
|
|
2647
|
-
requirements: fs.readFileSync(path.join(specDir, 'requirements.md'), 'utf8'),
|
|
2648
|
-
design: fs.readFileSync(path.join(specDir, 'design.md'), 'utf8'),
|
|
2649
|
-
research: spec.research === 'research.md' && fs.existsSync(path.join(specDir, 'research.md'))
|
|
2650
|
-
? fs.readFileSync(path.join(specDir, 'research.md'), 'utf8') : null,
|
|
2651
|
-
tasks: taskFiles.map((taskPath) => [taskPath, semanticTaskMarkdown(fs.readFileSync(path.join(specDir, taskPath), 'utf8'))]),
|
|
2652
|
-
};
|
|
2653
|
-
return `sha256:${crypto.createHash('sha256').update(stableJson(artifacts)).digest('hex')}`;
|
|
2654
|
-
}
|
|
2655
|
-
|
|
2656
|
-
function computeSemanticDigest21(specDir, spec) {
|
|
2657
|
-
const errors = [];
|
|
2658
|
-
const canonicalSpecDir = validateSpecRoot(specDir, errors);
|
|
2659
|
-
if (!canonicalSpecDir) return { errors, digest: null };
|
|
2660
|
-
if (spec === undefined) {
|
|
2661
|
-
const specArtifact = inspectSpecArtifact(specDir, canonicalSpecDir, 'spec.json', 'spec.json', errors, { required: true });
|
|
2662
|
-
if (specArtifact) spec = readJson(specArtifact.path, errors);
|
|
2663
|
-
}
|
|
2664
|
-
if (!isPlainObject(spec)) {
|
|
2665
|
-
if (errors.length === 0) errors.push('spec.json: must contain a JSON object');
|
|
2666
|
-
return { errors, digest: null };
|
|
2667
|
-
}
|
|
2668
|
-
const requiredArtifacts = ['requirements.md', 'design.md'];
|
|
2669
|
-
const contents = new Map();
|
|
2670
|
-
for (const relativePath of requiredArtifacts) {
|
|
2671
|
-
const artifact = inspectSpecArtifact(specDir, canonicalSpecDir, relativePath, relativePath, errors, { required: true });
|
|
2672
|
-
if (artifact) contents.set(relativePath, fs.readFileSync(artifact.path, 'utf8'));
|
|
2673
|
-
}
|
|
2674
|
-
const taskFiles = listTaskFiles(specDir, canonicalSpecDir, errors);
|
|
2675
|
-
const projection = SEMANTIC.modelFromMarkdown(canonicalSpecDir, spec);
|
|
2676
|
-
errors.push(...projection.errors.map((error) => `semantic projection: ${error}`));
|
|
2677
|
-
let digestSpec = spec;
|
|
2678
|
-
if (!isPlainObject(spec.semantic_model)) {
|
|
2679
|
-
digestSpec = { ...spec, semantic_model: projection.model };
|
|
2680
|
-
} else {
|
|
2681
|
-
errors.push(...SEMANTIC.validateSemanticModel(spec.semantic_model, spec));
|
|
2682
|
-
if (SEMANTIC.stableJson(spec.semantic_model) !== SEMANTIC.stableJson(projection.model)) {
|
|
2683
|
-
errors.push('spec.json.semantic_model: digest refuses Markdown projection drift');
|
|
2684
|
-
}
|
|
2685
|
-
}
|
|
2686
|
-
const verificationDefinitions = new Map(((spec.semantic_model || projection.model).verification_definitions || [])
|
|
2687
|
-
.map((definition) => [definition.id, definition]));
|
|
2688
|
-
const declared = Array.isArray(spec.task_files) ? [...spec.task_files].sort() : [];
|
|
2689
|
-
if (taskFiles.length === 0) {
|
|
2690
|
-
if (Object.prototype.hasOwnProperty.call(spec, 'task_files') || Object.prototype.hasOwnProperty.call(spec, 'task_registry')) {
|
|
2691
|
-
errors.push('schema 2.1 digest refuses task inventory without physical tasks');
|
|
2692
|
-
}
|
|
2693
|
-
} else {
|
|
2694
|
-
if (JSON.stringify(declared) !== JSON.stringify(taskFiles)) errors.push('schema 2.1 digest requires task_files to match physical tasks exactly');
|
|
2695
|
-
if (!isPlainObject(spec.task_registry) || Object.keys(spec.task_registry).sort().join(',') !== taskFiles.join(',')) {
|
|
2696
|
-
errors.push('schema 2.1 digest requires task_registry to match physical tasks exactly');
|
|
2697
|
-
}
|
|
2698
|
-
}
|
|
2699
|
-
validateResearchLifecycle21(specDir, canonicalSpecDir, spec, errors);
|
|
2700
|
-
const anchorsByTask = new Map();
|
|
2701
|
-
const criteriaByTask = new Map();
|
|
2702
|
-
for (const taskPath of taskFiles) {
|
|
2703
|
-
const artifact = inspectSpecArtifact(specDir, canonicalSpecDir, taskPath, taskPath, errors, { required: true });
|
|
2704
|
-
if (!artifact) continue;
|
|
2705
|
-
const content = fs.readFileSync(artifact.path, 'utf8');
|
|
2706
|
-
for (const section of TASK_21_SECTIONS) if (headingCount(content, section) !== 1) {
|
|
2707
|
-
errors.push(`${taskPath}: digest requires exactly one ## ${section} section`);
|
|
2708
|
-
}
|
|
2709
|
-
validateTaskIdentity(taskPath, content, spec.task_registry?.[taskPath], errors);
|
|
2710
|
-
anchorsByTask.set(taskPath, task21Anchors(taskPath, content, errors));
|
|
2711
|
-
criteriaByTask.set(taskPath, canonicalTaskCriterionIds(extractSection(content, 'Acceptance') || ''));
|
|
2712
|
-
}
|
|
2713
|
-
if (contents.has('design.md')) {
|
|
2714
|
-
validateBoundaries21(
|
|
2715
|
-
spec, taskFiles, spec.task_registry || {}, anchorsByTask, criteriaByTask,
|
|
2716
|
-
contents.get('design.md'), verificationDefinitions, errors,
|
|
2717
|
-
);
|
|
2718
|
-
}
|
|
2719
|
-
if (errors.length > 0) return { errors, digest: null };
|
|
2720
|
-
return { errors, digest: semanticDigest21(specDir, digestSpec, taskFiles) };
|
|
2721
|
-
}
|
|
2722
|
-
|
|
2723
|
-
function validateSemanticReviewFindings(review, errors) {
|
|
2724
|
-
if (!Array.isArray(review.findings)) {
|
|
2725
|
-
errors.push('spec.json.validation.semantic_review.findings: must be an array');
|
|
2726
|
-
return;
|
|
2727
|
-
}
|
|
2728
|
-
const findingIds = new Set();
|
|
2729
|
-
for (const [index, finding] of review.findings.entries()) {
|
|
2730
|
-
const label = `semantic_review.findings[${index}]`;
|
|
2731
|
-
if (!isPlainObject(finding) || Object.keys(finding).sort().join(',') !== [...C2_FINDING_FIELDS].sort().join(',')) {
|
|
2732
|
-
errors.push(`${label}: fields must be exactly ${C2_FINDING_FIELDS.join(', ')}`); continue;
|
|
2733
|
-
}
|
|
2734
|
-
if (typeof finding.id !== 'string' || finding.id === '' || findingIds.has(finding.id)) errors.push(`${label}.id: must be a unique non-empty string`);
|
|
2735
|
-
findingIds.add(finding.id);
|
|
2736
|
-
if (!C2_FINDING_SEVERITIES.has(finding.severity)) errors.push(`${label}.severity: must be Critical, High, Medium, or Low`);
|
|
2737
|
-
if (typeof finding.location !== 'string' || finding.location.trim() === '') errors.push(`${label}.location: must be a non-empty string`);
|
|
2738
|
-
if (!concreteSemanticReviewText(finding.summary)) errors.push(`${label}.summary: must be concrete with minimum length 8`);
|
|
2739
|
-
if (typeof finding.blocking !== 'boolean') errors.push(`${label}.blocking: must be boolean`);
|
|
2740
|
-
if (!C2_FINDING_DISPOSITIONS.has(finding.disposition)) errors.push(`${label}.disposition: must be open, accepted, or mitigated`);
|
|
2741
|
-
}
|
|
2742
|
-
}
|
|
2743
|
-
|
|
2744
|
-
function validateSemanticReviewUnresolvedDecisions(review, errors) {
|
|
2745
|
-
if (!Array.isArray(review.unresolved_decisions)) {
|
|
2746
|
-
errors.push('spec.json.validation.semantic_review.unresolved_decisions: must be an array');
|
|
2747
|
-
return;
|
|
2748
|
-
}
|
|
2749
|
-
const decisionIds = new Set();
|
|
2750
|
-
for (const [index, decision] of review.unresolved_decisions.entries()) {
|
|
2751
|
-
const label = `semantic_review.unresolved_decisions[${index}]`;
|
|
2752
|
-
if (!isPlainObject(decision) || Object.keys(decision).sort().join(',') !== [...C2_UNRESOLVED_DECISION_FIELDS].sort().join(',')) {
|
|
2753
|
-
errors.push(`${label}: fields must be exactly ${C2_UNRESOLVED_DECISION_FIELDS.join(', ')}`); continue;
|
|
2754
|
-
}
|
|
2755
|
-
if (typeof decision.id !== 'string' || decision.id === '' || decisionIds.has(decision.id)) errors.push(`${label}.id: must be a unique non-empty string`);
|
|
2756
|
-
decisionIds.add(decision.id);
|
|
2757
|
-
if (!concreteSemanticReviewText(decision.summary)) errors.push(`${label}.summary: must be concrete with minimum length 8`);
|
|
2758
|
-
if (typeof decision.blocking !== 'boolean') errors.push(`${label}.blocking: must be boolean`);
|
|
2759
|
-
}
|
|
2760
|
-
}
|
|
2761
|
-
|
|
2762
|
-
function validateSemanticReviewGraphCoverage(review, errors) {
|
|
2763
|
-
if (!Array.isArray(review.graph_coverage)) {
|
|
2764
|
-
errors.push('spec.json.validation.semantic_review.graph_coverage: must be an array');
|
|
2765
|
-
return;
|
|
2766
|
-
}
|
|
2767
|
-
const coveredSurfaces = new Set();
|
|
2768
|
-
for (const [index, coverage] of review.graph_coverage.entries()) {
|
|
2769
|
-
const label = `semantic_review.graph_coverage[${index}]`;
|
|
2770
|
-
if (!isPlainObject(coverage) || Object.keys(coverage).sort().join(',') !== [...C2_GRAPH_COVERAGE_FIELDS].sort().join(',')) {
|
|
2771
|
-
errors.push(`${label}: fields must be exactly ${C2_GRAPH_COVERAGE_FIELDS.join(', ')}`); continue;
|
|
2772
|
-
}
|
|
2773
|
-
if (!C2_GRAPH_COVERAGE_SURFACES.includes(coverage.surface)) errors.push(`${label}.surface: must be a canonical graph coverage surface`);
|
|
2774
|
-
else coveredSurfaces.add(coverage.surface);
|
|
2775
|
-
if (typeof coverage.covered !== 'boolean') errors.push(`${label}.covered: must be boolean`);
|
|
2776
|
-
if (typeof coverage.notes !== 'string') errors.push(`${label}.notes: must be a string`);
|
|
2777
|
-
}
|
|
2778
|
-
for (const surface of C2_GRAPH_COVERAGE_SURFACES) if (!coveredSurfaces.has(surface)) {
|
|
2779
|
-
errors.push(`spec.json.validation.semantic_review.graph_coverage: missing required surface ${surface}`);
|
|
2780
|
-
}
|
|
2781
|
-
}
|
|
2782
|
-
|
|
2783
|
-
function validateSemanticReviewerEvidence(review, errors) {
|
|
2784
|
-
if (review.reviewer_evidence === null) return;
|
|
2785
|
-
if (!isPlainObject(review.reviewer_evidence)
|
|
2786
|
-
|| Object.keys(review.reviewer_evidence).sort().join(',') !== [...C2_REVIEWER_EVIDENCE_FIELDS].sort().join(',')) {
|
|
2787
|
-
errors.push(`spec.json.validation.semantic_review.reviewer_evidence: fields must be exactly ${C2_REVIEWER_EVIDENCE_FIELDS.join(', ')} or null`);
|
|
2788
|
-
return;
|
|
2789
|
-
}
|
|
2790
|
-
if (!C2_REVIEWER_EVIDENCE_ASSURANCES.has(review.reviewer_evidence.assurance)) {
|
|
2791
|
-
errors.push('spec.json.validation.semantic_review.reviewer_evidence.assurance: must be Routine, Elevated, or Strict');
|
|
2792
|
-
}
|
|
2793
|
-
if (!concreteSemanticReviewText(review.reviewer_evidence.summary)) {
|
|
2794
|
-
errors.push('spec.json.validation.semantic_review.reviewer_evidence.summary: must be concrete with minimum length 8');
|
|
2795
|
-
}
|
|
2796
|
-
}
|
|
2797
|
-
|
|
2798
|
-
function validateSemanticReview21(specDir, spec, criteria, designIds, verificationDefs, errors) {
|
|
2799
|
-
const review = spec.validation?.semantic_review;
|
|
2800
|
-
if (!isPlainObject(review) || Object.keys(review).sort().join(',') !== [...C2_FIELDS].sort().join(',')) {
|
|
2801
|
-
errors.push(`spec.json.validation.semantic_review: schema 2.1 fields must be exactly ${C2_FIELDS.join(', ')}`);
|
|
2802
|
-
return;
|
|
2803
|
-
}
|
|
2804
|
-
if (!['not-run', 'completed'].includes(review.status)) errors.push('spec.json.validation.semantic_review.status: must be not-run or completed');
|
|
2805
|
-
if (review.status === 'not-run') {
|
|
2806
|
-
if (review.semantic_digest !== null || review.verdict !== null || review.lifecycle_disposition !== null
|
|
2807
|
-
|| review.reviewer_evidence !== null || review.repair_round !== 0
|
|
2808
|
-
|| review.findings?.length || review.unresolved_decisions?.length
|
|
2809
|
-
|| review.graph_coverage?.length || review.reviewed_criteria?.length || review.counterexamples?.length) {
|
|
2810
|
-
errors.push('spec.json.validation.semantic_review: not-run receipt must keep verdict, lifecycle_disposition, and reviewer_evidence null, repair_round 0, and every array empty');
|
|
2811
|
-
}
|
|
2812
|
-
if (spec.ready_for_implementation === true) errors.push('spec.json.ready_for_implementation: requires completed semantic review');
|
|
2813
|
-
return;
|
|
2814
|
-
}
|
|
2815
|
-
const computed = computeSemanticDigest21(specDir, spec);
|
|
2816
|
-
if (computed.errors.length > 0) {
|
|
2817
|
-
errors.push(`spec.json.validation.semantic_review.semantic_digest: cannot recompute malformed physical topology (${computed.errors.join('; ')})`);
|
|
2818
|
-
} else if (review.semantic_digest !== computed.digest) {
|
|
2819
|
-
errors.push('spec.json.validation.semantic_review.semantic_digest: stale for current authoring, policy, topology, or research');
|
|
2820
|
-
}
|
|
2821
|
-
if (typeof review.semantic_digest !== 'string' || !SHA256_RE.test(review.semantic_digest)) {
|
|
2822
|
-
errors.push('spec.json.validation.semantic_review.semantic_digest: must be sha256:<64 lowercase hex> when completed');
|
|
2823
|
-
}
|
|
2824
|
-
if (!C2_VERDICTS.has(review.verdict)) errors.push('spec.json.validation.semantic_review.verdict: must be PASS or FAIL when completed');
|
|
2825
|
-
if (!C2_LIFECYCLE_DISPOSITIONS.has(review.lifecycle_disposition)) errors.push('spec.json.validation.semantic_review.lifecycle_disposition: must be CONTINUE or BLOCKED when completed');
|
|
2826
|
-
if (!Number.isInteger(review.repair_round) || review.repair_round < 0 || review.repair_round > 2) {
|
|
2827
|
-
errors.push('spec.json.validation.semantic_review.repair_round: must be an integer 0..2 when completed');
|
|
2828
|
-
}
|
|
2829
|
-
validateSemanticReviewFindings(review, errors);
|
|
2830
|
-
validateSemanticReviewUnresolvedDecisions(review, errors);
|
|
2831
|
-
validateSemanticReviewGraphCoverage(review, errors);
|
|
2832
|
-
validateSemanticReviewerEvidence(review, errors);
|
|
2833
|
-
if (!Array.isArray(review.reviewed_criteria) || new Set(review.reviewed_criteria).size !== review.reviewed_criteria.length) {
|
|
2834
|
-
errors.push('spec.json.validation.semantic_review.reviewed_criteria: must be a unique array');
|
|
2835
|
-
} else {
|
|
2836
|
-
for (const criterion of criteria) if (!review.reviewed_criteria.includes(criterion)) errors.push(`semantic review missing criterion ${criterion}`);
|
|
2837
|
-
for (const criterion of review.reviewed_criteria) if (!criteria.includes(criterion)) errors.push(`semantic review has unknown criterion ${criterion}`);
|
|
2838
|
-
}
|
|
2839
|
-
if (!Array.isArray(review.counterexamples)) {
|
|
2840
|
-
errors.push('spec.json.validation.semantic_review.counterexamples: must be an array');
|
|
2841
|
-
return;
|
|
2842
|
-
}
|
|
2843
|
-
const fingerprints = new Map();
|
|
2844
|
-
const covered = new Set();
|
|
2845
|
-
for (const [index, counterexample] of review.counterexamples.entries()) {
|
|
2846
|
-
const label = `semantic_review.counterexamples[${index}]`;
|
|
2847
|
-
const expected = ['criterion', 'case_kind', 'scenario', 'expected', 'decision_refs', 'verification_ref'];
|
|
2848
|
-
if (!isPlainObject(counterexample) || Object.keys(counterexample).sort().join(',') !== expected.sort().join(',')) {
|
|
2849
|
-
errors.push(`${label}: fields must be exactly ${expected.join(', ')}`);
|
|
2850
|
-
continue;
|
|
2851
|
-
}
|
|
2852
|
-
if (!criteria.includes(counterexample.criterion)) errors.push(`${label}.criterion: unknown criterion ${counterexample.criterion}`);
|
|
2853
|
-
if (covered.has(counterexample.criterion)) errors.push(`${label}.criterion: duplicate counterexample for ${counterexample.criterion}`);
|
|
2854
|
-
covered.add(counterexample.criterion);
|
|
2855
|
-
if (!['negative', 'boundary', 'failure', 'recovery', 'adversarial'].includes(counterexample.case_kind)) {
|
|
2856
|
-
errors.push(`${label}.case_kind: must name a concrete semantic case kind`);
|
|
2857
|
-
}
|
|
2858
|
-
for (const field of ['scenario', 'expected']) {
|
|
2859
|
-
const value = counterexample[field];
|
|
2860
|
-
if (!concreteSemanticReviewText(value) || /^(?:works?|fails?|handles? (?:it|error)|correct behavior|expected behavior)$/i.test(String(value).trim())) {
|
|
2861
|
-
errors.push(`${label}.${field}: obvious placeholder or generic boilerplate; structural checks do not replace semantic judgment`);
|
|
2862
|
-
}
|
|
2863
|
-
}
|
|
2864
|
-
const fingerprint = [counterexample.case_kind, counterexample.scenario, counterexample.expected]
|
|
2865
|
-
.map((value) => String(value).toLowerCase().replace(/\bR\d+(?:\.\d+)?\b/gi, '').replace(/[^a-z0-9]+/g, ' ').trim()).join('|');
|
|
2866
|
-
if (fingerprints.has(fingerprint)) {
|
|
2867
|
-
errors.push(`${label}: normalized duplicate counterexample fingerprint also used by ${fingerprints.get(fingerprint)}; structural checks do not replace semantic judgment`);
|
|
2868
|
-
} else fingerprints.set(fingerprint, counterexample.criterion);
|
|
2869
|
-
if (!Array.isArray(counterexample.decision_refs) || counterexample.decision_refs.length === 0) {
|
|
2870
|
-
errors.push(`${label}.decision_refs: requires at least one D/I/C ref`);
|
|
2871
|
-
} else for (const ref of counterexample.decision_refs) {
|
|
2872
|
-
if (!/^[DIC]\d+(?:\.\d+)?$/i.test(ref) || !designIds.has(ref.toUpperCase())) errors.push(`${label}.decision_refs: ${ref} does not exist in design.md`);
|
|
2873
|
-
}
|
|
2874
|
-
const verificationRef = String(counterexample.verification_ref || '').toUpperCase();
|
|
2875
|
-
const definition = verificationDefs.get(verificationRef);
|
|
2876
|
-
if (!definition) errors.push(`${label}.verification_ref: ${verificationRef || '(empty)'} has no V definition in design.md`);
|
|
2877
|
-
else {
|
|
2878
|
-
const definitionCriteria = [...definition.subject_criteria, ...definition.proof_criteria];
|
|
2879
|
-
if (!definitionCriteria.includes(counterexample.criterion)) errors.push(`${label}.verification_ref: ${verificationRef} does not trace criterion ${counterexample.criterion}`);
|
|
2880
|
-
for (const ref of counterexample.decision_refs || []) {
|
|
2881
|
-
if (!definition.decision_refs.includes(ref.toUpperCase())) errors.push(`${label}.verification_ref: ${verificationRef} does not trace decision ${ref}`);
|
|
2882
|
-
}
|
|
2883
|
-
}
|
|
2884
|
-
}
|
|
2885
|
-
for (const criterion of criteria) if (!covered.has(criterion)) errors.push(`semantic review counterexamples missing criterion ${criterion}`);
|
|
2886
|
-
if (spec.workflow_policy?.assurance_level === 'Strict') errors.push('spec.json.validation.semantic_review: Strict assurance is no longer supported (its reviewer attestation hook was removed); move the packet to plan.md with flat task files');
|
|
2887
|
-
}
|
|
2888
|
-
|
|
2889
|
-
function computeLineageId(featureName, createdAt) {
|
|
2890
|
-
return `sha256:${crypto.createHash('sha256').update(SEMANTIC.stableJson({ feature_name: featureName, created_at: createdAt })).digest('hex')}`;
|
|
2891
|
-
}
|
|
2892
|
-
|
|
2893
|
-
// C13 SemanticReviewHistory: structural completeness, types, nullability,
|
|
2894
|
-
// enums, cardinality, and whole-lineage digest dedupe only — never semantic
|
|
2895
|
-
// judgment of the epoch/attempt-index derivation itself (that is the R1-02
|
|
2896
|
-
// finalizer's own write-time responsibility, see C3/D4).
|
|
2897
|
-
function validateSemanticReviewHistory21(spec, errors) {
|
|
2898
|
-
const history = spec.validation?.semantic_review_history;
|
|
2899
|
-
if (!isPlainObject(history) || Object.keys(history).sort().join(',') !== [...C13_HISTORY_FIELDS].sort().join(',')) {
|
|
2900
|
-
errors.push(`spec.json.validation.semantic_review_history: fields must be exactly ${C13_HISTORY_FIELDS.join(', ')}`);
|
|
2901
|
-
return;
|
|
2902
|
-
}
|
|
2903
|
-
const expectedLineageId = computeLineageId(spec.feature_name, spec.created_at);
|
|
2904
|
-
if (history.lineage_id !== expectedLineageId) {
|
|
2905
|
-
errors.push('spec.json.validation.semantic_review_history.lineage_id: must be sha256 of feature_name and created_at; callers never set it directly');
|
|
2906
|
-
}
|
|
2907
|
-
if (!Array.isArray(history.entries)) {
|
|
2908
|
-
errors.push('spec.json.validation.semantic_review_history.entries: must be an array');
|
|
2909
|
-
return;
|
|
2910
|
-
}
|
|
2911
|
-
const digestsSeen = new Set();
|
|
2912
|
-
for (const [index, entry] of history.entries.entries()) {
|
|
2913
|
-
const label = `semantic_review_history.entries[${index}]`;
|
|
2914
|
-
if (!isPlainObject(entry) || Object.keys(entry).sort().join(',') !== [...C13_ENTRY_FIELDS].sort().join(',')) {
|
|
2915
|
-
errors.push(`${label}: fields must be exactly ${C13_ENTRY_FIELDS.join(', ')}`); continue;
|
|
2916
|
-
}
|
|
2917
|
-
if (entry.sequence !== index) errors.push(`${label}.sequence: must start at 0 and increase by exactly 1 per real append`);
|
|
2918
|
-
if (typeof entry.semantic_digest !== 'string' || !SHA256_RE.test(entry.semantic_digest)) errors.push(`${label}.semantic_digest: must be sha256:<64 lowercase hex>`);
|
|
2919
|
-
if (typeof entry.review_receipt_digest !== 'string' || !SHA256_RE.test(entry.review_receipt_digest)) {
|
|
2920
|
-
errors.push(`${label}.review_receipt_digest: must be sha256:<64 lowercase hex>`);
|
|
2921
|
-
} else if (digestsSeen.has(entry.review_receipt_digest)) {
|
|
2922
|
-
errors.push(`${label}.review_receipt_digest: replays an existing entry's digest anywhere in the lineage; a replay must not be appended twice`);
|
|
2923
|
-
} else digestsSeen.add(entry.review_receipt_digest);
|
|
2924
|
-
if (!C2_VERDICTS.has(entry.verdict)) errors.push(`${label}.verdict: must be PASS or FAIL`);
|
|
2925
|
-
if (!C2_LIFECYCLE_DISPOSITIONS.has(entry.lifecycle_disposition)) errors.push(`${label}.lifecycle_disposition: must be CONTINUE or BLOCKED`);
|
|
2926
|
-
if (!Number.isInteger(entry.blocking_count) || entry.blocking_count < 0) errors.push(`${label}.blocking_count: must be a non-negative integer`);
|
|
2927
|
-
if (!Number.isInteger(entry.attempt_index) || entry.attempt_index < 0 || entry.attempt_index > 2) errors.push(`${label}.attempt_index: must be an integer 0..2`);
|
|
2928
|
-
if (!Number.isInteger(entry.review_epoch) || entry.review_epoch < 0) errors.push(`${label}.review_epoch: must be a non-negative integer`);
|
|
2929
|
-
}
|
|
2930
|
-
}
|
|
2931
|
-
|
|
2932
|
-
// C16 AuthoringValidationReceipt: structural completeness plus the fail-closed
|
|
2933
|
-
// freshness check (I20/R3.8) — an authoring.<field> reading `validated`
|
|
2934
|
-
// without a matching fresh digest is a hard structural failure here, not a
|
|
2935
|
-
// silent downgrade to draft.
|
|
2936
|
-
function validateAuthoringValidation21(specDir, spec, errors) {
|
|
2937
|
-
const receipt = spec.validation?.authoring_validation;
|
|
2938
|
-
if (receipt !== null) {
|
|
2939
|
-
if (!isPlainObject(receipt) || Object.keys(receipt).sort().join(',') !== [...C16_FIELDS].sort().join(',')) {
|
|
2940
|
-
errors.push(`spec.json.validation.authoring_validation: fields must be exactly ${C16_FIELDS.join(', ')} or null`);
|
|
2941
|
-
return;
|
|
2942
|
-
}
|
|
2943
|
-
if (receipt.schema_version !== '1') errors.push('spec.json.validation.authoring_validation.schema_version: must be exactly "1"');
|
|
2944
|
-
for (const stage of ['requirements', 'design', 'research', 'tasks']) {
|
|
2945
|
-
const label = `spec.json.validation.authoring_validation.${stage}`;
|
|
2946
|
-
const entry = receipt[stage];
|
|
2947
|
-
if (!isPlainObject(entry) || Object.keys(entry).sort().join(',') !== [...C16_STAGE_FIELDS].sort().join(',')) {
|
|
2948
|
-
errors.push(`${label}: fields must be exactly ${C16_STAGE_FIELDS.join(', ')}`); continue;
|
|
2949
|
-
}
|
|
2950
|
-
const sentinel = stage === 'research' ? AUTHORING_DIGEST.RESEARCH_ABSENT_DIGEST
|
|
2951
|
-
: stage === 'tasks' ? AUTHORING_DIGEST.TASKS_ABSENT_DIGEST : null;
|
|
2952
|
-
const digestValid = typeof entry.digest === 'string'
|
|
2953
|
-
&& (SHA256_RE.test(entry.digest) || (sentinel !== null && entry.digest === sentinel));
|
|
2954
|
-
if (!digestValid) errors.push(`${label}.digest: must be sha256:<64 lowercase hex>${sentinel ? ` or ${sentinel}` : ''}`);
|
|
2955
|
-
if (!isIsoTimestamp(entry.validated_at)) errors.push(`${label}.validated_at: must be an ISO timestamp`);
|
|
2956
|
-
}
|
|
2957
|
-
}
|
|
2958
|
-
let freshness;
|
|
2959
|
-
try {
|
|
2960
|
-
freshness = AUTHORING_DIGEST.assertAuthoringFresh({
|
|
2961
|
-
specDir, authoring: spec.authoring, receipt, fields: ['requirements', 'design', 'research', 'tasks'],
|
|
2962
|
-
});
|
|
2963
|
-
} catch (error) {
|
|
2964
|
-
errors.push(`spec.json.validation.authoring_validation: cannot recompute current authoring digests (${error.message})`);
|
|
2965
|
-
return;
|
|
2966
|
-
}
|
|
2967
|
-
for (const field of ['requirements', 'design', 'research', 'tasks']) {
|
|
2968
|
-
if (spec.authoring?.[field] === 'validated' && !freshness[field]?.fresh) {
|
|
2969
|
-
errors.push(`spec.json.authoring.${field}: reads validated but validation.authoring_validation is absent, missing this field's entry, or digest-mismatched against current bytes (I20/C16)`);
|
|
2970
|
-
}
|
|
2971
|
-
}
|
|
2972
|
-
}
|
|
2973
|
-
|
|
2974
|
-
function validateDecisionState21(spec, errors) {
|
|
2975
|
-
if (!Object.prototype.hasOwnProperty.call(spec, 'decisions')) return;
|
|
2976
|
-
if (!Array.isArray(spec.decisions) || spec.decisions.length === 0) {
|
|
2977
|
-
errors.push('spec.json.decisions: omit when no decisions exist; otherwise use a non-empty array');
|
|
2978
|
-
return;
|
|
2979
|
-
}
|
|
2980
|
-
const ids = new Set();
|
|
2981
|
-
for (const [index, decision] of spec.decisions.entries()) {
|
|
2982
|
-
const label = `spec.json.decisions[${index}]`;
|
|
2983
|
-
const fields = ['id', 'classification', 'statement', 'status', 'evidence'];
|
|
2984
|
-
if (!isPlainObject(decision) || Object.keys(decision).sort().join(',') !== fields.sort().join(',')) {
|
|
2985
|
-
errors.push(`${label}: fields must be exactly ${fields.join(', ')}`); continue;
|
|
2986
|
-
}
|
|
2987
|
-
if (!/^Q\d+$/.test(decision.id) || ids.has(decision.id)) errors.push(`${label}.id: must be a unique Qn id`);
|
|
2988
|
-
ids.add(decision.id);
|
|
2989
|
-
if (!concreteSemanticReviewText(decision.statement)) errors.push(`${label}.statement: must be concrete`);
|
|
2990
|
-
if (decision.classification === 'repository_fact') {
|
|
2991
|
-
if (decision.status !== 'grounded' || !concreteSemanticReviewText(decision.evidence)) errors.push(`${label}: repository_fact requires grounded status and repository evidence`);
|
|
2992
|
-
} else if (decision.classification === 'reversible_assumption') {
|
|
2993
|
-
if (decision.status !== 'recorded' || !concreteSemanticReviewText(decision.evidence)) errors.push(`${label}: reversible_assumption requires recorded status and a bounded reversal boundary`);
|
|
2994
|
-
} else if (decision.classification === 'user_owned') {
|
|
2995
|
-
if (!['unresolved', 'resolved'].includes(decision.status)) errors.push(`${label}.status: user_owned must be unresolved or resolved`);
|
|
2996
|
-
if (decision.status === 'unresolved' && decision.evidence !== null) errors.push(`${label}.evidence: unresolved user_owned decision must be null`);
|
|
2997
|
-
if (decision.status === 'resolved' && !concreteSemanticReviewText(decision.evidence)) errors.push(`${label}.evidence: resolved user_owned decision requires the user resolution`);
|
|
2998
|
-
if (decision.status === 'unresolved' && spec.ready_for_implementation === true) errors.push(`${label}: unresolved material user-owned decision blocks readiness`);
|
|
2999
|
-
} else errors.push(`${label}.classification: must be repository_fact, reversible_assumption, or user_owned`);
|
|
3000
|
-
}
|
|
3001
|
-
}
|
|
3002
|
-
|
|
3003
|
-
function canonicalDesignDefinitions(designText, kind, errors) {
|
|
3004
|
-
const definitions = new Map();
|
|
3005
|
-
const semantic = semanticMarkdown(designText);
|
|
3006
|
-
const heading = new RegExp(`^#{3,6}\\s+(${kind}\\d+(?:\\.\\d+)?)\\s+(?:—|-)\\s+(.+)$`, 'gim');
|
|
3007
|
-
const matches = [...semantic.matchAll(heading)];
|
|
3008
|
-
for (const [index, match] of matches.entries()) {
|
|
3009
|
-
const nextHeading = semantic.slice(match.index + match[0].length).search(/^#{1,6}\s+/m);
|
|
3010
|
-
const end = nextHeading < 0 ? semantic.length : match.index + match[0].length + nextHeading;
|
|
3011
|
-
const definition = semantic.slice(match.index + match[0].length, end).trim();
|
|
3012
|
-
const id = match[1].toUpperCase();
|
|
3013
|
-
if (definitions.has(id)) errors.push(`design.md: duplicate canonical ${kind} definition ${id}`);
|
|
3014
|
-
else definitions.set(id, definition);
|
|
3015
|
-
}
|
|
3016
|
-
const bullet = new RegExp(`^\\s*(?:[-*]\\s*)?\\*\\*(${kind}\\d+(?:\\.\\d+)?)\\*\\*\\s*[:—-]\\s*(.+)$`, 'gim');
|
|
3017
|
-
for (const match of semantic.matchAll(bullet)) {
|
|
3018
|
-
const id = match[1].toUpperCase();
|
|
3019
|
-
if (definitions.has(id)) errors.push(`design.md: duplicate canonical ${kind} definition ${id}`);
|
|
3020
|
-
else definitions.set(id, match[2].trim());
|
|
3021
|
-
}
|
|
3022
|
-
return definitions;
|
|
3023
|
-
}
|
|
3024
|
-
|
|
3025
|
-
function boundaryParticipants(boundary) {
|
|
3026
|
-
if (boundary.type === 'ownership' || boundary.type === 'parallel') return boundary.tasks || [];
|
|
3027
|
-
if (boundary.type === 'dependency') return [boundary.producer, boundary.consumer];
|
|
3028
|
-
if (boundary.type === 'transition') return [boundary.owner, ...(boundary.consumers || [])];
|
|
3029
|
-
if (boundary.type === 'proof') return [boundary.subject, boundary.verifier];
|
|
3030
|
-
return [];
|
|
3031
|
-
}
|
|
3032
|
-
|
|
3033
|
-
function validateTaskCriterionGraph(
|
|
3034
|
-
taskFiles, registry, criteriaByTask, verificationRefsByTask, taskRolesByTask,
|
|
3035
|
-
knownCriteria, boundaries, verificationDefinitions, errors,
|
|
3036
|
-
) {
|
|
3037
|
-
if (taskFiles.length === 0) return;
|
|
3038
|
-
const owners = new Map(knownCriteria.map((criterion) => [criterion, []]));
|
|
3039
|
-
for (const taskPath of taskFiles) {
|
|
3040
|
-
for (const criterion of criteriaByTask.get(taskPath) || []) {
|
|
3041
|
-
if (!owners.has(criterion)) errors.push(`${taskPath}: Acceptance references unknown criterion ${criterion}`);
|
|
3042
|
-
else owners.get(criterion).push(registry[taskPath]?.id);
|
|
3043
|
-
}
|
|
3044
|
-
}
|
|
3045
|
-
for (const [criterion, taskIds] of owners) {
|
|
3046
|
-
if (taskIds.length === 0) errors.push(`requirements.md:${criterion}: acceptance criterion not covered by any task`);
|
|
3047
|
-
if (taskIds.length > 1) errors.push(`requirements.md:${criterion}: requires exactly one implementation owner; claimed by ${taskIds.join(', ')}`);
|
|
3048
|
-
}
|
|
3049
|
-
for (const taskPath of taskFiles) {
|
|
3050
|
-
for (const verificationRef of verificationRefsByTask.get(taskPath) || []) {
|
|
3051
|
-
const definition = verificationDefinitions.get(verificationRef);
|
|
3052
|
-
if (!definition) {
|
|
3053
|
-
errors.push(`${taskPath}: Verification ref ${verificationRef} has no concrete canonical V definition`);
|
|
3054
|
-
continue;
|
|
3055
|
-
}
|
|
3056
|
-
const definitionCriteria = [...definition.subject_criteria, ...definition.proof_criteria];
|
|
3057
|
-
for (const criterion of criteriaByTask.get(taskPath) || []) if (!definitionCriteria.includes(criterion)) {
|
|
3058
|
-
errors.push(`${taskPath}: Verification ref ${verificationRef} does not trace owned criterion ${criterion}`);
|
|
3059
|
-
}
|
|
3060
|
-
}
|
|
3061
|
-
}
|
|
3062
|
-
for (const boundary of (boundaries || []).filter((item) => item?.type === 'proof')) {
|
|
3063
|
-
const subjectPath = taskFiles.find((candidate) => registry[candidate]?.id === boundary.subject);
|
|
3064
|
-
const verifierPath = taskFiles.find((candidate) => registry[candidate]?.id === boundary.verifier);
|
|
3065
|
-
const subjectCriteria = criteriaByTask.get(subjectPath) || [];
|
|
3066
|
-
const verifierCriteria = criteriaByTask.get(verifierPath) || [];
|
|
3067
|
-
if (taskRolesByTask.get(subjectPath) !== 'subject') errors.push(`${subjectPath || boundary.subject}: proof subject must declare Task role subject`);
|
|
3068
|
-
if (taskRolesByTask.get(verifierPath) !== 'verifier') errors.push(`${verifierPath || boundary.verifier}: proof verifier must declare Task role verifier`);
|
|
3069
|
-
if (subjectCriteria.length === 0) errors.push(`${subjectPath || boundary.subject}: proof subject requires at least one implementation criterion`);
|
|
3070
|
-
if (verifierCriteria.length === 0) errors.push(`${verifierPath || boundary.verifier}: proof verifier requires a separately owned proof criterion`);
|
|
3071
|
-
for (const criterion of verifierCriteria) if (subjectCriteria.includes(criterion)) {
|
|
3072
|
-
errors.push(`${verifierPath || boundary.verifier}: proof verifier criterion ${criterion} duplicates subject implementation ownership`);
|
|
3073
|
-
}
|
|
3074
|
-
const definition = verificationDefinitions.get(String(boundary.verification_ref).toUpperCase());
|
|
3075
|
-
const definitionCriteria = definition ? [...definition.subject_criteria, ...definition.proof_criteria] : [];
|
|
3076
|
-
for (const criterion of [...subjectCriteria, ...verifierCriteria]) if (definition && !definitionCriteria.includes(criterion)) {
|
|
3077
|
-
errors.push(`${boundary.id}: shared V definition must trace subject and verifier criterion ${criterion}`);
|
|
3078
|
-
}
|
|
3079
|
-
for (const taskId of [boundary.subject, boundary.verifier]) {
|
|
3080
|
-
const taskPath = taskFiles.find((candidate) => registry[candidate]?.id === taskId);
|
|
3081
|
-
if (taskPath && !(verificationRefsByTask.get(taskPath) || []).includes(String(boundary.verification_ref).toUpperCase())) {
|
|
3082
|
-
errors.push(`${taskPath}: proof boundary ${boundary.id} must match task Verification ref ${boundary.verification_ref}`);
|
|
3083
|
-
}
|
|
3084
|
-
}
|
|
3085
|
-
}
|
|
3086
|
-
}
|
|
3087
|
-
|
|
3088
|
-
function validateBoundaries21(spec, taskFiles, registry, anchorsByTask, criteriaByTask, designText, verificationDefinitions, errors) {
|
|
3089
|
-
if (!isPlainObject(spec.coordination) || !Array.isArray(spec.coordination.boundaries)) {
|
|
3090
|
-
errors.push('spec.json.coordination.boundaries: must be an array');
|
|
3091
|
-
return;
|
|
3092
|
-
}
|
|
3093
|
-
const allowedCoordination = new Set(['boundaries', 'phases']);
|
|
3094
|
-
for (const key of Object.keys(spec.coordination)) if (!allowedCoordination.has(key)) {
|
|
3095
|
-
errors.push(`spec.json.coordination.${key}: marker/task trigger authority is not canonical in schema 2.1`);
|
|
3096
|
-
}
|
|
3097
|
-
if (taskFiles.length > 0 && spec.coordination.boundaries.length === 0) {
|
|
3098
|
-
errors.push('physical task bundle requires at least one typed coordination boundary with semantic evidence');
|
|
3099
|
-
}
|
|
3100
|
-
const pathById = new Map(taskFiles.map((taskPath) => [registry[taskPath]?.id, taskPath]));
|
|
3101
|
-
const taskIds = new Set(pathById.keys());
|
|
3102
|
-
const boundaryIds = new Set();
|
|
3103
|
-
const boundaryById = new Map();
|
|
3104
|
-
const dependencyEdges = [];
|
|
3105
|
-
const authorityByTask = new Map(taskFiles.map((taskPath) => [registry[taskPath]?.id, new Set()]));
|
|
3106
|
-
const transitionDefinitions = canonicalDesignDefinitions(designText, 'T', errors);
|
|
3107
|
-
const taskList = (value, label, minimum = 1) => {
|
|
3108
|
-
if (!Array.isArray(value) || value.length < minimum || new Set(value).size !== value.length) {
|
|
3109
|
-
errors.push(`${label}: must contain at least ${minimum} unique task id(s)`);
|
|
3110
|
-
return [];
|
|
3111
|
-
}
|
|
3112
|
-
for (const taskId of value) if (!taskIds.has(taskId)) errors.push(`${label}: unknown task ${taskId}`);
|
|
3113
|
-
return value;
|
|
3114
|
-
};
|
|
3115
|
-
const validateTargetMap = (value, tasks, label) => {
|
|
3116
|
-
if (!isPlainObject(value) || Object.keys(value).sort().join(',') !== [...tasks].sort().join(',')) {
|
|
3117
|
-
errors.push(`${label}: must be keyed exactly by boundary tasks`); return;
|
|
3118
|
-
}
|
|
3119
|
-
for (const taskId of tasks) {
|
|
3120
|
-
if (!Array.isArray(value[taskId]) || value[taskId].length === 0 || new Set(value[taskId]).size !== value[taskId].length) {
|
|
3121
|
-
errors.push(`${label}.${taskId}: must be a non-empty array of unique exact targets`);
|
|
3122
|
-
continue;
|
|
3123
|
-
}
|
|
3124
|
-
for (const target of value[taskId]) if (!exactTaskTarget(target)) errors.push(`${label}.${taskId}: ${target} is not an exact target`);
|
|
3125
|
-
}
|
|
3126
|
-
for (let left = 0; left < tasks.length; left += 1) for (let right = left + 1; right < tasks.length; right += 1) {
|
|
3127
|
-
for (const a of value[tasks[left]] || []) for (const b of value[tasks[right]] || []) {
|
|
3128
|
-
if (targetsContend(a, b)) errors.push(`${label}: parent/child or exact resource contention ${a} vs ${b}`);
|
|
3129
|
-
}
|
|
3130
|
-
}
|
|
3131
|
-
};
|
|
3132
|
-
for (const [index, boundary] of spec.coordination.boundaries.entries()) {
|
|
3133
|
-
const label = `spec.json.coordination.boundaries[${index}]`;
|
|
3134
|
-
const fields = BOUNDARY_21_FIELDS[boundary?.type];
|
|
3135
|
-
if (!fields || !isPlainObject(boundary) || Object.keys(boundary).sort().join(',') !== [...(fields || [])].sort().join(',')) {
|
|
3136
|
-
errors.push(`${label}: malformed typed boundary`); continue;
|
|
3137
|
-
}
|
|
3138
|
-
if (boundaryIds.has(boundary.id)) errors.push(`${label}: duplicate boundary ID ${boundary.id}`);
|
|
3139
|
-
boundaryIds.add(boundary.id);
|
|
3140
|
-
boundaryById.set(boundary.id, boundary);
|
|
3141
|
-
if (boundary.type === 'ownership') {
|
|
3142
|
-
const tasks = taskList(boundary.tasks, `${label}.tasks`, 2);
|
|
3143
|
-
validateTargetMap(boundary.write_sets, tasks, `${label}.write_sets`);
|
|
3144
|
-
for (const taskId of tasks) {
|
|
3145
|
-
const taskWrites = new Set((anchorsByTask.get(pathById.get(taskId)) || []).filter((anchor) => anchor.access === 'write').map((anchor) => anchor.target));
|
|
3146
|
-
const declaredWrites = new Set(boundary.write_sets?.[taskId] || []);
|
|
3147
|
-
for (const target of boundary.write_sets?.[taskId] || []) if (!taskWrites.has(target)) errors.push(`${label}.write_sets.${taskId}: ${target} lacks exact task write anchor`);
|
|
3148
|
-
for (const target of taskWrites) if (!declaredWrites.has(target)) errors.push(`${label}.write_sets.${taskId}: missing exact task write target ${target}`);
|
|
3149
|
-
}
|
|
3150
|
-
} else if (boundary.type === 'dependency') {
|
|
3151
|
-
taskList([boundary.producer, boundary.consumer], label, 2);
|
|
3152
|
-
if (boundary.producer === boundary.consumer) errors.push(`${label}: producer and consumer must differ`);
|
|
3153
|
-
if (!exactTaskTarget(boundary.deliverable)) errors.push(`${label}.deliverable: missing exact deliverable`);
|
|
3154
|
-
dependencyEdges.push([boundary.producer, boundary.consumer, boundary.deliverable]);
|
|
3155
|
-
const producerPath = pathById.get(boundary.producer);
|
|
3156
|
-
const consumerPath = pathById.get(boundary.consumer);
|
|
3157
|
-
const producerAnchors = anchorsByTask.get(producerPath) || [];
|
|
3158
|
-
const consumerAnchors = anchorsByTask.get(consumerPath) || [];
|
|
3159
|
-
if (!producerAnchors.some((anchor) => anchor.target === boundary.deliverable && anchor.access === 'write')) {
|
|
3160
|
-
errors.push(`${label}.deliverable: producer ${boundary.producer} must own an exact write anchor for ${boundary.deliverable}`);
|
|
3161
|
-
}
|
|
3162
|
-
if (!consumerAnchors.some((anchor) => anchor.target === boundary.deliverable && anchor.access === 'read')) {
|
|
3163
|
-
errors.push(`${label}.deliverable: consumer ${boundary.consumer} must own an exact read anchor for ${boundary.deliverable}`);
|
|
3164
|
-
}
|
|
3165
|
-
if (!registry[consumerPath]?.dependencies?.includes(producerPath)) errors.push(`${label}: dependency boundary must bind the registry DAG edge`);
|
|
3166
|
-
} else if (boundary.type === 'transition') {
|
|
3167
|
-
taskList([boundary.owner], `${label}.owner`);
|
|
3168
|
-
taskList(boundary.consumers, `${label}.consumers`);
|
|
3169
|
-
if (boundary.consumers.includes(boundary.owner)) errors.push(`${label}.consumers: transition owner cannot also be its consumer`);
|
|
3170
|
-
const transitionDefinition = transitionDefinitions.get(String(boundary.design_ref).toUpperCase());
|
|
3171
|
-
if (!transitionDefinition || !concreteSemanticReviewText(transitionDefinition)) errors.push(`${label}.design_ref: missing canonical design T definition`);
|
|
3172
|
-
for (const field of ['precondition', 'postcondition', 'failure', 'recovery']) if (!concreteSemanticReviewText(boundary[field])) errors.push(`${label}.${field}: missing concrete transition definition`);
|
|
3173
|
-
for (const taskId of [boundary.owner, ...boundary.consumers]) authorityByTask.get(taskId)?.add(boundary.id);
|
|
3174
|
-
} else if (boundary.type === 'proof') {
|
|
3175
|
-
taskList([boundary.subject, boundary.verifier], label, 2);
|
|
3176
|
-
if (boundary.subject === boundary.verifier) errors.push(`${label}: proof subject must differ from verifier`);
|
|
3177
|
-
const verificationDefinition = verificationDefinitions.get(String(boundary.verification_ref).toUpperCase());
|
|
3178
|
-
if (!verificationDefinition) errors.push(`${label}.verification_ref: missing canonical V definition`);
|
|
3179
|
-
else {
|
|
3180
|
-
if (verificationDefinition.subject_owner !== boundary.subject) errors.push(`${label}.subject: must match semantic V subject_owner`);
|
|
3181
|
-
if (verificationDefinition.proof_owner !== boundary.verifier) errors.push(`${label}.verifier: must match semantic V proof_owner`);
|
|
3182
|
-
if (verificationDefinition.evidence_anchor !== boundary.artifact_anchor) errors.push(`${label}.artifact_anchor: must match semantic V evidence_anchor`);
|
|
3183
|
-
}
|
|
3184
|
-
const verifierPath = pathById.get(boundary.verifier);
|
|
3185
|
-
const proofAnchor = (anchorsByTask.get(verifierPath) || []).find((anchor) => anchor.id === boundary.artifact_anchor);
|
|
3186
|
-
const observableProofAnchor = proofAnchor?.role === 'verifier' && (
|
|
3187
|
-
(proofAnchor.type === 'command' && proofAnchor.access === 'read' && proofAnchor.action === 'read')
|
|
3188
|
-
|| (proofAnchor.type === 'artifact' && proofAnchor.access === 'write' && ['create', 'modify'].includes(proofAnchor.action))
|
|
3189
|
-
);
|
|
3190
|
-
if (!observableProofAnchor) errors.push(`${label}.artifact_anchor: must be owned by verifier ${boundary.verifier} as an observable command or output artifact anchor; a source write is not proof`);
|
|
3191
|
-
const subjectPath = pathById.get(boundary.subject);
|
|
3192
|
-
const subjectCriteria = criteriaByTask.get(subjectPath) || [];
|
|
3193
|
-
const definitionCriteria = verificationDefinition
|
|
3194
|
-
? [...verificationDefinition.subject_criteria, ...verificationDefinition.proof_criteria] : [];
|
|
3195
|
-
if (verificationDefinition && !subjectCriteria.some((criterion) => definitionCriteria.includes(criterion))) {
|
|
3196
|
-
errors.push(`${label}.verification_ref: canonical V definition must trace a criterion owned by proof subject ${boundary.subject}`);
|
|
3197
|
-
}
|
|
3198
|
-
authorityByTask.get(boundary.subject)?.add(boundary.id);
|
|
3199
|
-
authorityByTask.get(boundary.verifier)?.add(boundary.id);
|
|
3200
|
-
} else if (boundary.type === 'parallel') {
|
|
3201
|
-
const tasks = taskList(boundary.tasks, `${label}.tasks`, 2);
|
|
3202
|
-
validateTargetMap(boundary.resources, tasks, `${label}.resources`);
|
|
3203
|
-
for (const taskId of tasks) {
|
|
3204
|
-
const taskWrites = (anchorsByTask.get(pathById.get(taskId)) || []).filter((anchor) => anchor.access === 'write').map((anchor) => anchor.target);
|
|
3205
|
-
const resources = [...(boundary.resources?.[taskId] || [])].sort();
|
|
3206
|
-
if (JSON.stringify([...new Set(taskWrites)].sort()) !== JSON.stringify(resources)) {
|
|
3207
|
-
errors.push(`${label}.resources.${taskId}: must exactly equal task write resources`);
|
|
3208
|
-
}
|
|
3209
|
-
}
|
|
3210
|
-
}
|
|
3211
|
-
}
|
|
3212
|
-
// D10/I7/R8.3: global ownership-contention scan. Candidate pairs are derived
|
|
3213
|
-
// directly from the complete anchorsByTask write-anchor projection — every
|
|
3214
|
-
// task's every write anchor — never only from declared write_sets or from
|
|
3215
|
-
// iterating already-declared boundaries. Any single existing dependency
|
|
3216
|
-
// boundary between a pair covers every target that pair shares, regardless
|
|
3217
|
-
// of which target it names as its own deliverable; a same-target write pair
|
|
3218
|
-
// with no dependency boundary at all (even one never named in any boundary)
|
|
3219
|
-
// is exactly the defect this scan exists to catch.
|
|
3220
|
-
const connectedPairs = new Set(dependencyEdges.map(([producer, consumer]) => [producer, consumer].sort().join('\0')));
|
|
3221
|
-
const writersByTarget = new Map();
|
|
3222
|
-
for (const taskPath of taskFiles) {
|
|
3223
|
-
const taskId = registry[taskPath]?.id;
|
|
3224
|
-
if (!taskId) continue;
|
|
3225
|
-
for (const anchor of anchorsByTask.get(taskPath) || []) {
|
|
3226
|
-
if (anchor.access !== 'write') continue;
|
|
3227
|
-
if (!writersByTarget.has(anchor.target)) writersByTarget.set(anchor.target, new Set());
|
|
3228
|
-
writersByTarget.get(anchor.target).add(taskId);
|
|
3229
|
-
}
|
|
3230
|
-
}
|
|
3231
|
-
const flaggedContentionPairs = new Set();
|
|
3232
|
-
for (const [target, writers] of writersByTarget) {
|
|
3233
|
-
const writerIds = [...writers].sort();
|
|
3234
|
-
for (let left = 0; left < writerIds.length; left += 1) for (let right = left + 1; right < writerIds.length; right += 1) {
|
|
3235
|
-
const a = writerIds[left]; const b = writerIds[right];
|
|
3236
|
-
const pairKey = [a, b].sort().join('\0');
|
|
3237
|
-
if (connectedPairs.has(pairKey) || flaggedContentionPairs.has(pairKey)) continue;
|
|
3238
|
-
flaggedContentionPairs.add(pairKey);
|
|
3239
|
-
errors.push(`spec.json.coordination.boundaries: ${a} and ${b} both hold a write anchor for ${target} with no dependency boundary connecting them`);
|
|
3240
|
-
}
|
|
3241
|
-
}
|
|
3242
|
-
const participation = new Map([...taskIds].map((taskId) => [taskId, 0]));
|
|
3243
|
-
for (const boundary of spec.coordination.boundaries) {
|
|
3244
|
-
for (const taskId of new Set(boundaryParticipants(boundary))) if (participation.has(taskId)) {
|
|
3245
|
-
participation.set(taskId, participation.get(taskId) + 1);
|
|
3246
|
-
}
|
|
3247
|
-
}
|
|
3248
|
-
for (const [taskId, count] of participation) if (count === 0) {
|
|
3249
|
-
errors.push(`spec.json.coordination.boundaries: task ${taskId} must participate in at least one justified typed boundary`);
|
|
3250
|
-
}
|
|
3251
|
-
for (const taskPath of taskFiles) {
|
|
3252
|
-
const expected = dependencyEdges.filter(([, consumer]) => consumer === registry[taskPath]?.id).map(([producer]) => pathById.get(producer)).sort();
|
|
3253
|
-
if (JSON.stringify([...(registry[taskPath]?.dependencies || [])].sort()) !== JSON.stringify(expected)) errors.push(`${taskPath}: registry dependencies must be an exact projection of dependency boundaries`);
|
|
3254
|
-
}
|
|
3255
|
-
if (Object.prototype.hasOwnProperty.call(spec.coordination, 'phases')) {
|
|
3256
|
-
if (!Array.isArray(spec.coordination.phases) || spec.coordination.phases.length === 0) {
|
|
3257
|
-
errors.push('spec.json.coordination.phases: must be a non-empty compact metadata array');
|
|
3258
|
-
} else {
|
|
3259
|
-
const phaseIds = new Set();
|
|
3260
|
-
for (const [index, phase] of spec.coordination.phases.entries()) {
|
|
3261
|
-
const label = `spec.json.coordination.phases[${index}]`;
|
|
3262
|
-
const fields = ['id', 'task_ids', 'entry_condition', 'exit_condition', 'owner_boundary'];
|
|
3263
|
-
if (!isPlainObject(phase) || Object.keys(phase).sort().join(',') !== fields.sort().join(',')) {
|
|
3264
|
-
errors.push(`${label}: fields must be exactly ${fields.join(', ')}`); continue;
|
|
3265
|
-
}
|
|
3266
|
-
if (phaseIds.has(phase.id)) errors.push(`${label}: duplicate phase id ${phase.id}`);
|
|
3267
|
-
phaseIds.add(phase.id);
|
|
3268
|
-
taskList(phase.task_ids, `${label}.task_ids`);
|
|
3269
|
-
const ownerBoundary = boundaryById.get(phase.owner_boundary);
|
|
3270
|
-
if (!ownerBoundary) errors.push(`${label}.owner_boundary: unknown boundary ${phase.owner_boundary}`);
|
|
3271
|
-
else {
|
|
3272
|
-
const participants = [...new Set(boundaryParticipants(ownerBoundary))].sort();
|
|
3273
|
-
const phaseTasks = [...new Set(phase.task_ids || [])].sort();
|
|
3274
|
-
if (!['dependency', 'transition', 'proof'].includes(ownerBoundary.type)) {
|
|
3275
|
-
errors.push(`${label}.owner_boundary: ${ownerBoundary.type} cannot own phase progression`);
|
|
3276
|
-
}
|
|
3277
|
-
if (JSON.stringify(participants) !== JSON.stringify(phaseTasks)) {
|
|
3278
|
-
errors.push(`${label}.owner_boundary: participants must match phase task_ids exactly`);
|
|
3279
|
-
}
|
|
3280
|
-
}
|
|
3281
|
-
for (const field of ['entry_condition', 'exit_condition']) if (!concreteSemanticReviewText(phase[field])) errors.push(`${label}.${field}: must be concrete`);
|
|
3282
|
-
}
|
|
3283
|
-
const phaseTasks = spec.coordination.phases.flatMap((phase) => Array.isArray(phase?.task_ids) ? phase.task_ids : []);
|
|
3284
|
-
const counts = new Map([...taskIds].map((taskId) => [taskId, phaseTasks.filter((candidate) => candidate === taskId).length]));
|
|
3285
|
-
for (const [taskId, count] of counts) if (count !== 1) {
|
|
3286
|
-
errors.push(`spec.json.coordination.phases: task ${taskId} must appear in exactly one phase (found ${count})`);
|
|
3287
|
-
}
|
|
3288
|
-
}
|
|
3289
|
-
}
|
|
3290
|
-
const graph = new Map([...taskIds].map((taskId) => [taskId, []]));
|
|
3291
|
-
for (const [producer, consumer] of dependencyEdges) graph.get(producer)?.push(consumer);
|
|
3292
|
-
const visiting = new Set();
|
|
3293
|
-
const visited = new Set();
|
|
3294
|
-
const visit = (taskId, stack = []) => {
|
|
3295
|
-
if (visiting.has(taskId)) {
|
|
3296
|
-
const start = stack.indexOf(taskId);
|
|
3297
|
-
errors.push(`spec.json.coordination.boundaries: dependency cycle ${stack.slice(start).concat(taskId).join(' -> ')}`);
|
|
3298
|
-
return;
|
|
3299
|
-
}
|
|
3300
|
-
if (visited.has(taskId)) return;
|
|
3301
|
-
visiting.add(taskId);
|
|
3302
|
-
for (const next of graph.get(taskId) || []) visit(next, [...stack, taskId]);
|
|
3303
|
-
visiting.delete(taskId);
|
|
3304
|
-
visited.add(taskId);
|
|
3305
|
-
};
|
|
3306
|
-
for (const taskId of taskIds) visit(taskId);
|
|
3307
|
-
const pathExists = (from, to, seen = new Set()) => {
|
|
3308
|
-
if (from === to) return true;
|
|
3309
|
-
if (seen.has(from)) return false;
|
|
3310
|
-
seen.add(from);
|
|
3311
|
-
return (graph.get(from) || []).some((next) => pathExists(next, to, seen));
|
|
3312
|
-
};
|
|
3313
|
-
for (const boundary of spec.coordination.boundaries.filter((item) => item.type === 'parallel')) {
|
|
3314
|
-
for (let left = 0; left < boundary.tasks.length; left += 1) for (let right = left + 1; right < boundary.tasks.length; right += 1) {
|
|
3315
|
-
const a = boundary.tasks[left]; const b = boundary.tasks[right];
|
|
3316
|
-
if (pathExists(a, b) || pathExists(b, a)) errors.push(`${boundary.id}: parallel tasks have a dependency path`);
|
|
3317
|
-
for (const authority of authorityByTask.get(a) || []) if (authorityByTask.get(b)?.has(authority)) errors.push(`${boundary.id}: parallel tasks share transition/proof authority ${authority}`);
|
|
3318
|
-
}
|
|
3319
|
-
}
|
|
3320
|
-
}
|
|
3321
|
-
|
|
3322
|
-
function validateResearchLifecycle21(specDir, canonicalSpecDir, spec, errors) {
|
|
3323
|
-
const state = spec.authoring?.research;
|
|
3324
|
-
const hasPointer = Object.prototype.hasOwnProperty.call(spec, 'research');
|
|
3325
|
-
const artifact = inspectSpecArtifact(specDir, canonicalSpecDir, 'research.md', 'research.md', errors);
|
|
3326
|
-
if (state === 'absent') {
|
|
3327
|
-
if (hasPointer) errors.push('spec.json.research: authoring.research absent requires no pointer');
|
|
3328
|
-
if (artifact) errors.push('research.md: authoring.research absent requires no physical artifact');
|
|
3329
|
-
return null;
|
|
3330
|
-
}
|
|
3331
|
-
if (state === 'draft' || state === 'validated') {
|
|
3332
|
-
if (spec.research !== 'research.md') errors.push(`spec.json.research: authoring.research ${state} requires canonical pointer research.md`);
|
|
3333
|
-
if (!artifact) errors.push(`research.md: authoring.research ${state} requires a physical regular file`);
|
|
3334
|
-
else validateResearchArtifact21(artifact, canonicalSpecDir, errors);
|
|
3335
|
-
return artifact;
|
|
3336
|
-
}
|
|
3337
|
-
return artifact;
|
|
3338
|
-
}
|
|
3339
|
-
|
|
3340
|
-
function validateSpec21(specDir, canonicalSpecDir, spec, errors, warnings) {
|
|
3341
|
-
const allowedFields = new Set([...SPEC_21_REQUIRED_FIELDS, ...SPEC_21_OPTIONAL_FIELDS]);
|
|
3342
|
-
for (const field of SPEC_21_REQUIRED_FIELDS) if (!Object.prototype.hasOwnProperty.call(spec, field)) {
|
|
3343
|
-
errors.push(`spec.json.${field}: required by closed schema 2.1 machine state`);
|
|
3344
|
-
}
|
|
3345
|
-
for (const field of Object.keys(spec)) if (!allowedFields.has(field)) {
|
|
3346
|
-
errors.push(`spec.json.${field}: unknown or legacy field is not canonical in closed schema 2.1`);
|
|
3347
|
-
}
|
|
3348
|
-
const policy = POLICY.validateWorkflowPolicySnapshot(spec.workflow_policy);
|
|
3349
|
-
if (!policy.valid || spec.workflow_policy?.version !== SCHEMA_21) {
|
|
3350
|
-
errors.push(...policy.errors.map((error) => `spec.json.${error}`));
|
|
3351
|
-
if (spec.workflow_policy?.version !== SCHEMA_21) errors.push('spec.json.workflow_policy.version: schema 2.1 mutation requires policy 2.1');
|
|
3352
|
-
}
|
|
3353
|
-
const authoringFields = ['requirements', 'design', 'research', 'tasks'];
|
|
3354
|
-
if (!isPlainObject(spec.authoring) || Object.keys(spec.authoring).sort().join(',') !== authoringFields.sort().join(',')) {
|
|
3355
|
-
errors.push(`spec.json.authoring: fields must be exactly ${authoringFields.join(', ')}`);
|
|
3356
|
-
} else for (const [artifact, state] of Object.entries(spec.authoring)) {
|
|
3357
|
-
if (!['draft', 'validated', 'absent'].includes(state)) errors.push(`spec.json.authoring.${artifact}: must be draft, validated, or absent`);
|
|
3358
|
-
}
|
|
3359
|
-
if (Object.prototype.hasOwnProperty.call(spec, 'approvals')) errors.push('spec.json.approvals: legacy approval semantics are inert and not canonical in schema 2.1');
|
|
3360
|
-
const scopeFields = ['source', 'in_scope', 'out_of_scope', 'expansion_policy'];
|
|
3361
|
-
if (isPlainObject(spec.scope_lock)
|
|
3362
|
-
&& Object.keys(spec.scope_lock).sort().join(',') !== [...scopeFields].sort().join(',')) {
|
|
3363
|
-
errors.push(`spec.json.scope_lock: fields must be exactly ${scopeFields.join(', ')}`);
|
|
3364
|
-
}
|
|
3365
|
-
if (isPlainObject(spec.scope_lock)) {
|
|
3366
|
-
if (typeof spec.scope_lock.source !== 'string' || spec.scope_lock.source.trim() === '') errors.push('spec.json.scope_lock.source: must be a non-empty string');
|
|
3367
|
-
for (const field of ['in_scope', 'out_of_scope']) {
|
|
3368
|
-
const values = spec.scope_lock[field];
|
|
3369
|
-
if (!Array.isArray(values) || values.some((value) => typeof value !== 'string' || value.trim() !== value || value === '')
|
|
3370
|
-
|| new Set(values || []).size !== (values || []).length) {
|
|
3371
|
-
errors.push(`spec.json.scope_lock.${field}: must be an array of unique non-empty trimmed strings`);
|
|
3372
|
-
}
|
|
3373
|
-
}
|
|
3374
|
-
if (spec.scope_lock.expansion_policy !== 'requires-user-approval') errors.push('spec.json.scope_lock.expansion_policy: must be requires-user-approval');
|
|
3375
|
-
}
|
|
3376
|
-
if (isPlainObject(spec.validation)
|
|
3377
|
-
&& Object.keys(spec.validation).sort().join(',') !== [...VALIDATION_21_FIELDS].sort().join(',')) {
|
|
3378
|
-
errors.push(`spec.json.validation: fields must be exactly ${VALIDATION_21_FIELDS.join(', ')}`);
|
|
3379
|
-
}
|
|
3380
|
-
const taskFiles = listTaskFiles(specDir, canonicalSpecDir, errors);
|
|
3381
|
-
validateCoreMachineState(spec, taskFiles, errors, { schema21: true });
|
|
3382
|
-
validateDecisionState21(spec, errors);
|
|
3383
|
-
validateResearchLifecycle21(specDir, canonicalSpecDir, spec, errors);
|
|
3384
|
-
if (taskFiles.length === 0) {
|
|
3385
|
-
if (Object.prototype.hasOwnProperty.call(spec, 'task_files') || Object.prototype.hasOwnProperty.call(spec, 'task_registry')) errors.push('taskless schema 2.1 must omit task_files and task_registry');
|
|
3386
|
-
if (spec.authoring?.tasks !== 'absent') errors.push('spec.json.authoring.tasks: taskless spec must be absent');
|
|
3387
|
-
} else {
|
|
3388
|
-
if (JSON.stringify([...(spec.task_files || [])].sort()) !== JSON.stringify(taskFiles)) errors.push('spec.json.task_files: must exactly match physical tasks');
|
|
3389
|
-
if (!isPlainObject(spec.task_registry) || Object.keys(spec.task_registry).sort().join(',') !== taskFiles.join(',')) errors.push('spec.json.task_registry: must exactly match physical tasks');
|
|
3390
|
-
if (!['draft', 'validated'].includes(spec.authoring?.tasks)) errors.push('spec.json.authoring.tasks: physical tasks require draft or validated');
|
|
3391
|
-
}
|
|
3392
|
-
const requirementsText = fs.readFileSync(path.join(specDir, 'requirements.md'), 'utf8');
|
|
3393
|
-
const designText = fs.readFileSync(path.join(specDir, 'design.md'), 'utf8');
|
|
3394
|
-
const projection = SEMANTIC.modelFromMarkdown(canonicalSpecDir, spec);
|
|
3395
|
-
errors.push(...projection.errors.map((error) => `semantic projection: ${error}`));
|
|
3396
|
-
if (!isPlainObject(spec.semantic_model)) {
|
|
3397
|
-
errors.push('spec.json.semantic_model: schema 2.1 requires explicit promoted machine semantic authority');
|
|
3398
|
-
} else {
|
|
3399
|
-
errors.push(...SEMANTIC.validateSemanticModel(spec.semantic_model, spec));
|
|
3400
|
-
if (SEMANTIC.stableJson(spec.semantic_model) !== SEMANTIC.stableJson(projection.model)) {
|
|
3401
|
-
errors.push('spec.json.semantic_model: differs from Markdown projection; rerun spec-readiness with the current semantic review result');
|
|
3402
|
-
}
|
|
3403
|
-
}
|
|
3404
|
-
const authorityModel = isPlainObject(spec.semantic_model) ? spec.semantic_model : projection.model;
|
|
3405
|
-
const verificationDefinitions = new Map((authorityModel.verification_definitions || []).map((definition) => [definition.id, definition]));
|
|
3406
|
-
const designIds = new Set(designSemanticReviewIds(designText));
|
|
3407
|
-
for (const definition of verificationDefinitions.values()) for (const decisionRef of definition.decision_refs) {
|
|
3408
|
-
if (!designIds.has(decisionRef)) errors.push(`spec.json.semantic_model.${definition.id}: decision ref ${decisionRef} does not exist in design.md`);
|
|
3409
|
-
}
|
|
3410
|
-
const designAnchors = (authorityModel.anchors || []).filter((anchor) => /^A-D-/.test(anchor.id)).map((anchor) => ({
|
|
3411
|
-
...anchor, label: 'design.md', design: true, schema21: true,
|
|
3412
|
-
}));
|
|
3413
|
-
validateRequirementInventory(requirementsText, errors);
|
|
3414
|
-
const criteria = (authorityModel.criteria || []).map((criterion) => criterion.id);
|
|
3415
|
-
const anchorsByTask = new Map();
|
|
3416
|
-
const criteriaByTask = new Map();
|
|
3417
|
-
const verificationRefsByTask = new Map();
|
|
3418
|
-
const taskRolesByTask = new Map();
|
|
3419
|
-
const taskContents = new Map();
|
|
3420
|
-
for (const taskPath of taskFiles) {
|
|
3421
|
-
const content = fs.readFileSync(path.join(specDir, taskPath), 'utf8');
|
|
3422
|
-
const task = validateTask21(
|
|
3423
|
-
taskPath,
|
|
3424
|
-
content,
|
|
3425
|
-
spec.task_registry?.[taskPath],
|
|
3426
|
-
spec.ready_for_implementation === true,
|
|
3427
|
-
errors,
|
|
3428
|
-
);
|
|
3429
|
-
taskContents.set(taskPath, content);
|
|
3430
|
-
anchorsByTask.set(taskPath, task.anchors);
|
|
3431
|
-
criteriaByTask.set(taskPath, task.criteria);
|
|
3432
|
-
verificationRefsByTask.set(taskPath, task.verificationRefs);
|
|
3433
|
-
taskRolesByTask.set(taskPath, task.taskRole);
|
|
3434
|
-
}
|
|
3435
|
-
const anchorsById = new Map();
|
|
3436
|
-
const designTargets = new Map(designAnchors.map((anchor) => [`${anchor.type}\0${anchor.target}`, anchor]));
|
|
3437
|
-
for (const anchor of [...designAnchors, ...[...anchorsByTask.values()].flat()]) {
|
|
3438
|
-
if (anchorsById.has(anchor.id)) errors.push(`${anchor.label}: duplicate anchor ID ${anchor.id}; first declared in ${anchorsById.get(anchor.id).label}`);
|
|
3439
|
-
else anchorsById.set(anchor.id, anchor);
|
|
3440
|
-
if (!anchor.design && designTargets.has(`${anchor.type}\0${anchor.target}`)) {
|
|
3441
|
-
errors.push(`${anchor.label}: anchor ${anchor.id} duplicates canonical design target ${designTargets.get(`${anchor.type}\0${anchor.target}`).id}`);
|
|
3442
|
-
}
|
|
3443
|
-
}
|
|
3444
|
-
for (const [taskPath, content] of taskContents) for (const reference of content.match(/\bA-D-\d{2}\b/g) || []) {
|
|
3445
|
-
if (!anchorsById.has(reference)) errors.push(`${taskPath}: dangling canonical design anchor reference ${reference}`);
|
|
3446
|
-
}
|
|
3447
|
-
validateTaskCriterionGraph(
|
|
3448
|
-
taskFiles,
|
|
3449
|
-
spec.task_registry || {},
|
|
3450
|
-
criteriaByTask,
|
|
3451
|
-
verificationRefsByTask,
|
|
3452
|
-
taskRolesByTask,
|
|
3453
|
-
criteria,
|
|
3454
|
-
spec.coordination?.boundaries,
|
|
3455
|
-
verificationDefinitions,
|
|
3456
|
-
errors,
|
|
3457
|
-
);
|
|
3458
|
-
validateBoundaries21(
|
|
3459
|
-
spec, taskFiles, spec.task_registry || {}, anchorsByTask, criteriaByTask,
|
|
3460
|
-
designText, verificationDefinitions, errors,
|
|
3461
|
-
);
|
|
3462
|
-
validateSemanticReview21(specDir, spec, criteria, designIds, verificationDefinitions, errors);
|
|
3463
|
-
validateSemanticReviewHistory21(spec, errors);
|
|
3464
|
-
validateAuthoringValidation21(specDir, spec, errors);
|
|
3465
|
-
if (spec.ready_for_implementation === true) {
|
|
3466
|
-
for (const artifact of ['requirements', 'design']) if (spec.authoring?.[artifact] !== 'validated') errors.push(`spec.json.authoring.${artifact}: readiness requires validated`);
|
|
3467
|
-
if (taskFiles.length > 0 && spec.authoring?.tasks !== 'validated') errors.push('spec.json.authoring.tasks: readiness requires validated task projection');
|
|
3468
|
-
if (spec.research === 'research.md' && spec.authoring?.research !== 'validated') errors.push('spec.json.authoring.research: explicit research readiness requires validated');
|
|
3469
|
-
let projectRoot = null;
|
|
3470
|
-
try { projectRoot = canonicalProjectRoot(canonicalSpecDir); } catch (error) {
|
|
3471
|
-
errors.push(`spec.json.ready_for_implementation: canonical project root cannot be resolved (${error.message})`);
|
|
3472
|
-
}
|
|
3473
|
-
if (projectRoot) {
|
|
3474
|
-
const grounding = groundSpec({ specDir: canonicalSpecDir, root: projectRoot, spec });
|
|
3475
|
-
for (const error of grounding.errors) errors.push(`grounding: ${error}`);
|
|
3476
|
-
for (const warning of grounding.warnings) warnings.push(`grounding: ${warning}`);
|
|
3477
|
-
}
|
|
3478
|
-
if (errors.length > 0) errors.push('spec.json.ready_for_implementation: cannot be true while schema 2.1 semantic errors exist');
|
|
3479
|
-
}
|
|
3480
|
-
warnings.push('schema 2.1 structural checks do not replace semantic judgment');
|
|
3481
|
-
}
|
|
3482
|
-
|
|
3483
|
-
function validateSpec(specDir, overrideSpec) {
|
|
3484
|
-
const errors = [];
|
|
3485
|
-
const warnings = [];
|
|
3486
|
-
const canonicalSpecDir = validateSpecRoot(specDir, errors);
|
|
3487
|
-
if (!canonicalSpecDir) return { errors, warnings };
|
|
3488
|
-
|
|
3489
|
-
for (const forbidden of ['init.json', 'spec-state.json', 'hydration.md']) {
|
|
3490
|
-
const artifact = inspectSpecArtifact(specDir, canonicalSpecDir, forbidden, forbidden, errors);
|
|
3491
|
-
if (artifact) {
|
|
3492
|
-
errors.push(`${forbidden}: forbidden generated artifact`);
|
|
3493
|
-
}
|
|
3494
|
-
}
|
|
3495
|
-
|
|
3496
|
-
const specJsonArtifact = inspectSpecArtifact(specDir, canonicalSpecDir, 'spec.json', 'spec.json', errors, { required: true });
|
|
3497
|
-
if (!specJsonArtifact) return { errors, warnings };
|
|
3498
|
-
|
|
3499
|
-
const spec = overrideSpec === undefined ? readJson(specJsonArtifact.path, errors) : overrideSpec;
|
|
3500
|
-
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
3501
|
-
if (errors.length === 0) errors.push('spec.json: must contain a JSON object');
|
|
3502
|
-
return { errors, warnings };
|
|
3503
|
-
}
|
|
3504
|
-
|
|
3505
|
-
if (spec.schema_version === SCHEMA_21 && Object.prototype.hasOwnProperty.call(spec, 'override_receipt')) {
|
|
3506
|
-
errors.push('spec.json.override_receipt: legacy override receipt is inert read compatibility and must be omitted from canonical state');
|
|
3507
|
-
}
|
|
3508
|
-
|
|
3509
|
-
if (spec.schema_version === SCHEMA_21) {
|
|
3510
|
-
const requirements = inspectSpecArtifact(specDir, canonicalSpecDir, 'requirements.md', 'requirements.md', errors, { required: true });
|
|
3511
|
-
const design = inspectSpecArtifact(specDir, canonicalSpecDir, 'design.md', 'design.md', errors, { required: true });
|
|
3512
|
-
if (requirements && design) validateSpec21(specDir, canonicalSpecDir, spec, errors, warnings);
|
|
3513
|
-
return { errors, warnings };
|
|
3514
|
-
}
|
|
3515
|
-
|
|
3516
|
-
const policyContext = workflowPolicyContext(spec, errors);
|
|
3517
|
-
const taskFiles = listTaskFiles(specDir, canonicalSpecDir, errors);
|
|
3518
|
-
const taskFileSet = new Set(taskFiles);
|
|
3519
|
-
validateCoreMachineState(spec, taskFiles, errors);
|
|
3520
|
-
const topology = validateCoordinationTopology(spec, policyContext, taskFiles, specDir, errors);
|
|
3521
|
-
policyContext.taskRequired = topology.tasksRequired;
|
|
3522
|
-
const requiresTaskBundle = topology.tasksRequired;
|
|
3523
|
-
validateStateTransitions(spec, errors, policyContext);
|
|
3524
|
-
validateFinalLifecycleTimestamps(spec, taskFiles, policyContext, errors);
|
|
3525
|
-
if (!requiresTaskBundle && !policyContext.legacy) {
|
|
3526
|
-
if (Object.prototype.hasOwnProperty.call(spec, 'task_files')) {
|
|
3527
|
-
errors.push('spec.json.task_files: taskless Specs v2 must omit task inventory');
|
|
3528
|
-
}
|
|
3529
|
-
if (Object.prototype.hasOwnProperty.call(spec, 'task_registry')) {
|
|
3530
|
-
errors.push('spec.json.task_registry: taskless Specs v2 must omit task registry');
|
|
3531
|
-
}
|
|
3532
|
-
}
|
|
3533
|
-
|
|
3534
|
-
if (!Array.isArray(spec.task_files)) {
|
|
3535
|
-
if (requiresTaskBundle) errors.push('spec.json.task_files: missing array');
|
|
3536
|
-
if (Array.isArray(spec.tasks)) {
|
|
3537
|
-
errors.push('spec.json.tasks: legacy field detected; use task_files');
|
|
3538
|
-
}
|
|
3539
|
-
} else {
|
|
3540
|
-
const declared = [...spec.task_files].sort();
|
|
3541
|
-
if (JSON.stringify(declared) !== JSON.stringify(taskFiles)) {
|
|
3542
|
-
errors.push('spec.json.task_files: must exactly match files under tasks/');
|
|
3543
|
-
warnings.push(`expected task_files=${JSON.stringify(taskFiles)}`);
|
|
3544
|
-
}
|
|
3545
|
-
}
|
|
3546
|
-
|
|
3547
|
-
if (!spec.task_registry || typeof spec.task_registry !== 'object' || Array.isArray(spec.task_registry)) {
|
|
3548
|
-
if (requiresTaskBundle) errors.push('spec.json.task_registry: missing object keyed by task file path');
|
|
3549
|
-
} else {
|
|
3550
|
-
const registryKeys = Object.keys(spec.task_registry).sort();
|
|
3551
|
-
if (JSON.stringify(registryKeys) !== JSON.stringify(taskFiles)) {
|
|
3552
|
-
errors.push('spec.json.task_registry: keys must exactly match task file paths');
|
|
3553
|
-
}
|
|
3554
|
-
|
|
3555
|
-
for (const [registryPath, entry] of Object.entries(spec.task_registry)) {
|
|
3556
|
-
if (!taskFileSet.has(registryPath)) {
|
|
3557
|
-
errors.push(`spec.json.task_registry.${registryPath}: no matching task file`);
|
|
3558
|
-
}
|
|
3559
|
-
validateArtifactDeclaration(registryPath, entry, errors);
|
|
3560
|
-
}
|
|
3561
|
-
}
|
|
3562
|
-
|
|
3563
|
-
for (const taskFile of taskFiles) {
|
|
3564
|
-
if (!TASK_PATH_RE.test(taskFile)) {
|
|
3565
|
-
errors.push(`${taskFile}: must match tasks/task-R{N}-{SEQ}-<slug>.md with two-digit SEQ`);
|
|
3566
|
-
}
|
|
3567
|
-
}
|
|
3568
|
-
|
|
3569
|
-
if (taskFiles.length > 2 && taskFiles.every((taskFile) => /^tasks\/task-R0-/.test(taskFile))) {
|
|
3570
|
-
errors.push('tasks/: feature work cannot be entirely R0; reserve R0 for shared foundation tasks');
|
|
3571
|
-
}
|
|
3572
|
-
|
|
3573
|
-
const validationRecommended = spec.design_context?.validation_recommended === true;
|
|
3574
|
-
if (taskFiles.length >= 5 && !validationRecommended) {
|
|
3575
|
-
errors.push('spec.json.design_context.validation_recommended: must be true for specs with 5+ task files');
|
|
3576
|
-
}
|
|
3577
|
-
if (
|
|
3578
|
-
(validationRecommended || taskFiles.length >= 5) &&
|
|
3579
|
-
spec.ready_for_implementation === true &&
|
|
3580
|
-
spec.validation?.status !== 'completed'
|
|
3581
|
-
) {
|
|
3582
|
-
errors.push(
|
|
3583
|
-
'spec.json.ready_for_implementation: cannot be true when validation is recommended but validation.status is not completed',
|
|
3584
|
-
);
|
|
3585
|
-
}
|
|
3586
|
-
if (spec.validation?.status === 'completed') {
|
|
3587
|
-
if (!spec.timestamps?.validation_done) {
|
|
3588
|
-
errors.push('spec.json.timestamps.validation_done: required when validation.status is completed');
|
|
3589
|
-
}
|
|
3590
|
-
if (taskFiles.length >= 5 && !spec.timestamps?.review_done) {
|
|
3591
|
-
errors.push('spec.json.timestamps.review_done: required after validation for a large task graph');
|
|
3592
|
-
}
|
|
3593
|
-
}
|
|
3594
|
-
|
|
3595
|
-
const requirementsArtifact = inspectSpecArtifact(
|
|
3596
|
-
specDir,
|
|
3597
|
-
canonicalSpecDir,
|
|
3598
|
-
'requirements.md',
|
|
3599
|
-
'requirements.md',
|
|
3600
|
-
errors,
|
|
3601
|
-
{ required: true },
|
|
3602
|
-
);
|
|
3603
|
-
const designArtifact = inspectSpecArtifact(
|
|
3604
|
-
specDir,
|
|
3605
|
-
canonicalSpecDir,
|
|
3606
|
-
'design.md',
|
|
3607
|
-
'design.md',
|
|
3608
|
-
errors,
|
|
3609
|
-
{ required: true },
|
|
3610
|
-
);
|
|
3611
|
-
const researchArtifact = inspectSpecArtifact(specDir, canonicalSpecDir, 'research.md', 'research.md', errors);
|
|
3612
|
-
const receiptArtifact = inspectSpecArtifact(specDir, canonicalSpecDir, FEATURE_RECEIPT_FILE, FEATURE_RECEIPT_FILE, errors);
|
|
3613
|
-
validateResearchPointer(specDir, canonicalSpecDir, spec, errors);
|
|
3614
|
-
|
|
3615
|
-
const researchPointer = explicitResearchPath(spec);
|
|
3616
|
-
if (researchPointer !== null && researchPointer !== 'research.md') {
|
|
3617
|
-
errors.push('spec.research: explicit research must use the canonical research.md path');
|
|
3618
|
-
}
|
|
3619
|
-
const legacyResearchRequired = policyContext.legacy
|
|
3620
|
-
&& spec.workflow_policy?.proof_obligations?.includes('needsResearchGrounding');
|
|
3621
|
-
if (researchPointer === 'research.md' || (policyContext.legacy && researchArtifact)) {
|
|
3622
|
-
validateResearchArtifact(researchArtifact, errors, true);
|
|
3623
|
-
} else if (legacyResearchRequired) {
|
|
3624
|
-
validateResearchArtifact(null, errors, true);
|
|
3625
|
-
} else if (researchArtifact) {
|
|
3626
|
-
errors.push('research.md: physical research artifact requires explicit spec.research="research.md"');
|
|
3627
|
-
}
|
|
3628
|
-
|
|
3629
|
-
validateFeatureReceipt(spec, policyContext, errors, warnings, receiptArtifact);
|
|
3630
|
-
|
|
3631
|
-
let requirementIds = [];
|
|
3632
|
-
let subCriteriaIds = [];
|
|
3633
|
-
let requirementsText = '';
|
|
3634
|
-
let designText = '';
|
|
3635
|
-
if (requirementsArtifact) {
|
|
3636
|
-
requirementsText = fs.readFileSync(requirementsArtifact.path, 'utf8');
|
|
3637
|
-
validateUniqueSectionGroups('requirements.md', requirementsText, [['Requirements']], errors);
|
|
3638
|
-
validateRequirementDialect(requirementsText, errors);
|
|
3639
|
-
validateRequirementInventory(requirementsText, errors);
|
|
3640
|
-
requirementIds = extractRequirementIds(requirementsText);
|
|
3641
|
-
subCriteriaIds = extractSubCriteriaIds(requirementsText);
|
|
3642
|
-
}
|
|
3643
|
-
if (designArtifact) {
|
|
3644
|
-
designText = fs.readFileSync(designArtifact.path, 'utf8');
|
|
3645
|
-
validateUniqueSectionGroups('design.md', designText, [
|
|
3646
|
-
['Architecture'],
|
|
3647
|
-
['Canonical Contracts & Invariants'],
|
|
3648
|
-
['Execution Closure'],
|
|
3649
|
-
['Requirements Traceability'],
|
|
3650
|
-
['Components and Interfaces'],
|
|
3651
|
-
], errors);
|
|
3652
|
-
}
|
|
3653
|
-
if (!policyContext.legacy) {
|
|
3654
|
-
validateSemanticReview(
|
|
3655
|
-
specDir,
|
|
3656
|
-
spec,
|
|
3657
|
-
policyContext,
|
|
3658
|
-
subCriteriaIds,
|
|
3659
|
-
designText,
|
|
3660
|
-
errors,
|
|
3661
|
-
);
|
|
3662
|
-
}
|
|
3663
|
-
validateSemanticReadiness(spec, requirementsText, designText, policyContext, errors);
|
|
3664
|
-
|
|
3665
|
-
const coveredRequirementIds = new Set();
|
|
3666
|
-
const coveredSubCriteriaIds = new Set();
|
|
3667
|
-
const mappedRequirementReferences = [];
|
|
3668
|
-
const taskRecords = new Map();
|
|
3669
|
-
const taskClosures = new Map();
|
|
3670
|
-
const taskContents = new Map();
|
|
3671
|
-
// Cross-layer contract defs (opt-in): empty unless design.md uses
|
|
3672
|
-
// <!-- contract:NAME --> markers, so legacy specs are unaffected.
|
|
3673
|
-
const contractDefs = designArtifact ? extractContractDefs(designText, 'design.md', errors) : new Map();
|
|
3674
|
-
const designClosure = designArtifact
|
|
3675
|
-
? implementationObligationClosure(designText, 'design.md', errors)
|
|
3676
|
-
: { ids: [], section: '', headingCount: 0, blocks: new Map(), notApplicable: false };
|
|
3677
|
-
for (const taskFile of taskFiles) {
|
|
3678
|
-
const taskArtifact = inspectSpecArtifact(specDir, canonicalSpecDir, taskFile, taskFile, errors);
|
|
3679
|
-
if (!taskArtifact) continue;
|
|
3680
|
-
const content = fs.readFileSync(taskArtifact.path, 'utf8');
|
|
3681
|
-
taskContents.set(taskFile, content);
|
|
3682
|
-
const registryEntry = spec.task_registry?.[taskFile];
|
|
3683
|
-
validateTaskIdentity(taskFile, content, registryEntry, errors);
|
|
3684
|
-
validateTaskSections(taskFile, content, errors);
|
|
3685
|
-
validateTaskPlaceholders(taskFile, content, errors, warnings);
|
|
3686
|
-
validateRelatedFiles(taskFile, content, errors);
|
|
3687
|
-
validateTaskDependencyHeader(
|
|
3688
|
-
taskFile,
|
|
3689
|
-
content,
|
|
3690
|
-
registryEntry,
|
|
3691
|
-
spec.ready_for_implementation === true,
|
|
3692
|
-
errors,
|
|
3693
|
-
);
|
|
3694
|
-
validateTaskStatusHeader(
|
|
3695
|
-
taskFile,
|
|
3696
|
-
content,
|
|
3697
|
-
registryEntry,
|
|
3698
|
-
spec.ready_for_implementation === true,
|
|
3699
|
-
errors,
|
|
3700
|
-
);
|
|
3701
|
-
taskClosures.set(
|
|
3702
|
-
taskFile,
|
|
3703
|
-
implementationObligationClosure(content, taskFile, errors),
|
|
3704
|
-
);
|
|
3705
|
-
taskRecords.set(taskFile, {
|
|
3706
|
-
rows: relatedFilesSection(content).rows,
|
|
3707
|
-
parallel: taskDeclaresParallel(content, taskFile, errors),
|
|
3708
|
-
});
|
|
3709
|
-
|
|
3710
|
-
// Requirement traceability: only structured _Requirements: ..._ mappings inside Steps/Requirements sections count.
|
|
3711
|
-
// Incidental mentions elsewhere (prose Context, Constraints) must not be counted.
|
|
3712
|
-
const stepsSection = extractSection(content, 'Changes') || extractSection(content, 'Steps') || extractSection(content, 'Implementation Steps') || '';
|
|
3713
|
-
const reqSectionInTask = extractSection(content, 'Requirements') || '';
|
|
3714
|
-
const acceptanceSection = extractSection(content, 'Acceptance') || '';
|
|
3715
|
-
const mappingSource = `${stepsSection}\n${reqSectionInTask}\n${acceptanceSection}`;
|
|
3716
|
-
// Also consider inline _Requirements: inside Steps bullet lines that may be outside section extraction edge cases,
|
|
3717
|
-
// so fallback to whole content scanning only if sections are null (e.g., malformed heading). But prefer scoped.
|
|
3718
|
-
const effectiveMappingSource = mappingSource.trim() ? mappingSource : content;
|
|
3719
|
-
const numericMappingRe = /_Requirements:\s*([^_\n]+)_/gi;
|
|
3720
|
-
let match;
|
|
3721
|
-
while ((match = numericMappingRe.exec(effectiveMappingSource)) !== null) {
|
|
3722
|
-
for (const token of match[1].split(',')) {
|
|
3723
|
-
const trimmed = token.trim();
|
|
3724
|
-
const major = trimmed.match(/^(\d+)(?:\.\d+)?$/);
|
|
3725
|
-
if (major) {
|
|
3726
|
-
const requirementId = `R${major[1]}`;
|
|
3727
|
-
const sub = trimmed.match(/^(\d+\.\d+)$/);
|
|
3728
|
-
const subId = sub ? `R${sub[1]}` : null;
|
|
3729
|
-
coveredRequirementIds.add(requirementId);
|
|
3730
|
-
mappedRequirementReferences.push({ taskFile, token: trimmed, requirementId, subId });
|
|
3731
|
-
} else if (trimmed !== '') {
|
|
3732
|
-
errors.push(`${taskFile}: requirement mapping "${trimmed}" must use numeric IDs such as 1 or 1.1`);
|
|
3733
|
-
}
|
|
3734
|
-
// Record the full sub-criterion (e.g. 3.4 -> R3.4) for per-criterion coverage.
|
|
3735
|
-
const sub = trimmed.match(/^(\d+\.\d+)$/);
|
|
3736
|
-
if (sub) coveredSubCriteriaIds.add(`R${sub[1]}`);
|
|
3737
|
-
}
|
|
3738
|
-
}
|
|
3739
|
-
|
|
3740
|
-
if (taskFormat(content) === 'v2') {
|
|
3741
|
-
for (const subId of criterionReferences(`${stepsSection}\n${acceptanceSection}`)) {
|
|
3742
|
-
coveredSubCriteriaIds.add(subId);
|
|
3743
|
-
coveredRequirementIds.add(subId.split('.')[0]);
|
|
3744
|
-
mappedRequirementReferences.push({
|
|
3745
|
-
taskFile,
|
|
3746
|
-
token: subId.slice(1),
|
|
3747
|
-
requirementId: subId.split('.')[0],
|
|
3748
|
-
subId,
|
|
3749
|
-
});
|
|
3750
|
-
}
|
|
3751
|
-
}
|
|
3752
|
-
|
|
3753
|
-
// Cross-layer contract check (opt-in via design.md markers). When a task
|
|
3754
|
-
// claims `Contracts: NAME`, design.md must define the name and the task's
|
|
3755
|
-
// local copy must match the canonical definition byte-for-byte (after
|
|
3756
|
-
// whitespace normalization). This catches BE/FE drift like user_name vs
|
|
3757
|
-
// userName before integration, and rejects declarations with no canonical
|
|
3758
|
-
// source instead of silently accepting an orphan contract.
|
|
3759
|
-
if (taskFormat(content) === 'v2') continue;
|
|
3760
|
-
const { names, blocks, firstBlock } = extractTaskContracts(content, taskFile, errors);
|
|
3761
|
-
for (const taggedName of blocks.keys()) {
|
|
3762
|
-
if (!names.includes(taggedName)) {
|
|
3763
|
-
errors.push(
|
|
3764
|
-
`${taskFile}: carries <!-- contract:${taggedName} --> block but must declare it on one Contracts: line`,
|
|
3765
|
-
);
|
|
3766
|
-
}
|
|
3767
|
-
}
|
|
3768
|
-
if (names.length > 0 && contractDefs.size === 0) {
|
|
3769
|
-
errors.push(`${taskFile}: declares contract(s) but design.md defines no canonical contract blocks`);
|
|
3770
|
-
}
|
|
3771
|
-
if (contractDefs.size > 0) {
|
|
3772
|
-
for (const name of names) {
|
|
3773
|
-
if (!contractDefs.has(name)) {
|
|
3774
|
-
errors.push(`${taskFile}: declares unknown contract "${name}" (not defined in design.md)`);
|
|
3775
|
-
continue;
|
|
3776
|
-
}
|
|
3777
|
-
// Prefer the task's marker-tagged copy for this contract; fall back to
|
|
3778
|
-
// the first fenced block only for single-contract tasks (legacy format).
|
|
3779
|
-
const localCopy = blocks.has(name)
|
|
3780
|
-
? blocks.get(name)
|
|
3781
|
-
: (names.length === 1 ? firstBlock : null);
|
|
3782
|
-
if (localCopy === null) {
|
|
3783
|
-
errors.push(`${taskFile}: contract "${name}" is missing a copied canonical block`);
|
|
3784
|
-
continue;
|
|
3785
|
-
}
|
|
3786
|
-
if (localCopy !== contractDefs.get(name)) {
|
|
3787
|
-
errors.push(`${taskFile}: contract "${name}" body diverges from the canonical definition in design.md`);
|
|
3788
|
-
}
|
|
3789
|
-
}
|
|
3790
|
-
}
|
|
3791
|
-
}
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
if (!policyContext.legacy) validateTypedAnchorInventory(designText, taskContents, errors);
|
|
3795
|
-
validateNamedContractReferences(designText, taskContents, errors);
|
|
3796
|
-
|
|
3797
|
-
validateDependencyTopology(spec, taskFiles, spec.task_registry, taskRecords, errors);
|
|
3798
|
-
validateParallelEligibility(taskFiles, spec.task_registry, taskRecords, errors);
|
|
3799
|
-
if (!policyContext.legacy) {
|
|
3800
|
-
validateTaskTriggerEvidence(spec, topology, taskFiles, taskRecords, errors);
|
|
3801
|
-
}
|
|
3802
|
-
if (policyContext.legacy) {
|
|
3803
|
-
validateImplementationObligations(
|
|
3804
|
-
spec,
|
|
3805
|
-
policyContext,
|
|
3806
|
-
requirementIds,
|
|
3807
|
-
subCriteriaIds,
|
|
3808
|
-
taskFiles,
|
|
3809
|
-
spec.task_registry,
|
|
3810
|
-
designClosure,
|
|
3811
|
-
taskClosures,
|
|
3812
|
-
errors,
|
|
3813
|
-
);
|
|
3814
|
-
} else if (Object.prototype.hasOwnProperty.call(spec, 'implementation_obligations')) {
|
|
3815
|
-
errors.push('spec.json.implementation_obligations: legacy copied-closure contract is not part of Specs v2; use named design contracts and task references');
|
|
3816
|
-
}
|
|
3817
|
-
|
|
3818
|
-
if (policyContext.strict && requirementIds.length === 0) {
|
|
3819
|
-
errors.push('requirements.md: strict workflow requires numeric requirement IDs (for example Requirement 1 or R1)');
|
|
3820
|
-
}
|
|
3821
|
-
if (requiresTaskBundle) {
|
|
3822
|
-
const knownRequirements = new Set(requirementIds);
|
|
3823
|
-
const knownSubCriteria = new Set(subCriteriaIds);
|
|
3824
|
-
for (const reference of mappedRequirementReferences) {
|
|
3825
|
-
if (!knownRequirements.has(reference.requirementId)) {
|
|
3826
|
-
errors.push(`${reference.taskFile}: requirement mapping "${reference.token}" references unknown requirement ${reference.requirementId}`);
|
|
3827
|
-
} else if (reference.subId && !knownSubCriteria.has(reference.subId)) {
|
|
3828
|
-
errors.push(`${reference.taskFile}: requirement mapping "${reference.token}" references unknown acceptance criterion ${reference.subId}`);
|
|
3829
|
-
}
|
|
3830
|
-
}
|
|
3831
|
-
}
|
|
3832
|
-
|
|
3833
|
-
if (requiresTaskBundle) {
|
|
3834
|
-
for (const requirementId of requirementIds) {
|
|
3835
|
-
if (!coveredRequirementIds.has(requirementId)) {
|
|
3836
|
-
errors.push(`requirements.md:${requirementId}: not covered by any task`);
|
|
3837
|
-
}
|
|
3838
|
-
}
|
|
3839
|
-
}
|
|
3840
|
-
|
|
3841
|
-
// Per-criterion coverage: every explicit R{N}.{M} literal in requirements.md
|
|
3842
|
-
// must appear in a numeric `_Requirements: x.y_` task mapping.
|
|
3843
|
-
if (requiresTaskBundle && subCriteriaIds.length > 0) {
|
|
3844
|
-
for (const subId of subCriteriaIds) {
|
|
3845
|
-
if (!coveredSubCriteriaIds.has(subId)) {
|
|
3846
|
-
errors.push(`requirements.md:${subId}: acceptance criterion not covered by any task`);
|
|
3847
|
-
}
|
|
3848
|
-
}
|
|
3849
|
-
}
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
const designVerification = extractSection(designText, 'Verification') || '';
|
|
3853
|
-
const designVerificationCoverage = criterionReferences(designVerification);
|
|
3854
|
-
for (const subId of subCriteriaIds) {
|
|
3855
|
-
if (!designVerificationCoverage.has(subId)) {
|
|
3856
|
-
errors.push(`requirements.md:${subId}: missing deterministic design Verification coverage`);
|
|
3857
|
-
}
|
|
3858
|
-
}
|
|
3859
|
-
|
|
3860
|
-
if (spec.ready_for_implementation === true && errors.length > 0) {
|
|
3861
|
-
errors.push('spec.json.ready_for_implementation: cannot be true while validator errors exist');
|
|
3862
|
-
}
|
|
3863
|
-
|
|
3864
|
-
return { errors, warnings };
|
|
3865
|
-
}
|
|
3866
|
-
|
|
3867
|
-
function main() {
|
|
3868
|
-
const args = process.argv.slice(2);
|
|
3869
|
-
const semanticDigestMode = args.includes('--semantic-digest');
|
|
3870
|
-
const positional = args.filter((arg) => arg !== '--semantic-digest');
|
|
3871
|
-
const specDir = positional.length === 1 ? resolveSpecDir(positional[0]) : null;
|
|
3872
|
-
if (!specDir) {
|
|
3873
|
-
usage();
|
|
3874
|
-
process.exit(2);
|
|
3875
|
-
}
|
|
3876
|
-
|
|
3877
|
-
if (semanticDigestMode) {
|
|
3878
|
-
let spec = null;
|
|
3879
|
-
try { spec = JSON.parse(fs.readFileSync(path.join(specDir, 'spec.json'), 'utf8')); } catch {}
|
|
3880
|
-
const result = spec?.schema_version === SCHEMA_21
|
|
3881
|
-
? computeSemanticDigest21(specDir, spec)
|
|
3882
|
-
: computeSemanticDigest(specDir);
|
|
3883
|
-
if (result.errors.length > 0) {
|
|
3884
|
-
console.error(`SEMANTIC_DIGEST_FAIL ${path.relative(process.cwd(), specDir) || specDir}`);
|
|
3885
|
-
for (const error of result.errors) console.error(`- ${error}`);
|
|
3886
|
-
process.exit(1);
|
|
3887
|
-
}
|
|
3888
|
-
console.log(result.digest);
|
|
3889
|
-
return;
|
|
3890
|
-
}
|
|
3891
|
-
|
|
3892
|
-
const { errors, warnings } = validateSpec(specDir);
|
|
3893
|
-
for (const warning of warnings) {
|
|
3894
|
-
console.warn(`[WARN] ${warning}`);
|
|
3895
|
-
}
|
|
3896
|
-
|
|
3897
|
-
if (errors.length > 0) {
|
|
3898
|
-
console.error(`STRUCTURAL_FAIL ${path.relative(process.cwd(), specDir) || specDir}`);
|
|
3899
|
-
for (const error of errors) {
|
|
3900
|
-
console.error(`- ${error}`);
|
|
3901
|
-
}
|
|
3902
|
-
process.exit(1);
|
|
3903
|
-
}
|
|
3904
|
-
|
|
3905
|
-
console.log(`STRUCTURALLY_VALID ${path.relative(process.cwd(), specDir) || specDir} (semantic review and execution proof are not claimed)`);
|
|
3906
|
-
}
|
|
3907
|
-
|
|
3908
|
-
module.exports = { validateSpec, computeSemanticDigest21 };
|
|
3909
|
-
Object.defineProperty(module.exports, 'C2_FIELDS', {
|
|
3910
|
-
value: C2_FIELDS,
|
|
3911
|
-
enumerable: false,
|
|
3912
|
-
});
|
|
3913
|
-
|
|
3914
|
-
if (require.main === module) main();
|