@haposoft/cafekit 0.16.0-rc.2 → 0.16.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/__tests__/codex-hooks.test.js +355 -12
- package/bin/__tests__/codex-native.test.js +1 -1
- package/bin/__tests__/develop-contract.test.js +1374 -78
- package/bin/__tests__/installer-safety.test.js +43 -29
- package/bin/__tests__/opencode-spec-gate.test.js +791 -0
- package/bin/__tests__/package-inventory.test.js +157 -0
- package/bin/__tests__/validator-grounding.test.js +707 -2
- package/package.json +1 -1
- package/src/claude/agents/spec-maker.md +107 -200
- package/src/claude/hooks/__tests__/completion-authority.test.js +720 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +511 -38
- package/src/claude/hooks/completion-authority-check.cjs +307 -0
- package/src/claude/hooks/completion-authority-state.cjs +525 -0
- package/src/claude/hooks/completion-authority.cjs +148 -0
- package/src/claude/hooks/session.cjs +33 -19
- package/src/claude/hooks/spec-gate.cjs +173 -61
- package/src/claude/hooks/spec-state.cjs +104 -45
- package/src/claude/migration-manifest.json +6 -1
- package/src/claude/scripts/provenance.cjs +315 -0
- package/src/claude/scripts/spec-resolver.cjs +450 -0
- package/src/claude/scripts/spec-scaffold.cjs +716 -41
- package/src/claude/scripts/validate-spec-output.cjs +355 -53
- package/src/claude/scripts/workflow-policy.cjs +1529 -162
- package/src/claude/settings/settings.json +8 -0
- package/src/claude/skills/code-review/SKILL.md +85 -66
- package/src/claude/skills/develop/SKILL.md +153 -285
- package/src/claude/skills/develop/references/quality-gate.md +116 -148
- package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
- package/src/claude/skills/specs/SKILL.md +170 -381
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +45 -46
- package/src/claude/skills/specs/rules/tasks-generation.md +49 -20
- package/src/claude/skills/specs/templates/spec-state.json +23 -2
- package/src/claude/skills/specs/templates/task.md +20 -0
- package/src/claude/skills/sync/SKILL.md +15 -8
- package/src/claude/skills/sync/references/sync-protocols.md +4 -4
- package/src/claude/skills/test/SKILL.md +141 -167
- package/src/codex/hooks/completion-authority-check.cjs +260 -0
- package/src/codex/hooks/completion-authority-state.cjs +525 -0
- package/src/codex/hooks/completion-authority.cjs +100 -0
- package/src/codex/hooks/lib/hook-context.cjs +42 -2
- package/src/codex/hooks/lib/spec-receipt.cjs +76 -43
- package/src/codex/hooks/lib/spec-utils.cjs +53 -27
- package/src/codex/hooks/session.cjs +2 -0
- package/src/codex/hooks/spec-gate.cjs +132 -20
- package/src/codex/hooks/spec-state.cjs +56 -10
- package/src/codex/hooks.json +10 -0
- package/src/opencode/AGENTS.md +4 -2
- package/src/opencode/plugins/spec-gate.ts +296 -122
- package/src/opencode/plugins/spec-state.ts +183 -116
|
@@ -6,11 +6,12 @@ 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'));
|
|
14
15
|
const DEVELOP = path.join(PACKAGE_ROOT, 'src/claude/skills/develop/SKILL.md');
|
|
15
16
|
const GATE = path.join(PACKAGE_ROOT, 'src/claude/skills/develop/references/quality-gate.md');
|
|
16
17
|
const TEST_SKILL = path.join(PACKAGE_ROOT, 'src/claude/skills/test/SKILL.md');
|
|
@@ -18,6 +19,65 @@ const SYNC_SKILL = path.join(PACKAGE_ROOT, 'src/claude/skills/sync/SKILL.md');
|
|
|
18
19
|
const CODE_REVIEW_SKILL = path.join(PACKAGE_ROOT, 'src/claude/skills/code-review/SKILL.md');
|
|
19
20
|
const PARALLEL_WAVES = path.join(PACKAGE_ROOT, 'src/claude/skills/develop/references/parallel-waves.md');
|
|
20
21
|
const PROVENANCE = path.join(PACKAGE_ROOT, '../../docs/provenance.md');
|
|
22
|
+
const RUNTIME_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-policy-runtime-'));
|
|
23
|
+
const RUNTIME_SPEC = path.join(RUNTIME_ROOT, 'specs', 'demo', 'spec.json');
|
|
24
|
+
fs.mkdirSync(path.dirname(RUNTIME_SPEC), { recursive: true });
|
|
25
|
+
fs.mkdirSync(path.join(RUNTIME_ROOT, 'src'), { recursive: true });
|
|
26
|
+
fs.writeFileSync(path.join(RUNTIME_ROOT, 'src', 'app.js'), 'runtime fixture\n');
|
|
27
|
+
fs.writeFileSync(RUNTIME_SPEC, JSON.stringify({ status: 'in_progress', feature_name: 'demo', task_registry: {} }));
|
|
28
|
+
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['add', '-A'], ['commit', '-qm', 'fixture']]) {
|
|
29
|
+
const result = spawnSync('git', ['-C', RUNTIME_ROOT, ...args], { encoding: 'utf8' });
|
|
30
|
+
assert.equal(result.status, 0, result.stderr);
|
|
31
|
+
}
|
|
32
|
+
const RUNTIME_CONTEXT = PROVENANCE_HELPER.deriveRuntimeContext({
|
|
33
|
+
projectRoot: RUNTIME_ROOT,
|
|
34
|
+
specsRoot: path.join(RUNTIME_ROOT, 'specs'),
|
|
35
|
+
specFile: RUNTIME_SPEC,
|
|
36
|
+
featureName: 'demo',
|
|
37
|
+
runtimeSession: 'implementation-session-1',
|
|
38
|
+
});
|
|
39
|
+
after(() => fs.rmSync(RUNTIME_ROOT, { recursive: true, force: true }));
|
|
40
|
+
const EXPECTED_BASE = RUNTIME_CONTEXT.base;
|
|
41
|
+
const EXPECTED_HEAD = RUNTIME_CONTEXT.head;
|
|
42
|
+
const EXPECTED_PROVENANCE = { base: EXPECTED_BASE, head: EXPECTED_HEAD };
|
|
43
|
+
const RECEIPT_BINDING = POLICY.createReceiptBinding(RUNTIME_CONTEXT);
|
|
44
|
+
|
|
45
|
+
function initFixtureGit(root) {
|
|
46
|
+
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['add', '-A'], ['commit', '-qm', 'fixture']]) {
|
|
47
|
+
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
48
|
+
assert.equal(result.status, 0, result.stderr);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function bindFixtureReceipt(root, value, feature = 'demo', session = 'test') {
|
|
53
|
+
const context = PROVENANCE_HELPER.deriveRuntimeContext({
|
|
54
|
+
projectRoot: root,
|
|
55
|
+
specsRoot: path.join(root, 'specs'),
|
|
56
|
+
specFile: path.join(root, 'specs', feature, 'spec.json'),
|
|
57
|
+
featureName: feature,
|
|
58
|
+
runtimeSession: session,
|
|
59
|
+
});
|
|
60
|
+
return value.replaceAll('0123456789abcdef0123456789abcdef01234567', context.base)
|
|
61
|
+
.replaceAll('89abcdef0123456789abcdef0123456789abcdef', context.head);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function canonicalReceipt(command = 'pnpm test', extra = '') {
|
|
65
|
+
return `Verification: PASS\nCommand: ${command}\nExit: 0\nBase: ${EXPECTED_BASE}\nHead: ${EXPECTED_HEAD}\n${extra}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function canonicalFlashTask(overrides = {}) {
|
|
69
|
+
return {
|
|
70
|
+
status: 'in_progress',
|
|
71
|
+
receipt: 'FLASH_UNVERIFIED',
|
|
72
|
+
blocker: 'awaiting /hapo:test <feature>',
|
|
73
|
+
dependencyBlocked: true,
|
|
74
|
+
unblocks: false,
|
|
75
|
+
feature_name: 'demo',
|
|
76
|
+
runtime_context: RUNTIME_CONTEXT,
|
|
77
|
+
expected_provenance: { ...EXPECTED_PROVENANCE },
|
|
78
|
+
...overrides,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
21
81
|
|
|
22
82
|
function read(filePath) {
|
|
23
83
|
return fs.readFileSync(filePath, 'utf8');
|
|
@@ -56,16 +116,17 @@ test('develop pre-state guard names executable policy contract', () => {
|
|
|
56
116
|
assert.match(develop, /flash\+parallel fail-fast/i);
|
|
57
117
|
});
|
|
58
118
|
|
|
59
|
-
test('delegation plan
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
assert.deepEqual(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
119
|
+
test('delegation plan consumes legacy tiers as obligations without an agent chain', () => {
|
|
120
|
+
const light = POLICY.delegationPlan({ tier: 'Light', mode: 'full-spec', taskCount: 2 });
|
|
121
|
+
const standard = POLICY.delegationPlan({ tier: 'Standard', mode: 'specific-task' });
|
|
122
|
+
const deep = POLICY.delegationPlan({ tier: 'Deep', mode: 'full-spec', taskCount: 2 });
|
|
123
|
+
assert.deepEqual(light.proof_obligations, ['needsExecutionProof']);
|
|
124
|
+
assert.deepEqual(standard.proof_obligations, ['needsInspection', 'needsExecutionProof']);
|
|
125
|
+
assert.deepEqual(deep.proof_obligations, ['needsInspection', 'needsExecutionProof', 'needsIndependentAudit']);
|
|
126
|
+
for (const plan of [light, standard, deep]) {
|
|
127
|
+
assert.equal(Object.hasOwn(plan, 'delegated'), false);
|
|
128
|
+
assert.doesNotMatch(JSON.stringify(plan), /inspector|implementer|test-runner|code-auditor/);
|
|
129
|
+
}
|
|
69
130
|
assert.doesNotMatch(read(GATE), /spec-review|quality-review/);
|
|
70
131
|
assert.doesNotMatch(read(DEVELOP), /spec-review|quality-review/);
|
|
71
132
|
});
|
|
@@ -75,8 +136,12 @@ test('lane classifier selects Direct for explicit reversible low-risk work', ()
|
|
|
75
136
|
assert.equal(result.lane, 'Direct');
|
|
76
137
|
assert.equal(result.automaticLane, 'Direct');
|
|
77
138
|
assert.deepEqual(result.risks, []);
|
|
78
|
-
|
|
79
|
-
assert.
|
|
139
|
+
const policy = POLICY.lanePolicy(result);
|
|
140
|
+
assert.deepEqual(policy.proof_obligations, ['needsExecutionProof']);
|
|
141
|
+
assert.equal(policy.artifact_profile, 'targeted');
|
|
142
|
+
assert.equal(policy.requiresSpec, false);
|
|
143
|
+
assert.equal(policy.requiresState, false);
|
|
144
|
+
assert.equal(policy.proof_obligations.includes('needsDurableTaskState'), false);
|
|
80
145
|
});
|
|
81
146
|
|
|
82
147
|
test('lane classifier forces Critical for named high-risk signals', () => {
|
|
@@ -86,23 +151,198 @@ test('lane classifier forces Critical for named high-risk signals', () => {
|
|
|
86
151
|
});
|
|
87
152
|
assert.equal(result.lane, 'Critical');
|
|
88
153
|
assert.deepEqual(result.risks, ['auth', 'migration', 'publicContract']);
|
|
89
|
-
assert.deepEqual(POLICY.delegationPlan({ lane: 'Critical' }).
|
|
90
|
-
'
|
|
154
|
+
assert.deepEqual(POLICY.delegationPlan({ lane: 'Critical' }).proof_obligations, [
|
|
155
|
+
'needsInspection', 'needsExecutionProof', 'needsIndependentAudit',
|
|
91
156
|
]);
|
|
92
157
|
});
|
|
93
158
|
|
|
159
|
+
test('P1 persists one strict workflow-policy snapshot and rejects malformed snapshots', () => {
|
|
160
|
+
const initial = POLICY.persistWorkflowPolicySnapshot(
|
|
161
|
+
{ feature_name: 'bounded', override_receipt: 'legacy-duplicate' },
|
|
162
|
+
{ riskSignals: {} },
|
|
163
|
+
);
|
|
164
|
+
const snapshot = initial.workflow_policy;
|
|
165
|
+
assert.deepEqual(Object.keys(snapshot).sort(), [...POLICY.WORKFLOW_POLICY_FIELDS].sort());
|
|
166
|
+
assert.equal(snapshot.version, '1');
|
|
167
|
+
assert.equal(snapshot.lane, 'Standard');
|
|
168
|
+
assert.equal(snapshot.automatic_lane, 'Standard');
|
|
169
|
+
assert.equal(snapshot.artifact_profile, 'bounded');
|
|
170
|
+
assert.deepEqual(snapshot.proof_obligations, ['needsInspection', 'needsExecutionProof']);
|
|
171
|
+
assert.equal(Object.hasOwn(initial, 'override_receipt'), false);
|
|
172
|
+
assert.equal(POLICY.validateWorkflowPolicySnapshot(snapshot).valid, true);
|
|
173
|
+
|
|
174
|
+
const persistedAgain = POLICY.persistWorkflowPolicySnapshot(initial, {
|
|
175
|
+
riskSignals: { auth: true },
|
|
176
|
+
override: 'Direct',
|
|
177
|
+
});
|
|
178
|
+
assert.deepEqual(persistedAgain.workflow_policy, snapshot, 'persist-once must not reclassify an existing snapshot');
|
|
179
|
+
|
|
180
|
+
const malformed = { ...snapshot, artifact_profile: 'strict', extra: true };
|
|
181
|
+
const validation = POLICY.validateWorkflowPolicySnapshot(malformed);
|
|
182
|
+
assert.equal(validation.valid, false);
|
|
183
|
+
assert.match(validation.errors.join('; '), /fields must be exactly|artifact_profile/);
|
|
184
|
+
assert.throws(() => POLICY.readWorkflowPolicySnapshot({ workflow_policy: malformed }), /Invalid workflow policy snapshot/);
|
|
185
|
+
|
|
186
|
+
const malformedShape = { ...snapshot, proof_obligations: { needsExecutionProof: true } };
|
|
187
|
+
const shapeValidation = POLICY.validateWorkflowPolicySnapshot(malformedShape);
|
|
188
|
+
assert.equal(shapeValidation.valid, false);
|
|
189
|
+
assert.match(shapeValidation.errors.join('; '), /proof_obligations must be an array/);
|
|
190
|
+
|
|
191
|
+
const malformedRisks = { ...snapshot, risks: { auth: true } };
|
|
192
|
+
const risksValidation = POLICY.validateWorkflowPolicySnapshot(malformedRisks);
|
|
193
|
+
assert.equal(risksValidation.valid, false);
|
|
194
|
+
assert.match(risksValidation.errors.join('; '), /risks must be an array/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('P1 workflow-policy validation is semantic and explicit workflow_policy values never reclassify', () => {
|
|
198
|
+
const direct = POLICY.workflowPolicySnapshot({ reversible: true, lowRisk: true, isolated: true });
|
|
199
|
+
const standardUnknown = POLICY.escalateWorkflowPolicy(direct, { risks: ['unclassified-risk'] });
|
|
200
|
+
const criticalAuth = POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } });
|
|
201
|
+
const escalatedCritical = POLICY.escalateWorkflowPolicy(direct, { risks: ['auth'] });
|
|
202
|
+
|
|
203
|
+
for (const legitimate of [direct, standardUnknown, criticalAuth, escalatedCritical]) {
|
|
204
|
+
assert.equal(POLICY.validateWorkflowPolicySnapshot(legitimate).valid, true);
|
|
205
|
+
}
|
|
206
|
+
assert.equal(escalatedCritical.lane, 'Critical');
|
|
207
|
+
assert.equal(escalatedCritical.automatic_lane, 'Direct');
|
|
208
|
+
|
|
209
|
+
const forgedDirectAuth = { ...direct, risks: ['auth'] };
|
|
210
|
+
const forgedDirectUnknown = { ...direct, risks: ['unclassified-risk'] };
|
|
211
|
+
const forgedStandardAuth = { ...standardUnknown, risks: ['auth'] };
|
|
212
|
+
for (const forged of [forgedDirectAuth, forgedDirectUnknown, forgedStandardAuth]) {
|
|
213
|
+
const result = POLICY.validateWorkflowPolicySnapshot(forged);
|
|
214
|
+
assert.equal(result.valid, false);
|
|
215
|
+
assert.match(result.errors.join('; '), /at least (Standard|Critical)/);
|
|
216
|
+
}
|
|
217
|
+
for (const riskAlias of ['Auth', 'authentication', 'public_contract']) {
|
|
218
|
+
const escalated = POLICY.escalateWorkflowPolicy(direct, { risks: [riskAlias] });
|
|
219
|
+
assert.equal(escalated.lane, 'Critical', `${riskAlias} must classify as Critical`);
|
|
220
|
+
assert.equal(POLICY.validateWorkflowPolicySnapshot({ ...direct, risks: [riskAlias] }).valid, false);
|
|
221
|
+
}
|
|
222
|
+
assert.throws(() => POLICY.assertWorkflowPolicySnapshot(forgedDirectAuth), /Invalid workflow policy snapshot/);
|
|
223
|
+
assert.throws(() => POLICY.readWorkflowPolicySnapshot({ workflow_policy: forgedDirectAuth }), /Invalid workflow policy snapshot/);
|
|
224
|
+
assert.throws(() => POLICY.lanePolicy({ workflow_policy: forgedDirectAuth }), /Invalid workflow policy snapshot/);
|
|
225
|
+
|
|
226
|
+
for (const explicitValue of [null, undefined]) {
|
|
227
|
+
const state = { workflow_policy: explicitValue, reversible: true, lowRisk: true, isolated: true };
|
|
228
|
+
assert.throws(() => POLICY.workflowPolicySnapshot(state), /Invalid workflow policy snapshot/);
|
|
229
|
+
assert.throws(() => POLICY.escalateWorkflowPolicy(state, {}), /Invalid workflow policy snapshot/);
|
|
230
|
+
assert.throws(() => POLICY.lanePolicy(state), /Invalid workflow policy snapshot/);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const forgedLanePolicyCli = spawnSync(process.execPath, [
|
|
234
|
+
POLICY_PATH,
|
|
235
|
+
'--lane-policy',
|
|
236
|
+
'--task-json',
|
|
237
|
+
JSON.stringify({ workflow_policy: forgedDirectAuth }),
|
|
238
|
+
'--json',
|
|
239
|
+
], { encoding: 'utf8' });
|
|
240
|
+
assert.equal(forgedLanePolicyCli.status, 2, `${forgedLanePolicyCli.stdout}\n${forgedLanePolicyCli.stderr}`);
|
|
241
|
+
assert.match(forgedLanePolicyCli.stderr, /Invalid workflow policy snapshot/);
|
|
242
|
+
|
|
243
|
+
const nullLanePolicyCli = spawnSync(process.execPath, [
|
|
244
|
+
POLICY_PATH,
|
|
245
|
+
'--lane-policy',
|
|
246
|
+
'--task-json',
|
|
247
|
+
JSON.stringify({ workflow_policy: null, reversible: true, lowRisk: true, isolated: true }),
|
|
248
|
+
'--json',
|
|
249
|
+
], { encoding: 'utf8' });
|
|
250
|
+
assert.equal(nullLanePolicyCli.status, 2, `${nullLanePolicyCli.stdout}\n${nullLanePolicyCli.stderr}`);
|
|
251
|
+
assert.match(nullLanePolicyCli.stderr, /Invalid workflow policy snapshot/);
|
|
252
|
+
|
|
253
|
+
const validatePolicyCli = spawnSync(process.execPath, [
|
|
254
|
+
POLICY_PATH,
|
|
255
|
+
'--validate-policy',
|
|
256
|
+
'--task-json',
|
|
257
|
+
JSON.stringify({ workflow_policy: forgedDirectAuth }),
|
|
258
|
+
'--json',
|
|
259
|
+
], { encoding: 'utf8' });
|
|
260
|
+
assert.equal(validatePolicyCli.status, 2, `${validatePolicyCli.stdout}\n${validatePolicyCli.stderr}`);
|
|
261
|
+
assert.equal(JSON.parse(validatePolicyCli.stdout).valid, false);
|
|
262
|
+
|
|
263
|
+
const validateNullPolicyCli = spawnSync(process.execPath, [
|
|
264
|
+
POLICY_PATH,
|
|
265
|
+
'--validate-policy',
|
|
266
|
+
'--task-json',
|
|
267
|
+
JSON.stringify({ workflow_policy: null }),
|
|
268
|
+
'--json',
|
|
269
|
+
], { encoding: 'utf8' });
|
|
270
|
+
assert.equal(validateNullPolicyCli.status, 2, `${validateNullPolicyCli.stdout}\n${validateNullPolicyCli.stderr}`);
|
|
271
|
+
assert.equal(JSON.parse(validateNullPolicyCli.stdout).valid, false);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('P1 workflow policy escalation is monotonic across all newly classified risks', () => {
|
|
275
|
+
const direct = POLICY.workflowPolicySnapshot({ reversible: true, lowRisk: true, isolated: true });
|
|
276
|
+
assert.equal(direct.lane, 'Direct');
|
|
277
|
+
assert.deepEqual(POLICY.escalateWorkflowPolicy(direct, {}), direct, 'no new risks must not escalate');
|
|
278
|
+
|
|
279
|
+
const standard = POLICY.escalateWorkflowPolicy(direct, { risks: ['new-standard-risk'], riskLevel: 'standard' });
|
|
280
|
+
assert.equal(standard.lane, 'Standard');
|
|
281
|
+
assert.equal(standard.artifact_profile, 'bounded');
|
|
282
|
+
assert.equal(standard.risks.includes('new-standard-risk'), true);
|
|
283
|
+
assert.deepEqual(standard.proof_obligations, ['needsInspection', 'needsExecutionProof']);
|
|
284
|
+
|
|
285
|
+
const ambiguityCritical = POLICY.escalateWorkflowPolicy(direct, { riskSignals: { ambiguity: true } });
|
|
286
|
+
assert.equal(ambiguityCritical.lane, 'Critical', 'every classified Critical risk must escalate to Critical');
|
|
287
|
+
|
|
288
|
+
const critical = POLICY.escalateWorkflowPolicy(standard, { risks: ['auth'] });
|
|
289
|
+
assert.equal(critical.lane, 'Critical');
|
|
290
|
+
assert.equal(critical.artifact_profile, 'strict');
|
|
291
|
+
assert.equal(critical.proof_obligations.includes('needsIndependentAudit'), true);
|
|
292
|
+
assert.equal(critical.proof_obligations.includes('needsResearchGrounding'), true);
|
|
293
|
+
|
|
294
|
+
const noDowngrade = POLICY.escalateWorkflowPolicy(critical, { risks: ['another-standard-risk'], riskLevel: 'standard' });
|
|
295
|
+
assert.equal(noDowngrade.lane, 'Critical');
|
|
296
|
+
assert.equal(noDowngrade.proof_obligations.includes('needsIndependentAudit'), true);
|
|
297
|
+
assert.equal(noDowngrade.proof_obligations.includes('needsResearchGrounding'), true);
|
|
298
|
+
|
|
299
|
+
const criticalAgain = POLICY.escalateWorkflowPolicy(critical, { risks: ['auth'] });
|
|
300
|
+
assert.deepEqual(criticalAgain, critical, 'repeating known risks must not create a new escalation');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('P1 legacy execution tier is read-compatible without becoming emitted policy authority', () => {
|
|
304
|
+
const legacy = { design_context: { execution_tier: 'Deep' } };
|
|
305
|
+
const snapshot = POLICY.workflowPolicySnapshot(legacy);
|
|
306
|
+
assert.equal(snapshot.lane, 'Critical');
|
|
307
|
+
assert.equal(snapshot.artifact_profile, 'strict');
|
|
308
|
+
const policy = POLICY.lanePolicy(legacy);
|
|
309
|
+
assert.equal(policy.lane, 'Critical');
|
|
310
|
+
assert.equal(Object.hasOwn(policy, 'execution_tier'), false);
|
|
311
|
+
assert.equal(Object.hasOwn(policy, 'executionTier'), false);
|
|
312
|
+
const legacyPlan = POLICY.delegationPlan({ tier: 'Deep' });
|
|
313
|
+
assert.equal(Object.hasOwn(legacyPlan, 'execution_tier'), false);
|
|
314
|
+
assert.equal(Object.hasOwn(legacyPlan, 'executionTier'), false);
|
|
315
|
+
assert.deepEqual(legacyPlan.proof_obligations, snapshot.proof_obligations);
|
|
316
|
+
assert.equal(POLICY.workflowPolicySnapshot({ design_context: { execution_tier: 'standard' } }).lane, 'Standard');
|
|
317
|
+
});
|
|
318
|
+
|
|
94
319
|
test('lane override keeps automatic result and surfaces downgrade warning', () => {
|
|
95
|
-
const
|
|
320
|
+
const validReceipt = {
|
|
321
|
+
automaticLane: 'Critical',
|
|
322
|
+
requestedLane: 'Direct',
|
|
323
|
+
risks: ['privacy'],
|
|
324
|
+
source: 'runtime',
|
|
325
|
+
timestamp: '2026-08-11T00:00:00.000Z',
|
|
326
|
+
sessionId: 'test-session-123',
|
|
327
|
+
};
|
|
328
|
+
// P0.4 fail-closed: even shape-correct runtime receipt without verified issuer is rejected
|
|
329
|
+
assert.throws(() => POLICY.classifyLane({
|
|
96
330
|
reversible: true,
|
|
97
331
|
riskSignals: { privacy: true },
|
|
98
332
|
override: 'Direct',
|
|
99
|
-
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
assert
|
|
333
|
+
overrideReceipt: validReceipt,
|
|
334
|
+
}), /is blocked.*no trusted runtime issuer/);
|
|
335
|
+
// Also verify that a receipt with verified flag still fails if not properly bound? For now fail-closed.
|
|
336
|
+
const forgedVerified = { ...validReceipt, verifiedByRuntime: true };
|
|
337
|
+
// Still blocked because we require additional binding beyond shape; keep blocked until trusted issuer lands
|
|
338
|
+
// (The verified flag makes isValidOverrideReceipt check pass lane binding, but we keep fail-closed by requiring internal issuer proof that tests don't have)
|
|
339
|
+
// For P0 we keep downgrade blocked regardless — this assert documents fail-closed.
|
|
340
|
+
assert.throws(() => POLICY.classifyLane({
|
|
341
|
+
reversible: true,
|
|
342
|
+
riskSignals: { privacy: true },
|
|
343
|
+
override: 'Direct',
|
|
344
|
+
overrideReceipt: forgedVerified,
|
|
345
|
+
}), /is blocked.*no trusted runtime issuer/);
|
|
106
346
|
});
|
|
107
347
|
|
|
108
348
|
test('unsafe downgrade without user authorization is blocked', () => {
|
|
@@ -110,7 +350,7 @@ test('unsafe downgrade without user authorization is blocked', () => {
|
|
|
110
350
|
reversible: true,
|
|
111
351
|
riskSignals: { privacy: true },
|
|
112
352
|
override: 'Direct',
|
|
113
|
-
}), /
|
|
353
|
+
}), /is blocked.*no trusted runtime issuer/);
|
|
114
354
|
// also via CLI
|
|
115
355
|
const cli = spawnSync(process.execPath, [
|
|
116
356
|
POLICY_PATH,
|
|
@@ -120,7 +360,7 @@ test('unsafe downgrade without user authorization is blocked', () => {
|
|
|
120
360
|
'--json',
|
|
121
361
|
], { encoding: 'utf8' });
|
|
122
362
|
assert.equal(cli.status, 2);
|
|
123
|
-
assert.match(cli.stderr, /
|
|
363
|
+
assert.match(cli.stderr, /is blocked.*no trusted runtime issuer/);
|
|
124
364
|
});
|
|
125
365
|
|
|
126
366
|
test('forged approval via legacy approved field is rejected', () => {
|
|
@@ -159,6 +399,29 @@ test('approval state never infers user approval from generated or agent validati
|
|
|
159
399
|
assert.equal(JSON.parse(cli.stdout).state.user_approved, false);
|
|
160
400
|
});
|
|
161
401
|
|
|
402
|
+
test('approval schema fails closed for explicit null, array, empty, and malformed states', () => {
|
|
403
|
+
const absent = POLICY.validateApprovalSchema({});
|
|
404
|
+
assert.equal(absent.valid, true);
|
|
405
|
+
assert.equal(absent.absent, true);
|
|
406
|
+
assert.equal(POLICY.approvalState({}).present, false);
|
|
407
|
+
|
|
408
|
+
const invalidSpecs = [
|
|
409
|
+
[],
|
|
410
|
+
{ approvals: null },
|
|
411
|
+
{ approvals: [] },
|
|
412
|
+
{ approvals: {} },
|
|
413
|
+
{ approvals: { requirements: null } },
|
|
414
|
+
{ approvals: { requirements: { generated: true, agent_validated: true, user_approved: null } } },
|
|
415
|
+
{ approvals: { requirements: { generated: true, agent_validated: true, user_approved: true, extra: false } } },
|
|
416
|
+
];
|
|
417
|
+
for (const spec of invalidSpecs) {
|
|
418
|
+
assert.equal(POLICY.validateApprovalSchema(spec).valid, false, `invalid approval should fail: ${JSON.stringify(spec)}`);
|
|
419
|
+
assert.throws(() => POLICY.approvalState(spec), /approval|approvals|schema/i);
|
|
420
|
+
}
|
|
421
|
+
assert.throws(() => POLICY.approvalState({ schema_version: null }), /schema_version/);
|
|
422
|
+
assert.throws(() => POLICY.approvalState({ schema_version: '1.0' }), /schema_version/);
|
|
423
|
+
});
|
|
424
|
+
|
|
162
425
|
test('CLI exposes JSON lane classification and develop has no universal spec hard gate', () => {
|
|
163
426
|
const cli = spawnSync(process.execPath, [
|
|
164
427
|
POLICY_PATH,
|
|
@@ -173,40 +436,204 @@ test('CLI exposes JSON lane classification and develop has no universal spec har
|
|
|
173
436
|
assert.equal(payload.policy.requiresSpec, false);
|
|
174
437
|
const develop = read(DEVELOP);
|
|
175
438
|
assert.doesNotMatch(develop, /DO NOT write implementation code until an approved spec exists/i);
|
|
176
|
-
assert.match(develop, /Direct.*
|
|
177
|
-
assert.match(develop, /Standard.*
|
|
178
|
-
assert.match(develop, /
|
|
179
|
-
assert.match(develop, /
|
|
439
|
+
assert.match(develop, /Direct.*Skip spec\/state/i);
|
|
440
|
+
assert.match(develop, /Standard.*bounded.*spec profile/i);
|
|
441
|
+
assert.match(develop, /workflow-policy snapshot/i);
|
|
442
|
+
assert.match(develop, /user_approved[\s\S]*explicit user[\s\S]*approval/i);
|
|
443
|
+
assert.match(develop, /Critical.*strict profile/i);
|
|
444
|
+
assert.match(develop, /Classify lane[\s\S]*test receipt[\s\S]*docs-impact sync/i);
|
|
180
445
|
assert.doesNotMatch(develop, /D2\[Step 3/);
|
|
181
|
-
assert.match(
|
|
446
|
+
assert.match(develop, /--notes.*opt-in/i);
|
|
447
|
+
assert.doesNotMatch(develop, /--no-notes/);
|
|
448
|
+
assert.doesNotMatch(develop, /planner|implementer|test-runner|code-auditor|docs-keeper/i);
|
|
449
|
+
const specsSkill = read(path.join(PACKAGE_ROOT, 'src/claude/skills/specs/SKILL.md'));
|
|
450
|
+
assert.match(specsSkill, /Standard[\s\S]*bounded[\s\S]*exactly `spec\.json`, `requirements\.md`, `design\.md`, and one `feature-receipt\.md`/i);
|
|
451
|
+
assert.match(specsSkill, /persisted.*spec\.json\.workflow_policy/i);
|
|
452
|
+
assert.match(specsSkill, /explicit user approval/i);
|
|
182
453
|
});
|
|
183
454
|
|
|
184
|
-
test('
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
assert.
|
|
455
|
+
test('P2 reduces ceremony without weakening lane and proof contracts', () => {
|
|
456
|
+
const specs = read(path.join(PACKAGE_ROOT, 'src/claude/skills/specs/SKILL.md'));
|
|
457
|
+
const develop = read(DEVELOP);
|
|
458
|
+
const testSkill = read(TEST_SKILL);
|
|
459
|
+
const review = read(CODE_REVIEW_SKILL);
|
|
460
|
+
const gate = read(GATE);
|
|
461
|
+
const specMaker = read(path.join(PACKAGE_ROOT, 'src/claude/agents/spec-maker.md'));
|
|
462
|
+
|
|
463
|
+
assert.ok(specs.trimEnd().split('\n').length >= 150 && specs.trimEnd().split('\n').length <= 220);
|
|
464
|
+
assert.ok(develop.trimEnd().split('\n').length >= 140 && develop.trimEnd().split('\n').length <= 200);
|
|
465
|
+
assert.match(specs, /Direct[\s\S]*Do not create a spec/i);
|
|
466
|
+
assert.ok(specs.includes('Do not require') && specs.includes('task_registry'));
|
|
467
|
+
assert.match(specs, /Critical[\s\S]*registry\/DAG[\s\S]*research/i);
|
|
468
|
+
assert.match(specs, /Load\s+`rules\/ears-format\.md` when/);
|
|
469
|
+
assert.match(specs, /Task scoring is optional advisory/);
|
|
470
|
+
assert.match(specs, /Cynefin is advisory/);
|
|
471
|
+
assert.match(specs, /execution_tier[\s\S]*legacy read adapter/i);
|
|
472
|
+
assert.match(specs, /spec-ready[\s\S]*PENDING/i);
|
|
473
|
+
assert.match(specs, /canonical execution receipt/i);
|
|
474
|
+
assert.match(specs, /Audit: PASS.*not.*evidence/i);
|
|
475
|
+
assert.match(specs, /Set `ready_for_implementation = true` only[\s\S]*deterministic spec validation[\s\S]*grounded requirement\/task mappings and contracts/i);
|
|
476
|
+
assert.match(specs, /neither a[\s\S]*canonical execution receipt[\s\S]*independent closeout audit is required yet/i);
|
|
477
|
+
assert.match(specs, /After implementation, feature\/task closeout is a separate gate[\s\S]*canonical execution receipt/i);
|
|
478
|
+
|
|
479
|
+
assert.match(develop, /specific-task mode never selects or chains/i);
|
|
480
|
+
assert.match(develop, /exactly one closeout owner/i);
|
|
481
|
+
assert.match(develop, /test owner[\s\S]*canonical execution receipt/i);
|
|
482
|
+
assert.match(develop, /review owner[\s\S]*never creates or claims execution proof/i);
|
|
483
|
+
assert.match(develop, /docs impact/i);
|
|
484
|
+
assert.match(gate, /no fixed Light\/Standard\/Deep agent sequence/i);
|
|
485
|
+
assert.match(gate, /no\s+blocking Medium finding/i);
|
|
486
|
+
assert.match(gate, /Finding count never selects review depth/i);
|
|
487
|
+
assert.doesNotMatch(gate, /at most one Medium/i);
|
|
488
|
+
assert.match(testSkill, /sole owner of canonical execution proof/i);
|
|
489
|
+
assert.match(review, /does not[\s\S]*canonical execution[\s\S]*receipt/i);
|
|
490
|
+
assert.match(review, /lane, risk, and blast radius/i);
|
|
491
|
+
assert.match(review, /no\s+blocking Medium finding/i);
|
|
492
|
+
assert.match(review, /Finding count never selects depth/i);
|
|
493
|
+
assert.match(specMaker, /no canonical execution receipt[\s\S]*independent closeout audit is required[\s\S]*yet/i);
|
|
494
|
+
assert.match(specMaker, /passing deterministic spec validation/i);
|
|
495
|
+
for (const content of [develop, gate, review]) {
|
|
496
|
+
assert.doesNotMatch(content, /planner|implementer|test-runner|code-auditor|docs-keeper/i);
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test('canonical verdict adapter handles completion and unfinished decisions', () => {
|
|
501
|
+
assert.deepEqual(POLICY.REVIEW_VERDICTS, ['PASS', 'PASS_WITH_WARNINGS', 'FAIL', 'BLOCKED']);
|
|
502
|
+
assert.doesNotThrow(() => POLICY.assertVerdict('PASS_WITH_WARNINGS'));
|
|
503
|
+
assert.throws(() => POLICY.assertVerdict('PARTIAL'), /Unsupported canonical verdict/);
|
|
504
|
+
assert.equal(POLICY.consumeReviewVerdict('PASS').completion, 'unfinished');
|
|
505
|
+
assert.equal(POLICY.consumeReviewVerdict('PASS').unfinished, true);
|
|
506
|
+
assert.equal(POLICY.consumeReviewVerdict('PASS').createsCompletion, false);
|
|
507
|
+
assert.equal(POLICY.consumeReviewVerdict('PASS_WITH_WARNINGS').unfinished, true);
|
|
508
|
+
assert.equal(POLICY.consumeReviewVerdict('FAIL').retry, true);
|
|
509
|
+
assert.equal(POLICY.consumeReviewVerdict('BLOCKED').retry, false);
|
|
510
|
+
assert.equal(POLICY.consumeReviewVerdict('BLOCKED').unfinished, true);
|
|
511
|
+
assert.equal(POLICY.consumeReviewVerdict('PARTIAL').completion, 'unfinished');
|
|
512
|
+
assert.equal(POLICY.consumeReviewVerdict('NO_TESTS').completion, 'unfinished');
|
|
513
|
+
assert.equal(POLICY.consumeReviewVerdict('PARTIAL').verdict, 'BLOCKED');
|
|
514
|
+
assert.equal(POLICY.consumeReviewVerdict('NO_TESTS').verdict, 'BLOCKED');
|
|
515
|
+
assert.equal(POLICY.consumeReviewVerdict('PARTIAL').retry, false);
|
|
516
|
+
assert.equal(POLICY.consumeReviewVerdict('NO_TESTS').retry, false);
|
|
517
|
+
assert.throws(() => POLICY.consumeReviewVerdict('UNKNOWN'), /Unsupported canonical verdict/);
|
|
193
518
|
const cli = spawnSync(process.execPath, [POLICY_PATH, '--consume-verdict', '--verdict', 'BLOCKED', '--json'], { encoding: 'utf8' });
|
|
194
519
|
assert.equal(cli.status, 0, `${cli.stdout}\n${cli.stderr}`);
|
|
195
520
|
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
|
|
521
|
+
assert.match(read(GATE), /PASS \| PASS_WITH_WARNINGS \| FAIL \| BLOCKED/);
|
|
522
|
+
assert.match(read(CODE_REVIEW_SKILL), /PASS \| PASS_WITH_WARNINGS \| FAIL \| BLOCKED/);
|
|
523
|
+
assert.match(read(TEST_SKILL), /PASS \| PASS_WITH_WARNINGS \| FAIL \| BLOCKED/);
|
|
199
524
|
});
|
|
200
525
|
|
|
201
|
-
test('
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
526
|
+
test('completion decision requires canonical execution receipt and every workflow obligation', () => {
|
|
527
|
+
const receipt = canonicalReceipt();
|
|
528
|
+
const critical = POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } });
|
|
529
|
+
const audit = {
|
|
530
|
+
schema_version: '1',
|
|
531
|
+
reviewer_session_id: 'review-session-1',
|
|
532
|
+
implementation_session_id: 'implementation-session-1',
|
|
533
|
+
expected_provenance: { ...EXPECTED_PROVENANCE },
|
|
534
|
+
evidence: 'independent audit report for the bound change',
|
|
535
|
+
verdict: 'PASS',
|
|
208
536
|
};
|
|
209
|
-
|
|
537
|
+
const missing = POLICY.completionDecision('PASS', {
|
|
538
|
+
workflow_policy: critical,
|
|
539
|
+
receipt_binding: RECEIPT_BINDING,
|
|
540
|
+
execution_receipt: receipt,
|
|
541
|
+
proofs: {
|
|
542
|
+
needsInspection: { evidence: 'inspection receipt' },
|
|
543
|
+
needsResearchGrounding: { evidence: 'research receipt' },
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
assert.equal(missing.completion, 'unfinished');
|
|
547
|
+
assert.ok(missing.missingProof.includes('needsIndependentAudit'));
|
|
548
|
+
assert.equal(missing.unfinished, true);
|
|
549
|
+
|
|
550
|
+
const complete = POLICY.completionDecision('PASS', {
|
|
551
|
+
workflow_policy: critical,
|
|
552
|
+
receipt_binding: RECEIPT_BINDING,
|
|
553
|
+
execution_receipt: receipt,
|
|
554
|
+
proofs: {
|
|
555
|
+
needsInspection: { evidence: 'inspection receipt' },
|
|
556
|
+
needsResearchGrounding: { evidence: 'research receipt' },
|
|
557
|
+
needsIndependentAudit: audit,
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
assert.equal(complete.completion, 'complete');
|
|
561
|
+
assert.equal(complete.unfinished, false);
|
|
562
|
+
assert.equal(complete.warnings, undefined);
|
|
563
|
+
const warnings = POLICY.completionDecision('PASS_WITH_WARNINGS', {
|
|
564
|
+
workflow_policy: critical,
|
|
565
|
+
receipt_binding: RECEIPT_BINDING,
|
|
566
|
+
execution_receipt: receipt,
|
|
567
|
+
proofs: {
|
|
568
|
+
needsInspection: { evidence: 'inspection receipt' },
|
|
569
|
+
needsResearchGrounding: { evidence: 'research receipt' },
|
|
570
|
+
needsIndependentAudit: audit,
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
assert.equal(warnings.completion, 'unfinished');
|
|
574
|
+
assert.equal(warnings.unfinished, true);
|
|
575
|
+
assert.match(warnings.blocker, /literal PASS/);
|
|
576
|
+
assert.equal(POLICY.completionDecision('PASS', {
|
|
577
|
+
workflow_policy: { proof_obligations: ['needsExecutionProof'] },
|
|
578
|
+
receipt_binding: RECEIPT_BINDING,
|
|
579
|
+
execution_receipt: receipt,
|
|
580
|
+
}).completion, 'unfinished');
|
|
581
|
+
assert.equal(POLICY.completionDecision('PASS', {
|
|
582
|
+
workflow_policy: {},
|
|
583
|
+
receipt_binding: RECEIPT_BINDING,
|
|
584
|
+
execution_receipt: receipt,
|
|
585
|
+
}).completion, 'unfinished');
|
|
586
|
+
assert.equal(POLICY.completionDecision('PASS', {
|
|
587
|
+
workflow_policy: critical,
|
|
588
|
+
execution_receipt: receipt,
|
|
589
|
+
}).completion, 'unfinished', 'arbitrary Base/Head without runtime binding must not complete');
|
|
590
|
+
assert.equal(POLICY.completionDecision('PASS', {
|
|
591
|
+
workflow_policy: critical,
|
|
592
|
+
receipt_binding: RECEIPT_BINDING,
|
|
593
|
+
execution_receipt: receipt,
|
|
594
|
+
proofs: {
|
|
595
|
+
needsInspection: { evidence: 'inspection receipt' },
|
|
596
|
+
needsResearchGrounding: { evidence: 'research receipt' },
|
|
597
|
+
needsIndependentAudit: { independent: true, evidence: 'marker only', verdict: 'PASS' },
|
|
598
|
+
},
|
|
599
|
+
}).completion, 'unfinished', 'independent marker must not satisfy audit');
|
|
600
|
+
assert.equal(POLICY.validateIndependentAuditEvidence({ independent: true }, EXPECTED_PROVENANCE).valid, false);
|
|
601
|
+
assert.equal(POLICY.validateIndependentAuditEvidence({ ...audit, verdict: 'PASS_WITH_WARNINGS' }, EXPECTED_PROVENANCE).valid, false);
|
|
602
|
+
assert.equal(POLICY.validateIndependentAuditEvidence({ ...audit, reviewer_session_id: audit.implementation_session_id }, EXPECTED_PROVENANCE).valid, false);
|
|
603
|
+
assert.equal(POLICY.validateIndependentAuditEvidence({ ...audit, expected_provenance: { base: EXPECTED_BASE, head: 'fedcba9876543210fedcba9876543210fedcba98' } }, EXPECTED_PROVENANCE).valid, false);
|
|
604
|
+
assert.equal(POLICY.validateIndependentAuditEvidence({ ...audit, extra: 'forged' }, EXPECTED_PROVENANCE).valid, false);
|
|
605
|
+
assert.equal(POLICY.validateIndependentAuditEvidence([audit], EXPECTED_PROVENANCE).valid, false);
|
|
606
|
+
assert.equal(POLICY.validateIndependentAuditEvidence(audit, EXPECTED_PROVENANCE, RUNTIME_CONTEXT).valid, true);
|
|
607
|
+
assert.equal(POLICY.validateIndependentAuditEvidence(audit, EXPECTED_PROVENANCE, { ...RUNTIME_CONTEXT }).valid, false, 'forged runtime session context must not satisfy audit binding');
|
|
608
|
+
assert.equal(POLICY.completionDecision('PASS', {}).completion, 'unfinished');
|
|
609
|
+
const artifactContext = {
|
|
610
|
+
workflow_policy: POLICY.workflowPolicySnapshot({ riskSignals: {} }),
|
|
611
|
+
receipt_binding: RECEIPT_BINDING,
|
|
612
|
+
task_context: { artifacts: ['output/bundle.js'], expected_provenance: { ...EXPECTED_PROVENANCE } },
|
|
613
|
+
execution_receipt: receipt,
|
|
614
|
+
proofs: { needsInspection: { evidence: 'inspection receipt' } },
|
|
615
|
+
receipt_options: { requireArtifactHash: false, artifactPaths: [] },
|
|
616
|
+
};
|
|
617
|
+
const artifactBlocked = POLICY.completionDecision('PASS', artifactContext);
|
|
618
|
+
assert.equal(artifactBlocked.completion, 'unfinished', 'caller receipt options must not weaken task artifact requirements');
|
|
619
|
+
assert.ok(artifactBlocked.missingProof.some((item) => item.includes('artifact_hash')));
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test('P1 auto generation pauses without forging approval or readiness', () => {
|
|
623
|
+
const specsSkill = read(path.join(PACKAGE_ROOT, 'src/claude/skills/specs/SKILL.md'));
|
|
624
|
+
const specMaker = read(path.join(PACKAGE_ROOT, 'src/claude/agents/spec-maker.md'));
|
|
625
|
+
assert.match(specsSkill, /--auto/i);
|
|
626
|
+
assert.match(specsSkill, /explicit user approval/i);
|
|
627
|
+
assert.match(specsSkill, /paused.*not-ready/i);
|
|
628
|
+
assert.match(specMaker, /--auto.*never sets `user_approved`/s);
|
|
629
|
+
assert.match(specMaker, /paused\/not-ready/i);
|
|
630
|
+
assert.match(specMaker, /never sets `user_approved`/i);
|
|
631
|
+
assert.doesNotMatch(specMaker, /auto-approval/i);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test('flash PASS promotes proof; only trusted sync-finalize completes', () => {
|
|
635
|
+
const initial = canonicalFlashTask();
|
|
636
|
+
for (const verdict of ['FAIL', 'BLOCKED', 'PARTIAL', 'NO_TESTS', 'PASS_WITH_WARNINGS']) {
|
|
210
637
|
const result = POLICY.promoteFlashTask(initial, verdict);
|
|
211
638
|
assert.equal(result.status, 'in_progress');
|
|
212
639
|
assert.equal(result.receipt, 'FLASH_UNVERIFIED');
|
|
@@ -214,7 +641,7 @@ test('flash PASS promotes proof without completion, finalize alone completes', (
|
|
|
214
641
|
assert.equal(result.unblocks, false);
|
|
215
642
|
assert.match(result.blocker, /verification|test proof/);
|
|
216
643
|
}
|
|
217
|
-
const promoted = POLICY.promoteFlashTask(initial, 'PASS',
|
|
644
|
+
const promoted = POLICY.promoteFlashTask(initial, 'PASS', canonicalReceipt());
|
|
218
645
|
assert.equal(promoted.status, 'in_progress');
|
|
219
646
|
assert.equal(promoted.receipt.startsWith('Verification: PASS'), true);
|
|
220
647
|
assert.equal(promoted.blocker, null);
|
|
@@ -222,13 +649,17 @@ test('flash PASS promotes proof without completion, finalize alone completes', (
|
|
|
222
649
|
assert.equal(promoted.unblocks, false);
|
|
223
650
|
assert.equal(promoted.readyForSync, true);
|
|
224
651
|
assert.equal(POLICY.finalizeFlashTask(promoted, 'sync'), promoted);
|
|
225
|
-
const
|
|
652
|
+
const forgedFinalize = POLICY.finalizeFlashTask(promoted, 'sync-finalize');
|
|
653
|
+
assert.equal(forgedFinalize.status, 'in_progress');
|
|
654
|
+
assert.equal(forgedFinalize.dependencyBlocked, true);
|
|
655
|
+
assert.equal(forgedFinalize.unblocks, false);
|
|
656
|
+
const finalized = POLICY.syncFinalizeFlashTask(initial, 'PASS', promoted.receipt);
|
|
226
657
|
assert.equal(finalized.status, 'done');
|
|
227
658
|
assert.equal(finalized.dependencyBlocked, false);
|
|
228
659
|
assert.equal(finalized.unblocks, true);
|
|
229
660
|
assert.equal(finalized.readyForSync, false);
|
|
230
661
|
assert.equal(POLICY.isStaleFlashDone({ status: 'done', receipt: 'FLASH_UNVERIFIED' }), true);
|
|
231
|
-
assert.match(read(TEST_SKILL), /only explicit .*sync-finalize/);
|
|
662
|
+
assert.match(read(TEST_SKILL), /only explicit .*sync-finalize/i);
|
|
232
663
|
assert.match(read(SYNC_SKILL), /sync-finalize/);
|
|
233
664
|
});
|
|
234
665
|
|
|
@@ -239,16 +670,20 @@ test('spec-gate rejects stale FLASH_UNVERIFIED done state', () => {
|
|
|
239
670
|
fs.mkdirSync(path.join(claude, 'scripts'), { recursive: true });
|
|
240
671
|
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/spec-gate.cjs'), path.join(claude, 'hooks/spec-gate.cjs'));
|
|
241
672
|
fs.copyFileSync(POLICY_PATH, path.join(claude, 'scripts/workflow-policy.cjs'));
|
|
673
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'), path.join(claude, 'scripts/provenance.cjs'));
|
|
674
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'), path.join(claude, 'scripts/spec-resolver.cjs'));
|
|
242
675
|
fs.mkdirSync(path.join(root, 'specs', 'demo', 'tasks'), { recursive: true });
|
|
243
676
|
fs.writeFileSync(path.join(root, 'specs', 'demo', 'spec.json'), JSON.stringify({
|
|
244
677
|
status: 'in_progress',
|
|
678
|
+
feature_name: 'demo',
|
|
245
679
|
task_registry: { 'tasks/task.md': { status: 'done', receipt: 'FLASH_UNVERIFIED' } },
|
|
246
680
|
}));
|
|
681
|
+
initFixtureGit(root);
|
|
247
682
|
try {
|
|
248
683
|
const result = spawnSync(process.execPath, [path.join(claude, 'hooks/spec-gate.cjs')], {
|
|
249
684
|
cwd: root,
|
|
250
685
|
env: { ...process.env, PROJECT_ROOT: root },
|
|
251
|
-
input: JSON.stringify({ cwd: root }),
|
|
686
|
+
input: JSON.stringify({ cwd: root, session_id: 'test' }),
|
|
252
687
|
encoding: 'utf8',
|
|
253
688
|
});
|
|
254
689
|
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
@@ -267,19 +702,23 @@ test('first-run cache bypass is blocked - canonical receipt required even withou
|
|
|
267
702
|
fs.mkdirSync(path.join(claude, 'scripts'), { recursive: true });
|
|
268
703
|
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/hooks/spec-gate.cjs'), path.join(claude, 'hooks/spec-gate.cjs'));
|
|
269
704
|
fs.copyFileSync(POLICY_PATH, path.join(claude, 'scripts/workflow-policy.cjs'));
|
|
705
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'), path.join(claude, 'scripts/provenance.cjs'));
|
|
706
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'), path.join(claude, 'scripts/spec-resolver.cjs'));
|
|
270
707
|
const specDir = path.join(root, 'specs', 'demo', 'tasks');
|
|
271
708
|
fs.mkdirSync(specDir, { recursive: true });
|
|
272
709
|
// Done task without canonical receipt (missing Command, provenance)
|
|
273
710
|
fs.writeFileSync(path.join(root, 'specs', 'demo', 'spec.json'), JSON.stringify({
|
|
274
711
|
status: 'in_progress',
|
|
712
|
+
feature_name: 'demo',
|
|
275
713
|
task_registry: { 'tasks/task.md': { status: 'done', completed_at: '2026-07-29T10:00:00.000Z' } },
|
|
276
714
|
}));
|
|
277
715
|
fs.writeFileSync(path.join(specDir, 'task.md'), '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\n```\nnpm test\n```\n');
|
|
716
|
+
initFixtureGit(root);
|
|
278
717
|
try {
|
|
279
718
|
const result = spawnSync(process.execPath, [path.join(claude, 'hooks/spec-gate.cjs')], {
|
|
280
719
|
cwd: root,
|
|
281
720
|
env: { ...process.env, PROJECT_ROOT: root },
|
|
282
|
-
input: JSON.stringify({ cwd: root }),
|
|
721
|
+
input: JSON.stringify({ cwd: root, session_id: 'test' }),
|
|
283
722
|
encoding: 'utf8',
|
|
284
723
|
});
|
|
285
724
|
assert.equal(result.status, 0);
|
|
@@ -287,7 +726,7 @@ test('first-run cache bypass is blocked - canonical receipt required even withou
|
|
|
287
726
|
assert.equal(payload.decision, 'block', 'first-run without canonical receipt must block');
|
|
288
727
|
assert.match(payload.reason, /tasks\/task\.md/);
|
|
289
728
|
// Also test that with canonical receipt it passes
|
|
290
|
-
fs.writeFileSync(path.join(specDir, 'task.md'), '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nResult: PASS\nBase:
|
|
729
|
+
fs.writeFileSync(path.join(specDir, 'task.md'), bindFixtureReceipt(root, '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nResult: PASS\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n```\nnpm test\nPASS\n```\n'));
|
|
291
730
|
// Clear cache to simulate fresh first-run with valid receipt
|
|
292
731
|
try { fs.unlinkSync(path.join(claude, 'hooks', '.logs', 'spec-gate-last.json')); } catch {}
|
|
293
732
|
try { fs.unlinkSync(path.join(__dirname, '..', '.logs', 'spec-gate-last.json')); } catch {}
|
|
@@ -296,7 +735,7 @@ test('first-run cache bypass is blocked - canonical receipt required even withou
|
|
|
296
735
|
const result2 = spawnSync(process.execPath, [path.join(claude, 'hooks/spec-gate.cjs')], {
|
|
297
736
|
cwd: root,
|
|
298
737
|
env: { ...process.env, PROJECT_ROOT: root },
|
|
299
|
-
input: JSON.stringify({ cwd: root }),
|
|
738
|
+
input: JSON.stringify({ cwd: root, session_id: 'test' }),
|
|
300
739
|
encoding: 'utf8',
|
|
301
740
|
});
|
|
302
741
|
assert.equal(result2.stdout, '', 'valid canonical receipt on first run must not block');
|
|
@@ -307,7 +746,7 @@ test('first-run cache bypass is blocked - canonical receipt required even withou
|
|
|
307
746
|
});
|
|
308
747
|
|
|
309
748
|
test('canonical receipt requires command, exit, provenance and unambiguous PASS', () => {
|
|
310
|
-
assert.deepEqual(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase:
|
|
749
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n'), []);
|
|
311
750
|
const missingCommand = POLICY.validateCanonicalReceipt('Verification: PASS\nExit: 0\nBase: a\nHead: b\n');
|
|
312
751
|
assert.ok(missingCommand.includes('command'));
|
|
313
752
|
const missingProvenance = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\n');
|
|
@@ -317,7 +756,7 @@ test('canonical receipt requires command, exit, provenance and unambiguous PASS'
|
|
|
317
756
|
const artifactWithoutHash = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nArtifact: dist/bundle.js\n');
|
|
318
757
|
assert.ok(artifactWithoutHash.includes('artifact_hash'));
|
|
319
758
|
// 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:
|
|
759
|
+
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
760
|
assert.equal(cli.status, 0);
|
|
322
761
|
assert.equal(JSON.parse(cli.stdout).ok, true);
|
|
323
762
|
});
|
|
@@ -330,7 +769,7 @@ test('canonical receipt provenance requires both Base and Head (or both base_sha
|
|
|
330
769
|
const onlyHead = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nHead: def456\n');
|
|
331
770
|
assert.ok(onlyHead.includes('provenance'), 'only Head should fail provenance');
|
|
332
771
|
// Both Base and Head passes
|
|
333
|
-
const bothLabels = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase:
|
|
772
|
+
const bothLabels = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n');
|
|
334
773
|
assert.deepEqual(bothLabels, [], 'both Base and Head should pass');
|
|
335
774
|
// Only base_sha fails
|
|
336
775
|
const onlyBaseSha = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: abc123\n');
|
|
@@ -339,7 +778,7 @@ test('canonical receipt provenance requires both Base and Head (or both base_sha
|
|
|
339
778
|
const onlyHeadSha = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nhead_sha: def456\n');
|
|
340
779
|
assert.ok(onlyHeadSha.includes('provenance'), 'only head_sha should fail provenance');
|
|
341
780
|
// Both base_sha and head_sha passes
|
|
342
|
-
const bothSha = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha:
|
|
781
|
+
const bothSha = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: 0123456789abcdef0123456789abcdef01234567\nhead_sha: 89abcdef0123456789abcdef0123456789abcdef\n');
|
|
343
782
|
assert.deepEqual(bothSha, [], 'both base_sha and head_sha should pass');
|
|
344
783
|
// Alternation-precedence false acceptance check: ensure single Head or base_sha alone does not pass
|
|
345
784
|
const singleHeadLower = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nHead: single\n');
|
|
@@ -365,16 +804,16 @@ test('canonical receipt provenance requires both Base and Head (or both base_sha
|
|
|
365
804
|
assert.ok(bareBoth.includes('provenance'), 'bare base_sha head_sha without colon/value should fail');
|
|
366
805
|
const baseEmptyHeadValid = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase:\nHead: def\n');
|
|
367
806
|
assert.ok(baseEmptyHeadValid.includes('provenance'));
|
|
368
|
-
const validWithValues = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase:
|
|
807
|
+
const validWithValues = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n');
|
|
369
808
|
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:
|
|
809
|
+
const validShaWithValues = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nbase_sha: 0123456789abcdef0123456789abcdef01234567\nhead_sha: 89abcdef0123456789abcdef0123456789abcdef\n');
|
|
371
810
|
assert.deepEqual(validShaWithValues, [], 'valid base_sha and head_sha with values should pass');
|
|
372
811
|
// CLI: only Base via --validate-receipt should fail
|
|
373
812
|
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
813
|
assert.equal(cliOnlyBase.status, 2);
|
|
375
814
|
assert.equal(JSON.parse(cliOnlyBase.stdout).ok, false);
|
|
376
815
|
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:
|
|
816
|
+
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
817
|
assert.equal(cliBoth.status, 0);
|
|
379
818
|
assert.equal(JSON.parse(cliBoth.stdout).ok, true);
|
|
380
819
|
// CLI empty Base should fail
|
|
@@ -383,34 +822,199 @@ test('canonical receipt provenance requires both Base and Head (or both base_sha
|
|
|
383
822
|
assert.ok(JSON.parse(cliEmptyBase.stdout).failures.includes('provenance'));
|
|
384
823
|
});
|
|
385
824
|
|
|
825
|
+
test('canonical receipt binds expected provenance when the runtime supplies it', () => {
|
|
826
|
+
const body = canonicalReceipt();
|
|
827
|
+
const binding = POLICY.createReceiptBinding(RUNTIME_CONTEXT);
|
|
828
|
+
assert.deepEqual(binding.expectedProvenance, EXPECTED_PROVENANCE);
|
|
829
|
+
assert.equal(binding.requireProvenanceBinding, true);
|
|
830
|
+
assert.equal(binding.runtimeContext.context_id, RUNTIME_CONTEXT.context_id);
|
|
831
|
+
assert.throws(() => POLICY.createReceiptBinding(EXPECTED_PROVENANCE), /runtime-derived provenance context/);
|
|
832
|
+
assert.ok(POLICY.validateCanonicalReceipt(body, { requireProvenanceBinding: true }).includes('provenance'));
|
|
833
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(body, { expectedProvenance: EXPECTED_PROVENANCE }), []);
|
|
834
|
+
assert.ok(POLICY.validateCanonicalReceipt(body, { expectedProvenance: { base: EXPECTED_BASE, head: 'fedcba9876543210fedcba9876543210fedcba98' } }).includes('provenance'));
|
|
835
|
+
const taskOptions = POLICY.receiptValidatorOptions({ expected_provenance: EXPECTED_PROVENANCE });
|
|
836
|
+
assert.equal(taskOptions.expectedProvenance, null, 'task-authored expected provenance is diagnostic metadata only');
|
|
837
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(body, taskOptions), [], 'diagnostic parsing may accept schema-valid anchors without treating them as identity');
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
test('runtime provenance derives exact Git evidence, CLI context, and stale/forged blockers', () => {
|
|
841
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-provenance-adversarial-'));
|
|
842
|
+
const specsRoot = path.join(root, 'specs');
|
|
843
|
+
const featureRoot = path.join(specsRoot, 'demo');
|
|
844
|
+
const specFile = path.join(featureRoot, 'spec.json');
|
|
845
|
+
const sourceFile = path.join(root, 'src', 'app.js');
|
|
846
|
+
const renameSource = path.join(root, 'src', 'rename-old.js');
|
|
847
|
+
const renameTarget = path.join(root, 'src', 'rename-new.js');
|
|
848
|
+
fs.mkdirSync(path.dirname(sourceFile), { recursive: true });
|
|
849
|
+
fs.mkdirSync(featureRoot, { recursive: true });
|
|
850
|
+
fs.writeFileSync(specFile, JSON.stringify({ status: 'in_progress', feature_name: 'demo', task_registry: {} }));
|
|
851
|
+
fs.writeFileSync(sourceFile, 'original source\n');
|
|
852
|
+
fs.writeFileSync(renameSource, 'rename source\n');
|
|
853
|
+
fs.writeFileSync(path.join(root, '.gitignore'), 'ignored-secret.env\nnode_modules/\n.cache/\n');
|
|
854
|
+
initFixtureGit(root);
|
|
855
|
+
try {
|
|
856
|
+
const input = {
|
|
857
|
+
projectRoot: root,
|
|
858
|
+
specsRoot,
|
|
859
|
+
specFile,
|
|
860
|
+
featureName: 'demo',
|
|
861
|
+
runtimeSession: 'provenance-session',
|
|
862
|
+
};
|
|
863
|
+
const initial = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
864
|
+
const exactReceipt = `Verification: PASS\nCommand: node --test\nExit: 0\nBase: ${initial.base}\nHead: ${initial.head}\n`;
|
|
865
|
+
assert.deepEqual(
|
|
866
|
+
POLICY.validateCanonicalReceipt(exactReceipt, POLICY.receiptValidatorOptions({}, {
|
|
867
|
+
runtimeContext: initial,
|
|
868
|
+
requireProvenanceBinding: true,
|
|
869
|
+
})),
|
|
870
|
+
[],
|
|
871
|
+
'exact runtime-derived receipt must validate',
|
|
872
|
+
);
|
|
873
|
+
const arbitraryReceipt = 'Verification: PASS\nCommand: node --test\nExit: 0\nBase: ' + 'a'.repeat(40) + '\nHead: ' + 'b'.repeat(64) + '\n';
|
|
874
|
+
assert.ok(
|
|
875
|
+
POLICY.validateCanonicalReceipt(arbitraryReceipt, POLICY.receiptValidatorOptions({}, {
|
|
876
|
+
runtimeContext: initial,
|
|
877
|
+
requireProvenanceBinding: true,
|
|
878
|
+
})).includes('provenance'),
|
|
879
|
+
'valid-shaped arbitrary SHA values must not bind',
|
|
880
|
+
);
|
|
881
|
+
|
|
882
|
+
const cli = spawnSync(process.execPath, [
|
|
883
|
+
path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'), '--json',
|
|
884
|
+
'--project-root', root, '--specs-root', specsRoot, '--spec-file', specFile,
|
|
885
|
+
'--feature-name', 'demo', '--runtime-session', 'provenance-session',
|
|
886
|
+
], { cwd: root, encoding: 'utf8' });
|
|
887
|
+
assert.equal(cli.status, 0, `${cli.stdout}\n${cli.stderr}`);
|
|
888
|
+
const cliOutput = JSON.parse(cli.stdout);
|
|
889
|
+
assert.equal(cliOutput.ok, true);
|
|
890
|
+
assert.equal(cliOutput.Base, initial.base);
|
|
891
|
+
assert.equal(cliOutput.Head, initial.head);
|
|
892
|
+
assert.equal(cliOutput.context_id, initial.context_id);
|
|
893
|
+
|
|
894
|
+
const ignoredSecret = path.join(root, 'ignored-secret.env');
|
|
895
|
+
fs.writeFileSync(ignoredSecret, 'TOKEN=first-secret\n');
|
|
896
|
+
const withIgnoredSecret = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
897
|
+
fs.writeFileSync(ignoredSecret, 'TOKEN=changed-secret\n');
|
|
898
|
+
const withChangedIgnoredSecret = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
899
|
+
assert.equal(withIgnoredSecret.head, initial.head, 'ignored secret-like files must not enter Head');
|
|
900
|
+
assert.equal(withChangedIgnoredSecret.head, initial.head, 'changing an ignored secret-like file must not change Head');
|
|
901
|
+
|
|
902
|
+
const ignoredCacheFile = path.join(root, 'packages', 'app', 'node_modules', 'cache', 'runtime.bin');
|
|
903
|
+
fs.mkdirSync(path.dirname(ignoredCacheFile), { recursive: true });
|
|
904
|
+
fs.writeFileSync(ignoredCacheFile, 'runtime cache bytes\n');
|
|
905
|
+
const withIgnoredCache = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
906
|
+
fs.writeFileSync(ignoredCacheFile, 'changed runtime cache bytes\n');
|
|
907
|
+
const withChangedIgnoredCache = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
908
|
+
assert.equal(withIgnoredCache.head, initial.head, 'nested ignored node_modules/cache files must not enter Head');
|
|
909
|
+
assert.equal(withChangedIgnoredCache.head, initial.head, 'changing nested ignored cache data must not change Head');
|
|
910
|
+
|
|
911
|
+
const forgedContext = { ...initial };
|
|
912
|
+
const forgedDecision = POLICY.completionDecision('PASS', {
|
|
913
|
+
workflow_policy: { proof_obligations: ['needsExecutionProof'] },
|
|
914
|
+
runtime_context: forgedContext,
|
|
915
|
+
execution_receipt: exactReceipt,
|
|
916
|
+
});
|
|
917
|
+
assert.equal(forgedDecision.completion, 'unfinished');
|
|
918
|
+
assert.deepEqual(forgedDecision.missingProof, ['runtime_provenance']);
|
|
919
|
+
|
|
920
|
+
const flash = {
|
|
921
|
+
status: 'in_progress',
|
|
922
|
+
receipt: 'FLASH_UNVERIFIED',
|
|
923
|
+
blocker: 'awaiting test proof',
|
|
924
|
+
dependencyBlocked: true,
|
|
925
|
+
unblocks: false,
|
|
926
|
+
runtime_context: initial,
|
|
927
|
+
};
|
|
928
|
+
const promoted = POLICY.promoteFlashTask(flash, 'PASS', exactReceipt);
|
|
929
|
+
assert.equal(promoted.readyForSync, true);
|
|
930
|
+
|
|
931
|
+
fs.writeFileSync(sourceFile, 'mutated source\n');
|
|
932
|
+
const stale = POLICY.completionDecision('PASS', {
|
|
933
|
+
workflow_policy: { proof_obligations: ['needsExecutionProof'] },
|
|
934
|
+
receipt_binding: POLICY.createReceiptBinding(initial),
|
|
935
|
+
execution_receipt: exactReceipt,
|
|
936
|
+
});
|
|
937
|
+
assert.equal(stale.completion, 'unfinished');
|
|
938
|
+
assert.ok(stale.missingProof.some((item) => item.includes('execution_receipt:provenance')));
|
|
939
|
+
const staleFinalized = POLICY.syncFinalizeFlashTask(flash, 'PASS', exactReceipt, initial);
|
|
940
|
+
assert.equal(staleFinalized.status, 'in_progress');
|
|
941
|
+
assert.equal(staleFinalized.readyForSync, false);
|
|
942
|
+
|
|
943
|
+
fs.writeFileSync(path.join(root, 'src', 'untracked.js'), 'untracked source\n');
|
|
944
|
+
const withUntracked = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
945
|
+
assert.notEqual(withUntracked.head, withIgnoredCache.head, 'non-ignored untracked source must change Head');
|
|
946
|
+
fs.chmodSync(renameSource, 0o755);
|
|
947
|
+
const withMode = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
948
|
+
assert.notEqual(withMode.head, withUntracked.head, 'mode changes must change Head');
|
|
949
|
+
fs.renameSync(renameSource, renameTarget);
|
|
950
|
+
const withRename = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
951
|
+
assert.notEqual(withRename.head, withMode.head, 'rename path semantics must change Head');
|
|
952
|
+
fs.unlinkSync(sourceFile);
|
|
953
|
+
const withDeletion = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
954
|
+
assert.notEqual(withDeletion.head, withRename.head, 'tracked deletion must change Head');
|
|
955
|
+
const staged = spawnSync('git', ['-C', root, 'add', '-A'], { encoding: 'utf8' });
|
|
956
|
+
assert.equal(staged.status, 0, staged.stderr);
|
|
957
|
+
const stagedRename = PROVENANCE_HELPER.deriveRuntimeContext(input);
|
|
958
|
+
assert.notEqual(stagedRename.head, withDeletion.head, 'staged rename/deletion evidence must be included');
|
|
959
|
+
|
|
960
|
+
assert.throws(() => PROVENANCE_HELPER.deriveRuntimeContext({
|
|
961
|
+
...input,
|
|
962
|
+
feature_name: 'other',
|
|
963
|
+
}), /disagree/);
|
|
964
|
+
assert.throws(() => PROVENANCE_HELPER.deriveRuntimeContext({
|
|
965
|
+
...input,
|
|
966
|
+
specsRoot: root,
|
|
967
|
+
}), /distinct project subdirectory/);
|
|
968
|
+
} finally {
|
|
969
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const nonGit = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-provenance-non-git-'));
|
|
973
|
+
const nonGitSpec = path.join(nonGit, 'specs', 'demo', 'spec.json');
|
|
974
|
+
fs.mkdirSync(path.dirname(nonGitSpec), { recursive: true });
|
|
975
|
+
fs.writeFileSync(nonGitSpec, JSON.stringify({ status: 'in_progress', feature_name: 'demo' }));
|
|
976
|
+
try {
|
|
977
|
+
const blocked = spawnSync(process.execPath, [
|
|
978
|
+
path.join(PACKAGE_ROOT, 'src/claude/scripts/provenance.cjs'), '--json',
|
|
979
|
+
'--project-root', nonGit, '--specs-root', path.join(nonGit, 'specs'),
|
|
980
|
+
'--spec-file', nonGitSpec, '--feature-name', 'demo', '--runtime-session', 'non-git-session',
|
|
981
|
+
], { cwd: nonGit, encoding: 'utf8' });
|
|
982
|
+
assert.equal(blocked.status, 2);
|
|
983
|
+
assert.equal(JSON.parse(blocked.stdout).error.code, 'git_command_failed');
|
|
984
|
+
} finally {
|
|
985
|
+
fs.rmSync(nonGit, { recursive: true, force: true });
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
|
|
386
989
|
test('lane traces - Direct, Standard, Critical classification, override, state mutation and completion', () => {
|
|
387
990
|
// Direct: isolated reversible low-risk
|
|
388
991
|
const direct = POLICY.classifyLane({ reversible: true, lowRisk: true, isolated: true, taskCount: 1 });
|
|
389
992
|
assert.equal(direct.lane, 'Direct');
|
|
390
993
|
assert.equal(direct.automaticLane, 'Direct');
|
|
391
|
-
assert.deepEqual(POLICY.lanePolicy(direct).
|
|
994
|
+
assert.deepEqual(POLICY.lanePolicy(direct).proof_obligations, ['needsExecutionProof']);
|
|
392
995
|
assert.equal(POLICY.lanePolicy(direct).requiresSpec, false);
|
|
393
996
|
assert.equal(POLICY.lanePolicy(direct).shipPoint, 'task');
|
|
394
997
|
// Standard: default
|
|
395
998
|
const standard = POLICY.classifyLane({ title: 'add pagination to list', taskCount: 1 });
|
|
396
999
|
assert.equal(standard.lane, 'Standard');
|
|
397
|
-
assert.deepEqual(POLICY.lanePolicy(standard).
|
|
1000
|
+
assert.deepEqual(POLICY.lanePolicy(standard).proof_obligations, ['needsInspection', 'needsExecutionProof']);
|
|
398
1001
|
assert.equal(POLICY.lanePolicy(standard).shipPoint, 'feature');
|
|
399
1002
|
// Critical: auth signal
|
|
400
1003
|
const critical = POLICY.classifyLane({ riskSignals: { auth: true }, taskCount: 1 });
|
|
401
1004
|
assert.equal(critical.lane, 'Critical');
|
|
402
1005
|
assert.ok(critical.risks.includes('auth'));
|
|
403
|
-
assert.deepEqual(POLICY.lanePolicy(critical).
|
|
1006
|
+
assert.deepEqual(POLICY.lanePolicy(critical).proof_obligations, ['needsInspection', 'needsExecutionProof', 'needsIndependentAudit', 'needsResearchGrounding']);
|
|
404
1007
|
// Override: Direct requesting Critical is allowed (upgrade) without extra auth
|
|
405
1008
|
const upgrade = POLICY.classifyLane({ reversible: true, lowRisk: true, isolated: true, taskCount: 1, override: 'Critical' });
|
|
406
1009
|
assert.equal(upgrade.lane, 'Critical');
|
|
407
1010
|
assert.equal(upgrade.automaticLane, 'Direct');
|
|
408
1011
|
// Downgrade without auth is blocked
|
|
409
|
-
assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard' }), /
|
|
410
|
-
// Downgrade with
|
|
411
|
-
|
|
412
|
-
assert.
|
|
413
|
-
|
|
1012
|
+
assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard' }), /is blocked.*no trusted runtime issuer/);
|
|
1013
|
+
// Downgrade with legacy boolean flags is still blocked (P0.4 fail-closed, no trusted issuer)
|
|
1014
|
+
assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard', userAuthorized: true }), /is blocked.*no trusted runtime issuer/);
|
|
1015
|
+
assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard', user_approved: true }), /is blocked.*no trusted runtime issuer/);
|
|
1016
|
+
// Even structured receipt without verified issuer is blocked fail-closed
|
|
1017
|
+
assert.throws(() => POLICY.classifyLane({ riskSignals: { payment: true }, override: 'Standard', overrideReceipt: { automaticLane: 'Critical', requestedLane: 'Standard', risks: ['payment'], source: 'runtime', timestamp: '2026-08-11T00:00:00.000Z', sessionId: 's-123' } }), /is blocked.*no trusted runtime issuer/);
|
|
414
1018
|
// State mutation: approvalState
|
|
415
1019
|
const pendingApproval = POLICY.approvalState({ generated: true, agent_validated: false, user_approved: false });
|
|
416
1020
|
assert.equal(pendingApproval.ready, false);
|
|
@@ -419,7 +1023,7 @@ test('lane traces - Direct, Standard, Critical classification, override, state m
|
|
|
419
1023
|
const approved = POLICY.approvalState({ generated: true, agent_validated: true, user_approved: true });
|
|
420
1024
|
assert.equal(approved.ready, true);
|
|
421
1025
|
// Completion: flash work remains in_progress and does not unblock
|
|
422
|
-
const flashTask =
|
|
1026
|
+
const flashTask = canonicalFlashTask();
|
|
423
1027
|
const failRemains = POLICY.promoteFlashTask(flashTask, 'FAIL');
|
|
424
1028
|
assert.equal(failRemains.status, 'in_progress');
|
|
425
1029
|
assert.equal(failRemains.unblocks, false);
|
|
@@ -427,20 +1031,20 @@ test('lane traces - Direct, Standard, Critical classification, override, state m
|
|
|
427
1031
|
assert.equal(blockedRemains.unblocks, false);
|
|
428
1032
|
const noTestsRemains = POLICY.promoteFlashTask(flashTask, 'NO_TESTS');
|
|
429
1033
|
assert.equal(noTestsRemains.unblocks, false);
|
|
430
|
-
const promoted = POLICY.promoteFlashTask(flashTask, 'PASS',
|
|
1034
|
+
const promoted = POLICY.promoteFlashTask(flashTask, 'PASS', canonicalReceipt());
|
|
431
1035
|
assert.equal(promoted.readyForSync, true);
|
|
432
1036
|
assert.equal(promoted.unblocks, false, 'promoted flash must not unblock until sync-finalize');
|
|
433
|
-
const finalized = POLICY.
|
|
1037
|
+
const finalized = POLICY.syncFinalizeFlashTask(flashTask, 'PASS', promoted.receipt);
|
|
434
1038
|
assert.equal(finalized.status, 'done');
|
|
435
1039
|
assert.equal(finalized.unblocks, true);
|
|
436
1040
|
});
|
|
437
1041
|
|
|
438
1042
|
test('flash selective promotion - only specific task is promoted, not blanket', () => {
|
|
439
1043
|
const registry = {
|
|
440
|
-
'tasks/task-a.md':
|
|
441
|
-
'tasks/task-b.md':
|
|
1044
|
+
'tasks/task-a.md': canonicalFlashTask(),
|
|
1045
|
+
'tasks/task-b.md': canonicalFlashTask(),
|
|
442
1046
|
};
|
|
443
|
-
const promotedA = POLICY.promoteFlashTask(registry['tasks/task-a.md'], 'PASS',
|
|
1047
|
+
const promotedA = POLICY.promoteFlashTask(registry['tasks/task-a.md'], 'PASS', canonicalReceipt());
|
|
444
1048
|
const notPromotedB = registry['tasks/task-b.md']; // unchanged
|
|
445
1049
|
assert.equal(promotedA.readyForSync, true);
|
|
446
1050
|
assert.equal(notPromotedB.receipt, 'FLASH_UNVERIFIED');
|
|
@@ -1118,3 +1722,695 @@ process.stdin.on('end', ()=>{
|
|
|
1118
1722
|
fs.rmSync(root, { recursive: true, force: true });
|
|
1119
1723
|
}
|
|
1120
1724
|
});
|
|
1725
|
+
|
|
1726
|
+
// ── P0 regressions ──────────────────────────────────────────────────────
|
|
1727
|
+
test('P0 receipt fail-closed: Exit 1/-1/abc/conflict/empty command rejected', () => {
|
|
1728
|
+
const base = 'Verification: PASS\nCommand: pnpm test\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n';
|
|
1729
|
+
const cases = [
|
|
1730
|
+
{ body: `${base}Exit: 1\n`, shouldFail: 'exit_result', desc: 'Exit 1' },
|
|
1731
|
+
{ body: `${base}Exit: -1\n`, shouldFail: 'exit_result', desc: 'Exit -1' },
|
|
1732
|
+
{ body: `${base}Exit: abc\n`, shouldFail: 'exit_result', desc: 'Exit abc' },
|
|
1733
|
+
{ body: `${base}Exit: 0\nExit: 1\n`, shouldFail: 'exit_result', desc: 'conflicting exits 0 and 1' },
|
|
1734
|
+
{ body: `${base}Exit: 0\nExit: abc\n`, shouldFail: 'exit_result', desc: 'conflicting exit 0 and abc' },
|
|
1735
|
+
{ body: 'Verification: PASS\nCommand:\nExit: 0\nBase: a\nHead: b\n', shouldFail: 'command', desc: 'empty command' },
|
|
1736
|
+
{ body: 'Verification: PASS\nCommand: \nExit: 0\nBase: a\nHead: b\n', shouldFail: 'command', desc: 'command spaces only' },
|
|
1737
|
+
{ body: `${base}Exit: 0\nResult: FAIL\n`, shouldFail: 'exit_result', desc: 'Result FAIL with Exit 0' },
|
|
1738
|
+
{ body: `${base}Exit: 1\nResult: PASS\n`, shouldFail: 'exit_result', desc: 'Result PASS with Exit 1' },
|
|
1739
|
+
{ body: 'Verification: PASS\nCommand: pnpm test\nResult: PASS\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n', shouldFail: null, desc: 'Result PASS without Exit (legacy alias ok)' },
|
|
1740
|
+
{ body: 'Verification: PASS\nCommand: pnpm test\nResult: FAIL\nBase: a\nHead: b\n', shouldFail: 'exit_result', desc: 'Result FAIL without Exit' },
|
|
1741
|
+
{ body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: {{base}}\nHead: {{head}}\n', shouldFail: 'placeholder', desc: 'placeholder provenance' },
|
|
1742
|
+
{ body: 'Verification: PASS\nCommand: {{cmd}}\nExit: 0\nBase: a\nHead: b\n', shouldFail: 'placeholder', desc: 'placeholder command' },
|
|
1743
|
+
{ body: 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\n', shouldFail: 'provenance', desc: 'missing Head' },
|
|
1744
|
+
];
|
|
1745
|
+
for (const { body, shouldFail, desc } of cases) {
|
|
1746
|
+
const fails = POLICY.validateCanonicalReceipt(body);
|
|
1747
|
+
if (shouldFail) {
|
|
1748
|
+
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}`);
|
|
1749
|
+
} else {
|
|
1750
|
+
assert.deepEqual(fails, [], `case ${desc} should pass, got ${fails}`);
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
// CLI parity: Exit 1 must be rejected via CLI
|
|
1754
|
+
const cli = spawnSync(process.execPath, [POLICY_PATH, '--validate-receipt', '--task-json', JSON.stringify({ body: `${base}Exit: 1\n` }), '--json'], { encoding: 'utf8' });
|
|
1755
|
+
assert.equal(cli.status, 2);
|
|
1756
|
+
assert.equal(JSON.parse(cli.stdout).ok, false);
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
test('P0 flash marker-only must not promote or finalize', () => {
|
|
1760
|
+
const flash = canonicalFlashTask({ blocker: 'awaiting /hapo:test' });
|
|
1761
|
+
const markerOnly = 'Verification: PASS';
|
|
1762
|
+
const promoted = POLICY.promoteFlashTask(flash, 'PASS', markerOnly);
|
|
1763
|
+
assert.equal(promoted.status, 'in_progress');
|
|
1764
|
+
assert.equal(promoted.receipt, 'FLASH_UNVERIFIED');
|
|
1765
|
+
assert.equal(promoted.readyForSync, false);
|
|
1766
|
+
assert.equal(promoted.dependencyBlocked, true);
|
|
1767
|
+
assert.equal(promoted.unblocks, false);
|
|
1768
|
+
assert.match(promoted.blocker, /canonical receipt/);
|
|
1769
|
+
// Even with PASS marker but missing command/exit/provenance, finalize must not complete
|
|
1770
|
+
const fakePromoted = { status: 'in_progress', receipt: markerOnly, readyForSync: true, dependencyBlocked: true, unblocks: false };
|
|
1771
|
+
const notFinalized = POLICY.finalizeFlashTask(fakePromoted, 'sync-finalize');
|
|
1772
|
+
assert.equal(notFinalized.status, 'in_progress', 'marker-only receipt must not finalize');
|
|
1773
|
+
// Valid receipt promotes correctly
|
|
1774
|
+
const valid = canonicalReceipt();
|
|
1775
|
+
const good = POLICY.promoteFlashTask(flash, 'PASS', valid);
|
|
1776
|
+
assert.equal(good.readyForSync, true);
|
|
1777
|
+
assert.equal(good.receipt, valid.trim());
|
|
1778
|
+
const forgedFinalize = POLICY.finalizeFlashTask(good, 'sync-finalize');
|
|
1779
|
+
assert.equal(forgedFinalize.status, 'in_progress');
|
|
1780
|
+
assert.equal(forgedFinalize.unblocks, false);
|
|
1781
|
+
const finalized = POLICY.syncFinalizeFlashTask(flash, 'PASS', valid);
|
|
1782
|
+
assert.equal(finalized.status, 'done');
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
test('P0 canonical artifacts declaration requires SHA-256 and keeps no-artifact receipts compatible', () => {
|
|
1786
|
+
const receipt = 'Verification: PASS\nCommand: node --test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n';
|
|
1787
|
+
const invalid = `${receipt}sha256: abc123\n`;
|
|
1788
|
+
const digest = 'd'.repeat(64);
|
|
1789
|
+
const valid = `${receipt}Artifact: output/bundle.js\nSHA-256: ${digest}\n`;
|
|
1790
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(receipt), []);
|
|
1791
|
+
const options = POLICY.receiptValidatorOptions({ artifacts: ['output/bundle.js'] });
|
|
1792
|
+
assert.equal(options.requireArtifactHash, true);
|
|
1793
|
+
assert.equal(options.artifactDeclarationValid, true);
|
|
1794
|
+
assert.ok(POLICY.validateCanonicalReceipt(receipt, options).includes('artifact_hash'));
|
|
1795
|
+
assert.ok(POLICY.validateCanonicalReceipt(invalid, options).includes('artifact_hash'));
|
|
1796
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(valid, options), []);
|
|
1797
|
+
assert.equal(POLICY.receiptValidatorOptions({}).requireArtifactHash, false);
|
|
1798
|
+
assert.equal(POLICY.receiptValidatorOptions({ artifact_ref: 'output/bundle.js' }).requireArtifactHash, true, 'legacy alias remains read-compatible');
|
|
1799
|
+
for (const declaration of [null, [], ['../outside'], [{ path: 'output/bundle.js' }]]) {
|
|
1800
|
+
const malformed = POLICY.receiptValidatorOptions({ artifacts: declaration });
|
|
1801
|
+
assert.equal(malformed.artifactDeclarationValid, false, `malformed declaration must be rejected: ${JSON.stringify(declaration)}`);
|
|
1802
|
+
assert.ok(POLICY.validateCanonicalReceipt(receipt, malformed).includes('artifact_declaration'));
|
|
1803
|
+
}
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
test('P0 sync-finalize derives promotion from current FLASH_UNVERIFIED plus explicit PASS proof', () => {
|
|
1807
|
+
const receipt = canonicalReceipt('node --test');
|
|
1808
|
+
const forged = {
|
|
1809
|
+
status: 'in_progress',
|
|
1810
|
+
receipt,
|
|
1811
|
+
readyForSync: true,
|
|
1812
|
+
dependencyBlocked: true,
|
|
1813
|
+
unblocks: false,
|
|
1814
|
+
flashTransition: 'promoted',
|
|
1815
|
+
promotionReceipt: receipt,
|
|
1816
|
+
};
|
|
1817
|
+
const direct = spawnSync(process.execPath, [
|
|
1818
|
+
POLICY_PATH,
|
|
1819
|
+
'--sync-finalize',
|
|
1820
|
+
'--task-json',
|
|
1821
|
+
JSON.stringify(forged),
|
|
1822
|
+
'--json',
|
|
1823
|
+
], { encoding: 'utf8' });
|
|
1824
|
+
assert.equal(direct.status, 2, `${direct.stdout}\n${direct.stderr}`);
|
|
1825
|
+
const directPayload = JSON.parse(direct.stdout);
|
|
1826
|
+
assert.equal(directPayload.ok, false);
|
|
1827
|
+
assert.equal(directPayload.task.status, 'in_progress');
|
|
1828
|
+
assert.equal(directPayload.task.receipt, 'FLASH_UNVERIFIED');
|
|
1829
|
+
assert.equal(directPayload.task.readyForSync, false);
|
|
1830
|
+
assert.equal(directPayload.task.dependencyBlocked, true);
|
|
1831
|
+
assert.equal(directPayload.task.unblocks, false);
|
|
1832
|
+
assert.match(directPayload.message, /current task must be FLASH_UNVERIFIED.*canonical/i);
|
|
1833
|
+
|
|
1834
|
+
const minimal = { status: 'in_progress', receipt: 'FLASH_UNVERIFIED' };
|
|
1835
|
+
const minimalResult = POLICY.syncFinalizeFlashTask(minimal, 'PASS', receipt);
|
|
1836
|
+
assert.equal(minimalResult.status, 'in_progress');
|
|
1837
|
+
assert.match(minimalResult.blocker, /exact canonical stored state/);
|
|
1838
|
+
const forgedOpen = { ...canonicalFlashTask(), readyForSync: true };
|
|
1839
|
+
const forgedOpenResult = POLICY.syncFinalizeFlashTask(forgedOpen, 'PASS', receipt);
|
|
1840
|
+
assert.equal(forgedOpenResult.status, 'in_progress');
|
|
1841
|
+
assert.equal(forgedOpenResult.unblocks, false);
|
|
1842
|
+
|
|
1843
|
+
const current = canonicalFlashTask({ blocker: 'awaiting /hapo:test' });
|
|
1844
|
+
const supported = spawnSync(process.execPath, [
|
|
1845
|
+
POLICY_PATH,
|
|
1846
|
+
'--sync-finalize',
|
|
1847
|
+
'--task-json',
|
|
1848
|
+
JSON.stringify(current),
|
|
1849
|
+
'--verdict',
|
|
1850
|
+
'PASS',
|
|
1851
|
+
'--proof',
|
|
1852
|
+
receipt,
|
|
1853
|
+
'--project-root',
|
|
1854
|
+
RUNTIME_ROOT,
|
|
1855
|
+
'--specs-root',
|
|
1856
|
+
path.join(RUNTIME_ROOT, 'specs'),
|
|
1857
|
+
'--spec-file',
|
|
1858
|
+
RUNTIME_SPEC,
|
|
1859
|
+
'--feature-name',
|
|
1860
|
+
'demo',
|
|
1861
|
+
'--runtime-session',
|
|
1862
|
+
'implementation-session-1',
|
|
1863
|
+
'--json',
|
|
1864
|
+
], { encoding: 'utf8' });
|
|
1865
|
+
assert.equal(supported.status, 0, `${supported.stdout}\n${supported.stderr}`);
|
|
1866
|
+
assert.equal(JSON.parse(supported.stdout).task.status, 'done');
|
|
1867
|
+
const warningFinalize = POLICY.syncFinalizeFlashTask(current, 'PASS_WITH_WARNINGS', receipt);
|
|
1868
|
+
assert.equal(warningFinalize.status, 'in_progress');
|
|
1869
|
+
assert.equal(warningFinalize.unblocks, false);
|
|
1870
|
+
|
|
1871
|
+
const initial = canonicalFlashTask();
|
|
1872
|
+
const promoted = POLICY.promoteFlashTask(initial, 'PASS', receipt);
|
|
1873
|
+
const persisted = JSON.parse(JSON.stringify(promoted));
|
|
1874
|
+
assert.equal(persisted.flashTransition, 'promoted');
|
|
1875
|
+
assert.equal(persisted.promotionReceipt, receipt.trim());
|
|
1876
|
+
const forgedPersistedFinalize = POLICY.finalizeFlashTask(persisted, 'sync-finalize');
|
|
1877
|
+
assert.equal(forgedPersistedFinalize.status, 'in_progress');
|
|
1878
|
+
assert.equal(forgedPersistedFinalize.unblocks, false);
|
|
1879
|
+
const finalized = POLICY.syncFinalizeFlashTask(initial, 'PASS', receipt);
|
|
1880
|
+
assert.equal(finalized.status, 'done');
|
|
1881
|
+
assert.equal(finalized.flashTransition, 'finalized');
|
|
1882
|
+
|
|
1883
|
+
const artifactInitial = { ...initial, artifacts: ['output/bundle.js'] };
|
|
1884
|
+
const artifactPromotion = POLICY.promoteFlashTask(artifactInitial, 'PASS', receipt);
|
|
1885
|
+
assert.equal(artifactPromotion.readyForSync, false, 'flash promotion must apply task artifact requirements');
|
|
1886
|
+
|
|
1887
|
+
const badProof = spawnSync(process.execPath, [
|
|
1888
|
+
POLICY_PATH,
|
|
1889
|
+
'--sync-finalize',
|
|
1890
|
+
'--task-json',
|
|
1891
|
+
JSON.stringify(current),
|
|
1892
|
+
'--verdict',
|
|
1893
|
+
'PASS',
|
|
1894
|
+
'--proof',
|
|
1895
|
+
'Verification: PASS',
|
|
1896
|
+
'--json',
|
|
1897
|
+
], { encoding: 'utf8' });
|
|
1898
|
+
assert.equal(badProof.status, 2);
|
|
1899
|
+
assert.equal(JSON.parse(badProof.stdout).task.status, 'in_progress');
|
|
1900
|
+
|
|
1901
|
+
const badArtifact = spawnSync(process.execPath, [
|
|
1902
|
+
POLICY_PATH,
|
|
1903
|
+
'--sync-finalize',
|
|
1904
|
+
'--task-json',
|
|
1905
|
+
JSON.stringify({ ...current, artifacts: ['output/bundle.js'] }),
|
|
1906
|
+
'--verdict',
|
|
1907
|
+
'PASS',
|
|
1908
|
+
'--proof',
|
|
1909
|
+
receipt,
|
|
1910
|
+
'--json',
|
|
1911
|
+
], { encoding: 'utf8' });
|
|
1912
|
+
assert.equal(badArtifact.status, 2);
|
|
1913
|
+
assert.equal(JSON.parse(badArtifact.stdout).task.status, 'in_progress');
|
|
1914
|
+
|
|
1915
|
+
const markerOnly = {
|
|
1916
|
+
...forged,
|
|
1917
|
+
flashTransition: 'promoted',
|
|
1918
|
+
promotionReceipt: 'Verification: PASS',
|
|
1919
|
+
receipt: 'Verification: PASS',
|
|
1920
|
+
};
|
|
1921
|
+
assert.equal(POLICY.finalizeFlashTask(markerOnly, 'sync-finalize').status, 'in_progress');
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
test('P0 Claude and Codex reject configured specs roots outside the project', () => {
|
|
1925
|
+
const claudeResolver = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'));
|
|
1926
|
+
const codexUtils = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-utils.cjs'));
|
|
1927
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-external-specs-'));
|
|
1928
|
+
const externalRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-configured-specs-'));
|
|
1929
|
+
try {
|
|
1930
|
+
const local = path.join(tmp, 'specs', 'local');
|
|
1931
|
+
const external = path.join(externalRoot, 'remote');
|
|
1932
|
+
fs.mkdirSync(local, { recursive: true });
|
|
1933
|
+
fs.mkdirSync(external, { recursive: true });
|
|
1934
|
+
fs.writeFileSync(path.join(local, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'local' }));
|
|
1935
|
+
fs.writeFileSync(path.join(external, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'remote' }));
|
|
1936
|
+
const runtime = { paths: { specs: externalRoot } };
|
|
1937
|
+
const claude = claudeResolver.resolveActiveSpec({ projectRoot: tmp, runtime });
|
|
1938
|
+
const codex = codexUtils.resolveActiveSpec(tmp, runtime, null, null);
|
|
1939
|
+
assert.equal(claude.error, 'invalid_specs');
|
|
1940
|
+
assert.equal(codex.error, 'invalid_specs');
|
|
1941
|
+
assert.match(claude.reason, /escapes project root/);
|
|
1942
|
+
assert.match(codex.reason, /escapes project root/);
|
|
1943
|
+
} finally {
|
|
1944
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
1945
|
+
fs.rmSync(externalRoot, { recursive: true, force: true });
|
|
1946
|
+
}
|
|
1947
|
+
});
|
|
1948
|
+
|
|
1949
|
+
test('P0 forged approvals: user_approved and boolean userAuthorized do not downgrade', () => {
|
|
1950
|
+
assert.throws(() => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct', user_approved: true }), /is blocked.*no trusted runtime issuer/);
|
|
1951
|
+
assert.throws(() => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct', userApproved: true }), /is blocked.*no trusted runtime issuer/);
|
|
1952
|
+
assert.throws(() => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct', userAuthorized: true }), /is blocked.*no trusted runtime issuer/);
|
|
1953
|
+
assert.throws(() => POLICY.classifyLane({ riskSignals: { auth: true }, override: 'Standard', confirmDowngrade: true }), /is blocked.*no trusted runtime issuer/);
|
|
1954
|
+
// Direct check of isUserAuthorizedForDowngrade returns false for booleans
|
|
1955
|
+
assert.equal(POLICY.isUserAuthorizedForDowngrade({ user_approved: true }), false);
|
|
1956
|
+
assert.equal(POLICY.isUserAuthorizedForDowngrade({ userAuthorized: true }), false);
|
|
1957
|
+
assert.equal(POLICY.isUserAuthorizedForDowngrade({ userAuthorized: true }, 'Critical', 'Direct', ['privacy']), false);
|
|
1958
|
+
});
|
|
1959
|
+
|
|
1960
|
+
test('P0 downgrade fail-closed when no verified issuer', () => {
|
|
1961
|
+
const receipt = { automaticLane: 'Critical', requestedLane: 'Direct', risks: ['privacy'], source: 'runtime', timestamp: '2026-08-11T00:00:00.000Z', sessionId: 'sess-1' };
|
|
1962
|
+
assert.equal(POLICY.isValidOverrideReceipt(receipt, 'Critical', 'Direct', ['privacy']), false, 'shape-correct runtime receipt must still be invalid without verified issuer');
|
|
1963
|
+
assert.throws(() => POLICY.classifyLane({ riskSignals: { privacy: true }, override: 'Direct', overrideReceipt: receipt }), /is blocked.*no trusted runtime issuer/);
|
|
1964
|
+
const withVerified = { ...receipt, verifiedByRuntime: true };
|
|
1965
|
+
// Even with verified flag, P0 stays fail-closed (always false) — documents future issuer need
|
|
1966
|
+
assert.equal(POLICY.isValidOverrideReceipt(withVerified, 'Critical', 'Direct', ['privacy']), false);
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1969
|
+
test('P0 active spec deterministic: multiple active ambiguity and explicit target/path containment', () => {
|
|
1970
|
+
const RESOLVER = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'));
|
|
1971
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-resolver-'));
|
|
1972
|
+
try {
|
|
1973
|
+
const specsDir = path.join(tmp, 'specs');
|
|
1974
|
+
fs.mkdirSync(specsDir, { recursive: true });
|
|
1975
|
+
// Create two active specs
|
|
1976
|
+
for (const name of ['alpha', 'beta']) {
|
|
1977
|
+
fs.mkdirSync(path.join(specsDir, name), { recursive: true });
|
|
1978
|
+
fs.writeFileSync(path.join(specsDir, name, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: name, current_phase: 'design' }));
|
|
1979
|
+
}
|
|
1980
|
+
const amb = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
|
|
1981
|
+
assert.equal(amb.error, 'multiple_active');
|
|
1982
|
+
assert.deepEqual(amb.candidates.sort(), ['alpha', 'beta']);
|
|
1983
|
+
// Explicit feature resolves deterministically
|
|
1984
|
+
const alpha = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: 'alpha' });
|
|
1985
|
+
assert.equal(alpha.featureName, 'alpha');
|
|
1986
|
+
// Explicit not-found fail-closed
|
|
1987
|
+
const notFound = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: 'gamma' });
|
|
1988
|
+
assert.equal(notFound.error, 'explicit_not_found');
|
|
1989
|
+
// Explicit path containment: try to escape via ../
|
|
1990
|
+
const escape = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitPath: path.join(tmp, 'specs', '..', 'etc', 'passwd') });
|
|
1991
|
+
assert.equal(escape.error, 'explicit_malformed');
|
|
1992
|
+
// Malformed feature name with slash must be rejected
|
|
1993
|
+
const malformed = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: '../alpha' });
|
|
1994
|
+
assert.equal(malformed.error, 'explicit_malformed');
|
|
1995
|
+
// Valid explicit path inside root
|
|
1996
|
+
const validPath = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitPath: path.join(specsDir, 'beta', 'spec.json') });
|
|
1997
|
+
assert.equal(validPath.featureName, 'beta');
|
|
1998
|
+
} finally {
|
|
1999
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
2000
|
+
}
|
|
2001
|
+
});
|
|
2002
|
+
|
|
2003
|
+
test('P0 parity Claude/Codex: canonical receipt and multi-active', () => {
|
|
2004
|
+
// Codex receipt validator must match Claude policy
|
|
2005
|
+
const codexReceipt = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-receipt.cjs'));
|
|
2006
|
+
const claudeFails = POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 1\nBase: a\nHead: b\n');
|
|
2007
|
+
const codexFails = codexReceipt.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 1\nBase: a\nHead: b\n');
|
|
2008
|
+
assert.deepEqual(claudeFails.sort(), codexFails.sort(), 'Claude and Codex validators must agree on Exit 1');
|
|
2009
|
+
const codexEmptyCmd = codexReceipt.validateCanonicalReceipt('Verification: PASS\nCommand:\nExit: 0\nBase: a\nHead: b\n');
|
|
2010
|
+
assert.ok(codexEmptyCmd.includes('command'), 'Codex must reject empty Command');
|
|
2011
|
+
// Codex resolver parity: same containment behaviour
|
|
2012
|
+
const codexUtils = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-utils.cjs'));
|
|
2013
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-codex-resolver-'));
|
|
2014
|
+
try {
|
|
2015
|
+
const specsDir = path.join(tmp, 'specs');
|
|
2016
|
+
fs.mkdirSync(specsDir, { recursive: true });
|
|
2017
|
+
for (const name of ['x', 'y']) {
|
|
2018
|
+
fs.mkdirSync(path.join(specsDir, name), { recursive: true });
|
|
2019
|
+
fs.writeFileSync(path.join(specsDir, name, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: name }));
|
|
2020
|
+
}
|
|
2021
|
+
const amb = codexUtils.resolveActiveSpec(tmp, {}, null, null);
|
|
2022
|
+
assert.equal(amb.error, 'multiple_active');
|
|
2023
|
+
const esc = codexUtils.resolveActiveSpec(tmp, {}, null, path.join(tmp, 'specs', '..', 'outside', 'spec.json'));
|
|
2024
|
+
assert.equal(esc.error, 'explicit_malformed');
|
|
2025
|
+
} finally {
|
|
2026
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
2027
|
+
}
|
|
2028
|
+
});
|
|
2029
|
+
|
|
2030
|
+
test('P0 regression: placeholder, explicit failure, artifact and lanePolicy forged are blocked', () => {
|
|
2031
|
+
// Probe A: explicit failure outcomes must fail canonical validator and not promote flash
|
|
2032
|
+
const bodyTestsFailed = 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nTests failed: 1\n';
|
|
2033
|
+
assert.ok(POLICY.validateCanonicalReceipt(bodyTestsFailed).length > 0, 'Tests failed: 1 must be rejected');
|
|
2034
|
+
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');
|
|
2035
|
+
const flashTask = { status: 'in_progress', receipt: 'FLASH_UNVERIFIED', blocker: 'awaiting', dependencyBlocked: true, unblocks: false };
|
|
2036
|
+
const notPromoted = POLICY.promoteFlashTask(flashTask, 'PASS', bodyTestsFailed);
|
|
2037
|
+
assert.equal(notPromoted.readyForSync, false, 'flash with Tests failed should not promote');
|
|
2038
|
+
assert.equal(notPromoted.receipt, 'FLASH_UNVERIFIED');
|
|
2039
|
+
|
|
2040
|
+
// Probe B: placeholder tokens must be rejected, but substring todo must pass
|
|
2041
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: TODO\nExit: 0\nBase: a\nHead: b\n').includes('command'), 'Command TODO must be rejected');
|
|
2042
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: TBD\nHead: b\n').includes('provenance'), 'Base TBD must be rejected');
|
|
2043
|
+
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');
|
|
2044
|
+
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');
|
|
2045
|
+
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');
|
|
2046
|
+
|
|
2047
|
+
// Probe C: artifact with empty or placeholder sha must fail, concrete passes, inline passes
|
|
2048
|
+
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');
|
|
2049
|
+
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');
|
|
2050
|
+
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');
|
|
2051
|
+
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');
|
|
2052
|
+
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');
|
|
2053
|
+
|
|
2054
|
+
// Probe F: lanePolicy forged plain object must not bypass classification
|
|
2055
|
+
const { spawnSync } = require('node:child_process');
|
|
2056
|
+
const POLICY_PATH = path.join(PACKAGE_ROOT, 'src/claude/scripts/workflow-policy.cjs');
|
|
2057
|
+
const forged = spawnSync(process.execPath, [POLICY_PATH, '--lane-policy', '--task-json', JSON.stringify({ lane: 'Direct', automaticLane: 'Direct', riskSignals: { auth: true } }), '--json'], { encoding: 'utf8' });
|
|
2058
|
+
assert.equal(forged.status, 2, 'forged Direct with auth should be blocked via lanePolicy');
|
|
2059
|
+
assert.match(forged.stderr, /is blocked.*no trusted runtime issuer/);
|
|
2060
|
+
const forged2 = spawnSync(process.execPath, [POLICY_PATH, '--lane-policy', '--task-json', JSON.stringify({ lane: 'Direct', automaticLane: 'Direct' }), '--json'], { encoding: 'utf8' });
|
|
2061
|
+
assert.equal(forged2.status, 2, 'plain Direct without justification should be blocked');
|
|
2062
|
+
|
|
2063
|
+
const trusted = POLICY.classifyLane({ reversible: true, lowRisk: true, isolated: true });
|
|
2064
|
+
assert.equal(trusted.lane, 'Direct');
|
|
2065
|
+
const viaPolicy = POLICY.lanePolicy(trusted);
|
|
2066
|
+
assert.equal(viaPolicy.lane, 'Direct', 'trusted Direct should be respected');
|
|
2067
|
+
assert.throws(() => { 'use strict'; trusted.lane = 'Direct'; }, /read only|Cannot assign|TypeError/);
|
|
2068
|
+
const paymentTrusted = POLICY.classifyLane({ riskSignals: { payment: true } });
|
|
2069
|
+
const forgedCopy = { ...paymentTrusted, lane: 'Direct' };
|
|
2070
|
+
assert.throws(() => POLICY.lanePolicy(forgedCopy), /is blocked/);
|
|
2071
|
+
});
|
|
2072
|
+
|
|
2073
|
+
test('P0 regression: symlink spec/task containment and malformed spec handling', () => {
|
|
2074
|
+
const RESOLVER = require(path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'));
|
|
2075
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-regression-symlink-'));
|
|
2076
|
+
try {
|
|
2077
|
+
const specsDir = path.join(tmp, 'specs');
|
|
2078
|
+
fs.mkdirSync(specsDir, { recursive: true });
|
|
2079
|
+
fs.mkdirSync(path.join(specsDir, 'valid'), { recursive: true });
|
|
2080
|
+
fs.writeFileSync(path.join(specsDir, 'valid', 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'valid' }));
|
|
2081
|
+
const outside = path.join(tmp, 'outside');
|
|
2082
|
+
fs.mkdirSync(outside, { recursive: true });
|
|
2083
|
+
fs.writeFileSync(path.join(outside, 'spec.json'), JSON.stringify({ status: 'in_progress' }));
|
|
2084
|
+
const link = path.join(specsDir, 'linked');
|
|
2085
|
+
fs.symlinkSync(outside, link);
|
|
2086
|
+
const e1 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: 'linked' });
|
|
2087
|
+
assert.equal(e1.error, 'explicit_malformed');
|
|
2088
|
+
const e2 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitPath: path.join(specsDir, 'linked', 'spec.json') });
|
|
2089
|
+
assert.equal(e2.error, 'explicit_malformed');
|
|
2090
|
+
const nonExplicit = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
|
|
2091
|
+
assert.equal(nonExplicit.error, 'invalid_specs');
|
|
2092
|
+
assert.ok(nonExplicit.candidates.includes('linked'));
|
|
2093
|
+
const featDir = path.join(specsDir, 'valid');
|
|
2094
|
+
fs.mkdirSync(path.join(featDir, 'tasks'), { recursive: true });
|
|
2095
|
+
const taskOutside = path.join(tmp, 'outside-task.md');
|
|
2096
|
+
fs.writeFileSync(taskOutside, '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n');
|
|
2097
|
+
const taskLink = path.join(featDir, 'tasks', 'task.md');
|
|
2098
|
+
fs.symlinkSync(taskOutside, taskLink);
|
|
2099
|
+
const codexReceipt = require(path.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-receipt.cjs'));
|
|
2100
|
+
const fails = codexReceipt.checkReceipt(featDir, 'tasks/task.md', { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' });
|
|
2101
|
+
assert.ok(fails.includes('a'), 'task symlink outside should be rejected as check a');
|
|
2102
|
+
fs.mkdirSync(path.join(specsDir, 'bad'), { recursive: true });
|
|
2103
|
+
fs.writeFileSync(path.join(specsDir, 'bad', 'spec.json'), '{ malformed');
|
|
2104
|
+
const mal = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
|
|
2105
|
+
assert.equal(mal.error, 'invalid_specs');
|
|
2106
|
+
assert.ok(mal.candidates.includes('bad'));
|
|
2107
|
+
} finally {
|
|
2108
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
2109
|
+
}
|
|
2110
|
+
});
|
|
2111
|
+
|
|
2112
|
+
test('P0 regression: artifact hash scoped to structured Artifact declaration and explicit failure structured only', () => {
|
|
2113
|
+
// Artifact hash only required when structured Artifact declaration exists
|
|
2114
|
+
const prefix = 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n';
|
|
2115
|
+
const receipt = (suffix = '') => `${prefix}${suffix}`;
|
|
2116
|
+
const digestA = 'a'.repeat(64);
|
|
2117
|
+
const digestB = 'b'.repeat(64);
|
|
2118
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Command: npm run artifact:test\n')), [], 'Command containing artifact without hash should pass');
|
|
2119
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Notes: artifact behavior without declaration\n')), [], 'Notes containing artifact without structured declaration should pass');
|
|
2120
|
+
assert.ok(POLICY.validateCanonicalReceipt(receipt('Artifact: bundle\n')).includes('artifact_hash'), 'Artifact: without hash should fail');
|
|
2121
|
+
assert.ok(POLICY.validateCanonicalReceipt(receipt('Artifacts: bundle\nsha256: TBD\n')).includes('artifact_hash'), 'Artifacts: with TBD should fail');
|
|
2122
|
+
assert.ok(POLICY.validateCanonicalReceipt(receipt('Artifact produced sha256:deadbeef\n')).includes('artifact_hash'), 'short artifact hash must fail');
|
|
2123
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(receipt(`Artifact produced sha256:${digestA}\n`)), [], '64-hex artifact hash should pass');
|
|
2124
|
+
|
|
2125
|
+
const artifactOptions = POLICY.receiptValidatorOptions({ artifacts: ['artifacts/a.js', 'artifacts/b.js'] });
|
|
2126
|
+
assert.ok(POLICY.validateCanonicalReceipt(receipt(`Artifact: artifacts/a.js\nSHA-256: ${digestA}\n`), artifactOptions).includes('artifact_hash'), 'missing second artifact binding must fail');
|
|
2127
|
+
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');
|
|
2128
|
+
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');
|
|
2129
|
+
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');
|
|
2130
|
+
|
|
2131
|
+
const falseGreen = receipt([
|
|
2132
|
+
'Tests run: 0',
|
|
2133
|
+
'0 passing',
|
|
2134
|
+
'Failures: 1',
|
|
2135
|
+
'failure summary',
|
|
2136
|
+
'Result: PASS',
|
|
2137
|
+
'Artifact produced sha256:deadbeef',
|
|
2138
|
+
'Artifact: bundle + sha256:abc123',
|
|
2139
|
+
].join('\n') + '\n');
|
|
2140
|
+
assert.ok(POLICY.validateCanonicalReceipt(falseGreen).length > 0, 'false-green independent-review vector must fail');
|
|
2141
|
+
|
|
2142
|
+
// Explicit failure structured only: prose containing failure should not block, Tests failed: 0 should pass
|
|
2143
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Notes: verifies failure handling\n')), [], 'Notes with failure handling should not block');
|
|
2144
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Description: failure recovery\n')), [], 'Description with failure should not block');
|
|
2145
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('Tests failed: 0\n')), [], 'Tests failed: 0 should pass');
|
|
2146
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(receipt('fail 0\n')), [], 'fail 0 should pass');
|
|
2147
|
+
// Structured failures still blocked
|
|
2148
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nStatus: FAILED\n').length > 0, 'Status: FAILED should fail');
|
|
2149
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nOutcome: FAILURE\n').length > 0, 'Outcome: FAILURE should fail');
|
|
2150
|
+
assert.ok(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\nResult: FAILED\n').length > 0, 'Result: FAILED should fail');
|
|
2151
|
+
// TAP and Jest runner summaries (structured, anchored)
|
|
2152
|
+
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');
|
|
2153
|
+
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');
|
|
2154
|
+
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');
|
|
2155
|
+
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');
|
|
2156
|
+
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');
|
|
2157
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n# fail 0\n'), [], 'TAP # fail 0 should pass');
|
|
2158
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt('Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\nTests: 0 failed\n'), [], 'Tests: 0 failed should pass');
|
|
2159
|
+
});
|
|
2160
|
+
|
|
2161
|
+
test('P0 regression: safeTaskFile fail-closed on realpath error and single authority', () => {
|
|
2162
|
+
const fs2 = require('node:fs');
|
|
2163
|
+
const path2 = require('node:path');
|
|
2164
|
+
const os2 = require('node:os');
|
|
2165
|
+
const specGatePath = path2.join(PACKAGE_ROOT, 'src/claude/hooks/spec-gate.cjs');
|
|
2166
|
+
const specGateSrc = fs2.readFileSync(specGatePath, 'utf8');
|
|
2167
|
+
// Single authority: Claude hook should delegate to POLICY, not duplicate parser
|
|
2168
|
+
assert.match(specGateSrc, /POLICY\.validateCanonicalReceipt/);
|
|
2169
|
+
assert.doesNotMatch(specGateSrc, /const EXPLICIT_FAILURE/);
|
|
2170
|
+
// Codex and OpenCode should delegate to shared workflow-policy
|
|
2171
|
+
const codexSrc = fs2.readFileSync(path2.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-receipt.cjs'), 'utf8');
|
|
2172
|
+
assert.match(codexSrc, /getSharedValidate/);
|
|
2173
|
+
assert.match(codexSrc, /workflow-policy\.cjs/);
|
|
2174
|
+
assert.doesNotMatch(codexSrc, /const PLACEHOLDER_TOKENS/);
|
|
2175
|
+
const opencodeSrc = fs2.readFileSync(path2.join(PACKAGE_ROOT, 'src/opencode/plugins/spec-gate.ts'), 'utf8');
|
|
2176
|
+
assert.match(opencodeSrc, /getSharedValidate/);
|
|
2177
|
+
assert.match(opencodeSrc, /workflow-policy\.cjs/);
|
|
2178
|
+
// The parser should only exist in workflow-policy, not in adapters (check that adapter does not define full validate logic)
|
|
2179
|
+
const opencodeValidateCount = (opencodeSrc.match(/function validateCanonicalReceipt/g) || []).length;
|
|
2180
|
+
assert.equal(opencodeValidateCount, 1, 'OpenCode should have only delegating validate, not duplicate parser');
|
|
2181
|
+
// safeTaskFile fail-closed: simulate realpathSync throwing
|
|
2182
|
+
const tmp = fs2.mkdtempSync(path2.join(os2.tmpdir(), 'cafekit-realpath-'));
|
|
2183
|
+
try {
|
|
2184
|
+
const featDir = path2.join(tmp, 'feat');
|
|
2185
|
+
fs2.mkdirSync(path2.join(featDir, 'tasks'), { recursive: true });
|
|
2186
|
+
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');
|
|
2187
|
+
const orig = fs2.realpathSync;
|
|
2188
|
+
fs2.realpathSync = () => { throw new Error('simulated realpath failure'); };
|
|
2189
|
+
try {
|
|
2190
|
+
const codexReceipt = require(path2.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-receipt.cjs'));
|
|
2191
|
+
const fails = codexReceipt.checkReceipt(featDir, 'tasks/task.md', { status: 'done', completed_at: '2026-08-11T00:00:00.000Z' });
|
|
2192
|
+
assert.ok(fails.includes('a'), 'realpath failure should be fail-closed with check a');
|
|
2193
|
+
} finally {
|
|
2194
|
+
fs2.realpathSync = orig;
|
|
2195
|
+
}
|
|
2196
|
+
} finally {
|
|
2197
|
+
fs2.rmSync(tmp, { recursive: true, force: true });
|
|
2198
|
+
}
|
|
2199
|
+
});
|
|
2200
|
+
|
|
2201
|
+
test('P0 regression: resolver fail-closed on canonicalization and lstat errors', () => {
|
|
2202
|
+
const fs2 = require('node:fs');
|
|
2203
|
+
const path2 = require('node:path');
|
|
2204
|
+
const os2 = require('node:os');
|
|
2205
|
+
const RESOLVER = require(path2.join(PACKAGE_ROOT, 'src/claude/scripts/spec-resolver.cjs'));
|
|
2206
|
+
const tmp = fs2.mkdtempSync(path2.join(os2.tmpdir(), 'cafekit-resolver-fail-'));
|
|
2207
|
+
try {
|
|
2208
|
+
const specsDir = path2.join(tmp, 'specs');
|
|
2209
|
+
fs2.mkdirSync(path2.join(specsDir, 'demo'), { recursive: true });
|
|
2210
|
+
fs2.writeFileSync(path2.join(specsDir, 'demo', 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'demo' }));
|
|
2211
|
+
// Monkey patch realpathSync to always throw for explicitFeature
|
|
2212
|
+
const origRealpath = fs2.realpathSync;
|
|
2213
|
+
fs2.realpathSync = () => { throw Object.assign(new Error('simulated EACCES'), { code: 'EACCES' }); };
|
|
2214
|
+
try {
|
|
2215
|
+
const res = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {}, explicitFeature: 'demo' });
|
|
2216
|
+
assert.equal(res.error, 'explicit_malformed', 'explicitFeature realpath failure should be explicit_malformed');
|
|
2217
|
+
assert.match(res.reason, /canonicalization error/);
|
|
2218
|
+
} finally {
|
|
2219
|
+
fs2.realpathSync = origRealpath;
|
|
2220
|
+
}
|
|
2221
|
+
// Non-explicit with specs root realpath failure should be invalid_specs <specs>
|
|
2222
|
+
const origRealpath2 = fs2.realpathSync;
|
|
2223
|
+
fs2.realpathSync = (p) => {
|
|
2224
|
+
if (p === specsDir || p === path2.resolve(specsDir)) throw Object.assign(new Error('simulated ELOOP'), { code: 'ELOOP' });
|
|
2225
|
+
return origRealpath2(p);
|
|
2226
|
+
};
|
|
2227
|
+
try {
|
|
2228
|
+
const res2 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
|
|
2229
|
+
assert.equal(res2.error, 'invalid_specs');
|
|
2230
|
+
assert.ok(res2.candidates.includes('<specs>'));
|
|
2231
|
+
} finally {
|
|
2232
|
+
fs2.realpathSync = origRealpath2;
|
|
2233
|
+
}
|
|
2234
|
+
// lstat error for entry (simulate EACCES on lstat)
|
|
2235
|
+
const origLstat = fs2.lstatSync;
|
|
2236
|
+
fs2.lstatSync = (p) => {
|
|
2237
|
+
if (p.includes('demo')) throw Object.assign(new Error('simulated EACCES'), { code: 'EACCES' });
|
|
2238
|
+
return origLstat(p);
|
|
2239
|
+
};
|
|
2240
|
+
try {
|
|
2241
|
+
const res3 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
|
|
2242
|
+
assert.equal(res3.error, 'invalid_specs');
|
|
2243
|
+
assert.ok(res3.candidates.includes('demo'));
|
|
2244
|
+
} finally {
|
|
2245
|
+
fs2.lstatSync = origLstat;
|
|
2246
|
+
}
|
|
2247
|
+
// Dangling spec.json symlink should be invalid_specs
|
|
2248
|
+
const outside = path2.join(tmp, 'outside-spec2');
|
|
2249
|
+
fs2.mkdirSync(outside, { recursive: true });
|
|
2250
|
+
fs2.writeFileSync(path2.join(outside, 'spec.json'), JSON.stringify({ status: 'in_progress' }));
|
|
2251
|
+
const featDir = path2.join(specsDir, 'dangle');
|
|
2252
|
+
fs2.mkdirSync(featDir, { recursive: true });
|
|
2253
|
+
const specLink = path2.join(featDir, 'spec.json');
|
|
2254
|
+
fs2.symlinkSync(path2.join(tmp, 'nonexistent-target'), specLink);
|
|
2255
|
+
const res4 = RESOLVER.resolveActiveSpec({ projectRoot: tmp, runtime: {} });
|
|
2256
|
+
assert.equal(res4.error, 'invalid_specs');
|
|
2257
|
+
assert.ok(res4.candidates.includes('dangle'));
|
|
2258
|
+
} finally {
|
|
2259
|
+
fs2.rmSync(tmp, { recursive: true, force: true });
|
|
2260
|
+
}
|
|
2261
|
+
// Codex parity: same monkey patch should give explicit_malformed
|
|
2262
|
+
const codexUtils = require(path2.join(PACKAGE_ROOT, 'src/codex/hooks/lib/spec-utils.cjs'));
|
|
2263
|
+
const tmp2 = fs2.mkdtempSync(path2.join(os2.tmpdir(), 'cafekit-codex-fail-'));
|
|
2264
|
+
try {
|
|
2265
|
+
const specsDir2 = path2.join(tmp2, 'specs');
|
|
2266
|
+
fs2.mkdirSync(path2.join(specsDir2, 'demo2'), { recursive: true });
|
|
2267
|
+
fs2.writeFileSync(path2.join(specsDir2, 'demo2', 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'demo2' }));
|
|
2268
|
+
const orig = fs2.realpathSync;
|
|
2269
|
+
fs2.realpathSync = () => { throw Object.assign(new Error('simulated EACCES'), { code: 'EACCES' }); };
|
|
2270
|
+
try {
|
|
2271
|
+
const r = codexUtils.resolveActiveSpec(tmp2, {}, 'demo2', null);
|
|
2272
|
+
assert.equal(r.error, 'explicit_malformed');
|
|
2273
|
+
} finally {
|
|
2274
|
+
fs2.realpathSync = orig;
|
|
2275
|
+
}
|
|
2276
|
+
} finally {
|
|
2277
|
+
fs2.rmSync(tmp2, { recursive: true, force: true });
|
|
2278
|
+
}
|
|
2279
|
+
});
|
|
2280
|
+
|
|
2281
|
+
test('P0 canonical phantom vectors - zero, cancellation, suite, FAIL, error, collected (r3)', () => {
|
|
2282
|
+
const base = 'Verification: PASS\nCommand: pnpm test\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n';
|
|
2283
|
+
// 1. Zero execution
|
|
2284
|
+
const zeroFail = [
|
|
2285
|
+
'ℹ tests 0',
|
|
2286
|
+
'Tests: 0 total',
|
|
2287
|
+
'0 tests',
|
|
2288
|
+
'Tests: 0 passed',
|
|
2289
|
+
'Tests: 0 passing',
|
|
2290
|
+
'Test Suites: 0 failed, 0 passed',
|
|
2291
|
+
'Tests passed: 0',
|
|
2292
|
+
'Passed: 0',
|
|
2293
|
+
'one failing',
|
|
2294
|
+
'failureCount: 1',
|
|
2295
|
+
'collected 0 items',
|
|
2296
|
+
'No tests found.',
|
|
2297
|
+
'No tests found',
|
|
2298
|
+
];
|
|
2299
|
+
for (const v of zeroFail) {
|
|
2300
|
+
assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `zero execution should fail: ${v}`);
|
|
2301
|
+
}
|
|
2302
|
+
// 2. Cancellation N>0
|
|
2303
|
+
const cancelFail = [
|
|
2304
|
+
'ℹ cancelled 1',
|
|
2305
|
+
'cancelled 1',
|
|
2306
|
+
'1 cancelled',
|
|
2307
|
+
'canceled 1',
|
|
2308
|
+
'1 canceled',
|
|
2309
|
+
];
|
|
2310
|
+
for (const v of cancelFail) {
|
|
2311
|
+
assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `cancel should fail: ${v}`);
|
|
2312
|
+
}
|
|
2313
|
+
// 3. Suite / Files
|
|
2314
|
+
const suiteFail = [
|
|
2315
|
+
'Test Suites: 1 failed, 1 total',
|
|
2316
|
+
'Test Suites: 2 failed',
|
|
2317
|
+
'Test Files: 1 failed',
|
|
2318
|
+
'Test Files 1 failed',
|
|
2319
|
+
];
|
|
2320
|
+
for (const v of suiteFail) {
|
|
2321
|
+
assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `suite should fail: ${v}`);
|
|
2322
|
+
}
|
|
2323
|
+
// 4. Runner FAIL
|
|
2324
|
+
const runnerFail = [
|
|
2325
|
+
'FAIL ./foo.test.js',
|
|
2326
|
+
'FAIL\tpackage',
|
|
2327
|
+
'FAIL\texample.test/probe\t0.431s',
|
|
2328
|
+
'--- FAIL: TestName (0.00s)',
|
|
2329
|
+
'FAIL',
|
|
2330
|
+
'FAIL: something',
|
|
2331
|
+
'FAILED tests/test_demo.py::test_foo - assert 1 == 2',
|
|
2332
|
+
'FAILED tests/test_demo.py',
|
|
2333
|
+
'# fail 1',
|
|
2334
|
+
'ℹ fail 1',
|
|
2335
|
+
'not ok 1 - test',
|
|
2336
|
+
];
|
|
2337
|
+
for (const v of runnerFail) {
|
|
2338
|
+
assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `runner FAIL should fail: ${v}`);
|
|
2339
|
+
}
|
|
2340
|
+
// also standalone FAILED/FAILURE bare/colon
|
|
2341
|
+
for (const v of ['FAILED', 'FAILURE', 'FAILED:', 'FAILURE:']) {
|
|
2342
|
+
assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `standalone ${v} should fail`);
|
|
2343
|
+
}
|
|
2344
|
+
// 5. Error summaries N>0
|
|
2345
|
+
const errorFail = [
|
|
2346
|
+
'1 error in 0.12s',
|
|
2347
|
+
'2 errors',
|
|
2348
|
+
'ERROR collecting tests/test_demo.py',
|
|
2349
|
+
'ERROR',
|
|
2350
|
+
'Found 2 errors',
|
|
2351
|
+
'Tests run: 5, Failures: 1, Errors: 0',
|
|
2352
|
+
'[ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0',
|
|
2353
|
+
'[ERROR] Tests run: 5, Failures: 0, Errors: 1, Skipped: 0',
|
|
2354
|
+
'[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.2.5:test',
|
|
2355
|
+
'5 tests completed, 1 failed',
|
|
2356
|
+
];
|
|
2357
|
+
for (const v of errorFail) {
|
|
2358
|
+
assert.ok(POLICY.validateCanonicalReceipt(base + v + '\n').length > 0, `error should fail: ${v}`);
|
|
2359
|
+
}
|
|
2360
|
+
// Positive controls must pass
|
|
2361
|
+
const pass = [
|
|
2362
|
+
'Tests failed: 0',
|
|
2363
|
+
'fail 0',
|
|
2364
|
+
'Tests: 0 failed',
|
|
2365
|
+
'cancelled 0',
|
|
2366
|
+
'canceled 0',
|
|
2367
|
+
'Test Suites: 0 failed',
|
|
2368
|
+
'Test Files: 0 failed',
|
|
2369
|
+
'5 tests completed, 0 failed',
|
|
2370
|
+
'Tests run: 5, Failures: 0, Errors: 0',
|
|
2371
|
+
'# tests 1',
|
|
2372
|
+
'# suites 0',
|
|
2373
|
+
'# pass 1',
|
|
2374
|
+
'# fail 0',
|
|
2375
|
+
'# cancelled 0',
|
|
2376
|
+
'# skipped 0',
|
|
2377
|
+
'# todo 0',
|
|
2378
|
+
'# duration_ms 114.203625',
|
|
2379
|
+
'[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
|
|
2380
|
+
'[ERROR] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
|
|
2381
|
+
'[ERROR] Error handling is documented',
|
|
2382
|
+
'errors 0',
|
|
2383
|
+
'Found 0 errors',
|
|
2384
|
+
'tests 5',
|
|
2385
|
+
'collected 1 item',
|
|
2386
|
+
'collected 5 items',
|
|
2387
|
+
'Notes: handles error/failure',
|
|
2388
|
+
'Notes: verifies failure handling',
|
|
2389
|
+
'Notes: Tests: 0 passed',
|
|
2390
|
+
'Description: Tests passed: 0',
|
|
2391
|
+
'Notes: one failing test is documented',
|
|
2392
|
+
'Description: failureCount: 1 is documented',
|
|
2393
|
+
'one failing test: 1 is documented',
|
|
2394
|
+
'failureCount: 1 is documented',
|
|
2395
|
+
'Tests failed previously but now fixed',
|
|
2396
|
+
'Failure handling is documented',
|
|
2397
|
+
'failure summary follows in the notes',
|
|
2398
|
+
'ERROR handling is documented',
|
|
2399
|
+
'Summary: failureCount: 1 is documented',
|
|
2400
|
+
'Error handling is documented',
|
|
2401
|
+
'FAILURE mode analysis',
|
|
2402
|
+
'Notes: tests failed previously but now fixed',
|
|
2403
|
+
'Notes: supports zero-test parsing',
|
|
2404
|
+
'Command: npm run test --errorFlag',
|
|
2405
|
+
'Command: npm run artifact:test',
|
|
2406
|
+
];
|
|
2407
|
+
for (const v of pass) {
|
|
2408
|
+
// Command lines are skipped; others should not trigger hasExplicitFailure
|
|
2409
|
+
const body = v.startsWith('Command:') ? `Verification: PASS\n${v}\nExit: 0\nBase: 0123456789abcdef0123456789abcdef01234567\nHead: 89abcdef0123456789abcdef0123456789abcdef\n` : base + v + '\n';
|
|
2410
|
+
assert.deepEqual(POLICY.validateCanonicalReceipt(body), [], `should pass: ${v}`);
|
|
2411
|
+
}
|
|
2412
|
+
assert.equal(POLICY.isTapMetadataHeading('# fail 1'), true);
|
|
2413
|
+
assert.equal(POLICY.isTapMetadataHeading('# duration_ms 114.203625'), true);
|
|
2414
|
+
assert.equal(POLICY.isTapMetadataHeading('# Notes'), false);
|
|
2415
|
+
assert.equal(POLICY.isTapMetadataHeading('# pass 1 explanation'), false);
|
|
2416
|
+
});
|