@haposoft/cafekit 0.16.0-rc.2 → 0.16.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/__tests__/codex-hooks.test.js +794 -21
- package/bin/__tests__/codex-native.test.js +228 -7
- package/bin/__tests__/develop-contract.test.js +1838 -112
- package/bin/__tests__/installer-safety.test.js +82 -29
- package/bin/__tests__/opencode-spec-gate.test.js +799 -0
- package/bin/__tests__/package-inventory.test.js +841 -10
- package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
- package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
- package/bin/__tests__/validator-grounding.test.js +2469 -71
- package/bin/lib/managed-writer.js +12 -3
- package/bin/phases/codex-runtime.js +17 -1
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +6 -0
- package/src/claude/agents/code-auditor.md +20 -0
- package/src/claude/agents/spec-maker.md +153 -202
- package/src/claude/hooks/__tests__/completion-authority.test.js +953 -0
- package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +604 -56
- package/src/claude/hooks/completion-authority-check.cjs +56 -0
- package/src/claude/hooks/completion-authority-state.cjs +527 -0
- package/src/claude/hooks/completion-authority.cjs +154 -0
- package/src/claude/hooks/docs-sync.cjs +5 -2
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
- package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
- package/src/claude/hooks/privacy-block.cjs +77 -30
- package/src/claude/hooks/semantic-review-authority.cjs +152 -0
- package/src/claude/hooks/session.cjs +33 -19
- package/src/claude/hooks/spec-gate.cjs +181 -139
- package/src/claude/hooks/spec-state.cjs +112 -47
- package/src/claude/migration-manifest.json +15 -2
- package/src/claude/rules/state-sync.md +52 -21
- package/src/claude/rules/workflow.md +17 -9
- package/src/claude/scripts/provenance.cjs +315 -0
- package/src/claude/scripts/spec-final-state.cjs +244 -0
- package/src/claude/scripts/spec-ground.cjs +535 -173
- package/src/claude/scripts/spec-readiness.cjs +118 -0
- package/src/claude/scripts/spec-receipt.cjs +162 -0
- package/src/claude/scripts/spec-resolver.cjs +501 -0
- package/src/claude/scripts/spec-scaffold.cjs +1945 -55
- package/src/claude/scripts/spec-semantic-model.cjs +396 -0
- package/src/claude/scripts/validate-spec-output.cjs +3204 -262
- package/src/claude/scripts/workflow-policy.cjs +1888 -203
- package/src/claude/settings/settings.json +12 -0
- package/src/claude/skills/code-review/SKILL.md +94 -66
- package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
- package/src/claude/skills/code-review/references/verification-gate.md +13 -0
- package/src/claude/skills/develop/SKILL.md +138 -283
- package/src/claude/skills/develop/references/parallel-waves.md +9 -6
- package/src/claude/skills/develop/references/quality-gate.md +123 -148
- package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
- package/src/claude/skills/specs/SKILL.md +192 -397
- package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
- package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
- package/src/claude/skills/specs/references/research-strategy.md +4 -1
- package/src/claude/skills/specs/references/review.md +200 -260
- package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
- package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
- package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
- package/src/claude/skills/specs/rules/design-principles.md +136 -180
- package/src/claude/skills/specs/rules/design-review.md +42 -97
- package/src/claude/skills/specs/rules/ears-format.md +42 -50
- package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +46 -46
- package/src/claude/skills/specs/rules/tasks-generation.md +189 -271
- package/src/claude/skills/specs/templates/design.md +49 -263
- package/src/claude/skills/specs/templates/requirements.md +26 -36
- package/src/claude/skills/specs/templates/research.md +9 -98
- package/src/claude/skills/specs/templates/spec-state.json +26 -67
- package/src/claude/skills/specs/templates/task.md +30 -82
- package/src/claude/skills/sync/SKILL.md +45 -15
- package/src/claude/skills/sync/references/sync-protocols.md +34 -16
- package/src/claude/skills/test/SKILL.md +151 -167
- package/src/codex/AGENTS.md +6 -0
- package/src/codex/hooks/completion-authority-check.cjs +63 -0
- package/src/codex/hooks/completion-authority-state.cjs +527 -0
- package/src/codex/hooks/completion-authority.cjs +106 -0
- package/src/codex/hooks/lib/hook-context.cjs +58 -4
- package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
- package/src/codex/hooks/lib/privacy-state.cjs +38 -16
- package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +86 -115
- package/src/codex/hooks/lib/spec-utils.cjs +104 -27
- package/src/codex/hooks/privacy-approval.cjs +1 -1
- package/src/codex/hooks/privacy-block.cjs +67 -11
- package/src/codex/hooks/semantic-review-authority.cjs +130 -0
- package/src/codex/hooks/session.cjs +2 -0
- package/src/codex/hooks/spec-gate.cjs +151 -24
- package/src/codex/hooks/spec-state.cjs +65 -12
- package/src/codex/hooks/state.cjs +2 -0
- package/src/codex/hooks.json +16 -1
- package/src/codex/rules/state-sync.md +47 -27
- package/src/common/AGENTS.md +1 -1
- package/src/opencode/AGENTS.md +4 -2
- package/src/opencode/plugins/spec-gate.ts +296 -122
- package/src/opencode/plugins/spec-state.ts +183 -116
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* Verifies every task Related Files declaration against the real work tree.
|
|
6
|
-
* Paths are relative, actions are explicit, and globs must match at least one
|
|
7
|
-
* filesystem entry. Create on existing paths is an error. Lifecycle ordering
|
|
8
|
-
* (Create before Modify/Delete/Read) is enforced within task and via
|
|
9
|
-
* dependency DAG for cross-task references; cross-task producer/consumer
|
|
10
|
-
* requires a valid spec.json task_registry (fail-closed).
|
|
11
|
-
*/
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/** CafeKit Specs v2 grounding checker (Layer 2, no command execution). */
|
|
12
5
|
|
|
13
6
|
const fs = require('fs');
|
|
14
7
|
const path = require('path');
|
|
8
|
+
const POLICY = require('./workflow-policy.cjs');
|
|
9
|
+
const SEMANTIC = require('./spec-semantic-model.cjs');
|
|
15
10
|
|
|
16
|
-
const
|
|
11
|
+
const ANCHOR_TYPES = new Set([
|
|
12
|
+
'file', 'symbol', 'command', 'route', 'schema', 'contract', 'artifact', 'external',
|
|
13
|
+
]);
|
|
14
|
+
const TASK_PATH_RE = /^task-(R\d+-\d{2})-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/;
|
|
17
15
|
|
|
18
16
|
function usage() {
|
|
19
17
|
console.error('Usage: node spec-ground.cjs <specDir> [--root <work-context>]');
|
|
@@ -21,49 +19,165 @@ function usage() {
|
|
|
21
19
|
|
|
22
20
|
function parseArgs(argv) {
|
|
23
21
|
const args = { specDir: null, root: null };
|
|
24
|
-
for (let
|
|
25
|
-
if (argv[
|
|
26
|
-
args.root = argv[++
|
|
22
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
23
|
+
if (argv[index] === '--root') {
|
|
24
|
+
args.root = argv[++index];
|
|
27
25
|
if (!args.root) throw new Error('--root requires a value');
|
|
28
|
-
} else if (!args.specDir) args.specDir = argv[
|
|
29
|
-
else throw new Error(`
|
|
26
|
+
} else if (!args.specDir) args.specDir = argv[index];
|
|
27
|
+
else throw new Error(`unknown argument: ${argv[index]}`);
|
|
30
28
|
}
|
|
31
29
|
return args;
|
|
32
30
|
}
|
|
33
31
|
|
|
34
|
-
function
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
.sort();
|
|
32
|
+
function isPlainObject(value) {
|
|
33
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function inside(root, target) {
|
|
37
|
+
const relative = path.relative(path.resolve(root), path.resolve(target));
|
|
38
|
+
return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
42
39
|
}
|
|
43
40
|
|
|
44
|
-
function
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
function unsafeRelative(value) {
|
|
42
|
+
return typeof value !== 'string' || value.trim() === '' || value !== value.trim()
|
|
43
|
+
|| path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value)
|
|
44
|
+
|| value.split(/[\\/]+/).includes('..');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function inspectRoot(directory, label, errors) {
|
|
48
|
+
try {
|
|
49
|
+
const stat = fs.lstatSync(directory);
|
|
50
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
51
|
+
errors.push(`${label}: must be a real directory, not a symlink`);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return fs.realpathSync(directory);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
errors.push(`${label}: cannot be inspected (${error.message})`);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
48
60
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
function inspectPath(root, relativePath) {
|
|
62
|
+
if (unsafeRelative(relativePath)) return { valid: false, reason: 'must be a safe relative path' };
|
|
63
|
+
const target = path.resolve(root, relativePath);
|
|
64
|
+
if (!inside(root, target)) return { valid: false, reason: 'escapes work root' };
|
|
65
|
+
let current = path.resolve(root);
|
|
66
|
+
for (const segment of path.relative(root, target).split(path.sep).filter(Boolean)) {
|
|
67
|
+
current = path.join(current, segment);
|
|
68
|
+
try {
|
|
69
|
+
const stat = fs.lstatSync(current);
|
|
70
|
+
if (stat.isSymbolicLink()) return { valid: false, reason: `contains symlink ${path.relative(root, current)}` };
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return { valid: true, exists: false, target };
|
|
73
|
+
return { valid: false, reason: error.message };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const real = fs.realpathSync(target);
|
|
78
|
+
if (!inside(root, real)) return { valid: false, reason: 'canonical path escapes work root' };
|
|
79
|
+
return { valid: true, exists: true, target, stat: fs.statSync(target) };
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return { valid: false, reason: error.message };
|
|
59
82
|
}
|
|
60
|
-
return { present: true, rows };
|
|
61
83
|
}
|
|
62
84
|
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
85
|
+
function extractSection(content, heading) {
|
|
86
|
+
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
87
|
+
const match = content.match(new RegExp(`^##\\s+${escaped}\\s*$`, 'im'));
|
|
88
|
+
if (!match) return null;
|
|
89
|
+
const after = content.slice(match.index + match[0].length);
|
|
90
|
+
const next = after.match(/^##\s+/m);
|
|
91
|
+
return next ? after.slice(0, next.index) : after;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseTable(section, expectedHeaders) {
|
|
95
|
+
if (typeof section !== 'string') return [];
|
|
96
|
+
const lines = section.split('\n');
|
|
97
|
+
for (let index = 0; index + 1 < lines.length; index += 1) {
|
|
98
|
+
if (!/^\s*\|/.test(lines[index]) || !/^\s*\|(?:\s*:?-{3,}:?\s*\|)+\s*$/.test(lines[index + 1])) continue;
|
|
99
|
+
const headers = lines[index].split('|').slice(1, -1).map((cell) => cell.trim().toLowerCase());
|
|
100
|
+
if (JSON.stringify(headers) !== JSON.stringify(expectedHeaders.map((header) => header.toLowerCase()))) continue;
|
|
101
|
+
const rows = [];
|
|
102
|
+
for (let row = index + 2; row < lines.length && /^\s*\|/.test(lines[row]); row += 1) {
|
|
103
|
+
const cells = lines[row].split('|').slice(1, -1)
|
|
104
|
+
.map((cell) => cell.trim().replace(/^`|`$/g, '').trim());
|
|
105
|
+
if (cells.some(Boolean)) rows.push(cells);
|
|
106
|
+
}
|
|
107
|
+
return rows;
|
|
108
|
+
}
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizeCanonicalAccessAction(rawAccess, rawAction) {
|
|
113
|
+
return SEMANTIC.normalizeAccessAction(rawAccess, rawAction);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function listTasks(specDir) {
|
|
117
|
+
const directory = path.join(specDir, 'tasks');
|
|
118
|
+
if (!fs.existsSync(directory)) return [];
|
|
119
|
+
return fs.readdirSync(directory, { withFileTypes: true })
|
|
120
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
121
|
+
.map((entry) => ({
|
|
122
|
+
path: path.join(directory, entry.name),
|
|
123
|
+
relative: `tasks/${entry.name}`,
|
|
124
|
+
basename: entry.name,
|
|
125
|
+
id: entry.name.match(TASK_PATH_RE)?.[1] || null,
|
|
126
|
+
}))
|
|
127
|
+
.sort((left, right) => left.relative.localeCompare(right.relative));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseTypedAnchors(content, label, design, errors, schema21 = false) {
|
|
131
|
+
const section = extractSection(content, design ? 'Typed Anchors' : (schema21 ? 'Anchors and Ownership' : 'Scope and Typed Anchors')) || '';
|
|
132
|
+
const rows = parseTable(section, schema21
|
|
133
|
+
? ['ID', 'Type', 'Target', 'Role', 'Access', 'Action']
|
|
134
|
+
: ['ID', 'Type', 'Target', 'Role']);
|
|
135
|
+
const consumesDesign = !design && /\bA-D-\d{2}\b/.test(section);
|
|
136
|
+
if (rows.length === 0 && !consumesDesign) errors.push(`${label}: typed anchor table is missing or empty`);
|
|
137
|
+
return rows.map(([id = '', rawType = '', target = '', role = '', access = '', action = '']) => {
|
|
138
|
+
const type = rawType.toLowerCase();
|
|
139
|
+
if (!ANCHOR_TYPES.has(type)) errors.push(`${label}: anchor ${id || '(missing id)'} has unknown Type ${rawType || '(empty)'}`);
|
|
140
|
+
if (schema21) {
|
|
141
|
+
const normalized = normalizeCanonicalAccessAction(access, action);
|
|
142
|
+
access = normalized.access;
|
|
143
|
+
action = normalized.action;
|
|
144
|
+
if (normalized.error) errors.push(`${label}: anchor ${id} ${normalized.error}`);
|
|
145
|
+
if (unsafeRelative(target) || /[{}*?\[\]]/.test(target) || /[\\/]$/.test(target)) errors.push(`${label}: anchor ${id} requires one exact grounded target`);
|
|
146
|
+
}
|
|
147
|
+
const namespace = design ? /^A-D-\d{2}$/ : new RegExp(`^A-${path.basename(label).match(TASK_PATH_RE)?.[1] || 'INVALID'}-\\d{2}$`);
|
|
148
|
+
if (schema21 && !namespace.test(id)) errors.push(`${label}: anchor ${id || '(missing id)'} has invalid ${design ? 'design' : 'task'} namespace`);
|
|
149
|
+
if (!target || unsafeRelative(target) || /[{}*?\[\]]/.test(target) || /[\\/]$/.test(target)) {
|
|
150
|
+
errors.push(`${label}: anchor ${id || '(missing id)'} requires one exact grounded target`);
|
|
151
|
+
}
|
|
152
|
+
if (!role) errors.push(`${label}: anchor ${id || '(missing id)'} Role must be concrete`);
|
|
153
|
+
return { id, type, target, role: role.toLowerCase(), access, action, label, design, schema21 };
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseCanonicalAnchors(content, options, errors) {
|
|
158
|
+
return SEMANTIC.parseAnchors(content, options, errors).map((anchor) => ({
|
|
159
|
+
...anchor,
|
|
160
|
+
label: options.label,
|
|
161
|
+
design: options.design === true,
|
|
162
|
+
schema21: true,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseVerificationDefinitions(designText, errors) {
|
|
167
|
+
return SEMANTIC.parseVerificationDefinitions(designText, errors);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseLegacyRelatedFiles(content, label) {
|
|
171
|
+
const section = extractSection(content, 'Related Files');
|
|
172
|
+
if (section === null) return { present: false, anchors: [] };
|
|
173
|
+
return { present: true, anchors: parseTable(section, ['Path', 'Action', 'Description']).map(([target, action], index) => ({
|
|
174
|
+
id: `${label}:${index}:${target}`,
|
|
175
|
+
type: 'file',
|
|
176
|
+
target,
|
|
177
|
+
role: action,
|
|
178
|
+
label,
|
|
179
|
+
legacy: true,
|
|
180
|
+
})) };
|
|
67
181
|
}
|
|
68
182
|
|
|
69
183
|
function expandBraces(pattern) {
|
|
@@ -77,176 +191,424 @@ function expandBraces(pattern) {
|
|
|
77
191
|
function globRegExp(pattern) {
|
|
78
192
|
let source = '^';
|
|
79
193
|
for (let index = 0; index < pattern.length; index += 1) {
|
|
80
|
-
const
|
|
81
|
-
if (
|
|
194
|
+
const character = pattern[index];
|
|
195
|
+
if (character === '*') {
|
|
82
196
|
if (pattern[index + 1] === '*') {
|
|
83
197
|
index += 1;
|
|
84
198
|
if (pattern[index + 1] === '/') index += 1;
|
|
85
199
|
source += '.*';
|
|
86
200
|
} else source += '[^/]*';
|
|
87
|
-
} else if (
|
|
88
|
-
else source +=
|
|
201
|
+
} else if (character === '?') source += '[^/]';
|
|
202
|
+
else source += character.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
89
203
|
}
|
|
90
204
|
return new RegExp(`${source}$`);
|
|
91
205
|
}
|
|
92
206
|
|
|
93
207
|
function relativeEntries(root) {
|
|
94
208
|
const entries = [];
|
|
95
|
-
function
|
|
209
|
+
function visit(directory, relative) {
|
|
96
210
|
let children;
|
|
97
|
-
try { children = fs.readdirSync(
|
|
98
|
-
for (const
|
|
99
|
-
const nextRelative = relative ? `${relative}/${
|
|
100
|
-
const next = path.join(current, entry.name);
|
|
211
|
+
try { children = fs.readdirSync(directory, { withFileTypes: true }); } catch (_) { return; }
|
|
212
|
+
for (const child of children) {
|
|
213
|
+
const nextRelative = relative ? `${relative}/${child.name}` : child.name;
|
|
101
214
|
entries.push(nextRelative);
|
|
102
|
-
if (
|
|
215
|
+
if (child.isDirectory() && !child.isSymbolicLink()) visit(path.join(directory, child.name), nextRelative);
|
|
103
216
|
}
|
|
104
217
|
}
|
|
105
|
-
|
|
218
|
+
visit(root, '');
|
|
106
219
|
return entries;
|
|
107
220
|
}
|
|
108
221
|
|
|
109
|
-
function matchingEntries(root,
|
|
110
|
-
if (!/[{}*?]/.test(
|
|
111
|
-
const
|
|
222
|
+
function matchingEntries(root, target) {
|
|
223
|
+
if (!/[{}*?]/.test(target)) return fs.existsSync(path.join(root, target)) ? [target] : [];
|
|
224
|
+
const entries = relativeEntries(root);
|
|
112
225
|
const matches = new Set();
|
|
113
|
-
for (const expanded of expandBraces(
|
|
114
|
-
const
|
|
115
|
-
for (const
|
|
226
|
+
for (const expanded of expandBraces(target)) {
|
|
227
|
+
const pattern = globRegExp(expanded.replaceAll('\\', '/'));
|
|
228
|
+
for (const entry of entries) if (pattern.test(entry)) matches.add(entry);
|
|
116
229
|
}
|
|
117
230
|
return [...matches];
|
|
118
231
|
}
|
|
119
232
|
|
|
120
|
-
function
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
233
|
+
function groundLegacy(anchors, taskRegistry, root, errors) {
|
|
234
|
+
const allowed = new Set(['create', 'modify', 'delete', 'read']);
|
|
235
|
+
const byTask = new Map();
|
|
236
|
+
for (const anchor of anchors) {
|
|
237
|
+
if (!byTask.has(anchor.label)) byTask.set(anchor.label, []);
|
|
238
|
+
byTask.get(anchor.label).push(anchor);
|
|
239
|
+
if (!allowed.has(anchor.role.toLowerCase())) errors.push(`${anchor.label}: unsupported Related Files action "${anchor.role}" for ${anchor.target}`);
|
|
240
|
+
if (unsafeRelative(anchor.target)) errors.push(`${anchor.label}: Related Files path must be relative and stay within work root: ${anchor.target}`);
|
|
241
|
+
}
|
|
242
|
+
for (const [taskFile, rows] of byTask) {
|
|
243
|
+
const firstCreate = new Map();
|
|
244
|
+
rows.forEach((row, index) => {
|
|
245
|
+
if (row.role.toLowerCase() === 'create' && !firstCreate.has(row.target)) firstCreate.set(row.target, index);
|
|
246
|
+
});
|
|
247
|
+
rows.forEach((row, index) => {
|
|
248
|
+
const action = row.role.toLowerCase();
|
|
249
|
+
if (!['modify', 'delete', 'read'].includes(action) || !firstCreate.has(row.target)) return;
|
|
250
|
+
if (firstCreate.get(row.target) > index) errors.push(`${taskFile}: Create must precede ${action} for ${row.target}`);
|
|
251
|
+
});
|
|
126
252
|
}
|
|
127
|
-
if (!args.specDir) { usage(); process.exit(2); }
|
|
128
253
|
|
|
129
|
-
const
|
|
130
|
-
if (!
|
|
131
|
-
|
|
132
|
-
|
|
254
|
+
const producers = new Map();
|
|
255
|
+
for (const anchor of anchors) if (anchor.role.toLowerCase() === 'create' && !producers.has(anchor.target)) producers.set(anchor.target, anchor.label);
|
|
256
|
+
const consumers = anchors.filter((anchor) => ['modify', 'delete', 'read'].includes(anchor.role.toLowerCase())
|
|
257
|
+
&& producers.has(anchor.target) && producers.get(anchor.target) !== anchor.label);
|
|
258
|
+
if (consumers.length > 0 && !isPlainObject(taskRegistry)) {
|
|
259
|
+
errors.push('spec.json task_registry is missing or invalid for a cross-task producer/consumer dependency (fail-closed)');
|
|
260
|
+
} else if (consumers.length > 0) {
|
|
261
|
+
const dependencies = new Map(Object.entries(taskRegistry).map(([taskFile, entry]) => [
|
|
262
|
+
taskFile,
|
|
263
|
+
new Set(Array.isArray(entry?.dependencies) ? entry.dependencies : []),
|
|
264
|
+
]));
|
|
265
|
+
function dependsOn(taskFile, ancestor, seen = new Set()) {
|
|
266
|
+
if (taskFile === ancestor) return true;
|
|
267
|
+
if (seen.has(taskFile)) return false;
|
|
268
|
+
seen.add(taskFile);
|
|
269
|
+
return [...(dependencies.get(taskFile) || [])].some((dependency) => dependsOn(dependency, ancestor, seen));
|
|
270
|
+
}
|
|
271
|
+
for (const consumer of consumers) {
|
|
272
|
+
if (!dependsOn(consumer.label, producers.get(consumer.target))) {
|
|
273
|
+
errors.push(`${consumer.label}: ${consumer.role.toLowerCase()} of ${consumer.target} must depend on creator ${producers.get(consumer.target)}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
133
276
|
}
|
|
134
|
-
const root = args.root
|
|
135
|
-
? path.resolve(process.cwd(), args.root)
|
|
136
|
-
: path.resolve(specDir, '..', '..');
|
|
137
277
|
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
278
|
+
const created = new Set(anchors.filter((anchor) => anchor.role.toLowerCase() === 'create').map((anchor) => anchor.target));
|
|
279
|
+
for (const anchor of anchors) {
|
|
280
|
+
const action = anchor.role.toLowerCase();
|
|
281
|
+
if (!allowed.has(action) || unsafeRelative(anchor.target)) continue;
|
|
282
|
+
const matches = matchingEntries(root, anchor.target);
|
|
283
|
+
if (/[{}*?]/.test(anchor.target) && matches.length === 0) errors.push(`${anchor.label}: glob matches no paths in work tree: ${anchor.target}`);
|
|
284
|
+
else if (action === 'create' && matches.length > 0) errors.push(`${anchor.label}: Create path already exists in work tree: ${anchor.target}`);
|
|
285
|
+
else if (action !== 'create' && matches.length === 0 && !created.has(anchor.target)) errors.push(`${anchor.label}: ${action} path not found in work tree: ${anchor.target}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function designContracts(designText) {
|
|
290
|
+
return new Set([
|
|
291
|
+
...[...designText.matchAll(/^#{3,6}\s+(C\d+)\s+(?:—|-)\s+.+$/gim)].map((match) => match[1].toUpperCase()),
|
|
292
|
+
...[...designText.matchAll(/<!--\s*contract:([A-Za-z0-9_.-]+)\s*-->/g)].map((match) => match[1]),
|
|
293
|
+
]);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function packageJsonFiles(root) {
|
|
297
|
+
const found = [];
|
|
298
|
+
function visit(directory, depth) {
|
|
299
|
+
if (depth > 5) return;
|
|
300
|
+
let entries;
|
|
301
|
+
try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch (_) { return; }
|
|
302
|
+
for (const entry of entries) {
|
|
303
|
+
if (entry.isSymbolicLink() || ['node_modules', '.git', 'dist', 'build', 'coverage'].includes(entry.name)) continue;
|
|
304
|
+
const target = path.join(directory, entry.name);
|
|
305
|
+
if (entry.isDirectory()) visit(target, depth + 1);
|
|
306
|
+
else if (entry.name === 'package.json') found.push(target);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
visit(root, 0);
|
|
310
|
+
return found;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function packageScripts(root) {
|
|
314
|
+
const scripts = new Map();
|
|
315
|
+
for (const file of packageJsonFiles(root)) {
|
|
316
|
+
try {
|
|
317
|
+
const json = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
318
|
+
for (const name of Object.keys(json.scripts || {})) {
|
|
319
|
+
if (!scripts.has(name)) scripts.set(name, []);
|
|
320
|
+
scripts.get(name).push(path.relative(root, file));
|
|
169
321
|
}
|
|
170
|
-
})
|
|
322
|
+
} catch (_) {}
|
|
323
|
+
}
|
|
324
|
+
return scripts;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function commandScript(command) {
|
|
328
|
+
const tokens = command.trim().split(/\s+/);
|
|
329
|
+
const manager = tokens[0];
|
|
330
|
+
if (!['npm', 'pnpm', 'yarn', 'bun'].includes(manager)) return null;
|
|
331
|
+
const runIndex = tokens.indexOf('run');
|
|
332
|
+
if (runIndex >= 0) return tokens[runIndex + 1] || '';
|
|
333
|
+
const filtered = tokens.filter((token, index) => index > 0 && !token.startsWith('-')
|
|
334
|
+
&& tokens[index - 1] !== '--filter' && tokens[index - 1] !== '-C' && tokens[index - 1] !== '--dir');
|
|
335
|
+
return filtered[0] || '';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function legacyIntendedAction(anchor) {
|
|
339
|
+
return /\b(?:create|planned|new|produce|output)\b/i.test(anchor.role) ? 'create' : 'read';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function intendedAction(anchor) {
|
|
343
|
+
return anchor.schema21 ? anchor.action : legacyIntendedAction(anchor);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function groundPathTarget(anchor, root, createdTargets, errors) {
|
|
347
|
+
const prefix = `${anchor.label}: ${anchor.id || anchor.type}`;
|
|
348
|
+
const inspected = inspectPath(root, anchor.target);
|
|
349
|
+
if (!inspected.valid) {
|
|
350
|
+
errors.push(`${prefix}: ${anchor.type} target ${anchor.target} ${inspected.reason}`);
|
|
351
|
+
return;
|
|
171
352
|
}
|
|
353
|
+
const action = intendedAction(anchor);
|
|
354
|
+
if (action === 'create') {
|
|
355
|
+
const parent = inspectPath(root, path.dirname(anchor.target));
|
|
356
|
+
if (!parent.valid || !parent.exists || !parent.stat.isDirectory()) {
|
|
357
|
+
errors.push(`${prefix}: create target parent is not grounded for ${anchor.target}`);
|
|
358
|
+
} else if (inspected.exists) errors.push(`${prefix}: create target already exists: ${anchor.target}`);
|
|
359
|
+
else createdTargets.add(anchor.target);
|
|
360
|
+
} else if (!inspected.exists && !createdTargets.has(anchor.target)) {
|
|
361
|
+
errors.push(`${prefix}: ${action} target not found in work tree: ${anchor.target}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
172
364
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
} else if (taskRegistry) {
|
|
201
|
-
// Build basename-indexed dependencies
|
|
202
|
-
const dependencies = new Map();
|
|
203
|
-
for (const [key, entry] of Object.entries(taskRegistry)) {
|
|
204
|
-
const base = path.basename(key);
|
|
205
|
-
const deps = new Set((entry.dependencies || []).map((d) => path.basename(d)));
|
|
206
|
-
dependencies.set(base, deps);
|
|
207
|
-
}
|
|
208
|
-
function dependsOn(taskBase, ancestorBase, seen = new Set()) {
|
|
209
|
-
if (taskBase === ancestorBase) return true;
|
|
210
|
-
if (seen.has(taskBase)) return false;
|
|
211
|
-
seen.add(taskBase);
|
|
212
|
-
const deps = dependencies.get(taskBase) || new Set();
|
|
213
|
-
for (const dep of deps) if (dependsOn(dep, ancestorBase, seen)) return true;
|
|
214
|
-
return false;
|
|
215
|
-
}
|
|
216
|
-
for (const { taskFile, row, producer } of crossTaskConsumers) {
|
|
217
|
-
if (!dependsOn(taskFile, producer.taskFile)) {
|
|
218
|
-
errors.push(`${taskFile}: ${row.action} of ${row.path} must depend on creator ${producer.taskFile}`);
|
|
365
|
+
function symbolParts(target) {
|
|
366
|
+
const separator = target.lastIndexOf('#');
|
|
367
|
+
if (separator <= 0 || separator === target.length - 1) return null;
|
|
368
|
+
return {
|
|
369
|
+
file: target.slice(0, separator),
|
|
370
|
+
symbol: target.slice(separator + 1).replace(/\(.*\)$/, ''),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function sourceBodies(root, excludedDirectories = []) {
|
|
375
|
+
const bodies = [];
|
|
376
|
+
const excluded = excludedDirectories.map((directory) => path.resolve(directory));
|
|
377
|
+
const ignored = new Set(['.git', 'node_modules', 'dist', 'build', 'coverage', 'vendor', '.next']);
|
|
378
|
+
function visit(directory, relative, depth) {
|
|
379
|
+
if (depth > 8) return;
|
|
380
|
+
let entries;
|
|
381
|
+
try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch (_) { return; }
|
|
382
|
+
for (const entry of entries) {
|
|
383
|
+
if (entry.isSymbolicLink() || ignored.has(entry.name)) continue;
|
|
384
|
+
const nextRelative = relative ? `${relative}/${entry.name}` : entry.name;
|
|
385
|
+
const target = path.join(directory, entry.name);
|
|
386
|
+
if (excluded.some((excludedDirectory) => target === excludedDirectory || inside(excludedDirectory, target))) continue;
|
|
387
|
+
if (entry.isDirectory()) visit(target, nextRelative, depth + 1);
|
|
388
|
+
else if (/\.(?:[cm]?[jt]sx?|py|go|rs|swift|java|kt|rb|php|sql|graphql|ya?ml|json)$/i.test(entry.name)) {
|
|
389
|
+
try {
|
|
390
|
+
if (fs.statSync(target).size <= 1024 * 1024) bodies.push([nextRelative, fs.readFileSync(target, 'utf8')]);
|
|
391
|
+
} catch (_) {}
|
|
219
392
|
}
|
|
220
393
|
}
|
|
221
394
|
}
|
|
395
|
+
visit(root, '', 0);
|
|
396
|
+
return bodies;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function semanticTargetExists(anchor, bodies) {
|
|
400
|
+
const needle = anchor.type === 'route'
|
|
401
|
+
? anchor.target.replace(/^\s*(?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+/i, '')
|
|
402
|
+
: anchor.target;
|
|
403
|
+
return bodies.some(([, body]) => body.includes(needle));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function groundCommand(anchor, root, scripts, errors, warnings) {
|
|
407
|
+
const prefix = `${anchor.label}: ${anchor.id || anchor.type}`;
|
|
408
|
+
const script = commandScript(anchor.target);
|
|
409
|
+
if (script === '') errors.push(`${prefix}: command is missing a package script name`);
|
|
410
|
+
else if (script !== null && !scripts.has(script)) errors.push(`${prefix}: package script "${script}" is not declared by project tooling`);
|
|
411
|
+
else if (script === null && !/^(?:node|npx|git|gh|make|pytest|jest|vitest|go|cargo|swift)\b/.test(anchor.target)) {
|
|
412
|
+
errors.push(`${prefix}: command tooling is not deterministically reachable: ${anchor.target}`);
|
|
413
|
+
}
|
|
414
|
+
if (script !== null) return;
|
|
415
|
+
const pathTokens = anchor.target.split(/\s+/).map((token) => token.replace(/^['"]|['";,]$/g, ''))
|
|
416
|
+
.filter((token) => /[\\/]/.test(token) && !token.startsWith('-') && !/^https?:/.test(token));
|
|
417
|
+
for (const candidate of pathTokens) {
|
|
418
|
+
const inspected = inspectPath(root, candidate);
|
|
419
|
+
if (!inspected.valid || !inspected.exists) errors.push(`${prefix}: command target not found in work tree: ${candidate}`);
|
|
420
|
+
}
|
|
421
|
+
void warnings;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function groundAnchor(anchor, root, scripts, bodies, createdTargets, reachableAnchors, errors, warnings) {
|
|
425
|
+
const prefix = `${anchor.label}: ${anchor.id || anchor.type}`;
|
|
426
|
+
if (!ANCHOR_TYPES.has(anchor.type)) return;
|
|
427
|
+
if (anchor.type === 'file' || anchor.type === 'artifact') return groundPathTarget(anchor, root, createdTargets, errors);
|
|
428
|
+
if (anchor.type === 'symbol') {
|
|
429
|
+
const parts = symbolParts(anchor.target);
|
|
430
|
+
if (!parts) {
|
|
431
|
+
errors.push(`${prefix}: symbol target must use path/to/file#Symbol`);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const inspected = inspectPath(root, parts.file);
|
|
435
|
+
if (!inspected.valid || !inspected.exists || !inspected.stat.isFile()) {
|
|
436
|
+
if (!(intendedAction(anchor) === 'create' && createdTargets.has(parts.file))) {
|
|
437
|
+
errors.push(`${prefix}: containing file not found for symbol ${anchor.target}`);
|
|
438
|
+
}
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const body = fs.readFileSync(inspected.target, 'utf8');
|
|
442
|
+
const escaped = parts.symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
443
|
+
const exists = new RegExp(`(?:^|[^A-Za-z0-9_$])${escaped}(?:$|[^A-Za-z0-9_$])`, 'm').test(body);
|
|
444
|
+
if (intendedAction(anchor) === 'create' && exists) errors.push(`${prefix}: create symbol already exists: ${anchor.target}`);
|
|
445
|
+
else if (intendedAction(anchor) !== 'create' && !exists) {
|
|
446
|
+
errors.push(`${prefix}: symbol ${parts.symbol} not found in containing file ${parts.file}`);
|
|
447
|
+
}
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (anchor.type === 'command') return groundCommand(anchor, root, scripts, errors, warnings);
|
|
451
|
+
if (['route', 'schema', 'contract'].includes(anchor.type)) {
|
|
452
|
+
const exists = semanticTargetExists(anchor, bodies);
|
|
453
|
+
if (intendedAction(anchor) === 'create' && !reachableAnchors.has(anchor.id)) {
|
|
454
|
+
errors.push(`${prefix}: create ${anchor.type} requires repository entrypoint registration reachability`);
|
|
455
|
+
} else if (intendedAction(anchor) === 'create' && exists) errors.push(`${prefix}: create ${anchor.type} already exists: ${anchor.target}`);
|
|
456
|
+
else if (intendedAction(anchor) !== 'create' && !exists) errors.push(`${prefix}: ${anchor.type} target is not semantically reachable: ${anchor.target}`);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (anchor.type === 'external' && !/^https?:\/\/[^\s]+$/.test(anchor.target)) {
|
|
460
|
+
errors.push(`${prefix}: external target must be one exact http(s) URL`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function canonicalProjectRoot(specDir, requestedRoot = null) {
|
|
465
|
+
const canonicalSpecDir = fs.realpathSync(specDir);
|
|
466
|
+
if (requestedRoot) return fs.realpathSync(requestedRoot);
|
|
467
|
+
const cwd = fs.realpathSync(process.cwd());
|
|
468
|
+
return inside(cwd, canonicalSpecDir) ? cwd : fs.realpathSync(path.resolve(canonicalSpecDir, '..', '..'));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function groundSpec({ specDir: inputSpecDir, root: inputRoot = null, spec: inputSpec = null }) {
|
|
472
|
+
const errors = [];
|
|
473
|
+
const warnings = [];
|
|
474
|
+
const specDir = inspectRoot(path.resolve(inputSpecDir), 'spec directory', errors);
|
|
475
|
+
if (!specDir) return { errors, warnings, checked: 0, specDir: null, root: null };
|
|
476
|
+
let root;
|
|
477
|
+
try { root = inspectRoot(canonicalProjectRoot(specDir, inputRoot), 'work context', errors); } catch (error) {
|
|
478
|
+
errors.push(`work context: cannot be resolved (${error.message})`);
|
|
479
|
+
}
|
|
480
|
+
if (!root) return { errors, warnings, checked: 0, specDir, root: null };
|
|
481
|
+
if (!inside(root, specDir)) errors.push('spec directory: must be inside canonical work context');
|
|
222
482
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
483
|
+
let spec = inputSpec;
|
|
484
|
+
if (!isPlainObject(spec)) {
|
|
485
|
+
try { spec = JSON.parse(fs.readFileSync(path.join(specDir, 'spec.json'), 'utf8')); } catch (_) { spec = null; }
|
|
486
|
+
}
|
|
487
|
+
let policy = null;
|
|
488
|
+
if (isPlainObject(spec) && Object.prototype.hasOwnProperty.call(spec, 'workflow_policy')) {
|
|
489
|
+
const validation = POLICY.validateWorkflowPolicySnapshot(spec.workflow_policy);
|
|
490
|
+
if (!validation.valid) errors.push(...validation.errors.map((error) => `spec.json: ${error}`));
|
|
491
|
+
else try { policy = POLICY.readWorkflowPolicySnapshot(spec); } catch (error) { errors.push(`spec.json: ${error.message}`); }
|
|
492
|
+
}
|
|
493
|
+
let designText = '';
|
|
494
|
+
try { designText = fs.readFileSync(path.join(specDir, 'design.md'), 'utf8'); } catch (error) {
|
|
495
|
+
if (spec?.workflow_policy?.version === '2' || spec?.schema_version === '2.1') errors.push(`design.md: cannot be read (${error.message})`);
|
|
496
|
+
}
|
|
497
|
+
const tasks = listTasks(specDir);
|
|
498
|
+
const isV2 = ['2', '2.1'].includes(spec?.workflow_policy?.version);
|
|
499
|
+
const is21 = spec?.schema_version === '2.1';
|
|
500
|
+
let anchors = isV2 ? parseTypedAnchors(designText, 'design.md', true, errors, is21) : [];
|
|
501
|
+
let verificationDefinitions = is21 ? parseVerificationDefinitions(designText, errors) : new Map();
|
|
502
|
+
const taskContents = new Map();
|
|
503
|
+
for (const task of tasks) {
|
|
504
|
+
const content = fs.readFileSync(task.path, 'utf8');
|
|
505
|
+
taskContents.set(task.relative, content);
|
|
506
|
+
if (isV2) anchors.push(...parseTypedAnchors(content, task.relative, false, errors, is21));
|
|
507
|
+
else {
|
|
508
|
+
const legacy = parseLegacyRelatedFiles(content, task.relative);
|
|
509
|
+
if (!legacy.present) errors.push(`${task.relative}: missing Related Files section`);
|
|
510
|
+
else if (legacy.anchors.length === 0) errors.push(`${task.relative}: Related Files section must not be empty`);
|
|
511
|
+
anchors.push(...legacy.anchors);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (is21) {
|
|
515
|
+
const projection = SEMANTIC.modelFromMarkdown(specDir, spec);
|
|
516
|
+
errors.push(...projection.errors);
|
|
517
|
+
if (!spec.semantic_model || typeof spec.semantic_model !== 'object' || Array.isArray(spec.semantic_model)) {
|
|
518
|
+
errors.push('spec.json: schema 2.1 grounding requires promoted semantic_model authority');
|
|
519
|
+
} else {
|
|
520
|
+
errors.push(...SEMANTIC.validateSemanticModel(spec.semantic_model, spec));
|
|
521
|
+
if (SEMANTIC.stableJson(spec.semantic_model) !== SEMANTIC.stableJson(projection.model)) {
|
|
522
|
+
errors.push('spec.json: semantic_model differs from authored Markdown projection; rerun spec-readiness with the current semantic review result');
|
|
523
|
+
}
|
|
524
|
+
const labelByTask = new Map(Object.entries(spec.task_registry || {}).map(([taskPath, entry]) => [entry?.id, taskPath]));
|
|
525
|
+
anchors = (spec.semantic_model.anchors || []).map((anchor) => {
|
|
526
|
+
const taskId = anchor.id.match(/^A-(R\d+-\d{2})-/)?.[1];
|
|
527
|
+
return { ...anchor, label: taskId ? (labelByTask.get(taskId) || taskId) : 'design.md', design: !taskId, schema21: true };
|
|
528
|
+
});
|
|
529
|
+
verificationDefinitions = new Map((spec.semantic_model.verification_definitions || []).map((definition) => [definition.id, definition]));
|
|
231
530
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
531
|
+
}
|
|
532
|
+
if (policy?.planning_depth === 'None') errors.push('spec.json: None planning depth cannot be grounded as a durable spec');
|
|
533
|
+
if (isV2) {
|
|
534
|
+
const tasksRequired = is21 ? tasks.length > 0 : spec?.coordination?.tasks_required === true;
|
|
535
|
+
if (tasksRequired !== (tasks.length > 0)) errors.push('spec.json: coordination.tasks_required must match physical task inventory');
|
|
536
|
+
const byId = new Map();
|
|
537
|
+
const designTargets = new Map();
|
|
538
|
+
for (const anchor of anchors) {
|
|
539
|
+
if (byId.has(anchor.id)) errors.push(`${anchor.label}: duplicate anchor ID ${anchor.id}`);
|
|
540
|
+
else byId.set(anchor.id, anchor);
|
|
541
|
+
if (anchor.design) designTargets.set(`${anchor.type}\0${anchor.target}`, anchor.id);
|
|
542
|
+
else if (designTargets.has(`${anchor.type}\0${anchor.target}`)) {
|
|
543
|
+
errors.push(`${anchor.label}: anchor ${anchor.id} duplicates canonical design target ${designTargets.get(`${anchor.type}\0${anchor.target}`)}; reference the A-D anchor instead`);
|
|
544
|
+
}
|
|
235
545
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
errors.push(`${row.taskFile}: ${row.action} path not found in work tree: ${row.path}`);
|
|
546
|
+
for (const [taskPath, content] of taskContents) for (const reference of content.match(/\bA-D-\d{2}\b/g) || []) {
|
|
547
|
+
if (!byId.has(reference)) errors.push(`${taskPath}: dangling canonical design anchor reference ${reference}`);
|
|
239
548
|
}
|
|
549
|
+
for (const reference of designText.match(/\bA-D-\d{2}\b/g) || []) if (!byId.has(reference)) {
|
|
550
|
+
errors.push(`design.md: dangling canonical design anchor reference ${reference}`);
|
|
551
|
+
}
|
|
552
|
+
for (const definition of verificationDefinitions.values()) {
|
|
553
|
+
for (const reference of definition.reachability?.anchor_refs || []) {
|
|
554
|
+
if (!byId.has(reference)) errors.push(`design.md: ${definition.id} has dangling reachability anchor ${reference}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
const scripts = packageScripts(root);
|
|
559
|
+
const bodies = sourceBodies(root, [specDir]);
|
|
560
|
+
const createdTargets = new Set();
|
|
561
|
+
const reachableAnchors = new Set();
|
|
562
|
+
if (is21) for (const definition of verificationDefinitions.values()) {
|
|
563
|
+
const entrypoint = inspectPath(root, definition.reachability?.entrypoint);
|
|
564
|
+
if (!entrypoint.valid || !entrypoint.exists || !entrypoint.stat.isFile()) {
|
|
565
|
+
errors.push(`design.md: ${definition.id} repository entrypoint not found: ${definition.reachability?.entrypoint || '(empty)'}`);
|
|
566
|
+
} else for (const anchorId of definition.reachability.anchor_refs) reachableAnchors.add(anchorId);
|
|
567
|
+
if (definition.method?.kind === 'command') {
|
|
568
|
+
groundCommand({
|
|
569
|
+
id: definition.id,
|
|
570
|
+
label: 'design.md',
|
|
571
|
+
type: 'command',
|
|
572
|
+
target: definition.method.value,
|
|
573
|
+
}, root, scripts, errors, warnings);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (isV2) for (const anchor of anchors) {
|
|
577
|
+
groundAnchor(anchor, root, scripts, bodies, createdTargets, reachableAnchors, errors, warnings);
|
|
240
578
|
}
|
|
579
|
+
else groundLegacy(anchors, spec?.task_registry, root, errors);
|
|
580
|
+
return { errors, warnings, checked: anchors.length, specDir, root };
|
|
581
|
+
}
|
|
241
582
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
583
|
+
function main() {
|
|
584
|
+
let args;
|
|
585
|
+
try { args = parseArgs(process.argv); } catch (error) {
|
|
586
|
+
usage();
|
|
587
|
+
console.error(`- ${error.message}`);
|
|
588
|
+
process.exit(2);
|
|
589
|
+
}
|
|
590
|
+
if (!args.specDir) { usage(); process.exit(2); }
|
|
591
|
+
|
|
592
|
+
const result = groundSpec({
|
|
593
|
+
specDir: path.resolve(process.cwd(), args.specDir),
|
|
594
|
+
root: args.root ? path.resolve(process.cwd(), args.root) : null,
|
|
595
|
+
});
|
|
596
|
+
for (const warning of result.warnings) console.warn(`[WARN] ${warning}`);
|
|
597
|
+
const relativeSpec = result.specDir ? path.relative(process.cwd(), result.specDir) || result.specDir : args.specDir;
|
|
598
|
+
if (result.errors.length > 0) {
|
|
599
|
+
console.error(`GROUNDING_FAIL ${relativeSpec}${result.root ? ` (root: ${result.root})` : ''}`);
|
|
600
|
+
for (const error of result.errors) console.error(`- ${error}`);
|
|
247
601
|
process.exit(1);
|
|
248
602
|
}
|
|
249
|
-
console.log(`GROUNDED ${relativeSpec}
|
|
603
|
+
console.log(`GROUNDED ${relativeSpec} (${result.checked} typed/legacy anchor(s) checked; semantic and execution proof not claimed)`);
|
|
250
604
|
}
|
|
251
605
|
|
|
252
|
-
|
|
606
|
+
module.exports = {
|
|
607
|
+
canonicalProjectRoot,
|
|
608
|
+
groundSpec,
|
|
609
|
+
normalizeCanonicalAccessAction,
|
|
610
|
+
parseCanonicalAnchors,
|
|
611
|
+
parseVerificationDefinitions,
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
if (require.main === module) main();
|