@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
|
@@ -6,18 +6,263 @@ const fs = require('node:fs');
|
|
|
6
6
|
const os = require('node:os');
|
|
7
7
|
const path = require('node:path');
|
|
8
8
|
const { spawnSync } = require('node:child_process');
|
|
9
|
-
const { test } = require('node:test');
|
|
9
|
+
const { test, after } = require('node:test');
|
|
10
10
|
|
|
11
11
|
const PACKAGE_ROOT = path.join(__dirname, '../..');
|
|
12
12
|
const POLICY_PATH = path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs');
|
|
13
13
|
const POLICY = require(POLICY_PATH);
|
|
14
|
+
const PROVENANCE_HELPER = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'));
|
|
15
|
+
const FINAL_STATE = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-final-state.cjs'));
|
|
16
|
+
const VALIDATOR = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/validate-spec-output.cjs'));
|
|
17
|
+
const GROUNDER = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-ground.cjs'));
|
|
18
|
+
const SEMANTIC_AUTHORITY = require(path.join(PACKAGE_ROOT, 'src/claude/hooks/semantic-review-authority.cjs'));
|
|
19
|
+
const READINESS = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-readiness.cjs'));
|
|
20
|
+
const { copyClaudeTestRuntime } = require('./test-runtime-dependency-closure.cjs');
|
|
14
21
|
const DEVELOP = path.join(PACKAGE_ROOT, 'src/claude/skills/develop/SKILL.md');
|
|
22
|
+
const SPECS = path.join(PACKAGE_ROOT, 'src/claude/skills/specs/SKILL.md');
|
|
23
|
+
const SCAFFOLD = path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-scaffold.cjs');
|
|
15
24
|
const GATE = path.join(PACKAGE_ROOT, 'src/claude/skills/develop/references/quality-gate.md');
|
|
16
25
|
const TEST_SKILL = path.join(PACKAGE_ROOT, 'src/claude/skills/test/SKILL.md');
|
|
17
26
|
const SYNC_SKILL = path.join(PACKAGE_ROOT, 'src/claude/skills/sync/SKILL.md');
|
|
18
27
|
const CODE_REVIEW_SKILL = path.join(PACKAGE_ROOT, 'src/claude/skills/code-review/SKILL.md');
|
|
19
28
|
const PARALLEL_WAVES = path.join(PACKAGE_ROOT, 'src/claude/skills/develop/references/parallel-waves.md');
|
|
20
29
|
const PROVENANCE = path.join(PACKAGE_ROOT, '../../docs/provenance.md');
|
|
30
|
+
const RUNTIME_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-policy-runtime-'));
|
|
31
|
+
const RUNTIME_SPEC = path.join(RUNTIME_ROOT, 'specs', 'demo', 'spec.json');
|
|
32
|
+
fs.mkdirSync(path.dirname(RUNTIME_SPEC), { recursive: true });
|
|
33
|
+
fs.mkdirSync(path.join(RUNTIME_ROOT, 'src'), { recursive: true });
|
|
34
|
+
fs.writeFileSync(path.join(RUNTIME_ROOT, 'src', 'app.js'), 'runtime fixture\n');
|
|
35
|
+
fs.writeFileSync(RUNTIME_SPEC, JSON.stringify({ status: 'in_progress', feature_name: 'demo', task_registry: {} }));
|
|
36
|
+
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['add', '-A'], ['commit', '-qm', 'fixture']]) {
|
|
37
|
+
const result = spawnSync('git', ['-C', RUNTIME_ROOT, ...args], { encoding: 'utf8' });
|
|
38
|
+
assert.equal(result.status, 0, result.stderr);
|
|
39
|
+
}
|
|
40
|
+
const RUNTIME_CONTEXT = PROVENANCE_HELPER.deriveRuntimeContext({
|
|
41
|
+
projectRoot: RUNTIME_ROOT,
|
|
42
|
+
specsRoot: path.join(RUNTIME_ROOT, 'specs'),
|
|
43
|
+
specFile: RUNTIME_SPEC,
|
|
44
|
+
featureName: 'demo',
|
|
45
|
+
runtimeSession: 'implementation-session-1',
|
|
46
|
+
});
|
|
47
|
+
after(() => fs.rmSync(RUNTIME_ROOT, { recursive: true, force: true }));
|
|
48
|
+
const EXPECTED_BASE = RUNTIME_CONTEXT.base;
|
|
49
|
+
const EXPECTED_HEAD = RUNTIME_CONTEXT.head;
|
|
50
|
+
const EXPECTED_PROVENANCE = { base: EXPECTED_BASE, head: EXPECTED_HEAD };
|
|
51
|
+
const RECEIPT_BINDING = POLICY.createReceiptBinding(RUNTIME_CONTEXT);
|
|
52
|
+
|
|
53
|
+
function initFixtureGit(root) {
|
|
54
|
+
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['add', '-A'], ['commit', '-qm', 'fixture']]) {
|
|
55
|
+
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
56
|
+
assert.equal(result.status, 0, result.stderr);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function installClaudeRuntimeClosure(fixtureRoot, entry = 'hooks/spec-gate.cjs') {
|
|
61
|
+
const destinationRoot = path.join(fixtureRoot, '.claude');
|
|
62
|
+
copyClaudeTestRuntime(PACKAGE_ROOT, destinationRoot);
|
|
63
|
+
const hooksRoot = path.join(PACKAGE_ROOT, 'src', 'claude', 'hooks');
|
|
64
|
+
for (const hook of [
|
|
65
|
+
'spec-gate.cjs',
|
|
66
|
+
'completion-authority-check.cjs',
|
|
67
|
+
'completion-authority-state.cjs',
|
|
68
|
+
'semantic-review-authority.cjs',
|
|
69
|
+
]) {
|
|
70
|
+
const target = path.join(destinationRoot, 'hooks', hook);
|
|
71
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
72
|
+
fs.copyFileSync(path.join(hooksRoot, hook), target);
|
|
73
|
+
}
|
|
74
|
+
return path.join(destinationRoot, entry);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function markdownSection(content, heading) {
|
|
78
|
+
const marker = `## ${heading}`;
|
|
79
|
+
const start = content.indexOf(marker);
|
|
80
|
+
assert.notEqual(start, -1, `missing section ${heading}`);
|
|
81
|
+
const rest = content.slice(start + marker.length);
|
|
82
|
+
const end = rest.search(/\n## /);
|
|
83
|
+
return end === -1 ? rest : rest.slice(0, end);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function markdownTable(section, firstHeader) {
|
|
87
|
+
const lines = section.split('\n').filter((line) => line.trim().startsWith('|'));
|
|
88
|
+
const cells = (line) => line.split('|').slice(1, -1).map((cell) => cell.trim());
|
|
89
|
+
const headerIndex = lines.findIndex((line) => cells(line)[0] === firstHeader);
|
|
90
|
+
assert.notEqual(headerIndex, -1, `missing table headed by ${firstHeader}`);
|
|
91
|
+
const headers = cells(lines[headerIndex]);
|
|
92
|
+
const rows = {};
|
|
93
|
+
for (const line of lines.slice(headerIndex + 2)) {
|
|
94
|
+
const values = cells(line);
|
|
95
|
+
if (values.length !== headers.length) break;
|
|
96
|
+
rows[values[0].replaceAll('`', '')] = Object.fromEntries(headers.map((header, index) => [header, values[index]]));
|
|
97
|
+
}
|
|
98
|
+
return rows;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function runNode(root, script, args) {
|
|
102
|
+
return spawnSync(process.execPath, [script, ...args], { cwd: root, encoding: 'utf8' });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function commandOutput(result) {
|
|
106
|
+
return `${result.stdout}\n${result.stderr}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function createCanonicalAuthoringFixture() {
|
|
110
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-develop-contract-'));
|
|
111
|
+
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
112
|
+
fs.mkdirSync(path.join(root, 'test'), { recursive: true });
|
|
113
|
+
fs.mkdirSync(path.join(root, 'artifacts'), { recursive: true });
|
|
114
|
+
fs.writeFileSync(path.join(root, 'src', 'entry.js'), 'module.exports = { enabled: true };\n');
|
|
115
|
+
fs.writeFileSync(path.join(root, 'test', 'service.test.js'), 'require("node:test")("service", () => {});\n');
|
|
116
|
+
const scaffold = runNode(root, SCAFFOLD, ['feature']);
|
|
117
|
+
assert.equal(scaffold.status, 0, commandOutput(scaffold));
|
|
118
|
+
const specDir = path.join(root, 'specs', 'feature');
|
|
119
|
+
const specPath = path.join(specDir, 'spec.json');
|
|
120
|
+
fs.writeFileSync(path.join(specDir, 'requirements.md'), `# Requirements
|
|
121
|
+
|
|
122
|
+
## Requirements
|
|
123
|
+
### Requirement 1: Observable service behavior
|
|
124
|
+
- **R1.1**: When valid input arrives, the service shall return enabled state; invalid input shall return rejected state.
|
|
125
|
+
- **R1.2**: The verifier shall produce separate observable proof for enabled and rejected states.
|
|
126
|
+
`);
|
|
127
|
+
fs.writeFileSync(path.join(specDir, 'design.md'), `# Design
|
|
128
|
+
|
|
129
|
+
## Architecture
|
|
130
|
+
The entrypoint exposes the bounded service behavior and a separate proof artifact.
|
|
131
|
+
|
|
132
|
+
## Typed Anchors
|
|
133
|
+
| ID | Type | Target | Role | Access | Action |
|
|
134
|
+
|---|---|---|---|---|---|
|
|
135
|
+
| A-D-01 | file | \`src/entry.js\` | service boundary | read | read |
|
|
136
|
+
| A-D-02 | artifact | \`artifacts/design-proof.json\` | proof evidence | write | create |
|
|
137
|
+
|
|
138
|
+
## Canonical Contracts & Invariants
|
|
139
|
+
### D1 — Dispatch decision
|
|
140
|
+
The entrypoint dispatches exactly once.
|
|
141
|
+
### I1 — Invalid-state invariant
|
|
142
|
+
Invalid input never returns enabled state.
|
|
143
|
+
### C1 — Result contract
|
|
144
|
+
The result contains one enabled boolean.
|
|
145
|
+
|
|
146
|
+
## Verification Definitions
|
|
147
|
+
- **V1**: Subject criteria R1.1; Subject owner A-D-01; Proof criteria R1.2; Proof owner A-D-02; Evidence anchor A-D-02; Decision refs D1, I1, C1; Method command \`node --test test/service.test.js\`; Expected exit 0 with enabled state and separate proof evidence; Negative/failure invalid input returns rejected state without enabled output; Reachability/grounding entrypoint \`src/entry.js\` via A-D-01, A-D-02.
|
|
148
|
+
`);
|
|
149
|
+
return { root, specDir, specPath };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function createOrdinaryAuthoringFixture() {
|
|
153
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-ordinary-readiness-'));
|
|
154
|
+
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
155
|
+
fs.mkdirSync(path.join(root, 'test'), { recursive: true });
|
|
156
|
+
fs.writeFileSync(path.join(root, 'src', 'entry.js'), 'module.exports = { action: "delete" };\n');
|
|
157
|
+
fs.writeFileSync(path.join(root, 'test', 'entry.test.js'), 'require("node:test")("entry", () => {});\n');
|
|
158
|
+
const scaffold = runNode(root, SCAFFOLD, ['ordinary']);
|
|
159
|
+
assert.equal(scaffold.status, 0, commandOutput(scaffold));
|
|
160
|
+
const specDir = path.join(root, 'specs', 'ordinary');
|
|
161
|
+
const specPath = path.join(specDir, 'spec.json');
|
|
162
|
+
fs.writeFileSync(path.join(specDir, 'requirements.md'), `# Requirements
|
|
163
|
+
|
|
164
|
+
## Requirements
|
|
165
|
+
### Requirement 1: Delete behavior
|
|
166
|
+
- **R1.1**: When deletion is requested, the service shall delete the selected record and report deleted state.
|
|
167
|
+
`);
|
|
168
|
+
fs.writeFileSync(path.join(specDir, 'design.md'), `# Design
|
|
169
|
+
|
|
170
|
+
## Typed Anchors
|
|
171
|
+
| ID | Type | Target | Role | Access | Action |
|
|
172
|
+
|---|---|---|---|---|---|
|
|
173
|
+
| A-D-01 | file | \`src/entry.js\` | behavior owner | read | read |
|
|
174
|
+
|
|
175
|
+
## Canonical Contracts & Invariants
|
|
176
|
+
### D1 — Delete decision
|
|
177
|
+
The selected record is deleted rather than archived.
|
|
178
|
+
### I1 — State invariant
|
|
179
|
+
Deleted state is reported only after deletion succeeds.
|
|
180
|
+
### C1 — Result contract
|
|
181
|
+
The result contains the literal deleted state.
|
|
182
|
+
|
|
183
|
+
## Verification Definitions
|
|
184
|
+
- **V1**: Criteria R1.1; Owner A-D-01; Decision refs D1, I1, C1; Method command \`node --test test/entry.test.js\`; Expected exit 0 and the service reports deleted state; Negative/failure a missing record returns not-found without deleted state; Reachability/grounding entrypoint \`src/entry.js\` via A-D-01.
|
|
185
|
+
`);
|
|
186
|
+
const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
|
|
187
|
+
spec.authoring.requirements = 'validated';
|
|
188
|
+
spec.authoring.design = 'validated';
|
|
189
|
+
fs.writeFileSync(specPath, `${JSON.stringify(spec, null, 2)}\n`);
|
|
190
|
+
initFixtureGit(root);
|
|
191
|
+
return { root, specDir, specPath };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function ordinaryReview() {
|
|
195
|
+
return {
|
|
196
|
+
reviewed_criteria: ['R1.1'],
|
|
197
|
+
counterexamples: [{
|
|
198
|
+
criterion: 'R1.1', case_kind: 'failure',
|
|
199
|
+
scenario: 'Deletion targets a record that does not exist in the current store.',
|
|
200
|
+
expected: 'The service returns not-found and never reports the record as deleted.',
|
|
201
|
+
decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
|
|
202
|
+
}],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function promoteCanonicalModel(fixture) {
|
|
207
|
+
const result = runNode(fixture.root, SCAFFOLD, ['feature', '--sync-semantic-model']);
|
|
208
|
+
assert.equal(result.status, 0, commandOutput(result));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function makeCanonicalReady(fixture) {
|
|
212
|
+
const spec = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
|
|
213
|
+
spec.authoring.requirements = 'validated';
|
|
214
|
+
spec.authoring.design = 'validated';
|
|
215
|
+
fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
|
|
216
|
+
const result = READINESS.finalizeReadiness({
|
|
217
|
+
specDir: fixture.specDir,
|
|
218
|
+
projectRoot: fixture.root,
|
|
219
|
+
reviewResult: {
|
|
220
|
+
reviewed_criteria: ['R1.1', 'R1.2'],
|
|
221
|
+
counterexamples: [{
|
|
222
|
+
criterion: 'R1.1', case_kind: 'failure',
|
|
223
|
+
scenario: 'The service receives invalid input without its required discriminator.',
|
|
224
|
+
expected: 'The service returns rejected state and never emits enabled true.',
|
|
225
|
+
decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
|
|
226
|
+
}, {
|
|
227
|
+
criterion: 'R1.2', case_kind: 'adversarial',
|
|
228
|
+
scenario: 'The service reports enabled state without producing separate proof evidence.',
|
|
229
|
+
expected: 'Verification fails because the independent proof artifact is absent.',
|
|
230
|
+
decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
|
|
231
|
+
}],
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
return result.spec;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function bindFixtureReceipt(root, value, feature = 'demo', session = 'test') {
|
|
238
|
+
const context = PROVENANCE_HELPER.deriveRuntimeContext({
|
|
239
|
+
projectRoot: root,
|
|
240
|
+
specsRoot: path.join(root, 'specs'),
|
|
241
|
+
specFile: path.join(root, 'specs', feature, 'spec.json'),
|
|
242
|
+
featureName: feature,
|
|
243
|
+
runtimeSession: session,
|
|
244
|
+
});
|
|
245
|
+
return value.replaceAll('0123456789abcdef0123456789abcdef01234567', context.base)
|
|
246
|
+
.replaceAll('89abcdef0123456789abcdef0123456789abcdef', context.head);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function canonicalReceipt(command = 'pnpm test', extra = '') {
|
|
250
|
+
return `Verification: PASS\nCommand: ${command}\nExit: 0\nBase: ${EXPECTED_BASE}\nHead: ${EXPECTED_HEAD}\n${extra}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function canonicalFlashTask(overrides = {}) {
|
|
254
|
+
return {
|
|
255
|
+
status: 'in_progress',
|
|
256
|
+
receipt: 'FLASH_UNVERIFIED',
|
|
257
|
+
blocker: 'awaiting /hapo:test <feature>',
|
|
258
|
+
dependencyBlocked: true,
|
|
259
|
+
unblocks: false,
|
|
260
|
+
feature_name: 'demo',
|
|
261
|
+
runtime_context: RUNTIME_CONTEXT,
|
|
262
|
+
expected_provenance: { ...EXPECTED_PROVENANCE },
|
|
263
|
+
...overrides,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
21
266
|
|
|
22
267
|
function read(filePath) {
|
|
23
268
|
return fs.readFileSync(filePath, 'utf8');
|
|
@@ -56,16 +301,17 @@ test('develop pre-state guard names executable policy contract', () => {
|
|
|
56
301
|
assert.match(develop, /flash\+parallel fail-fast/i);
|
|
57
302
|
});
|
|
58
303
|
|
|
59
|
-
test('delegation plan
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
assert.deepEqual(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
304
|
+
test('delegation plan consumes legacy tiers as obligations without an agent chain', () => {
|
|
305
|
+
const light = POLICY.delegationPlan({ tier: 'Light', mode: 'full-spec', taskCount: 2 });
|
|
306
|
+
const standard = POLICY.delegationPlan({ tier: 'Standard', mode: 'specific-task' });
|
|
307
|
+
const deep = POLICY.delegationPlan({ tier: 'Deep', mode: 'full-spec', taskCount: 2 });
|
|
308
|
+
assert.deepEqual(light.proof_obligations, ['needsExecutionProof']);
|
|
309
|
+
assert.deepEqual(standard.proof_obligations, ['needsExecutionProof']);
|
|
310
|
+
assert.deepEqual(deep.proof_obligations, ['needsInspection', 'needsExecutionProof', 'needsIndependentAudit']);
|
|
311
|
+
for (const plan of [light, standard, deep]) {
|
|
312
|
+
assert.equal(Object.hasOwn(plan, 'delegated'), false);
|
|
313
|
+
assert.doesNotMatch(JSON.stringify(plan), /inspector|implementer|test-runner|code-auditor/);
|
|
314
|
+
}
|
|
69
315
|
assert.doesNotMatch(read(GATE), /spec-review|quality-review/);
|
|
70
316
|
assert.doesNotMatch(read(DEVELOP), /spec-review|quality-review/);
|
|
71
317
|
});
|
|
@@ -75,42 +321,277 @@ test('lane classifier selects Direct for explicit reversible low-risk work', ()
|
|
|
75
321
|
assert.equal(result.lane, 'Direct');
|
|
76
322
|
assert.equal(result.automaticLane, 'Direct');
|
|
77
323
|
assert.deepEqual(result.risks, []);
|
|
78
|
-
|
|
79
|
-
assert.
|
|
324
|
+
const policy = POLICY.lanePolicy(result);
|
|
325
|
+
assert.deepEqual(policy.proof_obligations, ['needsExecutionProof']);
|
|
326
|
+
assert.equal(policy.artifact_profile, 'targeted');
|
|
327
|
+
assert.equal(policy.requiresSpec, false);
|
|
328
|
+
assert.equal(policy.requiresState, false);
|
|
329
|
+
assert.equal(policy.proof_obligations.includes('needsDurableTaskState'), false);
|
|
80
330
|
});
|
|
81
331
|
|
|
82
|
-
test('lane classifier
|
|
332
|
+
test('lane classifier raises named risks to Elevated without inventing independent-audit authority', () => {
|
|
83
333
|
const result = POLICY.classifyLane({
|
|
84
334
|
reversible: true,
|
|
85
335
|
riskSignals: { auth: true, migration: true, publicContract: true },
|
|
86
336
|
});
|
|
87
|
-
assert.equal(result.lane, '
|
|
337
|
+
assert.equal(result.lane, 'Standard');
|
|
338
|
+
assert.equal(result.assuranceLevel, 'Elevated');
|
|
339
|
+
assert.equal(result.automaticAssuranceLevel, 'Elevated');
|
|
88
340
|
assert.deepEqual(result.risks, ['auth', 'migration', 'publicContract']);
|
|
89
|
-
assert.deepEqual(POLICY.
|
|
90
|
-
'
|
|
341
|
+
assert.deepEqual(POLICY.lanePolicy(result).proof_obligations, [
|
|
342
|
+
'needsInspection', 'needsExecutionProof',
|
|
91
343
|
]);
|
|
344
|
+
|
|
345
|
+
const strict = POLICY.classifyLane({
|
|
346
|
+
riskSignals: { auth: true },
|
|
347
|
+
assurance_level: 'Strict',
|
|
348
|
+
});
|
|
349
|
+
assert.equal(strict.lane, 'Critical');
|
|
350
|
+
assert.equal(strict.automaticLane, 'Standard');
|
|
351
|
+
assert.equal(strict.assuranceLevel, 'Strict');
|
|
352
|
+
assert.equal(strict.automaticAssuranceLevel, 'Elevated');
|
|
353
|
+
assert.ok(POLICY.lanePolicy(strict).proof_obligations.includes('needsIndependentAudit'));
|
|
92
354
|
});
|
|
93
355
|
|
|
94
|
-
test('
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
riskSignals: {
|
|
356
|
+
test('P1 persists the minimal v2.1 policy and derives compatibility views', () => {
|
|
357
|
+
const initial = POLICY.persistWorkflowPolicySnapshot(
|
|
358
|
+
{ feature_name: 'bounded' },
|
|
359
|
+
{ riskSignals: {} },
|
|
360
|
+
);
|
|
361
|
+
const snapshot = initial.workflow_policy;
|
|
362
|
+
assert.deepEqual(Object.keys(snapshot).sort(), [...POLICY.CANONICAL_WORKFLOW_POLICY_FIELDS].sort());
|
|
363
|
+
assert.equal(snapshot.version, POLICY.CANONICAL_WORKFLOW_POLICY_VERSION);
|
|
364
|
+
assert.equal(snapshot.planning_depth, 'Compact');
|
|
365
|
+
assert.equal(snapshot.assurance_level, 'Routine');
|
|
366
|
+
assert.deepEqual(snapshot.classified_minimum, {
|
|
367
|
+
planning_depth: 'Compact',
|
|
368
|
+
assurance_level: 'Routine',
|
|
369
|
+
});
|
|
370
|
+
for (const derivedField of ['lane', 'automatic_lane', 'artifact_profile', 'proof_obligations', 'actor_needs']) {
|
|
371
|
+
assert.equal(Object.hasOwn(snapshot, derivedField), false, `${derivedField} must not be persisted`);
|
|
372
|
+
}
|
|
373
|
+
const view = POLICY.readWorkflowPolicySnapshot(initial);
|
|
374
|
+
assert.equal(view.lane, 'Standard');
|
|
375
|
+
assert.equal(view.automatic_lane, 'Standard');
|
|
376
|
+
assert.equal(view.artifact_profile, 'bounded');
|
|
377
|
+
assert.deepEqual(view.proof_obligations, ['needsExecutionProof']);
|
|
378
|
+
assert.equal(Object.hasOwn(initial, 'override_' + 'receipt'), false);
|
|
379
|
+
assert.equal(POLICY.validateWorkflowPolicySnapshot(snapshot).valid, true);
|
|
380
|
+
|
|
381
|
+
const persistedAgain = POLICY.persistWorkflowPolicySnapshot(initial, {
|
|
382
|
+
riskSignals: { auth: true },
|
|
98
383
|
override: 'Direct',
|
|
99
|
-
userAuthorized: true,
|
|
100
384
|
});
|
|
101
|
-
assert.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
assert.
|
|
385
|
+
assert.deepEqual(persistedAgain.workflow_policy, snapshot, 'persist-once must not reclassify an existing snapshot');
|
|
386
|
+
|
|
387
|
+
const malformed = { ...snapshot, lane: 'Critical' };
|
|
388
|
+
const validation = POLICY.validateWorkflowPolicySnapshot(malformed);
|
|
389
|
+
assert.equal(validation.valid, false);
|
|
390
|
+
assert.match(validation.errors.join('; '), /v2\.1 fields must be exactly/);
|
|
391
|
+
assert.throws(() => POLICY.readWorkflowPolicySnapshot({ workflow_policy: malformed }), /Invalid workflow policy snapshot/);
|
|
392
|
+
|
|
393
|
+
const malformedShape = { ...snapshot, classified_minimum: { planning_depth: 'Compact' } };
|
|
394
|
+
const shapeValidation = POLICY.validateWorkflowPolicySnapshot(malformedShape);
|
|
395
|
+
assert.equal(shapeValidation.valid, false);
|
|
396
|
+
assert.match(shapeValidation.errors.join('; '), /classified_minimum must contain exactly/);
|
|
397
|
+
|
|
398
|
+
const malformedRisks = { ...snapshot, risks: { auth: true } };
|
|
399
|
+
const risksValidation = POLICY.validateWorkflowPolicySnapshot(malformedRisks);
|
|
400
|
+
assert.equal(risksValidation.valid, false);
|
|
401
|
+
assert.match(risksValidation.errors.join('; '), /risks must be an array/);
|
|
106
402
|
});
|
|
107
403
|
|
|
108
|
-
test('
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
404
|
+
test('P1 workflow-policy validation is semantic and explicit workflow_policy values never reclassify', () => {
|
|
405
|
+
const direct = POLICY.workflowPolicySnapshot({ reversible: true, lowRisk: true, isolated: true });
|
|
406
|
+
const standardUnknown = POLICY.escalateWorkflowPolicy(direct, { risks: ['unclassified-risk'] });
|
|
407
|
+
const elevatedAuth = POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } });
|
|
408
|
+
const escalatedElevated = POLICY.escalateWorkflowPolicy(direct, { risks: ['auth'] });
|
|
409
|
+
|
|
410
|
+
for (const legitimate of [direct, standardUnknown, elevatedAuth, escalatedElevated]) {
|
|
411
|
+
assert.equal(POLICY.validateWorkflowPolicySnapshot(legitimate).valid, true);
|
|
412
|
+
}
|
|
413
|
+
assert.equal(escalatedElevated.lane, 'Standard');
|
|
414
|
+
assert.equal(escalatedElevated.automatic_lane, 'Standard');
|
|
415
|
+
assert.equal(escalatedElevated.planning_depth, 'None');
|
|
416
|
+
assert.equal(escalatedElevated.assurance_level, 'Elevated');
|
|
417
|
+
|
|
418
|
+
const forgedDirectAuth = { ...direct, risks: ['auth'] };
|
|
419
|
+
const forgedDirectUnknown = { ...direct, risks: ['unclassified-risk'] };
|
|
420
|
+
const forgedStandardAuth = { ...standardUnknown, risks: ['auth'] };
|
|
421
|
+
for (const forged of [forgedDirectAuth, forgedDirectUnknown]) {
|
|
422
|
+
const result = POLICY.validateWorkflowPolicySnapshot(forged);
|
|
423
|
+
assert.equal(result.valid, false);
|
|
424
|
+
assert.match(result.errors.join('; '), /at least (Elevated|Strict)/);
|
|
425
|
+
}
|
|
426
|
+
assert.equal(
|
|
427
|
+
POLICY.validateWorkflowPolicySnapshot(forgedStandardAuth).valid,
|
|
428
|
+
true,
|
|
429
|
+
'changing one normalized risk to another does not invent a higher assurance minimum',
|
|
430
|
+
);
|
|
431
|
+
for (const riskAlias of ['Auth', 'authentication', 'public_contract']) {
|
|
432
|
+
const escalated = POLICY.escalateWorkflowPolicy(direct, { risks: [riskAlias] });
|
|
433
|
+
assert.equal(escalated.lane, 'Standard', `${riskAlias} must classify as Elevated`);
|
|
434
|
+
assert.equal(escalated.assurance_level, 'Elevated');
|
|
435
|
+
assert.equal(POLICY.validateWorkflowPolicySnapshot({ ...direct, risks: [riskAlias] }).valid, false);
|
|
436
|
+
}
|
|
437
|
+
assert.throws(() => POLICY.assertWorkflowPolicySnapshot(forgedDirectAuth), /Invalid workflow policy snapshot/);
|
|
438
|
+
assert.throws(() => POLICY.readWorkflowPolicySnapshot({ workflow_policy: forgedDirectAuth }), /Invalid workflow policy snapshot/);
|
|
439
|
+
assert.throws(() => POLICY.lanePolicy({ workflow_policy: forgedDirectAuth }), /Invalid workflow policy snapshot/);
|
|
440
|
+
|
|
441
|
+
for (const explicitValue of [null, undefined]) {
|
|
442
|
+
const state = { workflow_policy: explicitValue, reversible: true, lowRisk: true, isolated: true };
|
|
443
|
+
assert.throws(() => POLICY.workflowPolicySnapshot(state), /Invalid workflow policy snapshot/);
|
|
444
|
+
assert.throws(() => POLICY.escalateWorkflowPolicy(state, {}), /Invalid workflow policy snapshot/);
|
|
445
|
+
assert.throws(() => POLICY.lanePolicy(state), /Invalid workflow policy snapshot/);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const forgedLanePolicyCli = spawnSync(process.execPath, [
|
|
449
|
+
POLICY_PATH,
|
|
450
|
+
'--lane-policy',
|
|
451
|
+
'--task-json',
|
|
452
|
+
JSON.stringify({ workflow_policy: forgedDirectAuth }),
|
|
453
|
+
'--json',
|
|
454
|
+
], { encoding: 'utf8' });
|
|
455
|
+
assert.equal(forgedLanePolicyCli.status, 2, `${forgedLanePolicyCli.stdout}\n${forgedLanePolicyCli.stderr}`);
|
|
456
|
+
assert.match(forgedLanePolicyCli.stderr, /Invalid workflow policy snapshot/);
|
|
457
|
+
|
|
458
|
+
const nullLanePolicyCli = spawnSync(process.execPath, [
|
|
459
|
+
POLICY_PATH,
|
|
460
|
+
'--lane-policy',
|
|
461
|
+
'--task-json',
|
|
462
|
+
JSON.stringify({ workflow_policy: null, reversible: true, lowRisk: true, isolated: true }),
|
|
463
|
+
'--json',
|
|
464
|
+
], { encoding: 'utf8' });
|
|
465
|
+
assert.equal(nullLanePolicyCli.status, 2, `${nullLanePolicyCli.stdout}\n${nullLanePolicyCli.stderr}`);
|
|
466
|
+
assert.match(nullLanePolicyCli.stderr, /Invalid workflow policy snapshot/);
|
|
467
|
+
|
|
468
|
+
const validatePolicyCli = spawnSync(process.execPath, [
|
|
469
|
+
POLICY_PATH,
|
|
470
|
+
'--validate-policy',
|
|
471
|
+
'--task-json',
|
|
472
|
+
JSON.stringify({ workflow_policy: forgedDirectAuth }),
|
|
473
|
+
'--json',
|
|
474
|
+
], { encoding: 'utf8' });
|
|
475
|
+
assert.equal(validatePolicyCli.status, 2, `${validatePolicyCli.stdout}\n${validatePolicyCli.stderr}`);
|
|
476
|
+
assert.equal(JSON.parse(validatePolicyCli.stdout).valid, false);
|
|
477
|
+
|
|
478
|
+
const validateNullPolicyCli = spawnSync(process.execPath, [
|
|
479
|
+
POLICY_PATH,
|
|
480
|
+
'--validate-policy',
|
|
481
|
+
'--task-json',
|
|
482
|
+
JSON.stringify({ workflow_policy: null }),
|
|
483
|
+
'--json',
|
|
484
|
+
], { encoding: 'utf8' });
|
|
485
|
+
assert.equal(validateNullPolicyCli.status, 2, `${validateNullPolicyCli.stdout}\n${validateNullPolicyCli.stderr}`);
|
|
486
|
+
assert.equal(JSON.parse(validateNullPolicyCli.stdout).valid, false);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test('P1 workflow policy escalation is monotonic across all newly classified risks', () => {
|
|
490
|
+
const direct = POLICY.workflowPolicySnapshot({ reversible: true, lowRisk: true, isolated: true });
|
|
491
|
+
assert.equal(direct.lane, 'Direct');
|
|
492
|
+
assert.deepEqual(POLICY.escalateWorkflowPolicy(direct, {}), direct, 'no new risks must not escalate');
|
|
493
|
+
|
|
494
|
+
const standard = POLICY.escalateWorkflowPolicy(direct, { risks: ['new-standard-risk'], riskLevel: 'standard' });
|
|
495
|
+
assert.equal(standard.lane, 'Standard');
|
|
496
|
+
assert.equal(standard.artifact_profile, 'targeted');
|
|
497
|
+
assert.equal(standard.planning_depth, 'None');
|
|
498
|
+
assert.equal(standard.assurance_level, 'Elevated');
|
|
499
|
+
assert.equal(standard.risks.includes('new-standard-risk'), true);
|
|
500
|
+
assert.deepEqual(standard.proof_obligations, ['needsInspection', 'needsExecutionProof']);
|
|
501
|
+
|
|
502
|
+
const ambiguityElevated = POLICY.escalateWorkflowPolicy(direct, { riskSignals: { ambiguity: true } });
|
|
503
|
+
assert.equal(ambiguityElevated.lane, 'Standard', 'risk discovery must require inspection without inventing an audit');
|
|
504
|
+
assert.equal(ambiguityElevated.assurance_level, 'Elevated');
|
|
505
|
+
|
|
506
|
+
const criticalSeverity = POLICY.escalateWorkflowPolicy(direct, { risks: ['payment'], riskLevel: 'critical' });
|
|
507
|
+
assert.equal(criticalSeverity.assurance_level, 'Elevated', 'severity labels must not create independent-audit authority');
|
|
508
|
+
assert.equal(criticalSeverity.proof_obligations.includes('needsIndependentAudit'), false);
|
|
509
|
+
|
|
510
|
+
const critical = POLICY.escalateWorkflowPolicy(standard, { risks: ['auth'], assurance_level: 'Strict' });
|
|
511
|
+
assert.equal(critical.lane, 'Critical');
|
|
512
|
+
assert.equal(critical.artifact_profile, 'targeted');
|
|
513
|
+
assert.equal(critical.planning_depth, 'None');
|
|
514
|
+
assert.equal(critical.assurance_level, 'Strict');
|
|
515
|
+
assert.equal(critical.automatic_assurance_level, 'Elevated');
|
|
516
|
+
assert.equal(critical.proof_obligations.includes('needsIndependentAudit'), true);
|
|
517
|
+
assert.equal(critical.proof_obligations.includes('needsResearchGrounding'), false);
|
|
518
|
+
|
|
519
|
+
const noDowngrade = POLICY.escalateWorkflowPolicy(critical, { risks: ['another-standard-risk'], riskLevel: 'standard' });
|
|
520
|
+
assert.equal(noDowngrade.lane, 'Critical');
|
|
521
|
+
assert.equal(noDowngrade.proof_obligations.includes('needsIndependentAudit'), true);
|
|
522
|
+
assert.equal(noDowngrade.proof_obligations.includes('needsResearchGrounding'), false);
|
|
523
|
+
|
|
524
|
+
const criticalAgain = POLICY.escalateWorkflowPolicy(critical, { risks: ['auth'] });
|
|
525
|
+
assert.deepEqual(criticalAgain, critical, 'repeating known risks must not create a new escalation');
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('P1 legacy execution tier is read-compatible without becoming emitted policy authority', () => {
|
|
529
|
+
const legacy = { design_context: { execution_tier: 'Deep' } };
|
|
530
|
+
const snapshot = POLICY.workflowPolicySnapshot(legacy);
|
|
531
|
+
assert.equal(snapshot.lane, 'Critical');
|
|
532
|
+
assert.equal(snapshot.artifact_profile, 'strict');
|
|
533
|
+
const policy = POLICY.lanePolicy(legacy);
|
|
534
|
+
assert.equal(policy.lane, 'Critical');
|
|
535
|
+
assert.equal(Object.hasOwn(policy, 'execution_tier'), false);
|
|
536
|
+
assert.equal(Object.hasOwn(policy, 'executionTier'), false);
|
|
537
|
+
const legacyPlan = POLICY.delegationPlan({ tier: 'Deep' });
|
|
538
|
+
assert.equal(Object.hasOwn(legacyPlan, 'execution_tier'), false);
|
|
539
|
+
assert.equal(Object.hasOwn(legacyPlan, 'executionTier'), false);
|
|
540
|
+
assert.deepEqual(legacyPlan.proof_obligations, snapshot.proof_obligations);
|
|
541
|
+
assert.equal(POLICY.workflowPolicySnapshot({ design_context: { execution_tier: 'standard' } }).lane, 'Standard');
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('legacy v1 policy is read-compatible, immutable on read, and explicitly migrates to v2.1', () => {
|
|
545
|
+
const cases = [
|
|
546
|
+
{ lane: 'Direct', risks: [], obligations: ['needsExecutionProof'] },
|
|
547
|
+
{ lane: 'Standard', risks: [], obligations: ['needsInspection', 'needsExecutionProof'] },
|
|
548
|
+
{ lane: 'Critical', risks: ['auth'], obligations: ['needsInspection', 'needsExecutionProof', 'needsIndependentAudit', 'needsResearchGrounding'] },
|
|
549
|
+
];
|
|
550
|
+
for (const fixture of cases) {
|
|
551
|
+
const v1 = {
|
|
552
|
+
version: '1',
|
|
553
|
+
lane: fixture.lane,
|
|
554
|
+
automatic_lane: fixture.lane,
|
|
555
|
+
risks: fixture.risks,
|
|
556
|
+
artifact_profile: { Direct: 'targeted', Standard: 'bounded', Critical: 'strict' }[fixture.lane],
|
|
557
|
+
proof_obligations: fixture.obligations,
|
|
558
|
+
actor_needs: POLICY.actorNeedsFor(fixture.obligations),
|
|
559
|
+
override_receipt: null,
|
|
560
|
+
};
|
|
561
|
+
assert.equal(POLICY.validateWorkflowPolicySnapshot(v1).valid, true, fixture.lane);
|
|
562
|
+
const source = { feature_name: `legacy-${fixture.lane}`, workflow_policy: v1 };
|
|
563
|
+
const before = JSON.stringify(source);
|
|
564
|
+
const adapted = POLICY.readWorkflowPolicySnapshot(source);
|
|
565
|
+
assert.equal(adapted.version, '2');
|
|
566
|
+
assert.deepEqual(adapted.proof_obligations, fixture.obligations, fixture.lane);
|
|
567
|
+
assert.deepEqual(adapted.actor_needs, v1.actor_needs, fixture.lane);
|
|
568
|
+
assert.equal(POLICY.validateWorkflowPolicySnapshot(adapted).valid, true, fixture.lane);
|
|
569
|
+
assert.equal(JSON.stringify(source), before, 'read adapter must not mutate persisted v1 state');
|
|
570
|
+
const migrated = POLICY.persistWorkflowPolicySnapshot(source).workflow_policy;
|
|
571
|
+
assert.equal(migrated.version, POLICY.CANONICAL_WORKFLOW_POLICY_VERSION);
|
|
572
|
+
assert.deepEqual(Object.keys(migrated).sort(), [...POLICY.CANONICAL_WORKFLOW_POLICY_FIELDS].sort());
|
|
573
|
+
const migratedView = POLICY.readWorkflowPolicySnapshot({ workflow_policy: migrated });
|
|
574
|
+
assert.equal(migratedView.lane, adapted.lane, fixture.lane);
|
|
575
|
+
assert.equal(migratedView.artifact_profile, adapted.artifact_profile, fixture.lane);
|
|
576
|
+
assert.deepEqual(migratedView.proof_obligations, POLICY.obligationsForAssurance(
|
|
577
|
+
migrated.assurance_level,
|
|
578
|
+
migrated.risks,
|
|
579
|
+
), fixture.lane);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test('post-classification downgrade is unsupported', () => {
|
|
584
|
+
assert.throws(
|
|
585
|
+
() => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct' }),
|
|
586
|
+
(error) => /planning_depth downgrade/.test(error.message) && /not permitted/.test(error.message),
|
|
587
|
+
);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test('caller-requested downgrade is blocked before persistence', () => {
|
|
591
|
+
assert.throws(
|
|
592
|
+
() => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct' }),
|
|
593
|
+
(error) => /planning_depth downgrade/.test(error.message) && /not permitted/.test(error.message),
|
|
594
|
+
);
|
|
114
595
|
// also via CLI
|
|
115
596
|
const cli = spawnSync(process.execPath, [
|
|
116
597
|
POLICY_PATH,
|
|
@@ -120,7 +601,7 @@ test('unsafe downgrade without user authorization is blocked', () => {
|
|
|
120
601
|
'--json',
|
|
121
602
|
], { encoding: 'utf8' });
|
|
122
603
|
assert.equal(cli.status, 2);
|
|
123
|
-
assert.match(cli.stderr, /
|
|
604
|
+
assert.match(cli.stderr, /planning_depth downgrade.*not permitted/i);
|
|
124
605
|
});
|
|
125
606
|
|
|
126
607
|
test('forged approval via legacy approved field is rejected', () => {
|
|
@@ -140,14 +621,14 @@ test('forged approval via legacy approved field is rejected', () => {
|
|
|
140
621
|
assert.match(legacyCheck.error, /Legacy/);
|
|
141
622
|
});
|
|
142
623
|
|
|
143
|
-
test('
|
|
624
|
+
test('technical approval readiness ignores legacy user_approved', () => {
|
|
144
625
|
const state = POLICY.approvalState({ generated: true, agent_validated: true });
|
|
145
626
|
assert.equal(state.generated, true);
|
|
146
627
|
assert.equal(state.agent_validated, true);
|
|
147
|
-
assert.equal(state.
|
|
148
|
-
assert.equal(state.ready, false);
|
|
628
|
+
assert.equal(state.ready, true);
|
|
149
629
|
assert.equal(state.schema_version, '2.0');
|
|
150
630
|
assert.equal(POLICY.approvalState({ generated: true, agent_validated: true, user_approved: true }).ready, true);
|
|
631
|
+
assert.equal(POLICY.approvalState({ generated: true, agent_validated: true, user_approved: false }).ready, true);
|
|
151
632
|
const cli = spawnSync(process.execPath, [
|
|
152
633
|
POLICY_PATH,
|
|
153
634
|
'--approval-state',
|
|
@@ -156,10 +637,33 @@ test('approval state never infers user approval from generated or agent validati
|
|
|
156
637
|
'--json',
|
|
157
638
|
], { encoding: 'utf8' });
|
|
158
639
|
assert.equal(cli.status, 0, `${cli.stdout}\n${cli.stderr}`);
|
|
159
|
-
assert.equal(JSON.parse(cli.stdout).state.
|
|
640
|
+
assert.equal(JSON.parse(cli.stdout).state.ready, true);
|
|
160
641
|
});
|
|
161
642
|
|
|
162
|
-
test('
|
|
643
|
+
test('approval schema fails closed for explicit null, array, empty, and malformed states', () => {
|
|
644
|
+
const absent = POLICY.validateApprovalSchema({});
|
|
645
|
+
assert.equal(absent.valid, true);
|
|
646
|
+
assert.equal(absent.absent, true);
|
|
647
|
+
assert.equal(POLICY.approvalState({}).present, false);
|
|
648
|
+
|
|
649
|
+
const invalidSpecs = [
|
|
650
|
+
[],
|
|
651
|
+
{ approvals: null },
|
|
652
|
+
{ approvals: [] },
|
|
653
|
+
{ approvals: {} },
|
|
654
|
+
{ approvals: { requirements: null } },
|
|
655
|
+
{ approvals: { requirements: { generated: true, agent_validated: true, user_approved: null } } },
|
|
656
|
+
{ approvals: { requirements: { generated: true, agent_validated: true, user_approved: true, extra: false } } },
|
|
657
|
+
];
|
|
658
|
+
for (const spec of invalidSpecs) {
|
|
659
|
+
assert.equal(POLICY.validateApprovalSchema(spec).valid, false, `invalid approval should fail: ${JSON.stringify(spec)}`);
|
|
660
|
+
assert.throws(() => POLICY.approvalState(spec), /approval|approvals|schema/i);
|
|
661
|
+
}
|
|
662
|
+
assert.throws(() => POLICY.approvalState({ schema_version: null }), /schema_version/);
|
|
663
|
+
assert.throws(() => POLICY.approvalState({ schema_version: '1.0' }), /schema_version/);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test('CLI exposes a derived lane and Develop consumes independent canonical policy axes', () => {
|
|
163
667
|
const cli = spawnSync(process.execPath, [
|
|
164
668
|
POLICY_PATH,
|
|
165
669
|
'--classify-lane',
|
|
@@ -173,40 +677,383 @@ test('CLI exposes JSON lane classification and develop has no universal spec har
|
|
|
173
677
|
assert.equal(payload.policy.requiresSpec, false);
|
|
174
678
|
const develop = read(DEVELOP);
|
|
175
679
|
assert.doesNotMatch(develop, /DO NOT write implementation code until an approved spec exists/i);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
680
|
+
const policySection = develop.split('## Policy authority')[1]?.split('\n## ')[0] || '';
|
|
681
|
+
for (const authorityTerm of ['planning_depth', 'assurance_level', 'Risk', 'derived']) {
|
|
682
|
+
assert.ok(policySection.includes(authorityTerm), `policy authority missing ${authorityTerm}`);
|
|
683
|
+
}
|
|
684
|
+
for (const compatibilityView of ['**Direct**', '**Standard**', '**Critical**']) {
|
|
685
|
+
assert.ok(policySection.includes(compatibilityView), `missing derived view ${compatibilityView}`);
|
|
686
|
+
}
|
|
687
|
+
assert.ok(policySection.includes('derived Strict view'));
|
|
688
|
+
assert.ok(policySection.includes('Legacy approval fields'));
|
|
689
|
+
assert.ok(policySection.replace(/\s+/g, ' ').includes('zero authority'));
|
|
690
|
+
assert.match(develop, /Classify lane[\s\S]*test receipt[\s\S]*docs-impact sync/i);
|
|
180
691
|
assert.doesNotMatch(develop, /D2\[Step 3/);
|
|
181
|
-
assert.match(
|
|
692
|
+
assert.match(develop, /--notes.*opt-in/i);
|
|
693
|
+
assert.doesNotMatch(develop, /--no-notes/);
|
|
694
|
+
assert.doesNotMatch(develop, /planner|implementer|test-runner|code-auditor|docs-keeper/i);
|
|
695
|
+
const fullRoutine = POLICY.readWorkflowPolicySnapshot({
|
|
696
|
+
workflow_policy: POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Full', assurance_level: 'Routine' }),
|
|
697
|
+
});
|
|
698
|
+
const compactStrict = POLICY.readWorkflowPolicySnapshot({
|
|
699
|
+
workflow_policy: POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Compact', assurance_level: 'Strict' }),
|
|
700
|
+
});
|
|
701
|
+
assert.equal(fullRoutine.planning_depth, 'Full');
|
|
702
|
+
assert.equal(fullRoutine.assurance_level, 'Routine');
|
|
703
|
+
assert.equal(compactStrict.planning_depth, 'Compact');
|
|
704
|
+
assert.equal(compactStrict.assurance_level, 'Strict');
|
|
705
|
+
assert.notEqual(fullRoutine.artifact_profile, compactStrict.artifact_profile);
|
|
706
|
+
assert.notDeepEqual(fullRoutine.proof_obligations, compactStrict.proof_obligations);
|
|
182
707
|
});
|
|
183
708
|
|
|
184
|
-
test('
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
assert.deepEqual(
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
709
|
+
test('artifact router keeps optional expansion bound to uncertainty and typed topology', () => {
|
|
710
|
+
const router = markdownSection(read(SPECS), 'Artifact router');
|
|
711
|
+
const rows = markdownTable(router, 'Depth');
|
|
712
|
+
assert.deepEqual(Object.keys(rows), ['None', 'Compact', 'Full']);
|
|
713
|
+
assert.equal(rows.None['Durable core'], 'none');
|
|
714
|
+
for (const depth of ['Compact', 'Full']) {
|
|
715
|
+
assert.deepEqual(
|
|
716
|
+
rows[depth]['Durable core'].match(/`[^`]+`/g),
|
|
717
|
+
['`spec.json`', '`requirements.md`', '`design.md`'],
|
|
718
|
+
`${depth} must share the canonical three-file core`,
|
|
719
|
+
);
|
|
720
|
+
assert.equal(rows[depth]['Research route'], 'only on a research trigger');
|
|
721
|
+
assert.equal(rows[depth]['Task route'], 'only on a typed topology trigger');
|
|
722
|
+
}
|
|
723
|
+
assert.notEqual(rows.Compact['Design expansion'], rows.Full['Design expansion']);
|
|
724
|
+
for (const falseTrigger of ['Requirement count', 'size', 'planning depth', 'assurance level', 'risk label', 'architectural layers']) {
|
|
725
|
+
assert.ok(router.includes(falseTrigger), `router must name inert trigger ${falseTrigger}`);
|
|
726
|
+
}
|
|
727
|
+
for (const boundary of ['ownership', 'dependency', 'transition', 'proof', 'parallel']) {
|
|
728
|
+
assert.ok(router.includes(boundary), `router must name typed topology ${boundary}`);
|
|
729
|
+
}
|
|
730
|
+
assert.ok(router.includes('unresolved material uncertainty'));
|
|
731
|
+
assert.ok(router.includes('external-current fact'));
|
|
732
|
+
assert.ok(router.includes('explicit user request'));
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test('canonical verdict adapter handles completion and unfinished decisions', () => {
|
|
736
|
+
assert.deepEqual(POLICY.REVIEW_VERDICTS, ['PASS', 'PASS_WITH_WARNINGS', 'FAIL', 'BLOCKED']);
|
|
737
|
+
assert.doesNotThrow(() => POLICY.assertVerdict('PASS_WITH_WARNINGS'));
|
|
738
|
+
assert.throws(() => POLICY.assertVerdict('PARTIAL'), /Unsupported canonical verdict/);
|
|
739
|
+
assert.equal(POLICY.consumeReviewVerdict('PASS').completion, 'unfinished');
|
|
740
|
+
assert.equal(POLICY.consumeReviewVerdict('PASS').unfinished, true);
|
|
741
|
+
assert.equal(POLICY.consumeReviewVerdict('PASS').createsCompletion, false);
|
|
742
|
+
assert.equal(POLICY.consumeReviewVerdict('PASS_WITH_WARNINGS').unfinished, true);
|
|
743
|
+
assert.equal(POLICY.consumeReviewVerdict('FAIL').retry, true);
|
|
744
|
+
assert.equal(POLICY.consumeReviewVerdict('BLOCKED').retry, false);
|
|
745
|
+
assert.equal(POLICY.consumeReviewVerdict('BLOCKED').unfinished, true);
|
|
746
|
+
assert.equal(POLICY.consumeReviewVerdict('PARTIAL').completion, 'unfinished');
|
|
747
|
+
assert.equal(POLICY.consumeReviewVerdict('NO_TESTS').completion, 'unfinished');
|
|
748
|
+
assert.equal(POLICY.consumeReviewVerdict('PARTIAL').verdict, 'BLOCKED');
|
|
749
|
+
assert.equal(POLICY.consumeReviewVerdict('NO_TESTS').verdict, 'BLOCKED');
|
|
750
|
+
assert.equal(POLICY.consumeReviewVerdict('PARTIAL').retry, false);
|
|
751
|
+
assert.equal(POLICY.consumeReviewVerdict('NO_TESTS').retry, false);
|
|
752
|
+
assert.throws(() => POLICY.consumeReviewVerdict('UNKNOWN'), /Unsupported canonical verdict/);
|
|
193
753
|
const cli = spawnSync(process.execPath, [POLICY_PATH, '--consume-verdict', '--verdict', 'BLOCKED', '--json'], { encoding: 'utf8' });
|
|
194
754
|
assert.equal(cli.status, 0, `${cli.stdout}\n${cli.stderr}`);
|
|
195
755
|
assert.deepEqual(JSON.parse(cli.stdout).action, 'stop');
|
|
196
|
-
assert.match(read(GATE), /PASS \| FAIL \| BLOCKED/);
|
|
197
|
-
assert.match(read(CODE_REVIEW_SKILL), /PASS \| FAIL \| BLOCKED/);
|
|
198
|
-
assert.match(read(TEST_SKILL), /PASS
|
|
756
|
+
assert.match(read(GATE), /PASS \| PASS_WITH_WARNINGS \| FAIL \| BLOCKED/);
|
|
757
|
+
assert.match(read(CODE_REVIEW_SKILL), /PASS \| PASS_WITH_WARNINGS \| FAIL \| BLOCKED/);
|
|
758
|
+
assert.match(read(TEST_SKILL), /PASS \| PASS_WITH_WARNINGS \| FAIL \| BLOCKED/);
|
|
199
759
|
});
|
|
200
760
|
|
|
201
|
-
test('
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
761
|
+
test('completion decision requires canonical execution receipt and every workflow obligation', () => {
|
|
762
|
+
const receipt = canonicalReceipt();
|
|
763
|
+
const critical = POLICY.workflowPolicySnapshot({ riskSignals: { auth: true }, assurance_level: 'Strict' });
|
|
764
|
+
const audit = {
|
|
765
|
+
schema_version: '1',
|
|
766
|
+
reviewer_session_id: 'review-session-1',
|
|
767
|
+
implementation_session_id: 'implementation-session-1',
|
|
768
|
+
expected_provenance: { ...EXPECTED_PROVENANCE },
|
|
769
|
+
evidence: 'independent audit report for the bound change',
|
|
770
|
+
verdict: 'PASS',
|
|
208
771
|
};
|
|
209
|
-
|
|
772
|
+
const missing = POLICY.completionDecision('PASS', {
|
|
773
|
+
workflow_policy: critical,
|
|
774
|
+
receipt_binding: RECEIPT_BINDING,
|
|
775
|
+
execution_receipt: receipt,
|
|
776
|
+
proofs: {
|
|
777
|
+
needsInspection: { evidence: 'inspection receipt' },
|
|
778
|
+
needsResearchGrounding: { evidence: 'research receipt' },
|
|
779
|
+
},
|
|
780
|
+
});
|
|
781
|
+
assert.equal(missing.completion, 'unfinished');
|
|
782
|
+
assert.ok(missing.missingProof.includes('needsIndependentAudit'));
|
|
783
|
+
assert.equal(missing.unfinished, true);
|
|
784
|
+
|
|
785
|
+
const complete = POLICY.completionDecision('PASS', {
|
|
786
|
+
workflow_policy: critical,
|
|
787
|
+
receipt_binding: RECEIPT_BINDING,
|
|
788
|
+
execution_receipt: receipt,
|
|
789
|
+
proofs: {
|
|
790
|
+
needsInspection: { evidence: 'inspection receipt' },
|
|
791
|
+
needsResearchGrounding: { evidence: 'research receipt' },
|
|
792
|
+
needsIndependentAudit: audit,
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
assert.equal(complete.completion, 'complete');
|
|
796
|
+
assert.equal(complete.unfinished, false);
|
|
797
|
+
assert.equal(complete.warnings, undefined);
|
|
798
|
+
const warnings = POLICY.completionDecision('PASS_WITH_WARNINGS', {
|
|
799
|
+
workflow_policy: critical,
|
|
800
|
+
receipt_binding: RECEIPT_BINDING,
|
|
801
|
+
execution_receipt: receipt,
|
|
802
|
+
proofs: {
|
|
803
|
+
needsInspection: { evidence: 'inspection receipt' },
|
|
804
|
+
needsResearchGrounding: { evidence: 'research receipt' },
|
|
805
|
+
needsIndependentAudit: audit,
|
|
806
|
+
},
|
|
807
|
+
});
|
|
808
|
+
assert.equal(warnings.completion, 'unfinished');
|
|
809
|
+
assert.equal(warnings.unfinished, true);
|
|
810
|
+
assert.match(warnings.blocker, /literal PASS/);
|
|
811
|
+
assert.equal(POLICY.completionDecision('PASS', {
|
|
812
|
+
workflow_policy: { proof_obligations: ['needsExecutionProof'] },
|
|
813
|
+
receipt_binding: RECEIPT_BINDING,
|
|
814
|
+
execution_receipt: receipt,
|
|
815
|
+
}).completion, 'unfinished');
|
|
816
|
+
assert.equal(POLICY.completionDecision('PASS', {
|
|
817
|
+
workflow_policy: {},
|
|
818
|
+
receipt_binding: RECEIPT_BINDING,
|
|
819
|
+
execution_receipt: receipt,
|
|
820
|
+
}).completion, 'unfinished');
|
|
821
|
+
assert.equal(POLICY.completionDecision('PASS', {
|
|
822
|
+
workflow_policy: critical,
|
|
823
|
+
execution_receipt: receipt,
|
|
824
|
+
}).completion, 'unfinished', 'arbitrary Base/Head without runtime binding must not complete');
|
|
825
|
+
assert.equal(POLICY.completionDecision('PASS', {
|
|
826
|
+
workflow_policy: critical,
|
|
827
|
+
receipt_binding: RECEIPT_BINDING,
|
|
828
|
+
execution_receipt: receipt,
|
|
829
|
+
proofs: {
|
|
830
|
+
needsInspection: { evidence: 'inspection receipt' },
|
|
831
|
+
needsResearchGrounding: { evidence: 'research receipt' },
|
|
832
|
+
needsIndependentAudit: { independent: true, evidence: 'marker only', verdict: 'PASS' },
|
|
833
|
+
},
|
|
834
|
+
}).completion, 'unfinished', 'independent marker must not satisfy audit');
|
|
835
|
+
assert.equal(POLICY.validateIndependentAuditEvidence({ independent: true }, EXPECTED_PROVENANCE).valid, false);
|
|
836
|
+
assert.equal(POLICY.validateIndependentAuditEvidence({ ...audit, verdict: 'PASS_WITH_WARNINGS' }, EXPECTED_PROVENANCE).valid, false);
|
|
837
|
+
assert.equal(POLICY.validateIndependentAuditEvidence({ ...audit, reviewer_session_id: audit.implementation_session_id }, EXPECTED_PROVENANCE).valid, false);
|
|
838
|
+
assert.equal(POLICY.validateIndependentAuditEvidence({ ...audit, expected_provenance: { base: EXPECTED_BASE, head: 'fedcba9876543210fedcba9876543210fedcba98' } }, EXPECTED_PROVENANCE).valid, false);
|
|
839
|
+
assert.equal(POLICY.validateIndependentAuditEvidence({ ...audit, extra: 'forged' }, EXPECTED_PROVENANCE).valid, false);
|
|
840
|
+
assert.equal(POLICY.validateIndependentAuditEvidence([audit], EXPECTED_PROVENANCE).valid, false);
|
|
841
|
+
assert.equal(POLICY.validateIndependentAuditEvidence(audit, EXPECTED_PROVENANCE, RUNTIME_CONTEXT).valid, true);
|
|
842
|
+
assert.equal(POLICY.validateIndependentAuditEvidence(audit, EXPECTED_PROVENANCE, { ...RUNTIME_CONTEXT }).valid, false, 'forged runtime session context must not satisfy audit binding');
|
|
843
|
+
assert.equal(POLICY.completionDecision('PASS', {}).completion, 'unfinished');
|
|
844
|
+
const artifactContext = {
|
|
845
|
+
workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: {} }),
|
|
846
|
+
receipt_binding: RECEIPT_BINDING,
|
|
847
|
+
task_context: { artifacts: ['output/bundle.js'], expected_provenance: { ...EXPECTED_PROVENANCE } },
|
|
848
|
+
execution_receipt: receipt,
|
|
849
|
+
proofs: { needsInspection: { evidence: 'inspection receipt' } },
|
|
850
|
+
receipt_options: { requireArtifactHash: false, artifactPaths: [] },
|
|
851
|
+
};
|
|
852
|
+
const artifactBlocked = POLICY.completionDecision('PASS', artifactContext);
|
|
853
|
+
assert.equal(artifactBlocked.completion, 'unfinished', 'caller receipt options must not weaken task artifact requirements');
|
|
854
|
+
assert.ok(artifactBlocked.missingProof.some((item) => item.includes('artifact_hash')));
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
test('auto authoring reaches readiness only after promotion, validation, grounding, and current review', () => {
|
|
858
|
+
const fixture = createCanonicalAuthoringFixture();
|
|
859
|
+
try {
|
|
860
|
+
let spec = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
|
|
861
|
+
assert.equal(spec.semantic_model, null);
|
|
862
|
+
spec.ready_for_implementation = true;
|
|
863
|
+
fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
|
|
864
|
+
let validation = VALIDATOR.validateSpec(fixture.specDir);
|
|
865
|
+
assert.ok(validation.errors.some((error) => error.includes('requires explicit promoted machine semantic authority')));
|
|
866
|
+
|
|
867
|
+
spec.ready_for_implementation = false;
|
|
868
|
+
fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
|
|
869
|
+
promoteCanonicalModel(fixture);
|
|
870
|
+
spec = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
|
|
871
|
+
assert.ok(spec.semantic_model && spec.semantic_model.criteria.length === 2);
|
|
872
|
+
spec.ready_for_implementation = true;
|
|
873
|
+
fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
|
|
874
|
+
validation = VALIDATOR.validateSpec(fixture.specDir);
|
|
875
|
+
assert.ok(validation.errors.some((error) => error.includes('readiness requires validated')));
|
|
876
|
+
assert.ok(validation.errors.some((error) => error.includes('requires completed semantic review')));
|
|
877
|
+
|
|
878
|
+
spec = makeCanonicalReady(fixture);
|
|
879
|
+
validation = VALIDATOR.validateSpec(fixture.specDir);
|
|
880
|
+
assert.deepEqual(validation.errors, [], validation.errors.join('\n'));
|
|
881
|
+
assert.equal(GROUNDER.groundSpec({ specDir: fixture.specDir, root: fixture.root, spec }).errors.length, 0);
|
|
882
|
+
|
|
883
|
+
const specs = read(SPECS);
|
|
884
|
+
const executableBlocks = [...specs.matchAll(/```bash\n([\s\S]*?)```/g)].map((match) => match[1]);
|
|
885
|
+
assert.ok(executableBlocks.every((block) => !/hapo:?develop/i.test(block)), 'Specs must never execute Develop');
|
|
886
|
+
const handoff = markdownSection(specs, 'Platform acceptance and handoff');
|
|
887
|
+
assert.ok(handoff.includes('After readiness'));
|
|
888
|
+
assert.ok(handoff.includes('/hapo:develop <feature>'));
|
|
889
|
+
assert.ok(handoff.includes('$hapo-develop <feature>'));
|
|
890
|
+
assert.ok(handoff.includes('must stop in Specs'));
|
|
891
|
+
} finally {
|
|
892
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
test('atomic readiness supports one-criterion Routine specs and binds semantic text', () => {
|
|
897
|
+
const fixture = createOrdinaryAuthoringFixture();
|
|
898
|
+
try {
|
|
899
|
+
const authored = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
|
|
900
|
+
authored.decisions = [
|
|
901
|
+
{ id: 'Q1', classification: 'repository_fact', statement: 'The runtime entrypoint is src/entry.js.', status: 'grounded', evidence: 'Repository anchor A-D-01 resolves to src/entry.js.' },
|
|
902
|
+
{ id: 'Q2', classification: 'reversible_assumption', statement: 'Use the existing direct module export for this bounded change.', status: 'recorded', evidence: 'Reversal is limited to src/entry.js and does not alter product data.' },
|
|
903
|
+
];
|
|
904
|
+
fs.writeFileSync(fixture.specPath, `${JSON.stringify(authored, null, 2)}\n`);
|
|
905
|
+
const result = READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() });
|
|
906
|
+
assert.equal(result.spec.ready_for_implementation, true);
|
|
907
|
+
assert.equal(result.spec.semantic_model.criteria.length, 1);
|
|
908
|
+
assert.match(result.spec.semantic_model.criteria[0].text, /delete the selected record/);
|
|
909
|
+
assert.deepEqual(VALIDATOR.validateSpec(fixture.specDir).errors, []);
|
|
910
|
+
|
|
911
|
+
const beforeDigest = result.semantic_digest;
|
|
912
|
+
const design = path.join(fixture.specDir, 'design.md');
|
|
913
|
+
const originalDesign = fs.readFileSync(design, 'utf8');
|
|
914
|
+
fs.writeFileSync(design, originalDesign.replace('deleted rather than archived', 'archived rather than deleted'));
|
|
915
|
+
const decisionProjection = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-semantic-model.cjs')).modelFromMarkdown(fixture.specDir, result.spec);
|
|
916
|
+
assert.notDeepEqual(decisionProjection.model.design_records, result.spec.semantic_model.design_records);
|
|
917
|
+
const decisionDigest = VALIDATOR.computeSemanticDigest21(fixture.specDir, { ...result.spec, semantic_model: decisionProjection.model });
|
|
918
|
+
assert.equal(decisionDigest.errors.length, 0, decisionDigest.errors.join('\n'));
|
|
919
|
+
assert.notEqual(decisionDigest.digest, beforeDigest);
|
|
920
|
+
fs.writeFileSync(design, originalDesign);
|
|
921
|
+
|
|
922
|
+
const requirements = path.join(fixture.specDir, 'requirements.md');
|
|
923
|
+
fs.writeFileSync(requirements, fs.readFileSync(requirements, 'utf8').replace('delete the selected record', 'archive the selected record'));
|
|
924
|
+
const projection = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-semantic-model.cjs')).modelFromMarkdown(fixture.specDir, result.spec);
|
|
925
|
+
assert.notEqual(projection.model.criteria[0].text, result.spec.semantic_model.criteria[0].text);
|
|
926
|
+
const changed = VALIDATOR.computeSemanticDigest21(fixture.specDir, { ...result.spec, semantic_model: projection.model });
|
|
927
|
+
assert.equal(changed.errors.length, 0, changed.errors.join('\n'));
|
|
928
|
+
assert.notEqual(changed.digest, beforeDigest);
|
|
929
|
+
assert.ok(VALIDATOR.validateSpec(fixture.specDir).errors.some((error) => /projection|stale/.test(error)));
|
|
930
|
+
} finally { fs.rmSync(fixture.root, { recursive: true, force: true }); }
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
test('atomic readiness preserves exact bytes on review, grounding, decision, and Strict authority failures', () => {
|
|
934
|
+
const fixture = createOrdinaryAuthoringFixture();
|
|
935
|
+
try {
|
|
936
|
+
const original = fs.readFileSync(fixture.specPath);
|
|
937
|
+
for (const mutate of [
|
|
938
|
+
(review) => ({ ...review, reviewed_criteria: [] }),
|
|
939
|
+
(review) => ({ ...review, counterexamples: [] }),
|
|
940
|
+
]) {
|
|
941
|
+
assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: mutate(ordinaryReview()) }), /readiness validation/);
|
|
942
|
+
assert.equal(fs.readFileSync(fixture.specPath).equals(original), true);
|
|
943
|
+
}
|
|
944
|
+
fs.unlinkSync(path.join(fixture.root, 'src', 'entry.js'));
|
|
945
|
+
assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /grounding|validation/);
|
|
946
|
+
assert.equal(fs.readFileSync(fixture.specPath).equals(original), true);
|
|
947
|
+
fs.writeFileSync(path.join(fixture.root, 'src', 'entry.js'), 'module.exports = { action: "delete" };\n');
|
|
948
|
+
|
|
949
|
+
process.env.CAFEKIT_FINALIZE_FAIL_BEFORE_RENAME = '1';
|
|
950
|
+
try {
|
|
951
|
+
assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /injected finalization failure/);
|
|
952
|
+
assert.equal(fs.readFileSync(fixture.specPath).equals(original), true);
|
|
953
|
+
} finally { delete process.env.CAFEKIT_FINALIZE_FAIL_BEFORE_RENAME; }
|
|
954
|
+
|
|
955
|
+
const blocked = JSON.parse(original);
|
|
956
|
+
blocked.decisions = [{ id: 'Q1', classification: 'user_owned', statement: 'Choose permanent deletion or retention archive behavior.', status: 'unresolved', evidence: null }];
|
|
957
|
+
fs.writeFileSync(fixture.specPath, `${JSON.stringify(blocked, null, 2)}\n`);
|
|
958
|
+
const blockedBytes = fs.readFileSync(fixture.specPath);
|
|
959
|
+
assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /unresolved material user-owned decision/);
|
|
960
|
+
assert.equal(fs.readFileSync(fixture.specPath).equals(blockedBytes), true);
|
|
961
|
+
|
|
962
|
+
delete blocked.decisions;
|
|
963
|
+
blocked.workflow_policy = POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Compact', assurance_level: 'Strict' });
|
|
964
|
+
fs.writeFileSync(fixture.specPath, `${JSON.stringify(blocked, null, 2)}\n`);
|
|
965
|
+
const strictBytes = fs.readFileSync(fixture.specPath);
|
|
966
|
+
assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /Strict readiness/);
|
|
967
|
+
assert.equal(fs.readFileSync(fixture.specPath).equals(strictBytes), true);
|
|
968
|
+
} finally { fs.rmSync(fixture.root, { recursive: true, force: true }); }
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
test('Strict atomic readiness succeeds only after current allowlisted host observation', () => {
|
|
972
|
+
const fixture = createOrdinaryAuthoringFixture();
|
|
973
|
+
try {
|
|
974
|
+
const spec = JSON.parse(fs.readFileSync(fixture.specPath, 'utf8'));
|
|
975
|
+
spec.workflow_policy = POLICY.canonicalWorkflowPolicySnapshot({ planning_depth: 'Compact', assurance_level: 'Strict' });
|
|
976
|
+
fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
|
|
977
|
+
const before = fs.readFileSync(fixture.specPath);
|
|
978
|
+
assert.throws(() => READINESS.finalizeReadiness({ specDir: fixture.specDir, projectRoot: fixture.root, reviewResult: ordinaryReview() }), /Strict readiness/);
|
|
979
|
+
assert.equal(fs.readFileSync(fixture.specPath).equals(before), true);
|
|
980
|
+
|
|
981
|
+
const digest = VALIDATOR.computeSemanticDigest21(fixture.specDir, spec);
|
|
982
|
+
assert.equal(digest.errors.length, 0, digest.errors.join('\n'));
|
|
983
|
+
const authorityHome = path.join(fixture.root, 'authority-home');
|
|
984
|
+
const claim = `CAFEKIT_SEMANTIC_REVIEW_ATTESTATION ${JSON.stringify({ feature_name: 'ordinary', spec_file: 'specs/ordinary/spec.json', semantic_digest: digest.digest, verdict: 'PASS' })}`;
|
|
985
|
+
const observed = spawnSync(process.execPath, [path.join(PACKAGE_ROOT, 'src/claude/hooks/semantic-review-authority.cjs')], {
|
|
986
|
+
cwd: fixture.root,
|
|
987
|
+
env: { ...process.env, HOME: authorityHome, USERPROFILE: authorityHome, PROJECT_ROOT: fixture.root },
|
|
988
|
+
input: JSON.stringify({ hook_event_name: 'SubagentStop', session_id: 'host-session', agent_id: 'reviewer-1', agent_type: 'code_auditor', last_assistant_message: claim, cwd: fixture.root }),
|
|
989
|
+
encoding: 'utf8',
|
|
990
|
+
});
|
|
991
|
+
assert.equal(observed.status, 0, observed.stderr);
|
|
992
|
+
const reviewFile = path.join(fixture.root, 'review.json');
|
|
993
|
+
fs.writeFileSync(reviewFile, `${JSON.stringify(ordinaryReview())}\n`);
|
|
994
|
+
const finalized = spawnSync(process.execPath, [path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-readiness.cjs'), fixture.specDir, '--review-result', reviewFile], {
|
|
995
|
+
cwd: fixture.root,
|
|
996
|
+
env: { ...process.env, HOME: authorityHome, USERPROFILE: authorityHome, PROJECT_ROOT: fixture.root },
|
|
997
|
+
encoding: 'utf8',
|
|
998
|
+
});
|
|
999
|
+
assert.equal(finalized.status, 0, `${finalized.stdout}\n${finalized.stderr}`);
|
|
1000
|
+
assert.equal(JSON.parse(fs.readFileSync(fixture.specPath, 'utf8')).ready_for_implementation, true);
|
|
1001
|
+
} finally { fs.rmSync(fixture.root, { recursive: true, force: true }); }
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
test('done is a final-state request bound to current semantic model and digest', () => {
|
|
1005
|
+
const fixture = createCanonicalAuthoringFixture();
|
|
1006
|
+
try {
|
|
1007
|
+
promoteCanonicalModel(fixture);
|
|
1008
|
+
const spec = makeCanonicalReady(fixture);
|
|
1009
|
+
spec.status = 'done';
|
|
1010
|
+
fs.writeFileSync(fixture.specPath, `${JSON.stringify(spec, null, 2)}\n`);
|
|
1011
|
+
const candidate = {
|
|
1012
|
+
featureDir: fixture.specDir,
|
|
1013
|
+
featureName: 'feature',
|
|
1014
|
+
specFile: fixture.specPath,
|
|
1015
|
+
spec,
|
|
1016
|
+
};
|
|
1017
|
+
const dependencies = () => ({
|
|
1018
|
+
validator: VALIDATOR,
|
|
1019
|
+
grounder: GROUNDER,
|
|
1020
|
+
semanticAuthority: SEMANTIC_AUTHORITY,
|
|
1021
|
+
});
|
|
1022
|
+
const current = FINAL_STATE.validateCanonicalFinalState({
|
|
1023
|
+
policy: POLICY, projectRoot: fixture.root, candidate, dependencies,
|
|
1024
|
+
});
|
|
1025
|
+
assert.equal(current.ok, true, current.reason);
|
|
1026
|
+
assert.equal(current.semanticDigest, spec.validation.semantic_review.semantic_digest);
|
|
1027
|
+
|
|
1028
|
+
const requirementsPath = path.join(fixture.specDir, 'requirements.md');
|
|
1029
|
+
const requirements = fs.readFileSync(requirementsPath, 'utf8');
|
|
1030
|
+
fs.writeFileSync(requirementsPath, requirements.replace('invalid input shall return rejected state', 'invalid input shall return a deterministic rejected state'));
|
|
1031
|
+
const staleDigest = FINAL_STATE.validateCanonicalFinalState({
|
|
1032
|
+
policy: POLICY, projectRoot: fixture.root, candidate, dependencies,
|
|
1033
|
+
});
|
|
1034
|
+
assert.equal(staleDigest.ok, false);
|
|
1035
|
+
assert.match(staleDigest.reason, /stale|differs from Markdown projection/i);
|
|
1036
|
+
|
|
1037
|
+
fs.writeFileSync(requirementsPath, requirements);
|
|
1038
|
+
const staleModel = structuredClone(spec);
|
|
1039
|
+
staleModel.semantic_model.criteria[0].owner = 'A-D-02';
|
|
1040
|
+
fs.writeFileSync(fixture.specPath, `${JSON.stringify(staleModel, null, 2)}\n`);
|
|
1041
|
+
const invalidModel = FINAL_STATE.validateCanonicalFinalState({
|
|
1042
|
+
policy: POLICY,
|
|
1043
|
+
projectRoot: fixture.root,
|
|
1044
|
+
candidate: { ...candidate, spec: staleModel },
|
|
1045
|
+
dependencies,
|
|
1046
|
+
});
|
|
1047
|
+
assert.equal(invalidModel.ok, false);
|
|
1048
|
+
assert.match(invalidModel.reason, /semantic projection|semantic_model|validation failed/i);
|
|
1049
|
+
} finally {
|
|
1050
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
test('flash PASS promotes proof; only trusted sync-finalize completes', () => {
|
|
1055
|
+
const initial = canonicalFlashTask();
|
|
1056
|
+
for (const verdict of ['FAIL', 'BLOCKED', 'PARTIAL', 'NO_TESTS', 'PASS_WITH_WARNINGS']) {
|
|
210
1057
|
const result = POLICY.promoteFlashTask(initial, verdict);
|
|
211
1058
|
assert.equal(result.status, 'in_progress');
|
|
212
1059
|
assert.equal(result.receipt, 'FLASH_UNVERIFIED');
|
|
@@ -214,7 +1061,7 @@ test('flash PASS promotes proof without completion, finalize alone completes', (
|
|
|
214
1061
|
assert.equal(result.unblocks, false);
|
|
215
1062
|
assert.match(result.blocker, /verification|test proof/);
|
|
216
1063
|
}
|
|
217
|
-
const promoted = POLICY.promoteFlashTask(initial, 'PASS',
|
|
1064
|
+
const promoted = POLICY.promoteFlashTask(initial, 'PASS', canonicalReceipt());
|
|
218
1065
|
assert.equal(promoted.status, 'in_progress');
|
|
219
1066
|
assert.equal(promoted.receipt.startsWith('Verification: PASS'), true);
|
|
220
1067
|
assert.equal(promoted.blocker, null);
|
|
@@ -222,33 +1069,38 @@ test('flash PASS promotes proof without completion, finalize alone completes', (
|
|
|
222
1069
|
assert.equal(promoted.unblocks, false);
|
|
223
1070
|
assert.equal(promoted.readyForSync, true);
|
|
224
1071
|
assert.equal(POLICY.finalizeFlashTask(promoted, 'sync'), promoted);
|
|
225
|
-
const
|
|
1072
|
+
const forgedFinalize = POLICY.finalizeFlashTask(promoted, 'sync-finalize');
|
|
1073
|
+
assert.equal(forgedFinalize.status, 'in_progress');
|
|
1074
|
+
assert.equal(forgedFinalize.dependencyBlocked, true);
|
|
1075
|
+
assert.equal(forgedFinalize.unblocks, false);
|
|
1076
|
+
const finalized = POLICY.syncFinalizeFlashTask(initial, 'PASS', promoted.receipt);
|
|
226
1077
|
assert.equal(finalized.status, 'done');
|
|
227
1078
|
assert.equal(finalized.dependencyBlocked, false);
|
|
228
1079
|
assert.equal(finalized.unblocks, true);
|
|
229
1080
|
assert.equal(finalized.readyForSync, false);
|
|
230
1081
|
assert.equal(POLICY.isStaleFlashDone({ status: 'done', receipt: 'FLASH_UNVERIFIED' }), true);
|
|
231
|
-
assert.match(read(TEST_SKILL), /only explicit .*sync-finalize/);
|
|
1082
|
+
assert.match(read(TEST_SKILL), /only explicit .*sync-finalize/i);
|
|
232
1083
|
assert.match(read(SYNC_SKILL), /sync-finalize/);
|
|
233
1084
|
});
|
|
234
1085
|
|
|
235
1086
|
test('spec-gate rejects stale FLASH_UNVERIFIED done state', () => {
|
|
236
1087
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-spec-gate-'));
|
|
237
|
-
const
|
|
238
|
-
fs.
|
|
239
|
-
fs.
|
|
240
|
-
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/spec-gate.cjs'), path.join(claude, 'hooks/spec-gate.cjs'));
|
|
241
|
-
fs.copyFileSync(POLICY_PATH, path.join(claude, 'scripts/workflow-policy.cjs'));
|
|
1088
|
+
const specGate = installClaudeRuntimeClosure(root);
|
|
1089
|
+
assert.equal(fs.existsSync(path.join(root, '.claude', 'scripts', 'spec-final-state.cjs')), true);
|
|
1090
|
+
assert.equal(fs.existsSync(path.join(root, '.claude', 'scripts', 'validate-spec-output.cjs')), true);
|
|
242
1091
|
fs.mkdirSync(path.join(root, 'specs', 'demo', 'tasks'), { recursive: true });
|
|
243
1092
|
fs.writeFileSync(path.join(root, 'specs', 'demo', 'spec.json'), JSON.stringify({
|
|
244
1093
|
status: 'in_progress',
|
|
1094
|
+
current_phase: 'closeout',
|
|
1095
|
+
feature_name: 'demo',
|
|
245
1096
|
task_registry: { 'tasks/task.md': { status: 'done', receipt: 'FLASH_UNVERIFIED' } },
|
|
246
1097
|
}));
|
|
1098
|
+
initFixtureGit(root);
|
|
247
1099
|
try {
|
|
248
|
-
const result = spawnSync(process.execPath, [
|
|
1100
|
+
const result = spawnSync(process.execPath, [specGate], {
|
|
249
1101
|
cwd: root,
|
|
250
1102
|
env: { ...process.env, PROJECT_ROOT: root },
|
|
251
|
-
input: JSON.stringify({ cwd: root }),
|
|
1103
|
+
input: JSON.stringify({ cwd: root, session_id: 'test' }),
|
|
252
1104
|
encoding: 'utf8',
|
|
253
1105
|
});
|
|
254
1106
|
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
@@ -263,40 +1115,51 @@ test('spec-gate rejects stale FLASH_UNVERIFIED done state', () => {
|
|
|
263
1115
|
test('first-run cache bypass is blocked - canonical receipt required even without cache', () => {
|
|
264
1116
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-first-run-'));
|
|
265
1117
|
const claude = path.join(root, '.claude');
|
|
266
|
-
|
|
267
|
-
fs.mkdirSync(path.join(claude, 'scripts'), { recursive: true });
|
|
268
|
-
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/spec-gate.cjs'), path.join(claude, 'hooks/spec-gate.cjs'));
|
|
269
|
-
fs.copyFileSync(POLICY_PATH, path.join(claude, 'scripts/workflow-policy.cjs'));
|
|
1118
|
+
const specGate = installClaudeRuntimeClosure(root);
|
|
270
1119
|
const specDir = path.join(root, 'specs', 'demo', 'tasks');
|
|
271
1120
|
fs.mkdirSync(specDir, { recursive: true });
|
|
272
1121
|
// Done task without canonical receipt (missing Command, provenance)
|
|
273
1122
|
fs.writeFileSync(path.join(root, 'specs', 'demo', 'spec.json'), JSON.stringify({
|
|
274
|
-
status: '
|
|
275
|
-
|
|
1123
|
+
status: 'done',
|
|
1124
|
+
current_phase: 'closeout',
|
|
1125
|
+
feature_name: 'demo',
|
|
1126
|
+
task_registry: { 'tasks/task-R1-01-one.md': { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } },
|
|
276
1127
|
}));
|
|
277
|
-
fs.writeFileSync(path.join(specDir, 'task.md'), '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\n```\nnpm test\n```\n');
|
|
1128
|
+
fs.writeFileSync(path.join(specDir, 'task-R1-01-one.md'), '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\n```\nnpm test\n```\n');
|
|
1129
|
+
initFixtureGit(root);
|
|
278
1130
|
try {
|
|
279
|
-
|
|
1131
|
+
assert.equal(fs.existsSync(path.join(claude, 'hooks', '.logs', 'spec-gate-last.json')), false);
|
|
1132
|
+
const result = spawnSync(process.execPath, [specGate], {
|
|
280
1133
|
cwd: root,
|
|
281
1134
|
env: { ...process.env, PROJECT_ROOT: root },
|
|
282
|
-
input: JSON.stringify({ cwd: root }),
|
|
1135
|
+
input: JSON.stringify({ cwd: root, session_id: 'test' }),
|
|
283
1136
|
encoding: 'utf8',
|
|
284
1137
|
});
|
|
285
1138
|
assert.equal(result.status, 0);
|
|
286
1139
|
const payload = JSON.parse(result.stdout);
|
|
287
1140
|
assert.equal(payload.decision, 'block', 'first-run without canonical receipt must block');
|
|
288
|
-
assert.match(payload.reason, /tasks\/task\.md/);
|
|
1141
|
+
assert.match(payload.reason, /tasks\/task-R1-01-one\.md/);
|
|
1142
|
+
assert.match(payload.reason, /receipt|command|provenance/i);
|
|
289
1143
|
// Also test that with canonical receipt it passes
|
|
290
|
-
fs.writeFileSync(path.join(specDir, 'task.md'), '# Task\n\n**Status:** done\n\n##
|
|
1144
|
+
fs.writeFileSync(path.join(specDir, 'task-R1-01-one.md'), '# Task R1-01: One\n\n**Status:** done\n\n## Outcome\n\nVerified result.\n\n## Scope and Typed Anchors\n\n- **In scope:** focused result\n- **Out of scope:** none\n\n## Changes\n\n- [x] Verify result.\n\n## Acceptance\n\n- Result is verified.\n\n## Dependencies\n\n- none\n\n## Verification Plan\n\n- **Command:** `pnpm test`\n- **Expected:** focused tests pass\n- **Negative path:** not relevant for fixture\n- **Reachability:** fixture test entrypoint\n');
|
|
1145
|
+
fs.mkdirSync(path.join(root, 'specs', 'demo', 'receipts'), { recursive: true });
|
|
1146
|
+
fs.writeFileSync(
|
|
1147
|
+
path.join(root, 'specs', 'demo', 'receipts', 'task-R1-01-one.md'),
|
|
1148
|
+
bindFixtureReceipt(root, '# Task Receipt\n\nTask: task-R1-01-one.md\nTask path: tasks/task-R1-01-one.md\nVerification: PASS\nCommand: pnpm test\nExit: 0\nResult: PASS\nExpected: focused tests pass\nObserved: focused tests passed\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n```\nnpm test\nPASS\n```\n'),
|
|
1149
|
+
);
|
|
1150
|
+
fs.writeFileSync(
|
|
1151
|
+
path.join(root, 'specs', 'demo', 'feature-receipt.md'),
|
|
1152
|
+
bindFixtureReceipt(root, '# Feature Receipt\n\nFeature: demo\nVerification: PASS\nCommand: pnpm test\nExit: 0\nResult: PASS\nExpected: integrated feature passes\nObserved: integrated feature passed\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n'),
|
|
1153
|
+
);
|
|
291
1154
|
// Clear cache to simulate fresh first-run with valid receipt
|
|
292
1155
|
try { fs.unlinkSync(path.join(claude, 'hooks', '.logs', 'spec-gate-last.json')); } catch {}
|
|
293
1156
|
try { fs.unlinkSync(path.join(__dirname, '..', '.logs', 'spec-gate-last.json')); } catch {}
|
|
294
1157
|
// Need to clear the cache that the hook uses (under claude hooks .logs) - we already cleared above
|
|
295
1158
|
// Re-run with valid receipt - should not block
|
|
296
|
-
const result2 = spawnSync(process.execPath, [
|
|
1159
|
+
const result2 = spawnSync(process.execPath, [specGate], {
|
|
297
1160
|
cwd: root,
|
|
298
1161
|
env: { ...process.env, PROJECT_ROOT: root },
|
|
299
|
-
input: JSON.stringify({ cwd: root }),
|
|
1162
|
+
input: JSON.stringify({ cwd: root, session_id: 'test' }),
|
|
300
1163
|
encoding: 'utf8',
|
|
301
1164
|
});
|
|
302
1165
|
assert.equal(result2.stdout, '', 'valid canonical receipt on first run must not block');
|
|
@@ -307,7 +1170,7 @@ test('first-run cache bypass is blocked - canonical receipt required even withou
|
|
|
307
1170
|
});
|
|
308
1171
|
|
|
309
1172
|
test('canonical receipt requires command, exit, provenance and unambiguous PASS', () => {
|
|
310
|
-
assert.deepEqual(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase:
|
|
1173
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n'), []);
|
|
311
1174
|
const missingCommand = POLICY.validateCanonicalReceipt('Verification: PASS\nExit: 0\nBase: a\nHead: b\n');
|
|
312
1175
|
assert.ok(missingCommand.includes('command'));
|
|
313
1176
|
const missingProvenance = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\n');
|
|
@@ -317,7 +1180,7 @@ test('canonical receipt requires command, exit, provenance and unambiguous PASS'
|
|
|
317
1180
|
const artifactWithoutHash = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: dist/bundle.js\n');
|
|
318
1181
|
assert.ok(artifactWithoutHash.includes('artifact_hash'));
|
|
319
1182
|
// CLI validation
|
|
320
|
-
const cli = spawnSync(process.execPath, [POLICY_PATH, '--validate-receipt', '--task-json', JSON.stringify({ body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase:
|
|
1183
|
+
const cli = spawnSync(process.execPath, [POLICY_PATH, '--validate-receipt', '--task-json', JSON.stringify({ body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n' }), '--json'], { encoding: 'utf8' });
|
|
321
1184
|
assert.equal(cli.status, 0);
|
|
322
1185
|
assert.equal(JSON.parse(cli.stdout).ok, true);
|
|
323
1186
|
});
|
|
@@ -330,7 +1193,7 @@ test('canonical receipt provenance requires both Base and Head (or both base_sha
|
|
|
330
1193
|
const onlyHead = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nHead: def456\n');
|
|
331
1194
|
assert.ok(onlyHead.includes('provenance'), 'only Head should fail provenance');
|
|
332
1195
|
// Both Base and Head passes
|
|
333
|
-
const bothLabels = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase:
|
|
1196
|
+
const bothLabels = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n');
|
|
334
1197
|
assert.deepEqual(bothLabels, [], 'both Base and Head should pass');
|
|
335
1198
|
// Only base_sha fails
|
|
336
1199
|
const onlyBaseSha = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: abc123\n');
|
|
@@ -339,7 +1202,7 @@ test('canonical receipt provenance requires both Base and Head (or both base_sha
|
|
|
339
1202
|
const onlyHeadSha = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nhead_sha: def456\n');
|
|
340
1203
|
assert.ok(onlyHeadSha.includes('provenance'), 'only head_sha should fail provenance');
|
|
341
1204
|
// Both base_sha and head_sha passes
|
|
342
|
-
const bothSha = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha:
|
|
1205
|
+
const bothSha = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: 0123456789abcdef0123456789abcdef01234567\nhead_sha: 89abcdef0123456789abcdef0123456789abcdef\n');
|
|
343
1206
|
assert.deepEqual(bothSha, [], 'both base_sha and head_sha should pass');
|
|
344
1207
|
// Alternation-precedence false acceptance check: ensure single Head or base_sha alone does not pass
|
|
345
1208
|
const singleHeadLower = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nHead: single\n');
|
|
@@ -365,16 +1228,16 @@ test('canonical receipt provenance requires both Base and Head (or both base_sha
|
|
|
365
1228
|
assert.ok(bareBoth.includes('provenance'), 'bare base_sha head_sha without colon/value should fail');
|
|
366
1229
|
const baseEmptyHeadValid = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase:\nHead: def\n');
|
|
367
1230
|
assert.ok(baseEmptyHeadValid.includes('provenance'));
|
|
368
|
-
const validWithValues = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase:
|
|
1231
|
+
const validWithValues = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n');
|
|
369
1232
|
assert.deepEqual(validWithValues, [], 'valid Base and Head with values should pass');
|
|
370
|
-
const validShaWithValues = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha:
|
|
1233
|
+
const validShaWithValues = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: 0123456789abcdef0123456789abcdef01234567\nhead_sha: 89abcdef0123456789abcdef0123456789abcdef\n');
|
|
371
1234
|
assert.deepEqual(validShaWithValues, [], 'valid base_sha and head_sha with values should pass');
|
|
372
1235
|
// CLI: only Base via --validate-receipt should fail
|
|
373
1236
|
const cliOnlyBase = spawnSync(process.execPath, [POLICY_PATH, '--validate-receipt', '--task-json', JSON.stringify({ body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\n' }), '--json'], { encoding: 'utf8' });
|
|
374
1237
|
assert.equal(cliOnlyBase.status, 2);
|
|
375
1238
|
assert.equal(JSON.parse(cliOnlyBase.stdout).ok, false);
|
|
376
1239
|
assert.ok(JSON.parse(cliOnlyBase.stdout).failures.includes('provenance'));
|
|
377
|
-
const cliBoth = spawnSync(process.execPath, [POLICY_PATH, '--validate-receipt', '--task-json', JSON.stringify({ body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase:
|
|
1240
|
+
const cliBoth = spawnSync(process.execPath, [POLICY_PATH, '--validate-receipt', '--task-json', JSON.stringify({ body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n' }), '--json'], { encoding: 'utf8' });
|
|
378
1241
|
assert.equal(cliBoth.status, 0);
|
|
379
1242
|
assert.equal(JSON.parse(cliBoth.stdout).ok, true);
|
|
380
1243
|
// CLI empty Base should fail
|
|
@@ -383,43 +1246,209 @@ test('canonical receipt provenance requires both Base and Head (or both base_sha
|
|
|
383
1246
|
assert.ok(JSON.parse(cliEmptyBase.stdout).failures.includes('provenance'));
|
|
384
1247
|
});
|
|
385
1248
|
|
|
386
|
-
test('
|
|
1249
|
+
test('canonical receipt binds expected provenance when the runtime supplies it', () => {
|
|
1250
|
+
const body = canonicalReceipt();
|
|
1251
|
+
const binding = POLICY.createReceiptBinding(RUNTIME_CONTEXT);
|
|
1252
|
+
assert.deepEqual(binding.expectedProvenance, EXPECTED_PROVENANCE);
|
|
1253
|
+
assert.equal(binding.requireProvenanceBinding, true);
|
|
1254
|
+
assert.equal(binding.runtimeContext.context_id, RUNTIME_CONTEXT.context_id);
|
|
1255
|
+
assert.throws(() => POLICY.createReceiptBinding(EXPECTED_PROVENANCE), /runtime-derived provenance context/);
|
|
1256
|
+
assert.ok(POLICY.validateCanonicalReceipt(body, { requireProvenanceBinding: true }).includes('provenance'));
|
|
1257
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(body, { expectedProvenance: EXPECTED_PROVENANCE }), []);
|
|
1258
|
+
assert.ok(POLICY.validateCanonicalReceipt(body, { expectedProvenance: { base: EXPECTED_BASE, head: 'fedcba9876543210fedcba9876543210fedcba98' } }).includes('provenance'));
|
|
1259
|
+
const taskOptions = POLICY.receiptValidatorOptions({ expected_provenance: EXPECTED_PROVENANCE });
|
|
1260
|
+
assert.equal(taskOptions.expectedProvenance, null, 'task-authored expected provenance is diagnostic metadata only');
|
|
1261
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(body, taskOptions), [], 'diagnostic parsing may accept schema-valid anchors without treating them as identity');
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
test('runtime provenance derives exact Git evidence, CLI context, and stale/forged blockers', () => {
|
|
1265
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-provenance-adversarial-'));
|
|
1266
|
+
const specsRoot = path.join(root, 'specs');
|
|
1267
|
+
const featureRoot = path.join(specsRoot, 'demo');
|
|
1268
|
+
const specFile = path.join(featureRoot, 'spec.json');
|
|
1269
|
+
const sourceFile = path.join(root, 'src', 'app.js');
|
|
1270
|
+
const renameSource = path.join(root, 'src', 'rename-old.js');
|
|
1271
|
+
const renameTarget = path.join(root, 'src', 'rename-new.js');
|
|
1272
|
+
fs.mkdirSync(path.dirname(sourceFile), { recursive: true });
|
|
1273
|
+
fs.mkdirSync(featureRoot, { recursive: true });
|
|
1274
|
+
fs.writeFileSync(specFile, JSON.stringify({ status: 'in_progress', feature_name: 'demo', task_registry: {} }));
|
|
1275
|
+
fs.writeFileSync(sourceFile, 'original source\n');
|
|
1276
|
+
fs.writeFileSync(renameSource, 'rename source\n');
|
|
1277
|
+
fs.writeFileSync(path.join(root, '.gitignore'), 'ignored-secret.env\nnode_modules/\n.cache/\n');
|
|
1278
|
+
initFixtureGit(root);
|
|
1279
|
+
try {
|
|
1280
|
+
const input = {
|
|
1281
|
+
projectRoot: root,
|
|
1282
|
+
specsRoot,
|
|
1283
|
+
specFile,
|
|
1284
|
+
featureName: 'demo',
|
|
1285
|
+
runtimeSession: 'provenance-session',
|
|
1286
|
+
};
|
|
1287
|
+
const initial = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
1288
|
+
const exactReceipt = `Verification: PASS\nCommand: node --test\nExit: 0\nBase: ${initial.base}\nHead: ${initial.head}\n`;
|
|
1289
|
+
assert.deepEqual(
|
|
1290
|
+
POLICY.validateCanonicalReceipt(exactReceipt, POLICY.receiptValidatorOptions({}, {
|
|
1291
|
+
runtimeContext: initial,
|
|
1292
|
+
requireProvenanceBinding: true,
|
|
1293
|
+
})),
|
|
1294
|
+
[],
|
|
1295
|
+
'exact runtime-derived receipt must validate',
|
|
1296
|
+
);
|
|
1297
|
+
const arbitraryReceipt = 'Verification: PASS\nCommand: node --test\nExit: 0\nBase: ' + 'a'.repeat(40) + '\nHead: ' + 'b'.repeat(64) + '\n';
|
|
1298
|
+
assert.ok(
|
|
1299
|
+
POLICY.validateCanonicalReceipt(arbitraryReceipt, POLICY.receiptValidatorOptions({}, {
|
|
1300
|
+
runtimeContext: initial,
|
|
1301
|
+
requireProvenanceBinding: true,
|
|
1302
|
+
})).includes('provenance'),
|
|
1303
|
+
'valid-shaped arbitrary SHA values must not bind',
|
|
1304
|
+
);
|
|
1305
|
+
|
|
1306
|
+
const cli = spawnSync(process.execPath, [
|
|
1307
|
+
path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'), '--json',
|
|
1308
|
+
'--project-root', root, '--specs-root', specsRoot, '--spec-file', specFile,
|
|
1309
|
+
'--feature-name', 'demo', '--runtime-session', 'provenance-session',
|
|
1310
|
+
], { cwd: root, encoding: 'utf8' });
|
|
1311
|
+
assert.equal(cli.status, 0, `${cli.stdout}\n${cli.stderr}`);
|
|
1312
|
+
const cliOutput = JSON.parse(cli.stdout);
|
|
1313
|
+
assert.equal(cliOutput.ok, true);
|
|
1314
|
+
assert.equal(cliOutput.Base, initial.base);
|
|
1315
|
+
assert.equal(cliOutput.Head, initial.head);
|
|
1316
|
+
assert.equal(cliOutput.context_id, initial.context_id);
|
|
1317
|
+
|
|
1318
|
+
const ignoredSecret = path.join(root, 'ignored-secret.env');
|
|
1319
|
+
fs.writeFileSync(ignoredSecret, 'TOKEN=first-secret\n');
|
|
1320
|
+
const withIgnoredSecret = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
1321
|
+
fs.writeFileSync(ignoredSecret, 'TOKEN=changed-secret\n');
|
|
1322
|
+
const withChangedIgnoredSecret = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
1323
|
+
assert.equal(withIgnoredSecret.head, initial.head, 'ignored secret-like files must not enter Head');
|
|
1324
|
+
assert.equal(withChangedIgnoredSecret.head, initial.head, 'changing an ignored secret-like file must not change Head');
|
|
1325
|
+
|
|
1326
|
+
const ignoredCacheFile = path.join(root, 'packages', 'app', 'node_modules', 'cache', 'runtime.bin');
|
|
1327
|
+
fs.mkdirSync(path.dirname(ignoredCacheFile), { recursive: true });
|
|
1328
|
+
fs.writeFileSync(ignoredCacheFile, 'runtime cache bytes\n');
|
|
1329
|
+
const withIgnoredCache = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
1330
|
+
fs.writeFileSync(ignoredCacheFile, 'changed runtime cache bytes\n');
|
|
1331
|
+
const withChangedIgnoredCache = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
1332
|
+
assert.equal(withIgnoredCache.head, initial.head, 'nested ignored node_modules/cache files must not enter Head');
|
|
1333
|
+
assert.equal(withChangedIgnoredCache.head, initial.head, 'changing nested ignored cache data must not change Head');
|
|
1334
|
+
|
|
1335
|
+
const forgedContext = { ...initial };
|
|
1336
|
+
const forgedDecision = POLICY.completionDecision('PASS', {
|
|
1337
|
+
workflow_policy: { proof_obligations: ['needsExecutionProof'] },
|
|
1338
|
+
runtime_context: forgedContext,
|
|
1339
|
+
execution_receipt: exactReceipt,
|
|
1340
|
+
});
|
|
1341
|
+
assert.equal(forgedDecision.completion, 'unfinished');
|
|
1342
|
+
assert.deepEqual(forgedDecision.missingProof, ['runtime_provenance']);
|
|
1343
|
+
|
|
1344
|
+
const flash = {
|
|
1345
|
+
status: 'in_progress',
|
|
1346
|
+
receipt: 'FLASH_UNVERIFIED',
|
|
1347
|
+
blocker: 'awaiting test proof',
|
|
1348
|
+
dependencyBlocked: true,
|
|
1349
|
+
unblocks: false,
|
|
1350
|
+
runtime_context: initial,
|
|
1351
|
+
};
|
|
1352
|
+
const promoted = POLICY.promoteFlashTask(flash, 'PASS', exactReceipt);
|
|
1353
|
+
assert.equal(promoted.readyForSync, true);
|
|
1354
|
+
|
|
1355
|
+
fs.writeFileSync(sourceFile, 'mutated source\n');
|
|
1356
|
+
const stale = POLICY.completionDecision('PASS', {
|
|
1357
|
+
workflow_policy: { proof_obligations: ['needsExecutionProof'] },
|
|
1358
|
+
receipt_binding: POLICY.createReceiptBinding(initial),
|
|
1359
|
+
execution_receipt: exactReceipt,
|
|
1360
|
+
});
|
|
1361
|
+
assert.equal(stale.completion, 'unfinished');
|
|
1362
|
+
assert.ok(stale.missingProof.some((item) => item.includes('execution_receipt:provenance')));
|
|
1363
|
+
const staleFinalized = POLICY.syncFinalizeFlashTask(flash, 'PASS', exactReceipt, initial);
|
|
1364
|
+
assert.equal(staleFinalized.status, 'in_progress');
|
|
1365
|
+
assert.equal(staleFinalized.readyForSync, false);
|
|
1366
|
+
|
|
1367
|
+
fs.writeFileSync(path.join(root, 'src', 'untracked.js'), 'untracked source\n');
|
|
1368
|
+
const withUntracked = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
1369
|
+
assert.notEqual(withUntracked.head, withIgnoredCache.head, 'non-ignored untracked source must change Head');
|
|
1370
|
+
fs.chmodSync(renameSource, 0o755);
|
|
1371
|
+
const withMode = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
1372
|
+
assert.notEqual(withMode.head, withUntracked.head, 'mode changes must change Head');
|
|
1373
|
+
fs.renameSync(renameSource, renameTarget);
|
|
1374
|
+
const withRename = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
1375
|
+
assert.notEqual(withRename.head, withMode.head, 'rename path semantics must change Head');
|
|
1376
|
+
fs.unlinkSync(sourceFile);
|
|
1377
|
+
const withDeletion = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
1378
|
+
assert.notEqual(withDeletion.head, withRename.head, 'tracked deletion must change Head');
|
|
1379
|
+
const staged = spawnSync('git', ['-C', root, 'add', '-A'], { encoding: 'utf8' });
|
|
1380
|
+
assert.equal(staged.status, 0, staged.stderr);
|
|
1381
|
+
const stagedRename = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
1382
|
+
assert.notEqual(stagedRename.head, withDeletion.head, 'staged rename/deletion evidence must be included');
|
|
1383
|
+
|
|
1384
|
+
assert.throws(() => PROVENANCE_HELPER.deriveRuntimeContext({
|
|
1385
|
+
...input,
|
|
1386
|
+
feature_name: 'other',
|
|
1387
|
+
}), /disagree/);
|
|
1388
|
+
assert.throws(() => PROVENANCE_HELPER.deriveRuntimeContext({
|
|
1389
|
+
...input,
|
|
1390
|
+
specsRoot: root,
|
|
1391
|
+
}), /distinct project subdirectory/);
|
|
1392
|
+
} finally {
|
|
1393
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
const nonGit = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-provenance-non-git-'));
|
|
1397
|
+
const nonGitSpec = path.join(nonGit, 'specs', 'demo', 'spec.json');
|
|
1398
|
+
fs.mkdirSync(path.dirname(nonGitSpec), { recursive: true });
|
|
1399
|
+
fs.writeFileSync(nonGitSpec, JSON.stringify({ status: 'in_progress', feature_name: 'demo' }));
|
|
1400
|
+
try {
|
|
1401
|
+
const blocked = spawnSync(process.execPath, [
|
|
1402
|
+
path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'), '--json',
|
|
1403
|
+
'--project-root', nonGit, '--specs-root', path.join(nonGit, 'specs'),
|
|
1404
|
+
'--spec-file', nonGitSpec, '--feature-name', 'demo', '--runtime-session', 'non-git-session',
|
|
1405
|
+
], { cwd: nonGit, encoding: 'utf8' });
|
|
1406
|
+
assert.equal(blocked.status, 2);
|
|
1407
|
+
assert.equal(JSON.parse(blocked.stdout).error.code, 'git_command_failed');
|
|
1408
|
+
} finally {
|
|
1409
|
+
fs.rmSync(nonGit, { recursive: true, force: true });
|
|
1410
|
+
}
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
test('lane traces - Direct, Standard, explicit Strict classification, override, state mutation and completion', () => {
|
|
387
1414
|
// Direct: isolated reversible low-risk
|
|
388
1415
|
const direct = POLICY.classifyLane({ reversible: true, lowRisk: true, isolated: true, taskCount: 1 });
|
|
389
1416
|
assert.equal(direct.lane, 'Direct');
|
|
390
1417
|
assert.equal(direct.automaticLane, 'Direct');
|
|
391
|
-
assert.deepEqual(POLICY.lanePolicy(direct).
|
|
1418
|
+
assert.deepEqual(POLICY.lanePolicy(direct).proof_obligations, ['needsExecutionProof']);
|
|
392
1419
|
assert.equal(POLICY.lanePolicy(direct).requiresSpec, false);
|
|
393
1420
|
assert.equal(POLICY.lanePolicy(direct).shipPoint, 'task');
|
|
394
1421
|
// Standard: default
|
|
395
1422
|
const standard = POLICY.classifyLane({ title: 'add pagination to list', taskCount: 1 });
|
|
396
1423
|
assert.equal(standard.lane, 'Standard');
|
|
397
|
-
assert.deepEqual(POLICY.lanePolicy(standard).
|
|
1424
|
+
assert.deepEqual(POLICY.lanePolicy(standard).proof_obligations, ['needsExecutionProof']);
|
|
398
1425
|
assert.equal(POLICY.lanePolicy(standard).shipPoint, 'feature');
|
|
399
|
-
// Critical
|
|
400
|
-
const
|
|
1426
|
+
// Risk alone is Elevated; Critical requires an explicit Strict assurance choice.
|
|
1427
|
+
const elevated = POLICY.classifyLane({ riskSignals: { auth: true }, taskCount: 1 });
|
|
1428
|
+
assert.equal(elevated.lane, 'Standard');
|
|
1429
|
+
assert.deepEqual(POLICY.lanePolicy(elevated).proof_obligations, ['needsInspection', 'needsExecutionProof']);
|
|
1430
|
+
const critical = POLICY.classifyLane({ riskSignals: { auth: true }, assurance_level: 'Strict', taskCount: 1 });
|
|
401
1431
|
assert.equal(critical.lane, 'Critical');
|
|
402
1432
|
assert.ok(critical.risks.includes('auth'));
|
|
403
|
-
assert.deepEqual(POLICY.lanePolicy(critical).
|
|
1433
|
+
assert.deepEqual(POLICY.lanePolicy(critical).proof_obligations, ['needsInspection', 'needsExecutionProof', 'needsIndependentAudit']);
|
|
404
1434
|
// Override: Direct requesting Critical is allowed (upgrade) without extra auth
|
|
405
1435
|
const upgrade = POLICY.classifyLane({ reversible: true, lowRisk: true, isolated: true, taskCount: 1, override: 'Critical' });
|
|
406
1436
|
assert.equal(upgrade.lane, 'Critical');
|
|
407
1437
|
assert.equal(upgrade.automaticLane, 'Direct');
|
|
408
1438
|
// Downgrade without auth is blocked
|
|
409
|
-
assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard' }), /
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
assert.
|
|
413
|
-
assert.match(downgrade.warnings.join(' '), /Downgrade authorized by user/);
|
|
1439
|
+
assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard' }), /assurance_level downgrade.*blocked/i);
|
|
1440
|
+
// Caller-owned booleans cannot weaken the classified minimum.
|
|
1441
|
+
assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard', userAuthorized: true }), /assurance_level downgrade.*blocked/i);
|
|
1442
|
+
assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard', user_approved: true }), /assurance_level downgrade.*blocked/i);
|
|
414
1443
|
// State mutation: approvalState
|
|
415
1444
|
const pendingApproval = POLICY.approvalState({ generated: true, agent_validated: false, user_approved: false });
|
|
416
1445
|
assert.equal(pendingApproval.ready, false);
|
|
417
1446
|
const agentValidated = POLICY.approvalState({ generated: true, agent_validated: true, user_approved: false });
|
|
418
|
-
assert.equal(agentValidated.ready,
|
|
1447
|
+
assert.equal(agentValidated.ready, true, 'generated plus agent validation is technical readiness');
|
|
419
1448
|
const approved = POLICY.approvalState({ generated: true, agent_validated: true, user_approved: true });
|
|
420
1449
|
assert.equal(approved.ready, true);
|
|
421
1450
|
// Completion: flash work remains in_progress and does not unblock
|
|
422
|
-
const flashTask =
|
|
1451
|
+
const flashTask = canonicalFlashTask();
|
|
423
1452
|
const failRemains = POLICY.promoteFlashTask(flashTask, 'FAIL');
|
|
424
1453
|
assert.equal(failRemains.status, 'in_progress');
|
|
425
1454
|
assert.equal(failRemains.unblocks, false);
|
|
@@ -427,20 +1456,20 @@ test('lane traces - Direct, Standard, Critical classification, override, state m
|
|
|
427
1456
|
assert.equal(blockedRemains.unblocks, false);
|
|
428
1457
|
const noTestsRemains = POLICY.promoteFlashTask(flashTask, 'NO_TESTS');
|
|
429
1458
|
assert.equal(noTestsRemains.unblocks, false);
|
|
430
|
-
const promoted = POLICY.promoteFlashTask(flashTask, 'PASS',
|
|
1459
|
+
const promoted = POLICY.promoteFlashTask(flashTask, 'PASS', canonicalReceipt());
|
|
431
1460
|
assert.equal(promoted.readyForSync, true);
|
|
432
1461
|
assert.equal(promoted.unblocks, false, 'promoted flash must not unblock until sync-finalize');
|
|
433
|
-
const finalized = POLICY.
|
|
1462
|
+
const finalized = POLICY.syncFinalizeFlashTask(flashTask, 'PASS', promoted.receipt);
|
|
434
1463
|
assert.equal(finalized.status, 'done');
|
|
435
1464
|
assert.equal(finalized.unblocks, true);
|
|
436
1465
|
});
|
|
437
1466
|
|
|
438
1467
|
test('flash selective promotion - only specific task is promoted, not blanket', () => {
|
|
439
1468
|
const registry = {
|
|
440
|
-
'tasks/task-a.md':
|
|
441
|
-
'tasks/task-b.md':
|
|
1469
|
+
'tasks/task-a.md': canonicalFlashTask(),
|
|
1470
|
+
'tasks/task-b.md': canonicalFlashTask(),
|
|
442
1471
|
};
|
|
443
|
-
const promotedA = POLICY.promoteFlashTask(registry['tasks/task-a.md'], 'PASS',
|
|
1472
|
+
const promotedA = POLICY.promoteFlashTask(registry['tasks/task-a.md'], 'PASS', canonicalReceipt());
|
|
444
1473
|
const notPromotedB = registry['tasks/task-b.md']; // unchanged
|
|
445
1474
|
assert.equal(promotedA.readyForSync, true);
|
|
446
1475
|
assert.equal(notPromotedB.receipt, 'FLASH_UNVERIFIED');
|
|
@@ -1118,3 +2147,700 @@ process.stdin.on('end', ()=>{
|
|
|
1118
2147
|
fs.rmSync(root, { recursive: true, force: true });
|
|
1119
2148
|
}
|
|
1120
2149
|
});
|
|
2150
|
+
|
|
2151
|
+
// ── P0 regressions ──────────────────────────────────────────────────────
|
|
2152
|
+
test('P0 receipt fail-closed: Exit 1/-1/abc/conflict/empty command rejected', () => {
|
|
2153
|
+
const base = 'Verification: PASS\nCommand: pnpm test\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n';
|
|
2154
|
+
const cases = [
|
|
2155
|
+
{ body: `${base}Exit: 1\n`, shouldFail: 'exit_result', desc: 'Exit 1' },
|
|
2156
|
+
{ body: `${base}Exit: -1\n`, shouldFail: 'exit_result', desc: 'Exit -1' },
|
|
2157
|
+
{ body: `${base}Exit: abc\n`, shouldFail: 'exit_result', desc: 'Exit abc' },
|
|
2158
|
+
{ body: `${base}Exit: 0\nExit: 1\n`, shouldFail: 'exit_result', desc: 'conflicting exits 0 and 1' },
|
|
2159
|
+
{ body: `${base}Exit: 0\nExit: abc\n`, shouldFail: 'exit_result', desc: 'conflicting exit 0 and abc' },
|
|
2160
|
+
{ body: 'Verification: PASS\nCommand:\nExit: 0\nBase: a\nHead: b\n', shouldFail: 'command', desc: 'empty command' },
|
|
2161
|
+
{ body: 'Verification: PASS\nCommand: \nExit: 0\nBase: a\nHead: b\n', shouldFail: 'command', desc: 'command spaces only' },
|
|
2162
|
+
{ body: `${base}Exit: 0\nResult: FAIL\n`, shouldFail: 'exit_result', desc: 'Result FAIL with Exit 0' },
|
|
2163
|
+
{ body: `${base}Exit: 1\nResult: PASS\n`, shouldFail: 'exit_result', desc: 'Result PASS with Exit 1' },
|
|
2164
|
+
{ body: 'Verification: PASS\nCommand: pnpm test\nResult: PASS\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n', shouldFail: null, desc: 'Result PASS without Exit (legacy alias ok)' },
|
|
2165
|
+
{ body: 'Verification: PASS\nCommand: pnpm test\nResult: FAIL\nBase: a\nHead: b\n', shouldFail: 'exit_result', desc: 'Result FAIL without Exit' },
|
|
2166
|
+
{ body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: {{base}}\nHead: {{head}}\n', shouldFail: 'placeholder', desc: 'placeholder provenance' },
|
|
2167
|
+
{ body: 'Verification: PASS\nCommand: {{cmd}}\nExit: 0\nBase: a\nHead: b\n', shouldFail: 'placeholder', desc: 'placeholder command' },
|
|
2168
|
+
{ body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\n', shouldFail: 'provenance', desc: 'missing Head' },
|
|
2169
|
+
];
|
|
2170
|
+
for (const { body, shouldFail, desc } of cases) {
|
|
2171
|
+
const fails = POLICY.validateCanonicalReceipt(body);
|
|
2172
|
+
if (shouldFail) {
|
|
2173
|
+
assert.ok(fails.includes(shouldFail) || fails.includes('placeholder') || fails.includes('exit_result') || fails.includes('command') || fails.includes('provenance'), `case ${desc} should fail with ${shouldFail}, got ${fails}`);
|
|
2174
|
+
} else {
|
|
2175
|
+
assert.deepEqual(fails, [], `case ${desc} should pass, got ${fails}`);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
// CLI parity: Exit 1 must be rejected via CLI
|
|
2179
|
+
const cli = spawnSync(process.execPath, [POLICY_PATH, '--validate-receipt', '--task-json', JSON.stringify({ body: `${base}Exit: 1\n` }), '--json'], { encoding: 'utf8' });
|
|
2180
|
+
assert.equal(cli.status, 2);
|
|
2181
|
+
assert.equal(JSON.parse(cli.stdout).ok, false);
|
|
2182
|
+
});
|
|
2183
|
+
|
|
2184
|
+
test('P0 flash marker-only must not promote or finalize', () => {
|
|
2185
|
+
const flash = canonicalFlashTask({ blocker: 'awaiting /hapo:test' });
|
|
2186
|
+
const markerOnly = 'Verification: PASS';
|
|
2187
|
+
const promoted = POLICY.promoteFlashTask(flash, 'PASS', markerOnly);
|
|
2188
|
+
assert.equal(promoted.status, 'in_progress');
|
|
2189
|
+
assert.equal(promoted.receipt, 'FLASH_UNVERIFIED');
|
|
2190
|
+
assert.equal(promoted.readyForSync, false);
|
|
2191
|
+
assert.equal(promoted.dependencyBlocked, true);
|
|
2192
|
+
assert.equal(promoted.unblocks, false);
|
|
2193
|
+
assert.match(promoted.blocker, /canonical receipt/);
|
|
2194
|
+
// Even with PASS marker but missing command/exit/provenance, finalize must not complete
|
|
2195
|
+
const fakePromoted = { status: 'in_progress', receipt: markerOnly, readyForSync: true, dependencyBlocked: true, unblocks: false };
|
|
2196
|
+
const notFinalized = POLICY.finalizeFlashTask(fakePromoted, 'sync-finalize');
|
|
2197
|
+
assert.equal(notFinalized.status, 'in_progress', 'marker-only receipt must not finalize');
|
|
2198
|
+
// Valid receipt promotes correctly
|
|
2199
|
+
const valid = canonicalReceipt();
|
|
2200
|
+
const good = POLICY.promoteFlashTask(flash, 'PASS', valid);
|
|
2201
|
+
assert.equal(good.readyForSync, true);
|
|
2202
|
+
assert.equal(good.receipt, valid.trim());
|
|
2203
|
+
const forgedFinalize = POLICY.finalizeFlashTask(good, 'sync-finalize');
|
|
2204
|
+
assert.equal(forgedFinalize.status, 'in_progress');
|
|
2205
|
+
assert.equal(forgedFinalize.unblocks, false);
|
|
2206
|
+
const finalized = POLICY.syncFinalizeFlashTask(flash, 'PASS', valid);
|
|
2207
|
+
assert.equal(finalized.status, 'done');
|
|
2208
|
+
});
|
|
2209
|
+
|
|
2210
|
+
test('P0 canonical artifacts declaration requires SHA-256 and keeps no-artifact receipts compatible', () => {
|
|
2211
|
+
const receipt = 'Verification: PASS\nCommand: node --test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n';
|
|
2212
|
+
const invalid = `${receipt}sha256: abc123\n`;
|
|
2213
|
+
const digest = 'd'.repeat(64);
|
|
2214
|
+
const valid = `${receipt}Artifact: output/bundle.js\nSHA-256: ${digest}\n`;
|
|
2215
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(receipt), []);
|
|
2216
|
+
const options = POLICY.receiptValidatorOptions({ artifacts: ['output/bundle.js'] });
|
|
2217
|
+
assert.equal(options.requireArtifactHash, true);
|
|
2218
|
+
assert.equal(options.artifactDeclarationValid, true);
|
|
2219
|
+
assert.ok(POLICY.validateCanonicalReceipt(receipt, options).includes('artifact_hash'));
|
|
2220
|
+
assert.ok(POLICY.validateCanonicalReceipt(invalid, options).includes('artifact_hash'));
|
|
2221
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(valid, options), []);
|
|
2222
|
+
assert.equal(POLICY.receiptValidatorOptions({}).requireArtifactHash, false);
|
|
2223
|
+
assert.equal(POLICY.receiptValidatorOptions({ artifact_ref: 'output/bundle.js' }).requireArtifactHash, true, 'legacy alias remains read-compatible');
|
|
2224
|
+
for (const declaration of [null, [], ['../outside'], [{ path: 'output/bundle.js' }]]) {
|
|
2225
|
+
const malformed = POLICY.receiptValidatorOptions({ artifacts: declaration });
|
|
2226
|
+
assert.equal(malformed.artifactDeclarationValid, false, `malformed declaration must be rejected: ${JSON.stringify(declaration)}`);
|
|
2227
|
+
assert.ok(POLICY.validateCanonicalReceipt(receipt, malformed).includes('artifact_declaration'));
|
|
2228
|
+
}
|
|
2229
|
+
});
|
|
2230
|
+
|
|
2231
|
+
test('P0 sync-finalize derives promotion from current FLASH_UNVERIFIED plus explicit PASS proof', () => {
|
|
2232
|
+
const receipt = canonicalReceipt('node --test');
|
|
2233
|
+
const forged = {
|
|
2234
|
+
status: 'in_progress',
|
|
2235
|
+
receipt,
|
|
2236
|
+
readyForSync: true,
|
|
2237
|
+
dependencyBlocked: true,
|
|
2238
|
+
unblocks: false,
|
|
2239
|
+
flashTransition: 'promoted',
|
|
2240
|
+
promotionReceipt: receipt,
|
|
2241
|
+
};
|
|
2242
|
+
const direct = spawnSync(process.execPath, [
|
|
2243
|
+
POLICY_PATH,
|
|
2244
|
+
'--sync-finalize',
|
|
2245
|
+
'--task-json',
|
|
2246
|
+
JSON.stringify(forged),
|
|
2247
|
+
'--json',
|
|
2248
|
+
], { encoding: 'utf8' });
|
|
2249
|
+
assert.equal(direct.status, 2, `${direct.stdout}\n${direct.stderr}`);
|
|
2250
|
+
const directPayload = JSON.parse(direct.stdout);
|
|
2251
|
+
assert.equal(directPayload.ok, false);
|
|
2252
|
+
assert.equal(directPayload.task.status, 'in_progress');
|
|
2253
|
+
assert.equal(directPayload.task.receipt, 'FLASH_UNVERIFIED');
|
|
2254
|
+
assert.equal(directPayload.task.readyForSync, false);
|
|
2255
|
+
assert.equal(directPayload.task.dependencyBlocked, true);
|
|
2256
|
+
assert.equal(directPayload.task.unblocks, false);
|
|
2257
|
+
assert.match(directPayload.message, /current task must be FLASH_UNVERIFIED.*canonical/i);
|
|
2258
|
+
|
|
2259
|
+
const minimal = { status: 'in_progress', receipt: 'FLASH_UNVERIFIED' };
|
|
2260
|
+
const minimalResult = POLICY.syncFinalizeFlashTask(minimal, 'PASS', receipt);
|
|
2261
|
+
assert.equal(minimalResult.status, 'in_progress');
|
|
2262
|
+
assert.match(minimalResult.blocker, /exact canonical stored state/);
|
|
2263
|
+
const forgedOpen = { ...canonicalFlashTask(), readyForSync: true };
|
|
2264
|
+
const forgedOpenResult = POLICY.syncFinalizeFlashTask(forgedOpen, 'PASS', receipt);
|
|
2265
|
+
assert.equal(forgedOpenResult.status, 'in_progress');
|
|
2266
|
+
assert.equal(forgedOpenResult.unblocks, false);
|
|
2267
|
+
|
|
2268
|
+
const current = canonicalFlashTask({ blocker: 'awaiting /hapo:test' });
|
|
2269
|
+
const supported = spawnSync(process.execPath, [
|
|
2270
|
+
POLICY_PATH,
|
|
2271
|
+
'--sync-finalize',
|
|
2272
|
+
'--task-json',
|
|
2273
|
+
JSON.stringify(current),
|
|
2274
|
+
'--verdict',
|
|
2275
|
+
'PASS',
|
|
2276
|
+
'--proof',
|
|
2277
|
+
receipt,
|
|
2278
|
+
'--project-root',
|
|
2279
|
+
RUNTIME_ROOT,
|
|
2280
|
+
'--specs-root',
|
|
2281
|
+
path.join(RUNTIME_ROOT, 'specs'),
|
|
2282
|
+
'--spec-file',
|
|
2283
|
+
RUNTIME_SPEC,
|
|
2284
|
+
'--feature-name',
|
|
2285
|
+
'demo',
|
|
2286
|
+
'--runtime-session',
|
|
2287
|
+
'implementation-session-1',
|
|
2288
|
+
'--json',
|
|
2289
|
+
], { encoding: 'utf8' });
|
|
2290
|
+
assert.equal(supported.status, 0, `${supported.stdout}\n${supported.stderr}`);
|
|
2291
|
+
assert.equal(JSON.parse(supported.stdout).task.status, 'done');
|
|
2292
|
+
const warningFinalize = POLICY.syncFinalizeFlashTask(current, 'PASS_WITH_WARNINGS', receipt);
|
|
2293
|
+
assert.equal(warningFinalize.status, 'in_progress');
|
|
2294
|
+
assert.equal(warningFinalize.unblocks, false);
|
|
2295
|
+
|
|
2296
|
+
const initial = canonicalFlashTask();
|
|
2297
|
+
const promoted = POLICY.promoteFlashTask(initial, 'PASS', receipt);
|
|
2298
|
+
const persisted = JSON.parse(JSON.stringify(promoted));
|
|
2299
|
+
assert.equal(persisted.flashTransition, 'promoted');
|
|
2300
|
+
assert.equal(persisted.promotionReceipt, receipt.trim());
|
|
2301
|
+
const forgedPersistedFinalize = POLICY.finalizeFlashTask(persisted, 'sync-finalize');
|
|
2302
|
+
assert.equal(forgedPersistedFinalize.status, 'in_progress');
|
|
2303
|
+
assert.equal(forgedPersistedFinalize.unblocks, false);
|
|
2304
|
+
const finalized = POLICY.syncFinalizeFlashTask(initial, 'PASS', receipt);
|
|
2305
|
+
assert.equal(finalized.status, 'done');
|
|
2306
|
+
assert.equal(finalized.flashTransition, 'finalized');
|
|
2307
|
+
|
|
2308
|
+
const artifactInitial = { ...initial, artifacts: ['output/bundle.js'] };
|
|
2309
|
+
const artifactPromotion = POLICY.promoteFlashTask(artifactInitial, 'PASS', receipt);
|
|
2310
|
+
assert.equal(artifactPromotion.readyForSync, false, 'flash promotion must apply task artifact requirements');
|
|
2311
|
+
|
|
2312
|
+
const badProof = spawnSync(process.execPath, [
|
|
2313
|
+
POLICY_PATH,
|
|
2314
|
+
'--sync-finalize',
|
|
2315
|
+
'--task-json',
|
|
2316
|
+
JSON.stringify(current),
|
|
2317
|
+
'--verdict',
|
|
2318
|
+
'PASS',
|
|
2319
|
+
'--proof',
|
|
2320
|
+
'Verification: PASS',
|
|
2321
|
+
'--json',
|
|
2322
|
+
], { encoding: 'utf8' });
|
|
2323
|
+
assert.equal(badProof.status, 2);
|
|
2324
|
+
assert.equal(JSON.parse(badProof.stdout).task.status, 'in_progress');
|
|
2325
|
+
|
|
2326
|
+
const badArtifact = spawnSync(process.execPath, [
|
|
2327
|
+
POLICY_PATH,
|
|
2328
|
+
'--sync-finalize',
|
|
2329
|
+
'--task-json',
|
|
2330
|
+
JSON.stringify({ ...current, artifacts: ['output/bundle.js'] }),
|
|
2331
|
+
'--verdict',
|
|
2332
|
+
'PASS',
|
|
2333
|
+
'--proof',
|
|
2334
|
+
receipt,
|
|
2335
|
+
'--json',
|
|
2336
|
+
], { encoding: 'utf8' });
|
|
2337
|
+
assert.equal(badArtifact.status, 2);
|
|
2338
|
+
assert.equal(JSON.parse(badArtifact.stdout).task.status, 'in_progress');
|
|
2339
|
+
|
|
2340
|
+
const markerOnly = {
|
|
2341
|
+
...forged,
|
|
2342
|
+
flashTransition: 'promoted',
|
|
2343
|
+
promotionReceipt: 'Verification: PASS',
|
|
2344
|
+
receipt: 'Verification: PASS',
|
|
2345
|
+
};
|
|
2346
|
+
assert.equal(POLICY.finalizeFlashTask(markerOnly, 'sync-finalize').status, 'in_progress');
|
|
2347
|
+
});
|
|
2348
|
+
|
|
2349
|
+
test('P0 Claude and Codex reject configured specs roots outside the project', () => {
|
|
2350
|
+
const claudeResolver = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'));
|
|
2351
|
+
const codexUtils = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-utils.cjs'));
|
|
2352
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-external-specs-'));
|
|
2353
|
+
const externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-configured-specs-'));
|
|
2354
|
+
try {
|
|
2355
|
+
const local = path.join(tmp, 'specs', 'local');
|
|
2356
|
+
const external = path.join(externalRoot, 'remote');
|
|
2357
|
+
fs.mkdirSync(local, { recursive: true });
|
|
2358
|
+
fs.mkdirSync(external, { recursive: true });
|
|
2359
|
+
fs.writeFileSync(path.join(local, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'local' }));
|
|
2360
|
+
fs.writeFileSync(path.join(external, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'remote' }));
|
|
2361
|
+
const runtime = { paths: { specs: externalRoot } };
|
|
2362
|
+
const claude = claudeResolver.resolveActiveSpec({ projectRoot: tmp, runtime });
|
|
2363
|
+
const codex = codexUtils.resolveActiveSpec(tmp, runtime, null, null);
|
|
2364
|
+
assert.equal(claude.error, 'invalid_specs');
|
|
2365
|
+
assert.equal(codex.error, 'invalid_specs');
|
|
2366
|
+
assert.match(claude.reason, /escapes project root/);
|
|
2367
|
+
assert.match(codex.reason, /escapes project root/);
|
|
2368
|
+
} finally {
|
|
2369
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
2370
|
+
fs.rmSync(externalRoot, { recursive: true, force: true });
|
|
2371
|
+
}
|
|
2372
|
+
});
|
|
2373
|
+
|
|
2374
|
+
test('P0 caller-owned downgrade flags have no canonical capability', () => {
|
|
2375
|
+
for (const callerClaim of [{ user_approved: true }, { userApproved: true }, { userAuthorized: true }]) {
|
|
2376
|
+
assert.throws(
|
|
2377
|
+
() => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct', ...callerClaim }),
|
|
2378
|
+
(error) => /downgrade/.test(error.message) && /not permitted/.test(error.message),
|
|
2379
|
+
);
|
|
2380
|
+
}
|
|
2381
|
+
assert.throws(() => POLICY.classifyLane({ riskSignals: { auth: true }, override: 'Standard', confirmDowngrade: true }), /assurance_level downgrade.*blocked/i);
|
|
2382
|
+
assert.equal(POLICY.isUserAuthorizedForDowngrade, undefined);
|
|
2383
|
+
});
|
|
2384
|
+
|
|
2385
|
+
test('P0 canonical policy exposes no caller-owned downgrade authority', () => {
|
|
2386
|
+
assert.equal(POLICY.isUserAuthorizedForDowngrade, undefined);
|
|
2387
|
+
assert.equal(POLICY.isValidOverrideReceipt, undefined);
|
|
2388
|
+
assert.equal(POLICY.CANONICAL_WORKFLOW_POLICY_FIELDS.includes('override_' + 'receipt'), false);
|
|
2389
|
+
assert.throws(
|
|
2390
|
+
() => POLICY.classifyLane({
|
|
2391
|
+
riskSignals: { privacy: true },
|
|
2392
|
+
override: 'Direct',
|
|
2393
|
+
overrideReceipt: { verifiedByRuntime: true },
|
|
2394
|
+
}),
|
|
2395
|
+
(error) => /downgrade/.test(error.message) && /not permitted/.test(error.message),
|
|
2396
|
+
);
|
|
2397
|
+
});
|
|
2398
|
+
|
|
2399
|
+
test('P0 active spec deterministic: multiple active ambiguity and explicit target/path containment', () => {
|
|
2400
|
+
const RESOLVER = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'));
|
|
2401
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-resolver-'));
|
|
2402
|
+
try {
|
|
2403
|
+
const specsDir = path.join(tmp, 'specs');
|
|
2404
|
+
fs.mkdirSync(specsDir, { recursive: true });
|
|
2405
|
+
// Create two active specs
|
|
2406
|
+
for (const name of ['alpha', 'beta']) {
|
|
2407
|
+
fs.mkdirSync(path.join(specsDir, name), { recursive: true });
|
|
2408
|
+
fs.writeFileSync(path.join(specsDir, name, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: name, current_phase: 'design' }));
|
|
2409
|
+
}
|
|
2410
|
+
const amb = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
|
|
2411
|
+
assert.equal(amb.error, 'multiple_active');
|
|
2412
|
+
assert.deepEqual(amb.candidates.sort(), ['alpha', 'beta']);
|
|
2413
|
+
// Explicit feature resolves deterministically
|
|
2414
|
+
const alpha = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: 'alpha' });
|
|
2415
|
+
assert.equal(alpha.featureName, 'alpha');
|
|
2416
|
+
// Explicit not-found fail-closed
|
|
2417
|
+
const notFound = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: 'gamma' });
|
|
2418
|
+
assert.equal(notFound.error, 'explicit_not_found');
|
|
2419
|
+
// Explicit path containment: try to escape via ../
|
|
2420
|
+
const escape = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitPath: path.join(tmp, 'specs', '..', 'etc', 'passwd') });
|
|
2421
|
+
assert.equal(escape.error, 'explicit_malformed');
|
|
2422
|
+
// Malformed feature name with slash must be rejected
|
|
2423
|
+
const malformed = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: '../alpha' });
|
|
2424
|
+
assert.equal(malformed.error, 'explicit_malformed');
|
|
2425
|
+
// Valid explicit path inside root
|
|
2426
|
+
const validPath = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitPath: path.join(specsDir, 'beta', 'spec.json') });
|
|
2427
|
+
assert.equal(validPath.featureName, 'beta');
|
|
2428
|
+
} finally {
|
|
2429
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
2430
|
+
}
|
|
2431
|
+
});
|
|
2432
|
+
|
|
2433
|
+
test('P0 parity Claude/Codex: canonical receipt and multi-active', () => {
|
|
2434
|
+
// Codex receipt validator must match Claude policy
|
|
2435
|
+
const codexReceipt = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-receipt.cjs'));
|
|
2436
|
+
const claudeFails = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 1\nBase: a\nHead: b\n');
|
|
2437
|
+
const codexFails = codexReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 1\nBase: a\nHead: b\n');
|
|
2438
|
+
assert.deepEqual(claudeFails.sort(), codexFails.sort(), 'Claude and Codex validators must agree on Exit 1');
|
|
2439
|
+
const codexEmptyCmd = codexReceipt.validateCanonicalReceipt('Verification: PASS\nCommand:\nExit: 0\nBase: a\nHead: b\n');
|
|
2440
|
+
assert.ok(codexEmptyCmd.includes('command'), 'Codex must reject empty Command');
|
|
2441
|
+
// Codex resolver parity: same containment behaviour
|
|
2442
|
+
const codexUtils = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-utils.cjs'));
|
|
2443
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-resolver-'));
|
|
2444
|
+
try {
|
|
2445
|
+
const specsDir = path.join(tmp, 'specs');
|
|
2446
|
+
fs.mkdirSync(specsDir, { recursive: true });
|
|
2447
|
+
for (const name of ['x', 'y']) {
|
|
2448
|
+
fs.mkdirSync(path.join(specsDir, name), { recursive: true });
|
|
2449
|
+
fs.writeFileSync(path.join(specsDir, name, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: name }));
|
|
2450
|
+
}
|
|
2451
|
+
const amb = codexUtils.resolveActiveSpec(tmp, {}, null, null);
|
|
2452
|
+
assert.equal(amb.error, 'multiple_active');
|
|
2453
|
+
const esc = codexUtils.resolveActiveSpec(tmp, {}, null, path.join(tmp, 'specs', '..', 'outside', 'spec.json'));
|
|
2454
|
+
assert.equal(esc.error, 'explicit_malformed');
|
|
2455
|
+
} finally {
|
|
2456
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
2457
|
+
}
|
|
2458
|
+
});
|
|
2459
|
+
|
|
2460
|
+
test('P0 regression: placeholder, explicit failure, artifact and lanePolicy forged are blocked', () => {
|
|
2461
|
+
// Probe A: explicit failure outcomes must fail canonical validator and not promote flash
|
|
2462
|
+
const bodyTestsFailed = 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nTests failed: 1\n';
|
|
2463
|
+
assert.ok(POLICY.validateCanonicalReceipt(bodyTestsFailed).length > 0, 'Tests failed: 1 must be rejected');
|
|
2464
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nResult: FAIL\nResult: PASS\n').includes('exit_result'), 'Result FAIL then PASS must be rejected');
|
|
2465
|
+
const flashTask = { status: 'in_progress', receipt: 'FLASH_UNVERIFIED', blocker: 'awaiting', dependencyBlocked: true, unblocks: false };
|
|
2466
|
+
const notPromoted = POLICY.promoteFlashTask(flashTask, 'PASS', bodyTestsFailed);
|
|
2467
|
+
assert.equal(notPromoted.readyForSync, false, 'flash with Tests failed should not promote');
|
|
2468
|
+
assert.equal(notPromoted.receipt, 'FLASH_UNVERIFIED');
|
|
2469
|
+
|
|
2470
|
+
// Probe B: placeholder tokens must be rejected, but substring todo must pass
|
|
2471
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: TODO\nExit: 0\nBase: a\nHead: b\n').includes('command'), 'Command TODO must be rejected');
|
|
2472
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: TBD\nHead: b\n').includes('provenance'), 'Base TBD must be rejected');
|
|
2473
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: N/A\n').includes('provenance'), 'Head N/A must be rejected');
|
|
2474
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: pending\nhead_sha: unknown\n').includes('provenance'), 'base_sha pending must be rejected');
|
|
2475
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: npm run todo:test --fix\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n'), [], 'command containing todo substring should pass');
|
|
2476
|
+
|
|
2477
|
+
// Probe C: artifact with empty or placeholder sha must fail, concrete passes, inline passes
|
|
2478
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: x\nsha256: \n').includes('artifact_hash'), 'artifact empty sha must fail');
|
|
2479
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: x\nsha256: TBD\n').includes('artifact_hash'), 'artifact TBD sha must fail');
|
|
2480
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: bundle\nsha256: abc123\n').includes('artifact_hash'), 'short artifact sha must fail');
|
|
2481
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact produced sha256:deadbeef\n').includes('artifact_hash'), 'short inline sha256 must fail');
|
|
2482
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(`Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\nArtifact: bundle\nsha256: ${'c'.repeat(64)}\n`), [], '64-hex artifact sha should pass');
|
|
2483
|
+
|
|
2484
|
+
// Probe F: lanePolicy forged plain object must not bypass classification
|
|
2485
|
+
const { spawnSync } = require('node:child_process');
|
|
2486
|
+
const POLICY_PATH = path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs');
|
|
2487
|
+
const forged = spawnSync(process.execPath, [POLICY_PATH, '--lane-policy', '--task-json', JSON.stringify({ lane: 'Direct', automaticLane: 'Direct', riskSignals: { auth: true } }), '--json'], { encoding: 'utf8' });
|
|
2488
|
+
assert.equal(forged.status, 2, 'forged Direct with auth should be blocked via lanePolicy');
|
|
2489
|
+
assert.match(forged.stderr, /downgrade.*not permitted/i);
|
|
2490
|
+
const forged2 = spawnSync(process.execPath, [POLICY_PATH, '--lane-policy', '--task-json', JSON.stringify({ lane: 'Direct', automaticLane: 'Direct' }), '--json'], { encoding: 'utf8' });
|
|
2491
|
+
assert.equal(forged2.status, 2, 'plain Direct without justification should be blocked');
|
|
2492
|
+
|
|
2493
|
+
const trusted = POLICY.classifyLane({ reversible: true, lowRisk: true, isolated: true });
|
|
2494
|
+
assert.equal(trusted.lane, 'Direct');
|
|
2495
|
+
const viaPolicy = POLICY.lanePolicy(trusted);
|
|
2496
|
+
assert.equal(viaPolicy.lane, 'Direct', 'trusted Direct should be respected');
|
|
2497
|
+
assert.throws(() => { 'use strict'; trusted.lane = 'Direct'; }, /read only|Cannot assign|TypeError/);
|
|
2498
|
+
const paymentTrusted = POLICY.classifyLane({ riskSignals: { payment: true } });
|
|
2499
|
+
const forgedCopy = { ...paymentTrusted, lane: 'Direct' };
|
|
2500
|
+
assert.equal(POLICY.lanePolicy(forgedCopy).lane, 'Standard', 'compatibility lane is derived from authoritative axes');
|
|
2501
|
+
});
|
|
2502
|
+
|
|
2503
|
+
test('P0 regression: symlink spec/task containment and malformed spec handling', () => {
|
|
2504
|
+
const RESOLVER = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'));
|
|
2505
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-regression-symlink-'));
|
|
2506
|
+
try {
|
|
2507
|
+
const specsDir = path.join(tmp, 'specs');
|
|
2508
|
+
fs.mkdirSync(specsDir, { recursive: true });
|
|
2509
|
+
fs.mkdirSync(path.join(specsDir, 'valid'), { recursive: true });
|
|
2510
|
+
fs.writeFileSync(path.join(specsDir, 'valid', 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'valid' }));
|
|
2511
|
+
const outside = path.join(tmp, 'outside');
|
|
2512
|
+
fs.mkdirSync(outside, { recursive: true });
|
|
2513
|
+
fs.writeFileSync(path.join(outside, 'spec.json'), JSON.stringify({ status: 'in_progress' }));
|
|
2514
|
+
const link = path.join(specsDir, 'linked');
|
|
2515
|
+
fs.symlinkSync(outside, link);
|
|
2516
|
+
const e1 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: 'linked' });
|
|
2517
|
+
assert.equal(e1.error, 'explicit_malformed');
|
|
2518
|
+
const e2 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitPath: path.join(specsDir, 'linked', 'spec.json') });
|
|
2519
|
+
assert.equal(e2.error, 'explicit_malformed');
|
|
2520
|
+
const nonExplicit = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
|
|
2521
|
+
assert.equal(nonExplicit.error, 'invalid_specs');
|
|
2522
|
+
assert.ok(nonExplicit.candidates.includes('linked'));
|
|
2523
|
+
const featDir = path.join(specsDir, 'valid');
|
|
2524
|
+
fs.mkdirSync(path.join(featDir, 'tasks'), { recursive: true });
|
|
2525
|
+
const taskOutside = path.join(tmp, 'outside-task.md');
|
|
2526
|
+
fs.writeFileSync(taskOutside, '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n');
|
|
2527
|
+
const taskLink = path.join(featDir, 'tasks', 'task.md');
|
|
2528
|
+
fs.symlinkSync(taskOutside, taskLink);
|
|
2529
|
+
const codexReceipt = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-receipt.cjs'));
|
|
2530
|
+
const fails = codexReceipt.checkReceipt(featDir, 'tasks/task.md', { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' });
|
|
2531
|
+
assert.ok(fails.includes('a'), 'task symlink outside should be rejected as check a');
|
|
2532
|
+
fs.mkdirSync(path.join(specsDir, 'bad'), { recursive: true });
|
|
2533
|
+
fs.writeFileSync(path.join(specsDir, 'bad', 'spec.json'), '{ malformed');
|
|
2534
|
+
const mal = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
|
|
2535
|
+
assert.equal(mal.error, 'invalid_specs');
|
|
2536
|
+
assert.ok(mal.candidates.includes('bad'));
|
|
2537
|
+
} finally {
|
|
2538
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
2539
|
+
}
|
|
2540
|
+
});
|
|
2541
|
+
|
|
2542
|
+
test('P0 regression: artifact hash scoped to structured Artifact declaration and explicit failure structured only', () => {
|
|
2543
|
+
// Artifact hash only required when structured Artifact declaration exists
|
|
2544
|
+
const prefix = 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n';
|
|
2545
|
+
const receipt = (suffix = '') => `${prefix}${suffix}`;
|
|
2546
|
+
const digestA = 'a'.repeat(64);
|
|
2547
|
+
const digestB = 'b'.repeat(64);
|
|
2548
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Command: npm run artifact:test\n')), [], 'Command containing artifact without hash should pass');
|
|
2549
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Notes: artifact behavior without declaration\n')), [], 'Notes containing artifact without structured declaration should pass');
|
|
2550
|
+
assert.ok(POLICY.validateCanonicalReceipt(receipt('Artifact: bundle\n')).includes('artifact_hash'), 'Artifact: without hash should fail');
|
|
2551
|
+
assert.ok(POLICY.validateCanonicalReceipt(receipt('Artifacts: bundle\nsha256: TBD\n')).includes('artifact_hash'), 'Artifacts: with TBD should fail');
|
|
2552
|
+
assert.ok(POLICY.validateCanonicalReceipt(receipt('Artifact produced sha256:deadbeef\n')).includes('artifact_hash'), 'short artifact hash must fail');
|
|
2553
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(receipt(`Artifact produced sha256:${digestA}\n`)), [], '64-hex artifact hash should pass');
|
|
2554
|
+
|
|
2555
|
+
const artifactOptions = POLICY.receiptValidatorOptions({ artifacts: ['artifacts/a.js', 'artifacts/b.js'] });
|
|
2556
|
+
assert.ok(POLICY.validateCanonicalReceipt(receipt(`Artifact: artifacts/a.js\nSHA-256: ${digestA}\n`), artifactOptions).includes('artifact_hash'), 'missing second artifact binding must fail');
|
|
2557
|
+
assert.ok(POLICY.validateCanonicalReceipt(receipt(`Artifact: artifacts/a.js + artifacts/b.js\nSHA-256: ${digestA}\n`), artifactOptions).includes('artifact_hash'), 'one hash must not bind a combined multi-artifact declaration');
|
|
2558
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(receipt(`Artifact: artifacts/a.js\nSHA-256: ${digestA}\nArtifact: artifacts/b.js\nSHA-256: ${digestB}\n`), artifactOptions), [], 'each declared artifact must bind to its hash');
|
|
2559
|
+
assert.ok(POLICY.validateCanonicalReceipt(receipt('Artifact: artifacts/a.js\nSHA-256: deadbeef\nArtifact: artifacts/b.js\nSHA-256: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n'), artifactOptions).includes('artifact_hash'), 'short per-artifact hash must fail');
|
|
2560
|
+
|
|
2561
|
+
const falseGreen = receipt([
|
|
2562
|
+
'Tests run: 0',
|
|
2563
|
+
'0 passing',
|
|
2564
|
+
'Failures: 1',
|
|
2565
|
+
'failure summary',
|
|
2566
|
+
'Result: PASS',
|
|
2567
|
+
'Artifact produced sha256:deadbeef',
|
|
2568
|
+
'Artifact: bundle + sha256:abc123',
|
|
2569
|
+
].join('\n') + '\n');
|
|
2570
|
+
assert.ok(POLICY.validateCanonicalReceipt(falseGreen).length > 0, 'false-green independent-review vector must fail');
|
|
2571
|
+
|
|
2572
|
+
// Explicit failure structured only: prose containing failure should not block, Tests failed: 0 should pass
|
|
2573
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Notes: verifies failure handling\n')), [], 'Notes with failure handling should not block');
|
|
2574
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Description: failure recovery\n')), [], 'Description with failure should not block');
|
|
2575
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Tests failed: 0\n')), [], 'Tests failed: 0 should pass');
|
|
2576
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('fail 0\n')), [], 'fail 0 should pass');
|
|
2577
|
+
// Structured failures still blocked
|
|
2578
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nStatus: FAILED\n').length > 0, 'Status: FAILED should fail');
|
|
2579
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nOutcome: FAILURE\n').length > 0, 'Outcome: FAILURE should fail');
|
|
2580
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nResult: FAILED\n').length > 0, 'Result: FAILED should fail');
|
|
2581
|
+
// TAP and Jest runner summaries (structured, anchored)
|
|
2582
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n# fail 1\n').length > 0, 'TAP # fail 1 should fail');
|
|
2583
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nℹ fail 2\n').length > 0, 'TAP ℹ fail 2 should fail');
|
|
2584
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nnot ok 1 - test\n').length > 0, 'TAP not ok 1 should fail');
|
|
2585
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nTests: 1 failed, 2 passed\n').length > 0, 'Jest Tests: 1 failed should fail');
|
|
2586
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n1 failed, 2 passed\n').length > 0, '1 failed summary should fail');
|
|
2587
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n# fail 0\n'), [], 'TAP # fail 0 should pass');
|
|
2588
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\nTests: 0 failed\n'), [], 'Tests: 0 failed should pass');
|
|
2589
|
+
});
|
|
2590
|
+
|
|
2591
|
+
test('P0 regression: safeTaskFile fail-closed on realpath error and single authority', () => {
|
|
2592
|
+
const fs2 = require('node:fs');
|
|
2593
|
+
const path2 = require('node:path');
|
|
2594
|
+
const os2 = require('node:os');
|
|
2595
|
+
const specGatePath = path2.join(PACKAGE_ROOT, 'src/claude/hooks/spec-gate.cjs');
|
|
2596
|
+
const specGateSrc = fs2.readFileSync(specGatePath, 'utf8');
|
|
2597
|
+
// Single authority: Claude hook should delegate to POLICY, not duplicate parser
|
|
2598
|
+
assert.match(specGateSrc, /POLICY\.validateCanonicalReceipt/);
|
|
2599
|
+
assert.doesNotMatch(specGateSrc, /const EXPLICIT_FAILURE/);
|
|
2600
|
+
// Codex and OpenCode should delegate to shared workflow-policy
|
|
2601
|
+
const codexSrc = fs2.readFileSync(path2.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-receipt.cjs'), 'utf8');
|
|
2602
|
+
assert.match(codexSrc, /getSharedValidate/);
|
|
2603
|
+
assert.match(codexSrc, /workflow-policy\.cjs/);
|
|
2604
|
+
assert.doesNotMatch(codexSrc, /const PLACEHOLDER_TOKENS/);
|
|
2605
|
+
const opencodeSrc = fs2.readFileSync(path2.join(PACKAGE_ROOT, 'src/opencode/plugins/spec-gate.ts'), 'utf8');
|
|
2606
|
+
assert.match(opencodeSrc, /getSharedValidate/);
|
|
2607
|
+
assert.match(opencodeSrc, /workflow-policy\.cjs/);
|
|
2608
|
+
// The parser should only exist in workflow-policy, not in adapters (check that adapter does not define full validate logic)
|
|
2609
|
+
const opencodeValidateCount = (opencodeSrc.match(/function validateCanonicalReceipt/g) || []).length;
|
|
2610
|
+
assert.equal(opencodeValidateCount, 1, 'OpenCode should have only delegating validate, not duplicate parser');
|
|
2611
|
+
// safeTaskFile fail-closed: simulate realpathSync throwing
|
|
2612
|
+
const tmp = fs2.mkdtempSync(path2.join(os2.tmpdir(), 'cafekit-realpath-'));
|
|
2613
|
+
try {
|
|
2614
|
+
const featDir = path2.join(tmp, 'feat');
|
|
2615
|
+
fs2.mkdirSync(path2.join(featDir, 'tasks'), { recursive: true });
|
|
2616
|
+
fs2.writeFileSync(path2.join(featDir, 'tasks', 'task.md'), '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n');
|
|
2617
|
+
const orig = fs2.realpathSync;
|
|
2618
|
+
fs2.realpathSync = () => { throw new Error('simulated realpath failure'); };
|
|
2619
|
+
try {
|
|
2620
|
+
const codexReceipt = require(path2.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-receipt.cjs'));
|
|
2621
|
+
const fails = codexReceipt.checkReceipt(featDir, 'tasks/task.md', { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' });
|
|
2622
|
+
assert.ok(fails.includes('a'), 'realpath failure should be fail-closed with check a');
|
|
2623
|
+
} finally {
|
|
2624
|
+
fs2.realpathSync = orig;
|
|
2625
|
+
}
|
|
2626
|
+
} finally {
|
|
2627
|
+
fs2.rmSync(tmp, { recursive: true, force: true });
|
|
2628
|
+
}
|
|
2629
|
+
});
|
|
2630
|
+
|
|
2631
|
+
test('P0 regression: resolver fail-closed on canonicalization and lstat errors', () => {
|
|
2632
|
+
const fs2 = require('node:fs');
|
|
2633
|
+
const path2 = require('node:path');
|
|
2634
|
+
const os2 = require('node:os');
|
|
2635
|
+
const RESOLVER = require(path2.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'));
|
|
2636
|
+
const tmp = fs2.mkdtempSync(path2.join(os2.tmpdir(), 'cafekit-resolver-fail-'));
|
|
2637
|
+
try {
|
|
2638
|
+
const specsDir = path2.join(tmp, 'specs');
|
|
2639
|
+
fs2.mkdirSync(path2.join(specsDir, 'demo'), { recursive: true });
|
|
2640
|
+
fs2.writeFileSync(path2.join(specsDir, 'demo', 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'demo' }));
|
|
2641
|
+
// Monkey patch realpathSync to always throw for explicitFeature
|
|
2642
|
+
const origRealpath = fs2.realpathSync;
|
|
2643
|
+
fs2.realpathSync = () => { throw Object.assign(new Error('simulated EACCES'), { code: 'EACCES' }); };
|
|
2644
|
+
try {
|
|
2645
|
+
const res = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: 'demo' });
|
|
2646
|
+
assert.equal(res.error, 'explicit_malformed', 'explicitFeature realpath failure should be explicit_malformed');
|
|
2647
|
+
assert.match(res.reason, /canonicalization error/);
|
|
2648
|
+
} finally {
|
|
2649
|
+
fs2.realpathSync = origRealpath;
|
|
2650
|
+
}
|
|
2651
|
+
// Non-explicit with specs root realpath failure should be invalid_specs <specs>
|
|
2652
|
+
const origRealpath2 = fs2.realpathSync;
|
|
2653
|
+
fs2.realpathSync = (p) => {
|
|
2654
|
+
if (p === specsDir || p === path2.resolve(specsDir)) throw Object.assign(new Error('simulated ELOOP'), { code: 'ELOOP' });
|
|
2655
|
+
return origRealpath2(p);
|
|
2656
|
+
};
|
|
2657
|
+
try {
|
|
2658
|
+
const res2 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
|
|
2659
|
+
assert.equal(res2.error, 'invalid_specs');
|
|
2660
|
+
assert.ok(res2.candidates.includes('<specs>'));
|
|
2661
|
+
} finally {
|
|
2662
|
+
fs2.realpathSync = origRealpath2;
|
|
2663
|
+
}
|
|
2664
|
+
// lstat error for entry (simulate EACCES on lstat)
|
|
2665
|
+
const origLstat = fs2.lstatSync;
|
|
2666
|
+
fs2.lstatSync = (p) => {
|
|
2667
|
+
if (p.includes('demo')) throw Object.assign(new Error('simulated EACCES'), { code: 'EACCES' });
|
|
2668
|
+
return origLstat(p);
|
|
2669
|
+
};
|
|
2670
|
+
try {
|
|
2671
|
+
const res3 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
|
|
2672
|
+
assert.equal(res3.error, 'invalid_specs');
|
|
2673
|
+
assert.ok(res3.candidates.includes('demo'));
|
|
2674
|
+
} finally {
|
|
2675
|
+
fs2.lstatSync = origLstat;
|
|
2676
|
+
}
|
|
2677
|
+
// Dangling spec.json symlink should be invalid_specs
|
|
2678
|
+
const outside = path2.join(tmp, 'outside-spec2');
|
|
2679
|
+
fs2.mkdirSync(outside, { recursive: true });
|
|
2680
|
+
fs2.writeFileSync(path2.join(outside, 'spec.json'), JSON.stringify({ status: 'in_progress' }));
|
|
2681
|
+
const featDir = path2.join(specsDir, 'dangle');
|
|
2682
|
+
fs2.mkdirSync(featDir, { recursive: true });
|
|
2683
|
+
const specLink = path2.join(featDir, 'spec.json');
|
|
2684
|
+
fs2.symlinkSync(path2.join(tmp, 'nonexistent-target'), specLink);
|
|
2685
|
+
const res4 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
|
|
2686
|
+
assert.equal(res4.error, 'invalid_specs');
|
|
2687
|
+
assert.ok(res4.candidates.includes('dangle'));
|
|
2688
|
+
} finally {
|
|
2689
|
+
fs2.rmSync(tmp, { recursive: true, force: true });
|
|
2690
|
+
}
|
|
2691
|
+
// Codex parity: same monkey patch should give explicit_malformed
|
|
2692
|
+
const codexUtils = require(path2.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-utils.cjs'));
|
|
2693
|
+
const tmp2 = fs2.mkdtempSync(path2.join(os2.tmpdir(), 'cafekit-codex-fail-'));
|
|
2694
|
+
try {
|
|
2695
|
+
const specsDir2 = path2.join(tmp2, 'specs');
|
|
2696
|
+
fs2.mkdirSync(path2.join(specsDir2, 'demo2'), { recursive: true });
|
|
2697
|
+
fs2.writeFileSync(path2.join(specsDir2, 'demo2', 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'demo2' }));
|
|
2698
|
+
const orig = fs2.realpathSync;
|
|
2699
|
+
fs2.realpathSync = () => { throw Object.assign(new Error('simulated EACCES'), { code: 'EACCES' }); };
|
|
2700
|
+
try {
|
|
2701
|
+
const r = codexUtils.resolveActiveSpec(tmp2, {}, 'demo2', null);
|
|
2702
|
+
assert.equal(r.error, 'explicit_malformed');
|
|
2703
|
+
} finally {
|
|
2704
|
+
fs2.realpathSync = orig;
|
|
2705
|
+
}
|
|
2706
|
+
} finally {
|
|
2707
|
+
fs2.rmSync(tmp2, { recursive: true, force: true });
|
|
2708
|
+
}
|
|
2709
|
+
});
|
|
2710
|
+
|
|
2711
|
+
test('P0 canonical phantom vectors - zero, cancellation, suite, FAIL, error, collected (r3)', () => {
|
|
2712
|
+
const base = 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n';
|
|
2713
|
+
// 1. Zero execution
|
|
2714
|
+
const zeroFail = [
|
|
2715
|
+
'ℹ tests 0',
|
|
2716
|
+
'Tests: 0 total',
|
|
2717
|
+
'0 tests',
|
|
2718
|
+
'Tests: 0 passed',
|
|
2719
|
+
'Tests: 0 passing',
|
|
2720
|
+
'Test Suites: 0 failed, 0 passed',
|
|
2721
|
+
'Tests passed: 0',
|
|
2722
|
+
'Passed: 0',
|
|
2723
|
+
'one failing',
|
|
2724
|
+
'failureCount: 1',
|
|
2725
|
+
'collected 0 items',
|
|
2726
|
+
'No tests found.',
|
|
2727
|
+
'No tests found',
|
|
2728
|
+
];
|
|
2729
|
+
for (const v of zeroFail) {
|
|
2730
|
+
assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `zero execution should fail: ${v}`);
|
|
2731
|
+
}
|
|
2732
|
+
// 2. Cancellation N>0
|
|
2733
|
+
const cancelFail = [
|
|
2734
|
+
'ℹ cancelled 1',
|
|
2735
|
+
'cancelled 1',
|
|
2736
|
+
'1 cancelled',
|
|
2737
|
+
'canceled 1',
|
|
2738
|
+
'1 canceled',
|
|
2739
|
+
];
|
|
2740
|
+
for (const v of cancelFail) {
|
|
2741
|
+
assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `cancel should fail: ${v}`);
|
|
2742
|
+
}
|
|
2743
|
+
// 3. Suite / Files
|
|
2744
|
+
const suiteFail = [
|
|
2745
|
+
'Test Suites: 1 failed, 1 total',
|
|
2746
|
+
'Test Suites: 2 failed',
|
|
2747
|
+
'Test Files: 1 failed',
|
|
2748
|
+
'Test Files 1 failed',
|
|
2749
|
+
];
|
|
2750
|
+
for (const v of suiteFail) {
|
|
2751
|
+
assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `suite should fail: ${v}`);
|
|
2752
|
+
}
|
|
2753
|
+
// 4. Runner FAIL
|
|
2754
|
+
const runnerFail = [
|
|
2755
|
+
'FAIL ./foo.test.js',
|
|
2756
|
+
'FAIL\tpackage',
|
|
2757
|
+
'FAIL\texample.test/probe\t0.431s',
|
|
2758
|
+
'--- FAIL: TestName (0.00s)',
|
|
2759
|
+
'FAIL',
|
|
2760
|
+
'FAIL: something',
|
|
2761
|
+
'FAILED tests/test_demo.py::test_foo - assert 1 == 2',
|
|
2762
|
+
'FAILED tests/test_demo.py',
|
|
2763
|
+
'# fail 1',
|
|
2764
|
+
'ℹ fail 1',
|
|
2765
|
+
'not ok 1 - test',
|
|
2766
|
+
];
|
|
2767
|
+
for (const v of runnerFail) {
|
|
2768
|
+
assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `runner FAIL should fail: ${v}`);
|
|
2769
|
+
}
|
|
2770
|
+
// also standalone FAILED/FAILURE bare/colon
|
|
2771
|
+
for (const v of ['FAILED', 'FAILURE', 'FAILED:', 'FAILURE:']) {
|
|
2772
|
+
assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `standalone ${v} should fail`);
|
|
2773
|
+
}
|
|
2774
|
+
// 5. Error summaries N>0
|
|
2775
|
+
const errorFail = [
|
|
2776
|
+
'1 error in 0.12s',
|
|
2777
|
+
'2 errors',
|
|
2778
|
+
'ERROR collecting tests/test_demo.py',
|
|
2779
|
+
'ERROR',
|
|
2780
|
+
'Found 2 errors',
|
|
2781
|
+
'Tests run: 5, Failures: 1, Errors: 0',
|
|
2782
|
+
'[ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0',
|
|
2783
|
+
'[ERROR] Tests run: 5, Failures: 0, Errors: 1, Skipped: 0',
|
|
2784
|
+
'[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.2.5:test',
|
|
2785
|
+
'5 tests completed, 1 failed',
|
|
2786
|
+
];
|
|
2787
|
+
for (const v of errorFail) {
|
|
2788
|
+
assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `error should fail: ${v}`);
|
|
2789
|
+
}
|
|
2790
|
+
// Positive controls must pass
|
|
2791
|
+
const pass = [
|
|
2792
|
+
'Tests failed: 0',
|
|
2793
|
+
'fail 0',
|
|
2794
|
+
'Tests: 0 failed',
|
|
2795
|
+
'cancelled 0',
|
|
2796
|
+
'canceled 0',
|
|
2797
|
+
'Test Suites: 0 failed',
|
|
2798
|
+
'Test Files: 0 failed',
|
|
2799
|
+
'5 tests completed, 0 failed',
|
|
2800
|
+
'Tests run: 5, Failures: 0, Errors: 0',
|
|
2801
|
+
'# tests 1',
|
|
2802
|
+
'# suites 0',
|
|
2803
|
+
'# pass 1',
|
|
2804
|
+
'# fail 0',
|
|
2805
|
+
'# cancelled 0',
|
|
2806
|
+
'# skipped 0',
|
|
2807
|
+
'# todo 0',
|
|
2808
|
+
'# duration_ms 114.203625',
|
|
2809
|
+
'[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
|
|
2810
|
+
'[ERROR] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
|
|
2811
|
+
'[ERROR] Error handling is documented',
|
|
2812
|
+
'errors 0',
|
|
2813
|
+
'Found 0 errors',
|
|
2814
|
+
'tests 5',
|
|
2815
|
+
'collected 1 item',
|
|
2816
|
+
'collected 5 items',
|
|
2817
|
+
'Notes: handles error/failure',
|
|
2818
|
+
'Notes: verifies failure handling',
|
|
2819
|
+
'Notes: Tests: 0 passed',
|
|
2820
|
+
'Description: Tests passed: 0',
|
|
2821
|
+
'Notes: one failing test is documented',
|
|
2822
|
+
'Description: failureCount: 1 is documented',
|
|
2823
|
+
'one failing test: 1 is documented',
|
|
2824
|
+
'failureCount: 1 is documented',
|
|
2825
|
+
'Tests failed previously but now fixed',
|
|
2826
|
+
'Failure handling is documented',
|
|
2827
|
+
'failure summary follows in the notes',
|
|
2828
|
+
'ERROR handling is documented',
|
|
2829
|
+
'Summary: failureCount: 1 is documented',
|
|
2830
|
+
'Error handling is documented',
|
|
2831
|
+
'FAILURE mode analysis',
|
|
2832
|
+
'Notes: tests failed previously but now fixed',
|
|
2833
|
+
'Notes: supports zero-test parsing',
|
|
2834
|
+
'Command: npm run test --errorFlag',
|
|
2835
|
+
'Command: npm run artifact:test',
|
|
2836
|
+
];
|
|
2837
|
+
for (const v of pass) {
|
|
2838
|
+
// Command lines are skipped; others should not trigger hasExplicitFailure
|
|
2839
|
+
const body = v.startsWith('Command:') ? `Verification: PASS\n${v}\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n` : base + v + '\n';
|
|
2840
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(body), [], `should pass: ${v}`);
|
|
2841
|
+
}
|
|
2842
|
+
assert.equal(POLICY.isTapMetadataHeading('# fail 1'), true);
|
|
2843
|
+
assert.equal(POLICY.isTapMetadataHeading('# duration_ms 114.203625'), true);
|
|
2844
|
+
assert.equal(POLICY.isTapMetadataHeading('# Notes'), false);
|
|
2845
|
+
assert.equal(POLICY.isTapMetadataHeading('# pass 1 explanation'), false);
|
|
2846
|
+
});
|