@haposoft/cafekit 0.16.0-rc.1 → 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/code-auditor.md +8 -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/backend-development/references/backend-security.md +52 -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 +112 -124
- 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
|
@@ -8,14 +8,21 @@
|
|
|
8
8
|
const { test, beforeEach, after } = require('node:test');
|
|
9
9
|
const assert = require('node:assert');
|
|
10
10
|
const { spawnSync } = require('node:child_process');
|
|
11
|
+
const crypto = require('node:crypto');
|
|
11
12
|
const fs = require('node:fs');
|
|
12
13
|
const os = require('node:os');
|
|
13
14
|
const path = require('node:path');
|
|
14
15
|
|
|
15
16
|
const HOOK = path.join(__dirname, '..', 'spec-gate.cjs');
|
|
16
17
|
const CACHE = path.join(__dirname, '..', '.logs', 'spec-gate-last.json');
|
|
18
|
+
const PROVENANCE = require(path.join(__dirname, '..', '..', 'scripts', 'provenance.cjs'));
|
|
19
|
+
const POLICY = require(path.join(__dirname, '..', '..', 'scripts', 'workflow-policy.cjs'));
|
|
17
20
|
const FEATURE = 'demo';
|
|
18
21
|
const TASK_REL = 'tasks/task-R0-01-x.md';
|
|
22
|
+
const VALID_BASE = '0123456789abcdef0123456789abcdef01234567';
|
|
23
|
+
const VALID_HEAD = '89abcdef0123456789abcdef0123456789abcdef';
|
|
24
|
+
const FIXTURE_ARTIFACT = Buffer.from('cafekit claude fixture artifact\n');
|
|
25
|
+
const FIXTURE_ARTIFACT_DIGEST = crypto.createHash('sha256').update(FIXTURE_ARTIFACT).digest('hex');
|
|
19
26
|
|
|
20
27
|
function runHook(payload, cwd) {
|
|
21
28
|
const projectRoot = cwd || payload.cwd;
|
|
@@ -42,7 +49,27 @@ function runHook(payload, cwd) {
|
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
function tmpDir() {
|
|
45
|
-
|
|
52
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-gate-'));
|
|
53
|
+
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
|
|
54
|
+
const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
|
|
55
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
56
|
+
}
|
|
57
|
+
return dir;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function runtimeContext(dir) {
|
|
61
|
+
return PROVENANCE.deriveRuntimeContext({
|
|
62
|
+
projectRoot: dir,
|
|
63
|
+
specsRoot: path.join(dir, 'specs'),
|
|
64
|
+
specFile: path.join(dir, 'specs', FEATURE, 'spec.json'),
|
|
65
|
+
featureName: FEATURE,
|
|
66
|
+
runtimeSession: 'test',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function bindFixtureReceipt(dir, value) {
|
|
71
|
+
const context = runtimeContext(dir);
|
|
72
|
+
return value.replaceAll(VALID_BASE, context.base).replaceAll(VALID_HEAD, context.head);
|
|
46
73
|
}
|
|
47
74
|
|
|
48
75
|
function clearCache() {
|
|
@@ -66,6 +93,7 @@ function readCache() {
|
|
|
66
93
|
* @param {string} [opts.mdStatus='done']
|
|
67
94
|
* @param {'valid'|'legacy-valid'|'failed'|'fence-only'|'missing-evidence'|'placeholder'|'none'} [opts.evidence='valid']
|
|
68
95
|
* @param {object|null} [opts.runtime] — if set, write .claude/runtime.json
|
|
96
|
+
* @param {object|null} [opts.workflowPolicy] — if set, persist completion obligations
|
|
69
97
|
*/
|
|
70
98
|
function makeFixture(opts = {}) {
|
|
71
99
|
const {
|
|
@@ -74,6 +102,7 @@ function makeFixture(opts = {}) {
|
|
|
74
102
|
mdStatus = 'done',
|
|
75
103
|
evidence = 'valid',
|
|
76
104
|
runtime = null,
|
|
105
|
+
workflowPolicy = null,
|
|
77
106
|
} = opts;
|
|
78
107
|
const dir = tmpDir();
|
|
79
108
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
@@ -83,14 +112,14 @@ function makeFixture(opts = {}) {
|
|
|
83
112
|
const entry = { status: taskStatus };
|
|
84
113
|
if (completed_at !== null) entry.completed_at = completed_at;
|
|
85
114
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
);
|
|
115
|
+
const spec = {
|
|
116
|
+
status: 'in_progress',
|
|
117
|
+
feature_name: FEATURE,
|
|
118
|
+
current_phase: 'implementation',
|
|
119
|
+
task_registry: { [TASK_REL]: entry },
|
|
120
|
+
};
|
|
121
|
+
if (workflowPolicy) spec.workflow_policy = workflowPolicy;
|
|
122
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify(spec));
|
|
94
123
|
|
|
95
124
|
let evidenceBlock = '';
|
|
96
125
|
if (evidence === 'valid') {
|
|
@@ -101,9 +130,9 @@ function makeFixture(opts = {}) {
|
|
|
101
130
|
'Command: npm test',
|
|
102
131
|
'Exit: 0',
|
|
103
132
|
'Result: PASS',
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
133
|
+
`Base: ${VALID_BASE}`,
|
|
134
|
+
`Head: ${VALID_HEAD}`,
|
|
135
|
+
`Artifact: output/bundle.js (sha256:${FIXTURE_ARTIFACT_DIGEST})`,
|
|
107
136
|
'```',
|
|
108
137
|
'npm test',
|
|
109
138
|
'PASS: 10 tests',
|
|
@@ -111,7 +140,7 @@ function makeFixture(opts = {}) {
|
|
|
111
140
|
'',
|
|
112
141
|
].join('\n');
|
|
113
142
|
} else if (evidence === 'legacy-valid') {
|
|
114
|
-
evidenceBlock =
|
|
143
|
+
evidenceBlock = `## Evidence\n\nVerification: PASS\nCommand: npm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\nnpm test — passed, exit code 0\n`;
|
|
115
144
|
} else if (evidence === 'failed') {
|
|
116
145
|
evidenceBlock = '## Evidence\n\nVerification: PASS\n\nFAIL: tests failed, exit code 1\n';
|
|
117
146
|
} else if (evidence === 'fence-only') {
|
|
@@ -149,6 +178,15 @@ function makeFixture(opts = {}) {
|
|
|
149
178
|
);
|
|
150
179
|
}
|
|
151
180
|
|
|
181
|
+
if (evidence === 'valid') {
|
|
182
|
+
fs.mkdirSync(path.join(dir, 'output'), { recursive: true });
|
|
183
|
+
fs.writeFileSync(path.join(dir, 'output', 'bundle.js'), FIXTURE_ARTIFACT);
|
|
184
|
+
}
|
|
185
|
+
if (evidence === 'valid' || evidence === 'legacy-valid') {
|
|
186
|
+
const taskFile = path.join(dir, 'specs', FEATURE, TASK_REL);
|
|
187
|
+
fs.writeFileSync(taskFile, bindFixtureReceipt(dir, fs.readFileSync(taskFile, 'utf8')));
|
|
188
|
+
}
|
|
189
|
+
|
|
152
190
|
return dir;
|
|
153
191
|
}
|
|
154
192
|
|
|
@@ -164,6 +202,52 @@ function parseBlock(stdout) {
|
|
|
164
202
|
}
|
|
165
203
|
}
|
|
166
204
|
|
|
205
|
+
function installClaudeGate(root, policyMode = 'valid') {
|
|
206
|
+
const hooks = path.join(root, '.claude', 'hooks');
|
|
207
|
+
const scripts = path.join(root, '.claude', 'scripts');
|
|
208
|
+
fs.mkdirSync(hooks, { recursive: true });
|
|
209
|
+
fs.mkdirSync(scripts, { recursive: true });
|
|
210
|
+
fs.copyFileSync(HOOK, path.join(hooks, 'spec-gate.cjs'));
|
|
211
|
+
fs.copyFileSync(path.join(__dirname, '..', '..', 'scripts', 'spec-resolver.cjs'), path.join(scripts, 'spec-resolver.cjs'));
|
|
212
|
+
if (policyMode === 'valid') {
|
|
213
|
+
fs.copyFileSync(path.join(__dirname, '..', '..', 'scripts', 'workflow-policy.cjs'), path.join(scripts, 'workflow-policy.cjs'));
|
|
214
|
+
fs.copyFileSync(path.join(__dirname, '..', '..', 'scripts', 'provenance.cjs'), path.join(scripts, 'provenance.cjs'));
|
|
215
|
+
} else if (policyMode === 'malformed') {
|
|
216
|
+
fs.writeFileSync(path.join(scripts, 'workflow-policy.cjs'), 'module.exports = {\n');
|
|
217
|
+
}
|
|
218
|
+
return path.join(hooks, 'spec-gate.cjs');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
test('CLAUDE_PROJECT_DIR wins over malicious payload.cwd and stale process cwd', () => {
|
|
222
|
+
const target = makeFixture({ evidence: 'missing-evidence' });
|
|
223
|
+
const stale = tmpDir();
|
|
224
|
+
try {
|
|
225
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
226
|
+
const result = spawnSync(process.execPath, [HOOK], {
|
|
227
|
+
cwd: stale,
|
|
228
|
+
env: {
|
|
229
|
+
...process.env,
|
|
230
|
+
CLAUDE_PROJECT_DIR: target,
|
|
231
|
+
PROJECT_ROOT: stale,
|
|
232
|
+
},
|
|
233
|
+
input: JSON.stringify({
|
|
234
|
+
hook_event_name: 'Stop',
|
|
235
|
+
session_id: 'test',
|
|
236
|
+
stop_hook_active: false,
|
|
237
|
+
cwd: stale,
|
|
238
|
+
}),
|
|
239
|
+
encoding: 'utf8',
|
|
240
|
+
});
|
|
241
|
+
assert.equal(result.status, 0, result.stderr);
|
|
242
|
+
const body = parseBlock(result.stdout.trim());
|
|
243
|
+
assert.equal(body?.decision, 'block');
|
|
244
|
+
assert.match(body.reason, new RegExp(TASK_REL.replace('/', '\\/')));
|
|
245
|
+
} finally {
|
|
246
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
247
|
+
fs.rmSync(stale, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
167
251
|
beforeEach(() => {
|
|
168
252
|
clearCache();
|
|
169
253
|
});
|
|
@@ -193,6 +277,131 @@ test('1. newly-done task WITHOUT receipt → block, reason names task path', ()
|
|
|
193
277
|
}
|
|
194
278
|
});
|
|
195
279
|
|
|
280
|
+
test('installed Claude gate fails closed with a controlled decision when policy is missing or malformed', () => {
|
|
281
|
+
for (const policyMode of ['missing', 'malformed']) {
|
|
282
|
+
const dir = tmpDir();
|
|
283
|
+
const gate = installClaudeGate(dir, policyMode);
|
|
284
|
+
try {
|
|
285
|
+
const result = spawnSync(process.execPath, [gate], {
|
|
286
|
+
cwd: dir,
|
|
287
|
+
env: { ...process.env, PROJECT_ROOT: dir },
|
|
288
|
+
input: JSON.stringify({ cwd: dir, session_id: 'test', stop_hook_active: false }),
|
|
289
|
+
encoding: 'utf8',
|
|
290
|
+
});
|
|
291
|
+
assert.equal(result.status, 0, `${policyMode}: ${result.stderr}`);
|
|
292
|
+
assert.equal(result.stderr, '', `${policyMode} must not leak a stack trace`);
|
|
293
|
+
const block = parseBlock(result.stdout);
|
|
294
|
+
assert.equal(block?.decision, 'block', `${policyMode} must emit a controlled block`);
|
|
295
|
+
assert.match(block.reason, /workflow policy could not be loaded/i);
|
|
296
|
+
assert.ok(fs.existsSync(path.join(dir, '.claude', 'hooks', '.logs', 'hook-log.jsonl')));
|
|
297
|
+
} finally {
|
|
298
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('Claude adapter requires canonical SHA-256 only for a declared task artifact', () => {
|
|
304
|
+
const digest = FIXTURE_ARTIFACT_DIGEST;
|
|
305
|
+
const bodies = [
|
|
306
|
+
{ suffix: '', expected: true, label: 'missing hash' },
|
|
307
|
+
{ suffix: 'sha256: abc123\n', expected: true, label: 'invalid hash' },
|
|
308
|
+
{ suffix: `sha256: ${'0'.repeat(64)}\n`, expected: true, label: 'mismatched hash' },
|
|
309
|
+
{ suffix: `sha256: ${digest}\n`, expected: false, label: 'valid hash' },
|
|
310
|
+
];
|
|
311
|
+
for (const { suffix, expected, label } of bodies) {
|
|
312
|
+
const dir = tmpDir();
|
|
313
|
+
const gate = installClaudeGate(dir);
|
|
314
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
315
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
316
|
+
fs.mkdirSync(path.join(dir, 'output'), { recursive: true });
|
|
317
|
+
fs.writeFileSync(path.join(dir, 'output', 'bundle.js'), FIXTURE_ARTIFACT);
|
|
318
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
319
|
+
status: 'in_progress',
|
|
320
|
+
feature_name: FEATURE,
|
|
321
|
+
task_registry: {
|
|
322
|
+
[TASK_REL]: {
|
|
323
|
+
status: 'done',
|
|
324
|
+
completed_at: '2026-08-11T00:00:00.000Z',
|
|
325
|
+
artifacts: ['output/bundle.js'],
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
}));
|
|
329
|
+
fs.writeFileSync(path.join(featureDir, TASK_REL), [
|
|
330
|
+
'# Task', '', '**Status:** done', '', '## Evidence', '',
|
|
331
|
+
'Verification: PASS', 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
332
|
+
'Artifact: output/bundle.js', suffix,
|
|
333
|
+
].join('\n'));
|
|
334
|
+
fs.writeFileSync(
|
|
335
|
+
path.join(featureDir, TASK_REL),
|
|
336
|
+
bindFixtureReceipt(dir, fs.readFileSync(path.join(featureDir, TASK_REL), 'utf8')),
|
|
337
|
+
);
|
|
338
|
+
try {
|
|
339
|
+
const result = spawnSync(process.execPath, [gate], {
|
|
340
|
+
cwd: dir,
|
|
341
|
+
env: { ...process.env, PROJECT_ROOT: dir },
|
|
342
|
+
input: JSON.stringify({ cwd: dir, session_id: 'test', stop_hook_active: false }),
|
|
343
|
+
encoding: 'utf8',
|
|
344
|
+
});
|
|
345
|
+
assert.equal(result.status, 0, `${label}: ${result.stderr}`);
|
|
346
|
+
if (expected) {
|
|
347
|
+
const block = parseBlock(result.stdout);
|
|
348
|
+
assert.equal(block?.decision, 'block', `${label} must block`);
|
|
349
|
+
assert.match(block.reason, /\bh\b/);
|
|
350
|
+
} else {
|
|
351
|
+
assert.equal(result.stdout, '', `${label} must pass`);
|
|
352
|
+
}
|
|
353
|
+
} finally {
|
|
354
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('Claude artifact verification rejects traversal and symlink paths', () => {
|
|
360
|
+
const cases = [
|
|
361
|
+
{ artifactPath: '../outside.js', symlink: false },
|
|
362
|
+
{ artifactPath: 'output/link.js', symlink: true },
|
|
363
|
+
];
|
|
364
|
+
for (const { artifactPath, symlink } of cases) {
|
|
365
|
+
const dir = tmpDir();
|
|
366
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
367
|
+
const artifactBytes = Buffer.from(`artifact-${artifactPath}\n`);
|
|
368
|
+
const digest = crypto.createHash('sha256').update(artifactBytes).digest('hex');
|
|
369
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
370
|
+
if (symlink) {
|
|
371
|
+
fs.mkdirSync(path.join(dir, 'output'), { recursive: true });
|
|
372
|
+
const target = path.join(dir, 'artifact-target.js');
|
|
373
|
+
fs.writeFileSync(target, artifactBytes);
|
|
374
|
+
fs.symlinkSync(target, path.join(dir, artifactPath));
|
|
375
|
+
} else {
|
|
376
|
+
fs.writeFileSync(path.join(dir, 'outside.js'), artifactBytes);
|
|
377
|
+
}
|
|
378
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
379
|
+
status: 'in_progress',
|
|
380
|
+
feature_name: FEATURE,
|
|
381
|
+
task_registry: {
|
|
382
|
+
[TASK_REL]: {
|
|
383
|
+
status: 'done',
|
|
384
|
+
completed_at: '2026-08-11T00:00:00.000Z',
|
|
385
|
+
artifacts: [artifactPath],
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
}));
|
|
389
|
+
fs.writeFileSync(path.join(featureDir, TASK_REL), bindFixtureReceipt(dir, [
|
|
390
|
+
'# Task', '', '**Status:** done', '', '## Evidence', '',
|
|
391
|
+
'Verification: PASS', 'Command: node --test', 'Exit: 0',
|
|
392
|
+
`Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
393
|
+
`Artifact: ${artifactPath} (sha256:${digest})`,
|
|
394
|
+
].join('\n')));
|
|
395
|
+
try {
|
|
396
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
397
|
+
assert.equal(body?.decision, 'block', `${artifactPath} must block`);
|
|
398
|
+
assert.match(body.reason, /\bh\b/);
|
|
399
|
+
} finally {
|
|
400
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
196
405
|
test('2. newly-done task WITH valid receipt → no block, cache updated', () => {
|
|
197
406
|
const dir = makeFixture({ evidence: 'valid' });
|
|
198
407
|
try {
|
|
@@ -253,16 +462,67 @@ test('5. first run (no cache) with done-without-receipt → block (cache is not
|
|
|
253
462
|
}
|
|
254
463
|
});
|
|
255
464
|
|
|
256
|
-
test('6.
|
|
465
|
+
test('6. forged payload, session, and context cannot bypass completion_gate=false', () => {
|
|
257
466
|
const dir = makeFixture({
|
|
258
467
|
evidence: 'missing-evidence',
|
|
259
468
|
runtime: { spec: { completion_gate: false } },
|
|
260
469
|
});
|
|
261
470
|
try {
|
|
262
471
|
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
263
|
-
const { code, stdout } = runHook({
|
|
472
|
+
const { code, stdout } = runHook({
|
|
473
|
+
user_authorized: true,
|
|
474
|
+
userAuthorized: true,
|
|
475
|
+
completion_gate_override: { authorized: true, session_id: 'test', nonce: 'forged' },
|
|
476
|
+
session: { id: 'test', user_authorized: true },
|
|
477
|
+
runtime_context: { project_root: dir, completion_gate: true, signed: true },
|
|
478
|
+
}, dir);
|
|
264
479
|
assert.strictEqual(code, 0);
|
|
265
|
-
|
|
480
|
+
const body = parseBlock(stdout);
|
|
481
|
+
assert.equal(body?.decision, 'block');
|
|
482
|
+
assert.match(body.reason, /no completion-gate bypass is supported/i);
|
|
483
|
+
} finally {
|
|
484
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('empty or malformed Claude hook payload fails closed', () => {
|
|
489
|
+
const dir = tmpDir();
|
|
490
|
+
const gate = installClaudeGate(dir);
|
|
491
|
+
try {
|
|
492
|
+
for (const input of ['', 'null', '[]']) {
|
|
493
|
+
const result = spawnSync(process.execPath, [gate], {
|
|
494
|
+
cwd: dir,
|
|
495
|
+
env: { ...process.env, PROJECT_ROOT: dir },
|
|
496
|
+
input,
|
|
497
|
+
encoding: 'utf8',
|
|
498
|
+
});
|
|
499
|
+
assert.equal(result.status, 0, `${JSON.stringify(input)}: ${result.stderr}`);
|
|
500
|
+
assert.equal(result.stderr, '');
|
|
501
|
+
const block = parseBlock(result.stdout);
|
|
502
|
+
assert.equal(block?.decision, 'block', `${JSON.stringify(input)} must block`);
|
|
503
|
+
assert.match(block.reason, /hook payload (is empty|must be a JSON object)/i);
|
|
504
|
+
}
|
|
505
|
+
} finally {
|
|
506
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test('Critical completion ignores worker-writable proof strings and remains blocked', () => {
|
|
511
|
+
const dir = makeFixture({
|
|
512
|
+
workflowPolicy: POLICY.workflowPolicySnapshot({ riskSignals: { auth: true } }),
|
|
513
|
+
});
|
|
514
|
+
try {
|
|
515
|
+
const specPath = path.join(dir, 'specs', FEATURE, 'spec.json');
|
|
516
|
+
const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
|
|
517
|
+
spec.proofs = {
|
|
518
|
+
needsInspection: 'inspection completed',
|
|
519
|
+
needsIndependentAudit: 'PASS',
|
|
520
|
+
needsResearchGrounding: 'research completed',
|
|
521
|
+
};
|
|
522
|
+
fs.writeFileSync(specPath, JSON.stringify(spec));
|
|
523
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
524
|
+
assert.equal(body?.decision, 'block');
|
|
525
|
+
assert.match(body.reason, /needsInspection|needsIndependentAudit|needsResearchGrounding/);
|
|
266
526
|
} finally {
|
|
267
527
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
268
528
|
}
|
|
@@ -360,11 +620,12 @@ test('13. provenance requires both Base and Head - only Base fails', () => {
|
|
|
360
620
|
fs.mkdirSync(tasksDir, { recursive: true });
|
|
361
621
|
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
362
622
|
status: 'in_progress',
|
|
623
|
+
feature_name: FEATURE,
|
|
363
624
|
current_phase: 'implementation',
|
|
364
625
|
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
365
626
|
}));
|
|
366
627
|
// Only Base, missing Head
|
|
367
|
-
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), [
|
|
628
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), bindFixtureReceipt(dir, [
|
|
368
629
|
'# Task',
|
|
369
630
|
'',
|
|
370
631
|
'**Status:** done',
|
|
@@ -374,11 +635,11 @@ test('13. provenance requires both Base and Head - only Base fails', () => {
|
|
|
374
635
|
'Verification: PASS',
|
|
375
636
|
'Command: npm test',
|
|
376
637
|
'Exit: 0',
|
|
377
|
-
|
|
638
|
+
`Base: ${VALID_BASE}`,
|
|
378
639
|
'```',
|
|
379
640
|
'npm test PASS',
|
|
380
641
|
'```',
|
|
381
|
-
].join('\n'));
|
|
642
|
+
].join('\n')));
|
|
382
643
|
try {
|
|
383
644
|
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
384
645
|
const body = parseBlock(runHook({}, dir).stdout);
|
|
@@ -397,6 +658,7 @@ test('14. provenance requires both Base and Head - only Head fails', () => {
|
|
|
397
658
|
fs.mkdirSync(tasksDir, { recursive: true });
|
|
398
659
|
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
399
660
|
status: 'in_progress',
|
|
661
|
+
feature_name: FEATURE,
|
|
400
662
|
current_phase: 'implementation',
|
|
401
663
|
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
402
664
|
}));
|
|
@@ -411,7 +673,7 @@ test('14. provenance requires both Base and Head - only Head fails', () => {
|
|
|
411
673
|
'Verification: PASS',
|
|
412
674
|
'Command: npm test',
|
|
413
675
|
'Exit: 0',
|
|
414
|
-
|
|
676
|
+
`Head: ${VALID_HEAD}`,
|
|
415
677
|
'```',
|
|
416
678
|
'npm test PASS',
|
|
417
679
|
'```',
|
|
@@ -434,10 +696,11 @@ test('15. provenance with both Base and Head passes', () => {
|
|
|
434
696
|
fs.mkdirSync(tasksDir, { recursive: true });
|
|
435
697
|
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
436
698
|
status: 'in_progress',
|
|
699
|
+
feature_name: FEATURE,
|
|
437
700
|
current_phase: 'implementation',
|
|
438
701
|
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
439
702
|
}));
|
|
440
|
-
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), [
|
|
703
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), bindFixtureReceipt(dir, [
|
|
441
704
|
'# Task',
|
|
442
705
|
'',
|
|
443
706
|
'**Status:** done',
|
|
@@ -447,12 +710,12 @@ test('15. provenance with both Base and Head passes', () => {
|
|
|
447
710
|
'Verification: PASS',
|
|
448
711
|
'Command: npm test',
|
|
449
712
|
'Exit: 0',
|
|
450
|
-
|
|
451
|
-
|
|
713
|
+
`Base: ${VALID_BASE}`,
|
|
714
|
+
`Head: ${VALID_HEAD}`,
|
|
452
715
|
'```',
|
|
453
716
|
'npm test PASS',
|
|
454
717
|
'```',
|
|
455
|
-
].join('\n'));
|
|
718
|
+
].join('\n')));
|
|
456
719
|
try {
|
|
457
720
|
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
458
721
|
const { stdout } = runHook({}, dir);
|
|
@@ -481,8 +744,8 @@ test('16. registry task path with ../ escape or sibling-prefix is rejected (chec
|
|
|
481
744
|
'Verification: PASS',
|
|
482
745
|
'Command: npm test',
|
|
483
746
|
'Exit: 0',
|
|
484
|
-
|
|
485
|
-
|
|
747
|
+
`Base: ${VALID_BASE}`,
|
|
748
|
+
`Head: ${VALID_HEAD}`,
|
|
486
749
|
'```',
|
|
487
750
|
'pass',
|
|
488
751
|
'```',
|
|
@@ -491,6 +754,7 @@ test('16. registry task path with ../ escape or sibling-prefix is rejected (chec
|
|
|
491
754
|
const siblingPrefixRel = `tasks/../../${FEATURE}-evil/tasks/task.md`;
|
|
492
755
|
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
493
756
|
status: 'in_progress',
|
|
757
|
+
feature_name: FEATURE,
|
|
494
758
|
current_phase: 'implementation',
|
|
495
759
|
task_registry: {
|
|
496
760
|
[maliciousRel]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' },
|
|
@@ -527,8 +791,9 @@ test('17. empty or bare provenance fails — Base:/Head: require non-empty same-
|
|
|
527
791
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
528
792
|
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
529
793
|
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
530
|
-
|
|
531
|
-
|
|
794
|
+
status: 'in_progress',
|
|
795
|
+
feature_name: FEATURE,
|
|
796
|
+
current_phase: 'implementation',
|
|
532
797
|
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
533
798
|
}));
|
|
534
799
|
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
|
|
@@ -543,23 +808,26 @@ test('17. empty or bare provenance fails — Base:/Head: require non-empty same-
|
|
|
543
808
|
|
|
544
809
|
test('18. valid non-empty Base: value and base_sha: value pass', () => {
|
|
545
810
|
const validCases = [
|
|
546
|
-
['Verification: PASS','Command: npm test','Exit: 0'
|
|
547
|
-
['Verification: PASS','Command: npm test','Exit: 0'
|
|
548
|
-
['Verification: PASS','Command: npm test','Exit: 0'
|
|
811
|
+
{ evidence: ['Verification: PASS','Command: npm test','Exit: 0',`Base: ${VALID_BASE}`,`Head: ${VALID_HEAD}`].join('\n'), shouldPass: true },
|
|
812
|
+
{ evidence: ['Verification: PASS','Command: npm test','Exit: 0',`base_sha: ${VALID_BASE}`,`head_sha: ${VALID_HEAD}`].join('\n'), shouldPass: true },
|
|
813
|
+
{ evidence: ['Verification: PASS','Command: npm test','Exit: 0',`Base: ${'a'.repeat(40)}`,`Head: ${'b'.repeat(40)}`].join('\n'), shouldPass: false },
|
|
549
814
|
];
|
|
550
|
-
for (const evidence of validCases) {
|
|
815
|
+
for (const { evidence, shouldPass } of validCases) {
|
|
551
816
|
const dir = tmpDir();
|
|
552
817
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
553
818
|
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
554
819
|
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
555
820
|
status: 'in_progress',
|
|
821
|
+
feature_name: FEATURE,
|
|
556
822
|
current_phase: 'implementation',
|
|
557
823
|
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
558
824
|
}));
|
|
559
|
-
|
|
825
|
+
const boundEvidence = shouldPass ? bindFixtureReceipt(dir, evidence) : evidence;
|
|
826
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + boundEvidence,'```','pass','```'].join('\n'));
|
|
560
827
|
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
561
|
-
const
|
|
562
|
-
assert.strictEqual(stdout, '', `valid provenance should not block: ${evidence.slice(0,40)}`);
|
|
828
|
+
const result = runHook({}, dir);
|
|
829
|
+
if (shouldPass) assert.strictEqual(result.stdout, '', `valid provenance should not block: ${evidence.slice(0,40)}`);
|
|
830
|
+
else assert.match(parseBlock(result.stdout).reason, /\bg\b/, 'arbitrary valid-length SHA values must block');
|
|
563
831
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
564
832
|
}
|
|
565
833
|
});
|
|
@@ -594,7 +862,7 @@ test('20. cache-hit: receipt mutation (removed Verification/Command) blocks even
|
|
|
594
862
|
fs.writeFileSync(taskFile, [
|
|
595
863
|
'# Task', '', '**Status:** done', '',
|
|
596
864
|
'## Evidence', '',
|
|
597
|
-
'Exit: 0',
|
|
865
|
+
'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
598
866
|
'```', 'pass', '```',
|
|
599
867
|
].join('\n'));
|
|
600
868
|
const blocked = parseBlock(runHook({}, dir).stdout);
|
|
@@ -618,7 +886,7 @@ test('21. cache-hit: provenance mutation (removed Head / changed to stale) block
|
|
|
618
886
|
const taskFile = path.join(dir, 'specs', FEATURE, TASK_REL);
|
|
619
887
|
fs.writeFileSync(taskFile, [
|
|
620
888
|
'# Task', '', '**Status:** done', '',
|
|
621
|
-
'## Evidence', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0',
|
|
889
|
+
'## Evidence', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0', `Base: ${VALID_BASE}`,
|
|
622
890
|
'```', 'pass', '```',
|
|
623
891
|
].join('\n'));
|
|
624
892
|
let body = parseBlock(runHook({}, dir).stdout);
|
|
@@ -627,7 +895,7 @@ test('21. cache-hit: provenance mutation (removed Head / changed to stale) block
|
|
|
627
895
|
// mutate back to include both but with empty Head value (stale)
|
|
628
896
|
fs.writeFileSync(taskFile, [
|
|
629
897
|
'# Task', '', '**Status:** done', '',
|
|
630
|
-
'## Evidence', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0',
|
|
898
|
+
'## Evidence', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0', `Base: ${VALID_BASE}`, 'Head:',
|
|
631
899
|
'```', 'pass', '```',
|
|
632
900
|
].join('\n'));
|
|
633
901
|
body = parseBlock(runHook({}, dir).stdout);
|
|
@@ -693,3 +961,208 @@ test('24. first-run (no cache file) with valid receipt passes and seeds cache',
|
|
|
693
961
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
694
962
|
}
|
|
695
963
|
});
|
|
964
|
+
|
|
965
|
+
test('25. placeholder tokens Command: TODO / Base: TBD / artifact sha256 empty must block', () => {
|
|
966
|
+
const cases = [
|
|
967
|
+
{ evidence: ['Verification: PASS','Command: TODO','Exit: 0','Base: a','Head: b'].join('\n'), check: /e\b/, desc: 'Command TODO' },
|
|
968
|
+
{ evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: TBD','Head: b'].join('\n'), check: /g\b/, desc: 'Base TBD' },
|
|
969
|
+
{ evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: N/A'].join('\n'), check: /g\b/, desc: 'Head N/A' },
|
|
970
|
+
{ evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','base_sha: pending','head_sha: unknown'].join('\n'), check: /g\b/, desc: 'base_sha pending' },
|
|
971
|
+
{ evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','Artifact: bundle','sha256: '].join('\n'), check: /h\b/, desc: 'artifact empty sha' },
|
|
972
|
+
{ evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','Artifact: bundle','sha256: TBD'].join('\n'), check: /h\b/, desc: 'artifact TBD sha' },
|
|
973
|
+
];
|
|
974
|
+
for (const { evidence, check, desc } of cases) {
|
|
975
|
+
const dir = tmpDir();
|
|
976
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
977
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
978
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: FEATURE, current_phase: 'implementation', task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } } }));
|
|
979
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
|
|
980
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
981
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
982
|
+
assert.ok(body, `placeholder case ${desc} should block`);
|
|
983
|
+
assert.strictEqual(body.decision, 'block', desc);
|
|
984
|
+
assert.match(body.reason, check, `check for ${desc}`);
|
|
985
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
986
|
+
}
|
|
987
|
+
// Ensure normal todo substring does NOT block
|
|
988
|
+
const dir2 = tmpDir();
|
|
989
|
+
const featureDir2 = path.join(dir2, 'specs', FEATURE);
|
|
990
|
+
fs.mkdirSync(path.join(featureDir2, 'tasks'), { recursive: true });
|
|
991
|
+
fs.writeFileSync(path.join(featureDir2, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: FEATURE, current_phase: 'implementation', task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } } }));
|
|
992
|
+
fs.writeFileSync(path.join(dir2, 'specs', FEATURE, TASK_REL), bindFixtureReceipt(dir2, ['# Task','','**Status:** done','','## Evidence','','Verification: PASS','Command: npm run todo:test','Exit: 0',`Base: ${VALID_BASE}`,`Head: ${VALID_HEAD}`,'```','pass','```'].join('\n')));
|
|
993
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
994
|
+
assert.strictEqual(runHook({}, dir2).stdout, '', 'command containing todo substring should not block');
|
|
995
|
+
fs.rmSync(dir2, { recursive: true, force: true });
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
test('26. explicit failure outcomes Tests failed / Result FAIL and multiple Results must block', () => {
|
|
999
|
+
const cases = [
|
|
1000
|
+
{ evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','Tests failed: 1'].join('\n'), desc: 'Tests failed: 1' },
|
|
1001
|
+
{ evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','Result: FAIL','Result: PASS'].join('\n'), desc: 'Result FAIL then PASS' },
|
|
1002
|
+
{ evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','0 tests passed'].join('\n'), desc: '0 tests' },
|
|
1003
|
+
];
|
|
1004
|
+
for (const { evidence, desc } of cases) {
|
|
1005
|
+
const dir = tmpDir();
|
|
1006
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
1007
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
1008
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: FEATURE, current_phase: 'implementation', task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } } }));
|
|
1009
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
|
|
1010
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
1011
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
1012
|
+
assert.ok(body, `explicit failure ${desc} should block`);
|
|
1013
|
+
assert.strictEqual(body.decision, 'block');
|
|
1014
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
test('27. task markdown symlink outside feature must be rejected (check a)', () => {
|
|
1019
|
+
const dir = tmpDir();
|
|
1020
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
1021
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
1022
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: FEATURE, current_phase: 'implementation', task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } } }));
|
|
1023
|
+
const outside = path.join(dir, 'outside.md');
|
|
1024
|
+
fs.writeFileSync(outside, '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n');
|
|
1025
|
+
const taskPath = path.join(dir, 'specs', FEATURE, TASK_REL);
|
|
1026
|
+
try { fs.unlinkSync(taskPath); } catch {}
|
|
1027
|
+
fs.symlinkSync(outside, taskPath);
|
|
1028
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
1029
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
1030
|
+
assert.ok(body, 'symlink task outside should block');
|
|
1031
|
+
assert.strictEqual(body.decision, 'block');
|
|
1032
|
+
assert.match(body.reason, /\ba\b/);
|
|
1033
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
test('28. malformed spec.json must block with invalid_specs', () => {
|
|
1037
|
+
const dir = tmpDir();
|
|
1038
|
+
fs.mkdirSync(path.join(dir, 'specs', 'good'), { recursive: true });
|
|
1039
|
+
fs.mkdirSync(path.join(dir, 'specs', 'bad'), { recursive: true });
|
|
1040
|
+
fs.writeFileSync(path.join(dir, 'specs', 'good', 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'good', task_registry: {} }));
|
|
1041
|
+
fs.writeFileSync(path.join(dir, 'specs', 'bad', 'spec.json'), '{ malformed');
|
|
1042
|
+
seedCache({});
|
|
1043
|
+
const { stdout } = runHook({}, dir);
|
|
1044
|
+
const body = parseBlock(stdout);
|
|
1045
|
+
assert.ok(body, 'malformed spec should block');
|
|
1046
|
+
assert.strictEqual(body.decision, 'block');
|
|
1047
|
+
assert.match(body.reason, /invalid spec/i);
|
|
1048
|
+
assert.match(body.reason, /bad/);
|
|
1049
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
test('29. specs/linked symlink outside must be explicit_malformed and non-explicit scan must not count it', () => {
|
|
1053
|
+
const dir = tmpDir();
|
|
1054
|
+
const specsDir = path.join(dir, 'specs');
|
|
1055
|
+
fs.mkdirSync(specsDir, { recursive: true });
|
|
1056
|
+
const outside = path.join(dir, 'outside-feat');
|
|
1057
|
+
fs.mkdirSync(outside, { recursive: true });
|
|
1058
|
+
fs.writeFileSync(path.join(outside, 'spec.json'), JSON.stringify({ status: 'in_progress' }));
|
|
1059
|
+
const link = path.join(specsDir, 'linked');
|
|
1060
|
+
fs.symlinkSync(outside, link);
|
|
1061
|
+
// non-explicit should block with invalid_specs (fail-closed, not phantom no-active)
|
|
1062
|
+
seedCache({});
|
|
1063
|
+
const nonExplicit = parseBlock(runHook({}, dir).stdout);
|
|
1064
|
+
assert.ok(nonExplicit, 'non-explicit symlink outside should block with invalid_specs');
|
|
1065
|
+
assert.strictEqual(nonExplicit.decision, 'block');
|
|
1066
|
+
assert.match(nonExplicit.reason, /invalid spec/i);
|
|
1067
|
+
assert.match(nonExplicit.reason, /linked/);
|
|
1068
|
+
// explicit should block explicit_malformed
|
|
1069
|
+
const body = parseBlock(runHook({ featureName: 'linked' }, dir).stdout);
|
|
1070
|
+
assert.ok(body);
|
|
1071
|
+
assert.strictEqual(body.decision, 'block');
|
|
1072
|
+
assert.match(body.reason, /explicit_malformed/);
|
|
1073
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
test('30. phantom vectors via gate evidence must block with check c (r3)', () => {
|
|
1077
|
+
const vectors = [
|
|
1078
|
+
'ℹ tests 0',
|
|
1079
|
+
'Tests: 0 total',
|
|
1080
|
+
'0 tests',
|
|
1081
|
+
'collected 0 items',
|
|
1082
|
+
'No tests found.',
|
|
1083
|
+
'ℹ cancelled 1',
|
|
1084
|
+
'cancelled 1',
|
|
1085
|
+
'1 cancelled',
|
|
1086
|
+
'Test Suites: 1 failed, 1 total',
|
|
1087
|
+
'FAIL ./foo.test.js',
|
|
1088
|
+
'FAIL\tpackage',
|
|
1089
|
+
'FAIL\texample.test/probe\t0.431s',
|
|
1090
|
+
'--- FAIL: TestName (0.00s)',
|
|
1091
|
+
'FAILED tests/test_demo.py::test_foo - assert 1 == 2',
|
|
1092
|
+
'FAILED tests/test_demo.py',
|
|
1093
|
+
'# fail 1',
|
|
1094
|
+
'ℹ fail 1',
|
|
1095
|
+
'not ok 1 - test',
|
|
1096
|
+
'1 error in 0.12s',
|
|
1097
|
+
'ERROR collecting tests/test_demo.py',
|
|
1098
|
+
'Found 2 errors',
|
|
1099
|
+
'Tests run: 5, Failures: 1, Errors: 0',
|
|
1100
|
+
'[ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0',
|
|
1101
|
+
'[ERROR] Tests run: 5, Failures: 0, Errors: 1, Skipped: 0',
|
|
1102
|
+
'[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.2.5:test',
|
|
1103
|
+
'5 tests completed, 1 failed',
|
|
1104
|
+
];
|
|
1105
|
+
for (const v of vectors) {
|
|
1106
|
+
const dir = tmpDir();
|
|
1107
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
1108
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
1109
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
1110
|
+
status: 'in_progress',
|
|
1111
|
+
feature_name: FEATURE,
|
|
1112
|
+
current_phase: 'implementation',
|
|
1113
|
+
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
1114
|
+
}));
|
|
1115
|
+
const evidence = bindFixtureReceipt(dir, ['Verification: PASS','Command: npm test','Exit: 0',`Base: ${VALID_BASE}`,`Head: ${VALID_HEAD}`, v].join('\n'));
|
|
1116
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
|
|
1117
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
1118
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
1119
|
+
assert.ok(body, `vector should block: ${v}`);
|
|
1120
|
+
assert.strictEqual(body.decision, 'block', `vector ${v} should block`);
|
|
1121
|
+
assert.match(body.reason, /\bc\b/, `vector ${v} should fail check c`);
|
|
1122
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1123
|
+
}
|
|
1124
|
+
// positive controls must not block
|
|
1125
|
+
const passes = [
|
|
1126
|
+
'Tests failed: 0',
|
|
1127
|
+
'fail 0',
|
|
1128
|
+
'Tests: 0 failed',
|
|
1129
|
+
'cancelled 0',
|
|
1130
|
+
'Test Suites: 0 failed',
|
|
1131
|
+
'5 tests completed, 0 failed',
|
|
1132
|
+
'errors 0',
|
|
1133
|
+
'Found 0 errors',
|
|
1134
|
+
'collected 1 item',
|
|
1135
|
+
'[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
|
|
1136
|
+
'[ERROR] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
|
|
1137
|
+
'[ERROR] Error handling is documented',
|
|
1138
|
+
'Error handling is documented',
|
|
1139
|
+
'FAILURE mode analysis',
|
|
1140
|
+
'Notes: tests failed previously but now fixed',
|
|
1141
|
+
'Notes: supports zero-test parsing',
|
|
1142
|
+
'# tests 1',
|
|
1143
|
+
'# suites 0',
|
|
1144
|
+
'# pass 1',
|
|
1145
|
+
'# fail 0',
|
|
1146
|
+
'# cancelled 0',
|
|
1147
|
+
'# skipped 0',
|
|
1148
|
+
'# todo 0',
|
|
1149
|
+
'# duration_ms 114.203625',
|
|
1150
|
+
];
|
|
1151
|
+
for (const v of passes) {
|
|
1152
|
+
const dir = tmpDir();
|
|
1153
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
1154
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
1155
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
1156
|
+
status: 'in_progress',
|
|
1157
|
+
feature_name: FEATURE,
|
|
1158
|
+
current_phase: 'implementation',
|
|
1159
|
+
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
1160
|
+
}));
|
|
1161
|
+
const evidence = bindFixtureReceipt(dir, ['Verification: PASS','Command: npm test','Exit: 0',`Base: ${VALID_BASE}`,`Head: ${VALID_HEAD}`, v].join('\n'));
|
|
1162
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
|
|
1163
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
1164
|
+
const { stdout } = runHook({}, dir);
|
|
1165
|
+
assert.strictEqual(stdout, '', `positive control should not block: ${v}`);
|
|
1166
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1167
|
+
}
|
|
1168
|
+
});
|