@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
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const MODEL_FIELDS = ['version', 'criteria', 'anchors', 'design_records', 'verification_definitions'];
|
|
7
|
+
const CRITERION_FIELDS = ['id', 'text', 'kind', 'owner'];
|
|
8
|
+
const DESIGN_RECORD_FIELDS = ['id', 'kind', 'title', 'text'];
|
|
9
|
+
const ANCHOR_FIELDS = ['id', 'type', 'target', 'role', 'access', 'action'];
|
|
10
|
+
const VERIFICATION_FIELDS = [
|
|
11
|
+
'id', 'subject_criteria', 'subject_owner', 'proof_criteria', 'proof_owner',
|
|
12
|
+
'evidence_anchor', 'decision_refs', 'method', 'expected', 'negative', 'reachability',
|
|
13
|
+
];
|
|
14
|
+
const ANCHOR_TYPES = new Set([
|
|
15
|
+
'file', 'symbol', 'command', 'route', 'schema', 'contract', 'artifact', 'external',
|
|
16
|
+
]);
|
|
17
|
+
const TASK_FILE_RE = /^tasks\/task-(R\d+-\d{2})-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/;
|
|
18
|
+
const CRITERION_RE = /^\s*[-*+]\s+\*\*(R\d+\.\d+):?\*\*\s*:?[ \t]+(.+)$/gim;
|
|
19
|
+
|
|
20
|
+
function plain(value) {
|
|
21
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function exactKeys(value, fields) {
|
|
25
|
+
return plain(value)
|
|
26
|
+
&& JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...fields].sort());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stableJson(value) {
|
|
30
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
|
31
|
+
if (plain(value)) {
|
|
32
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
|
|
33
|
+
}
|
|
34
|
+
return JSON.stringify(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function concrete(value, minimum = 8) {
|
|
38
|
+
return typeof value === 'string' && value === value.trim() && value.length >= minimum
|
|
39
|
+
&& !/[{}]|\b(?:tbd|todo|placeholder|works?|handles? it|expected behavior)\b/i.test(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeText(value) {
|
|
43
|
+
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function uniqueStrings(value) {
|
|
47
|
+
return Array.isArray(value) && value.length > 0
|
|
48
|
+
&& value.every((item) => typeof item === 'string' && item.trim() === item && item !== '')
|
|
49
|
+
&& new Set(value).size === value.length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function extractSection(content, heading) {
|
|
53
|
+
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
54
|
+
const match = String(content).match(new RegExp(`^##\\s+${escaped}\\s*$`, 'im'));
|
|
55
|
+
if (!match) return null;
|
|
56
|
+
const after = String(content).slice(match.index + match[0].length);
|
|
57
|
+
const next = after.match(/^##\s+/m);
|
|
58
|
+
return next ? after.slice(0, next.index) : after;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseTable(section, expectedHeaders) {
|
|
62
|
+
if (typeof section !== 'string') return [];
|
|
63
|
+
const lines = section.split('\n');
|
|
64
|
+
for (let index = 0; index + 1 < lines.length; index += 1) {
|
|
65
|
+
if (!/^\s*\|/.test(lines[index]) || !/^\s*\|(?:\s*:?-{3,}:?\s*\|)+\s*$/.test(lines[index + 1])) continue;
|
|
66
|
+
const headers = lines[index].split('|').slice(1, -1).map((cell) => cell.trim().toLowerCase());
|
|
67
|
+
if (JSON.stringify(headers) !== JSON.stringify(expectedHeaders.map((header) => header.toLowerCase()))) continue;
|
|
68
|
+
const rows = [];
|
|
69
|
+
for (let row = index + 2; row < lines.length && /^\s*\|/.test(lines[row]); row += 1) {
|
|
70
|
+
const cells = lines[row].split('|').slice(1, -1)
|
|
71
|
+
.map((cell) => cell.trim().replace(/^`|`$/g, '').trim());
|
|
72
|
+
if (cells.some(Boolean)) rows.push(cells);
|
|
73
|
+
}
|
|
74
|
+
return rows;
|
|
75
|
+
}
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeAccessAction(rawAccess, rawAction) {
|
|
80
|
+
const access = String(rawAccess || '').trim().toLowerCase();
|
|
81
|
+
const action = String(rawAction || '').trim().toLowerCase();
|
|
82
|
+
let error = null;
|
|
83
|
+
if (!['read', 'write'].includes(access)) error = 'Access must be read or write';
|
|
84
|
+
else if (access === 'read' && action !== 'read') error = 'access read requires action read';
|
|
85
|
+
else if (access === 'write' && !['create', 'modify', 'delete'].includes(action)) {
|
|
86
|
+
error = 'access write requires action create, modify, or delete';
|
|
87
|
+
}
|
|
88
|
+
return { access, action, error };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseAnchors(content, { label, design }, errors) {
|
|
92
|
+
const section = extractSection(content, design ? 'Typed Anchors' : 'Anchors and Ownership') || '';
|
|
93
|
+
const rows = parseTable(section, ['ID', 'Type', 'Target', 'Role', 'Access', 'Action']);
|
|
94
|
+
if (rows.length === 0 && !(design === false && /\bA-D-\d{2}\b/.test(section))) {
|
|
95
|
+
errors.push(`${label}: typed anchor table is missing or empty`);
|
|
96
|
+
}
|
|
97
|
+
const taskId = label.match(TASK_FILE_RE)?.[1];
|
|
98
|
+
return rows.map(([id = '', rawType = '', target = '', role = '', rawAccess = '', rawAction = '']) => {
|
|
99
|
+
const type = rawType.toLowerCase();
|
|
100
|
+
const { access, action, error } = normalizeAccessAction(rawAccess, rawAction);
|
|
101
|
+
const namespace = design ? /^A-D-\d{2}$/ : new RegExp(`^A-${taskId || 'INVALID'}-\\d{2}$`);
|
|
102
|
+
if (!namespace.test(id)) errors.push(`${label}: anchor ${id || '(missing id)'} has invalid namespace`);
|
|
103
|
+
if (!ANCHOR_TYPES.has(type)) errors.push(`${label}: anchor ${id || '(missing id)'} has unknown Type ${rawType || '(empty)'}`);
|
|
104
|
+
if (!concrete(target, 1) || /[{}*?\[\]]/.test(target)) errors.push(`${label}: anchor ${id || '(missing id)'} requires one exact target`);
|
|
105
|
+
if (!concrete(role, 2)) errors.push(`${label}: anchor ${id || '(missing id)'} Role must be concrete`);
|
|
106
|
+
if (error) errors.push(`${label}: anchor ${id || '(missing id)'} ${error}`);
|
|
107
|
+
return { id, type, target, role: role.toLowerCase(), access, action };
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseList(value, pattern, label, errors) {
|
|
112
|
+
const values = String(value).split(',').map((item) => item.trim().toUpperCase()).filter(Boolean);
|
|
113
|
+
if (values.length === 0 || new Set(values).size !== values.length || values.some((item) => !pattern.test(item))) {
|
|
114
|
+
errors.push(`design.md: ${label} must be a non-empty unique canonical list`);
|
|
115
|
+
}
|
|
116
|
+
return values;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseMethod(raw, errors) {
|
|
120
|
+
const command = raw.match(/^command\s+`([^`]+)`$/i);
|
|
121
|
+
if (command && concrete(command[1], 3)) return { kind: 'command', value: command[1] };
|
|
122
|
+
const inspection = raw.match(/^inspection\s+`([^`]+)`\s+via\s+(A-(?:D|R\d+-\d{2})-\d{2})$/i);
|
|
123
|
+
if (inspection && concrete(inspection[1], 1)) {
|
|
124
|
+
return { kind: 'inspection', target: inspection[1], anchor_ref: inspection[2].toUpperCase() };
|
|
125
|
+
}
|
|
126
|
+
errors.push('design.md: Method must be exact command `...` or inspection `exact target` via one anchor');
|
|
127
|
+
return { kind: 'invalid', value: raw };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseReachability(raw, errors) {
|
|
131
|
+
const match = raw.match(/^entrypoint\s+`([^`]+)`\s+via\s+(.+?)\.?$/i);
|
|
132
|
+
if (!match || !concrete(match[1], 1)) {
|
|
133
|
+
errors.push('design.md: Reachability/grounding must be entrypoint `exact repository path` via anchor refs');
|
|
134
|
+
return { entrypoint: '', anchor_refs: [] };
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
entrypoint: match[1],
|
|
138
|
+
anchor_refs: parseList(match[2], /^A-(?:D|R\d+-\d{2})-\d{2}$/, 'reachability anchor refs', errors),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseVerificationDefinitions(designText, errors) {
|
|
143
|
+
const count = (String(designText).match(/^##\s+Verification Definitions\s*$/gim) || []).length;
|
|
144
|
+
if (count !== 1) {
|
|
145
|
+
errors.push('design.md: requires exactly one ## Verification Definitions section');
|
|
146
|
+
return new Map();
|
|
147
|
+
}
|
|
148
|
+
const section = extractSection(designText, 'Verification Definitions') || '';
|
|
149
|
+
const definitions = new Map();
|
|
150
|
+
const candidates = section.split('\n').filter((line) => /^\s*[-*+]\s+\*\*V\d+(?:\.\d+)?\*\*/i.test(line));
|
|
151
|
+
const grammar = /^\s*[-*+]\s+\*\*(V\d+(?:\.\d+)?)\*\*:\s*Criteria\s+(.+?)\s*;\s*Owner\s+([^;]+)\s*;(?:\s*Proof criteria\s+(.+?)\s*;\s*Proof owner\s+([^;]+)\s*;\s*Evidence anchor\s+([^;]+)\s*;)?\s*Decision refs\s+(.+?)\s*;\s*Method\s+(.+?)\s*;\s*Expected\s+(.+?)\s*;\s*Negative\/failure\s+(.+?)\s*;\s*Reachability\/grounding\s+(.+?)\s*\.?\s*$/i;
|
|
152
|
+
// Read-only compatibility for persisted proof specs; new authoring uses the
|
|
153
|
+
// base grammar above and its conditional proof extension.
|
|
154
|
+
const legacyProofGrammar = /^\s*[-*+]\s+\*\*(V\d+(?:\.\d+)?)\*\*:\s*Subject criteria\s+(.+?)\s*;\s*Subject owner\s+([^;]+)\s*;\s*Proof criteria\s+(.+?)\s*;\s*Proof owner\s+([^;]+)\s*;\s*Evidence anchor\s+([^;]+)\s*;\s*Decision refs\s+(.+?)\s*;\s*Method\s+(.+?)\s*;\s*Expected\s+(.+?)\s*;\s*Negative\/failure\s+(.+?)\s*;\s*Reachability\/grounding\s+(.+?)\s*\.?\s*$/i;
|
|
155
|
+
for (const line of candidates) {
|
|
156
|
+
const match = line.match(grammar) || line.match(legacyProofGrammar);
|
|
157
|
+
if (!match) {
|
|
158
|
+
errors.push('design.md: V definition must use Criteria; Owner; optional Proof criteria; Proof owner; Evidence anchor; Decision refs; Method; Expected; Negative/failure; Reachability/grounding grammar');
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const id = match[1].toUpperCase();
|
|
162
|
+
const subjectCriteria = parseList(match[2], /^R\d+\.\d+$/, `${id} subject criteria`, errors);
|
|
163
|
+
const proofCriteria = match[4] ? parseList(match[4], /^R\d+\.\d+$/, `${id} proof criteria`, errors) : [];
|
|
164
|
+
const definition = {
|
|
165
|
+
id,
|
|
166
|
+
subject_criteria: subjectCriteria,
|
|
167
|
+
subject_owner: match[3].trim().toUpperCase(),
|
|
168
|
+
proof_criteria: proofCriteria,
|
|
169
|
+
proof_owner: match[5] ? match[5].trim().toUpperCase() : null,
|
|
170
|
+
evidence_anchor: match[6] ? match[6].trim().toUpperCase() : null,
|
|
171
|
+
decision_refs: parseList(match[7], /^[DIC]\d+(?:\.\d+)?$/, `${id} decision refs`, errors),
|
|
172
|
+
method: parseMethod(match[8].trim(), errors),
|
|
173
|
+
expected: match[9].trim(),
|
|
174
|
+
negative: match[10].trim(),
|
|
175
|
+
reachability: parseReachability(match[11].trim(), errors),
|
|
176
|
+
};
|
|
177
|
+
if (definitions.has(id)) errors.push(`design.md: duplicate canonical V definition ${id}`);
|
|
178
|
+
else definitions.set(id, definition);
|
|
179
|
+
}
|
|
180
|
+
if (definitions.size === 0) errors.push('design.md: Verification Definitions requires at least one canonical V definition');
|
|
181
|
+
return definitions;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function parseDesignRecords(designText, errors) {
|
|
185
|
+
const records = [];
|
|
186
|
+
const semantic = String(designText);
|
|
187
|
+
const heading = /^#{3,6}\s+([DIC]\d+(?:\.\d+)?)\s+(?:—|-)\s+(.+)$/gim;
|
|
188
|
+
const matches = [...semantic.matchAll(heading)];
|
|
189
|
+
for (const [index, match] of matches.entries()) {
|
|
190
|
+
const start = match.index + match[0].length;
|
|
191
|
+
const relativeEnd = semantic.slice(start).search(/^#{1,6}\s+/m);
|
|
192
|
+
const text = normalizeText(semantic.slice(start, relativeEnd < 0 ? semantic.length : start + relativeEnd));
|
|
193
|
+
const id = match[1].toUpperCase();
|
|
194
|
+
records.push({ id, kind: { D: 'decision', I: 'invariant', C: 'contract' }[id[0]], title: normalizeText(match[2]), text });
|
|
195
|
+
}
|
|
196
|
+
const ids = new Set();
|
|
197
|
+
for (const record of records) {
|
|
198
|
+
if (ids.has(record.id)) errors.push(`design.md: duplicate semantic record ${record.id}`);
|
|
199
|
+
ids.add(record.id);
|
|
200
|
+
if (!concrete(record.title, 3) || !concrete(record.text, 8)) errors.push(`design.md: ${record.id} requires concrete title and semantic text`);
|
|
201
|
+
}
|
|
202
|
+
return records.sort((a, b) => a.id.localeCompare(b.id));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function criterionIds(requirementsText) {
|
|
206
|
+
return [...String(requirementsText).matchAll(CRITERION_RE)].map((match) => match[1].toUpperCase());
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function taskFiles(specDir) {
|
|
210
|
+
const directory = path.join(specDir, 'tasks');
|
|
211
|
+
if (!fs.existsSync(directory)) return [];
|
|
212
|
+
return fs.readdirSync(directory, { withFileTypes: true })
|
|
213
|
+
.filter((entry) => entry.isFile() && TASK_FILE_RE.test(`tasks/${entry.name}`))
|
|
214
|
+
.map((entry) => `tasks/${entry.name}`).sort();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function ownerKind(owner) {
|
|
218
|
+
if (/^R\d+-\d{2}$/.test(owner)) return 'task';
|
|
219
|
+
if (/^A-D-\d{2}$/.test(owner)) return 'design';
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function modelFromMarkdown(specDir, spec = {}) {
|
|
224
|
+
const errors = [];
|
|
225
|
+
let requirementsText = '';
|
|
226
|
+
let designText = '';
|
|
227
|
+
try { requirementsText = fs.readFileSync(path.join(specDir, 'requirements.md'), 'utf8'); }
|
|
228
|
+
catch (error) { errors.push(`requirements.md: cannot be read (${error.message})`); }
|
|
229
|
+
try { designText = fs.readFileSync(path.join(specDir, 'design.md'), 'utf8'); }
|
|
230
|
+
catch (error) { errors.push(`design.md: cannot be read (${error.message})`); }
|
|
231
|
+
const criterionEntries = [...String(requirementsText).matchAll(CRITERION_RE)]
|
|
232
|
+
.map((match) => ({ id: match[1].toUpperCase(), text: normalizeText(match[2]) }));
|
|
233
|
+
const criteria = criterionEntries.map((entry) => entry.id);
|
|
234
|
+
if (criteria.length === 0) errors.push('requirements.md: semantic model requires a non-empty RN.M inventory');
|
|
235
|
+
if (new Set(criteria).size !== criteria.length) errors.push('requirements.md: semantic model criterion IDs must be unique');
|
|
236
|
+
const anchors = parseAnchors(designText, { label: 'design.md', design: true }, errors);
|
|
237
|
+
const taskOwners = new Map();
|
|
238
|
+
for (const taskFile of taskFiles(specDir)) {
|
|
239
|
+
const content = fs.readFileSync(path.join(specDir, taskFile), 'utf8');
|
|
240
|
+
anchors.push(...parseAnchors(content, { label: taskFile, design: false }, errors));
|
|
241
|
+
const taskId = taskFile.match(TASK_FILE_RE)[1];
|
|
242
|
+
const acceptance = extractSection(content, 'Acceptance') || '';
|
|
243
|
+
for (const criterion of criterionIds(acceptance)) {
|
|
244
|
+
if (!taskOwners.has(criterion)) taskOwners.set(criterion, []);
|
|
245
|
+
taskOwners.get(criterion).push(taskId);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const definitions = parseVerificationDefinitions(designText, errors);
|
|
249
|
+
const criterionSemantics = new Map();
|
|
250
|
+
for (const definition of definitions.values()) {
|
|
251
|
+
const overlap = definition.subject_criteria.filter((id) => definition.proof_criteria.includes(id));
|
|
252
|
+
if (overlap.length) errors.push(`design.md: ${definition.id} subject/proof criteria must be distinct (${overlap.join(', ')})`);
|
|
253
|
+
for (const [kind, ids, owner] of [
|
|
254
|
+
['subject', definition.subject_criteria, definition.subject_owner],
|
|
255
|
+
['proof', definition.proof_criteria, definition.proof_owner],
|
|
256
|
+
]) for (const id of ids) {
|
|
257
|
+
if (criterionSemantics.has(id)) errors.push(`design.md: criterion ${id} is assigned by multiple V definitions`);
|
|
258
|
+
else criterionSemantics.set(id, { kind, owner });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const taskful = taskFiles(specDir).length > 0;
|
|
262
|
+
const modelCriteria = criteria.map((id) => {
|
|
263
|
+
const semantic = criterionSemantics.get(id);
|
|
264
|
+
if (!semantic) errors.push(`requirements.md:${id}: orphan criterion has no structured V ownership`);
|
|
265
|
+
let owner = semantic?.owner || '';
|
|
266
|
+
const claimed = taskOwners.get(id) || [];
|
|
267
|
+
if (taskful) {
|
|
268
|
+
if (claimed.length !== 1) errors.push(`requirements.md:${id}: requires exactly one task implementation owner`);
|
|
269
|
+
else owner = claimed[0];
|
|
270
|
+
if (semantic && claimed.length === 1 && semantic.owner !== claimed[0]) {
|
|
271
|
+
errors.push(`design.md: ${id} V owner ${semantic.owner} differs from task Acceptance owner ${claimed[0]}`);
|
|
272
|
+
}
|
|
273
|
+
} else if (ownerKind(owner) !== 'design') {
|
|
274
|
+
errors.push(`requirements.md:${id}: taskless owner must be one exact A-D design anchor`);
|
|
275
|
+
}
|
|
276
|
+
const text = criterionEntries.find((entry) => entry.id === id)?.text || '';
|
|
277
|
+
if (!concrete(text, 8)) errors.push(`requirements.md:${id}: criterion text must be concrete`);
|
|
278
|
+
return { id, text, kind: semantic?.kind || 'subject', owner };
|
|
279
|
+
});
|
|
280
|
+
for (const id of criterionSemantics.keys()) if (!criteria.includes(id)) {
|
|
281
|
+
errors.push(`design.md: V definition references orphan criterion ${id}`);
|
|
282
|
+
}
|
|
283
|
+
const model = {
|
|
284
|
+
version: '2',
|
|
285
|
+
criteria: modelCriteria.sort((a, b) => a.id.localeCompare(b.id)),
|
|
286
|
+
anchors: anchors.sort((a, b) => a.id.localeCompare(b.id)),
|
|
287
|
+
design_records: parseDesignRecords(designText, errors),
|
|
288
|
+
verification_definitions: [...definitions.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
|
289
|
+
};
|
|
290
|
+
errors.push(...validateSemanticModel(model, spec));
|
|
291
|
+
return { model, errors: [...new Set(errors)] };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function validateSemanticModel(model, spec = {}) {
|
|
295
|
+
const errors = [];
|
|
296
|
+
if (!exactKeys(model, MODEL_FIELDS)) return ['spec.json.semantic_model: fields must be exactly version, criteria, anchors, design_records, verification_definitions'];
|
|
297
|
+
if (model.version !== '2') errors.push('spec.json.semantic_model.version: must be "2"');
|
|
298
|
+
if (!Array.isArray(model.criteria) || model.criteria.length === 0) errors.push('spec.json.semantic_model.criteria: must be a non-empty array');
|
|
299
|
+
if (!Array.isArray(model.anchors) || model.anchors.length === 0) errors.push('spec.json.semantic_model.anchors: must be a non-empty array');
|
|
300
|
+
if (!Array.isArray(model.design_records) || model.design_records.length === 0) errors.push('spec.json.semantic_model.design_records: must preserve at least one D/I/C semantic record');
|
|
301
|
+
if (!Array.isArray(model.verification_definitions) || model.verification_definitions.length === 0) errors.push('spec.json.semantic_model.verification_definitions: must be a non-empty array');
|
|
302
|
+
const anchorIds = new Set();
|
|
303
|
+
for (const [index, anchor] of (model.anchors || []).entries()) {
|
|
304
|
+
const label = `spec.json.semantic_model.anchors[${index}]`;
|
|
305
|
+
if (!exactKeys(anchor, ANCHOR_FIELDS)) { errors.push(`${label}: fields must be exactly ${ANCHOR_FIELDS.join(', ')}`); continue; }
|
|
306
|
+
if (!/^A-(?:D|R\d+-\d{2})-\d{2}$/.test(anchor.id) || anchorIds.has(anchor.id)) errors.push(`${label}.id: must be globally unique canonical anchor ID`);
|
|
307
|
+
anchorIds.add(anchor.id);
|
|
308
|
+
if (!ANCHOR_TYPES.has(anchor.type)) errors.push(`${label}.type: unsupported anchor type`);
|
|
309
|
+
if (!concrete(anchor.target, 1) || !concrete(anchor.role, 2)) errors.push(`${label}: target and role must be concrete`);
|
|
310
|
+
const normalized = normalizeAccessAction(anchor.access, anchor.action);
|
|
311
|
+
if (normalized.error) errors.push(`${label}: ${normalized.error}`);
|
|
312
|
+
}
|
|
313
|
+
const taskIds = new Set(Object.values(spec.task_registry || {}).map((entry) => entry?.id));
|
|
314
|
+
const designRecordIds = new Set();
|
|
315
|
+
for (const [index, record] of (model.design_records || []).entries()) {
|
|
316
|
+
const label = `spec.json.semantic_model.design_records[${index}]`;
|
|
317
|
+
if (!exactKeys(record, DESIGN_RECORD_FIELDS)) { errors.push(`${label}: fields must be exactly ${DESIGN_RECORD_FIELDS.join(', ')}`); continue; }
|
|
318
|
+
if (!/^[DIC]\d+(?:\.\d+)?$/.test(record.id) || designRecordIds.has(record.id)) errors.push(`${label}.id: must be a unique D/I/C id`);
|
|
319
|
+
designRecordIds.add(record.id);
|
|
320
|
+
if (record.kind !== ({ D: 'decision', I: 'invariant', C: 'contract' })[record.id?.[0]]) errors.push(`${label}.kind: does not match id`);
|
|
321
|
+
if (!concrete(record.title, 3) || !concrete(record.text, 8)) errors.push(`${label}: title and text must be concrete`);
|
|
322
|
+
}
|
|
323
|
+
const criterionIdsSeen = new Set();
|
|
324
|
+
for (const [index, criterion] of (model.criteria || []).entries()) {
|
|
325
|
+
const label = `spec.json.semantic_model.criteria[${index}]`;
|
|
326
|
+
if (!exactKeys(criterion, CRITERION_FIELDS)) { errors.push(`${label}: fields must be exactly ${CRITERION_FIELDS.join(', ')}`); continue; }
|
|
327
|
+
if (!/^R\d+\.\d+$/.test(criterion.id) || criterionIdsSeen.has(criterion.id)) errors.push(`${label}.id: must be unique RN.M`);
|
|
328
|
+
criterionIdsSeen.add(criterion.id);
|
|
329
|
+
if (!['subject', 'proof'].includes(criterion.kind)) errors.push(`${label}.kind: must be subject or proof`);
|
|
330
|
+
if (!concrete(criterion.text, 8)) errors.push(`${label}.text: must preserve normalized product semantics`);
|
|
331
|
+
const kind = ownerKind(criterion.owner);
|
|
332
|
+
if (!kind || (kind === 'task' && !taskIds.has(criterion.owner)) || (kind === 'design' && !anchorIds.has(criterion.owner))) {
|
|
333
|
+
errors.push(`${label}.owner: must resolve to one task ID or A-D anchor`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const verificationIds = new Set();
|
|
337
|
+
const coveredCriteria = new Set();
|
|
338
|
+
const criteriaById = new Map((model.criteria || []).map((criterion) => [criterion.id, criterion]));
|
|
339
|
+
const anchorsById = new Map((model.anchors || []).map((anchor) => [anchor.id, anchor]));
|
|
340
|
+
for (const [index, definition] of (model.verification_definitions || []).entries()) {
|
|
341
|
+
const label = `spec.json.semantic_model.verification_definitions[${index}]`;
|
|
342
|
+
if (!exactKeys(definition, VERIFICATION_FIELDS)) { errors.push(`${label}: fields must be exactly ${VERIFICATION_FIELDS.join(', ')}`); continue; }
|
|
343
|
+
if (!/^V\d+(?:\.\d+)?$/.test(definition.id) || verificationIds.has(definition.id)) errors.push(`${label}.id: must be unique canonical V ID`);
|
|
344
|
+
verificationIds.add(definition.id);
|
|
345
|
+
const hasProof = Array.isArray(definition.proof_criteria) && definition.proof_criteria.length > 0;
|
|
346
|
+
for (const [kind, values] of [['subject', definition.subject_criteria], ['proof', definition.proof_criteria]]) {
|
|
347
|
+
if ((kind === 'subject' && !uniqueStrings(values)) || (kind === 'proof' && hasProof && !uniqueStrings(values)) || !Array.isArray(values) || values.some((id) => !criterionIdsSeen.has(id))) errors.push(`${label}.${kind}_criteria: must be a unique known RN.M list; Criteria is non-empty and Proof criteria is conditional`);
|
|
348
|
+
for (const id of values || []) {
|
|
349
|
+
if (coveredCriteria.has(id)) errors.push(`${label}: criterion ${id} is covered by multiple V definitions`);
|
|
350
|
+
coveredCriteria.add(id);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if ((definition.subject_criteria || []).some((id) => (definition.proof_criteria || []).includes(id))) errors.push(`${label}: subject/proof criteria must be distinct`);
|
|
354
|
+
if (hasProof && definition.subject_owner === definition.proof_owner) errors.push(`${label}: subject/proof owners must be distinct`);
|
|
355
|
+
for (const [field, owner] of [['subject_owner', definition.subject_owner], ...(hasProof ? [['proof_owner', definition.proof_owner]] : [])]) {
|
|
356
|
+
const kind = ownerKind(owner);
|
|
357
|
+
if (!kind || (kind === 'task' && !taskIds.has(owner)) || (kind === 'design' && !anchorIds.has(owner))) errors.push(`${label}.${field}: unresolved owner`);
|
|
358
|
+
}
|
|
359
|
+
if (hasProof && !anchorIds.has(definition.evidence_anchor)) errors.push(`${label}.evidence_anchor: must resolve to a canonical anchor`);
|
|
360
|
+
else if (hasProof && ((ownerKind(definition.proof_owner) === 'task' && !definition.evidence_anchor.startsWith(`A-${definition.proof_owner}-`))
|
|
361
|
+
|| (ownerKind(definition.proof_owner) === 'design' && definition.evidence_anchor !== definition.proof_owner))) {
|
|
362
|
+
errors.push(`${label}.evidence_anchor: must be owned by the proof owner`);
|
|
363
|
+
}
|
|
364
|
+
for (const [kind, ids, owner] of [
|
|
365
|
+
['subject', definition.subject_criteria, definition.subject_owner],
|
|
366
|
+
['proof', definition.proof_criteria, definition.proof_owner],
|
|
367
|
+
]) for (const id of ids || []) {
|
|
368
|
+
const criterion = criteriaById.get(id);
|
|
369
|
+
if (criterion && (criterion.kind !== kind || criterion.owner !== owner)) errors.push(`${label}: ${id} ${kind} ownership differs from criteria inventory`);
|
|
370
|
+
}
|
|
371
|
+
if (!uniqueStrings(definition.decision_refs) || definition.decision_refs.some((id) => !designRecordIds.has(id))) errors.push(`${label}.decision_refs: invalid or absent from semantic design records`);
|
|
372
|
+
if (!plain(definition.method) || !['command', 'inspection'].includes(definition.method.kind)) errors.push(`${label}.method: invalid structured method`);
|
|
373
|
+
else if (definition.method.kind === 'command' && (!exactKeys(definition.method, ['kind', 'value']) || !concrete(definition.method.value, 3))) errors.push(`${label}.method: command must be exact and concrete`);
|
|
374
|
+
else if (definition.method.kind === 'inspection' && (!exactKeys(definition.method, ['kind', 'target', 'anchor_ref'])
|
|
375
|
+
|| !anchorIds.has(definition.method.anchor_ref) || anchorsById.get(definition.method.anchor_ref)?.target !== definition.method.target)) {
|
|
376
|
+
errors.push(`${label}.method: inspection target must exactly match one anchor`);
|
|
377
|
+
}
|
|
378
|
+
if (!concrete(definition.expected, 12) || !concrete(definition.negative, 12)) errors.push(`${label}: Expected and negative must be concrete non-placeholder text`);
|
|
379
|
+
if (!exactKeys(definition.reachability, ['entrypoint', 'anchor_refs']) || !concrete(definition.reachability?.entrypoint, 1)
|
|
380
|
+
|| !uniqueStrings(definition.reachability?.anchor_refs) || definition.reachability.anchor_refs.some((id) => !anchorIds.has(id))) {
|
|
381
|
+
errors.push(`${label}.reachability: requires exact entrypoint and non-empty grounded anchor refs`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
for (const criterion of model.criteria || []) if (!coveredCriteria.has(criterion.id)) errors.push(`spec.json.semantic_model.criteria: orphan criterion ${criterion.id}`);
|
|
385
|
+
return errors;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
module.exports = {
|
|
389
|
+
MODEL_FIELDS,
|
|
390
|
+
modelFromMarkdown,
|
|
391
|
+
normalizeAccessAction,
|
|
392
|
+
parseAnchors,
|
|
393
|
+
parseVerificationDefinitions,
|
|
394
|
+
stableJson,
|
|
395
|
+
validateSemanticModel,
|
|
396
|
+
};
|