@haposoft/cafekit 0.16.0-rc.2 → 0.16.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/__tests__/codex-hooks.test.js +794 -21
- package/bin/__tests__/codex-native.test.js +228 -7
- package/bin/__tests__/develop-contract.test.js +1838 -112
- package/bin/__tests__/installer-safety.test.js +82 -29
- package/bin/__tests__/opencode-spec-gate.test.js +799 -0
- package/bin/__tests__/package-inventory.test.js +841 -10
- package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
- package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
- package/bin/__tests__/validator-grounding.test.js +2469 -71
- package/bin/lib/managed-writer.js +12 -3
- package/bin/phases/codex-runtime.js +17 -1
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +6 -0
- package/src/claude/agents/code-auditor.md +20 -0
- package/src/claude/agents/spec-maker.md +153 -202
- package/src/claude/hooks/__tests__/completion-authority.test.js +953 -0
- package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +604 -56
- package/src/claude/hooks/completion-authority-check.cjs +56 -0
- package/src/claude/hooks/completion-authority-state.cjs +527 -0
- package/src/claude/hooks/completion-authority.cjs +154 -0
- package/src/claude/hooks/docs-sync.cjs +5 -2
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
- package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
- package/src/claude/hooks/privacy-block.cjs +77 -30
- package/src/claude/hooks/semantic-review-authority.cjs +152 -0
- package/src/claude/hooks/session.cjs +33 -19
- package/src/claude/hooks/spec-gate.cjs +181 -139
- package/src/claude/hooks/spec-state.cjs +112 -47
- package/src/claude/migration-manifest.json +15 -2
- package/src/claude/rules/state-sync.md +52 -21
- package/src/claude/rules/workflow.md +17 -9
- package/src/claude/scripts/provenance.cjs +315 -0
- package/src/claude/scripts/spec-final-state.cjs +244 -0
- package/src/claude/scripts/spec-ground.cjs +535 -173
- package/src/claude/scripts/spec-readiness.cjs +118 -0
- package/src/claude/scripts/spec-receipt.cjs +162 -0
- package/src/claude/scripts/spec-resolver.cjs +501 -0
- package/src/claude/scripts/spec-scaffold.cjs +1945 -55
- package/src/claude/scripts/spec-semantic-model.cjs +396 -0
- package/src/claude/scripts/validate-spec-output.cjs +3204 -262
- package/src/claude/scripts/workflow-policy.cjs +1888 -203
- package/src/claude/settings/settings.json +12 -0
- package/src/claude/skills/code-review/SKILL.md +94 -66
- package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
- package/src/claude/skills/code-review/references/verification-gate.md +13 -0
- package/src/claude/skills/develop/SKILL.md +138 -283
- package/src/claude/skills/develop/references/parallel-waves.md +9 -6
- package/src/claude/skills/develop/references/quality-gate.md +123 -148
- package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
- package/src/claude/skills/specs/SKILL.md +192 -397
- package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
- package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
- package/src/claude/skills/specs/references/research-strategy.md +4 -1
- package/src/claude/skills/specs/references/review.md +200 -260
- package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
- package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
- package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
- package/src/claude/skills/specs/rules/design-principles.md +136 -180
- package/src/claude/skills/specs/rules/design-review.md +42 -97
- package/src/claude/skills/specs/rules/ears-format.md +42 -50
- package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +46 -46
- package/src/claude/skills/specs/rules/tasks-generation.md +189 -271
- package/src/claude/skills/specs/templates/design.md +49 -263
- package/src/claude/skills/specs/templates/requirements.md +26 -36
- package/src/claude/skills/specs/templates/research.md +9 -98
- package/src/claude/skills/specs/templates/spec-state.json +26 -67
- package/src/claude/skills/specs/templates/task.md +30 -82
- package/src/claude/skills/sync/SKILL.md +45 -15
- package/src/claude/skills/sync/references/sync-protocols.md +34 -16
- package/src/claude/skills/test/SKILL.md +151 -167
- package/src/codex/AGENTS.md +6 -0
- package/src/codex/hooks/completion-authority-check.cjs +63 -0
- package/src/codex/hooks/completion-authority-state.cjs +527 -0
- package/src/codex/hooks/completion-authority.cjs +106 -0
- package/src/codex/hooks/lib/hook-context.cjs +58 -4
- package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
- package/src/codex/hooks/lib/privacy-state.cjs +38 -16
- package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +86 -115
- package/src/codex/hooks/lib/spec-utils.cjs +104 -27
- package/src/codex/hooks/privacy-approval.cjs +1 -1
- package/src/codex/hooks/privacy-block.cjs +67 -11
- package/src/codex/hooks/semantic-review-authority.cjs +130 -0
- package/src/codex/hooks/session.cjs +2 -0
- package/src/codex/hooks/spec-gate.cjs +151 -24
- package/src/codex/hooks/spec-state.cjs +65 -12
- package/src/codex/hooks/state.cjs +2 -0
- package/src/codex/hooks.json +16 -1
- package/src/codex/rules/state-sync.md +47 -27
- package/src/common/AGENTS.md +1 -1
- package/src/opencode/AGENTS.md +4 -2
- package/src/opencode/plugins/spec-gate.ts +296 -122
- package/src/opencode/plugins/spec-state.ts +183 -116
|
@@ -8,17 +8,30 @@
|
|
|
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;
|
|
29
|
+
const specsRoot = path.join(projectRoot, 'specs');
|
|
30
|
+
if (fs.existsSync(specsRoot)) {
|
|
31
|
+
for (const featureName of fs.readdirSync(specsRoot)) {
|
|
32
|
+
installFeatureReceipt(projectRoot, featureName, payload.session_id || 'test');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
22
35
|
// PROJECT_ROOT wins over cwd in the hook; pin it to the fixture so a host
|
|
23
36
|
// monorepo's real specs/ cannot leak into the test (e.g. active rtk-* specs).
|
|
24
37
|
const env = { ...process.env, PROJECT_ROOT: projectRoot };
|
|
@@ -42,7 +55,59 @@ function runHook(payload, cwd) {
|
|
|
42
55
|
}
|
|
43
56
|
|
|
44
57
|
function tmpDir() {
|
|
45
|
-
|
|
58
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-gate-'));
|
|
59
|
+
for (const args of [['init', '-q'], ['config', 'user.email', 'cafekit@example.invalid'], ['config', 'user.name', 'CafeKit Test'], ['commit', '--allow-empty', '-qm', 'fixture']]) {
|
|
60
|
+
const result = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
|
|
61
|
+
assert.strictEqual(result.status, 0, result.stderr);
|
|
62
|
+
}
|
|
63
|
+
return dir;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function runtimeContext(dir) {
|
|
67
|
+
return PROVENANCE.deriveRuntimeContext({
|
|
68
|
+
projectRoot: dir,
|
|
69
|
+
specsRoot: path.join(dir, 'specs'),
|
|
70
|
+
specFile: path.join(dir, 'specs', FEATURE, 'spec.json'),
|
|
71
|
+
featureName: FEATURE,
|
|
72
|
+
runtimeSession: 'test',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function bindFixtureReceipt(dir, value) {
|
|
77
|
+
const context = runtimeContext(dir);
|
|
78
|
+
return value.replaceAll(VALID_BASE, context.base).replaceAll(VALID_HEAD, context.head);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function installFeatureReceipt(dir, featureName = FEATURE, session = 'test') {
|
|
82
|
+
const featureDir = path.join(dir, 'specs', featureName);
|
|
83
|
+
const specFile = path.join(featureDir, 'spec.json');
|
|
84
|
+
if (!fs.existsSync(specFile)) return;
|
|
85
|
+
let spec;
|
|
86
|
+
try { spec = JSON.parse(fs.readFileSync(specFile, 'utf8')); } catch { return; }
|
|
87
|
+
const lifecyclePhase = spec.current_phase || spec.phase;
|
|
88
|
+
const explicitCloseout = ['completed', 'complete'].includes(spec.status)
|
|
89
|
+
|| ['closeout', 'completion', 'completed', 'complete'].includes(lifecyclePhase);
|
|
90
|
+
if (!explicitCloseout) return;
|
|
91
|
+
const tasks = Object.values(spec.task_registry || {});
|
|
92
|
+
if (tasks.length === 0 || tasks.some((task) => task.status !== 'done')) return;
|
|
93
|
+
const context = PROVENANCE.deriveRuntimeContext({
|
|
94
|
+
projectRoot: dir,
|
|
95
|
+
specsRoot: path.join(dir, 'specs'),
|
|
96
|
+
specFile,
|
|
97
|
+
featureName,
|
|
98
|
+
runtimeSession: session,
|
|
99
|
+
});
|
|
100
|
+
fs.writeFileSync(path.join(featureDir, 'feature-receipt.md'), [
|
|
101
|
+
`Feature: ${featureName}`,
|
|
102
|
+
'Expected: final integration verification passes',
|
|
103
|
+
'Observed: final integration verification passed',
|
|
104
|
+
'Verification: PASS',
|
|
105
|
+
'Command: node --test',
|
|
106
|
+
'Exit: 0',
|
|
107
|
+
`Base: ${context.base}`,
|
|
108
|
+
`Head: ${context.head}`,
|
|
109
|
+
'',
|
|
110
|
+
].join('\n'));
|
|
46
111
|
}
|
|
47
112
|
|
|
48
113
|
function clearCache() {
|
|
@@ -66,6 +131,8 @@ function readCache() {
|
|
|
66
131
|
* @param {string} [opts.mdStatus='done']
|
|
67
132
|
* @param {'valid'|'legacy-valid'|'failed'|'fence-only'|'missing-evidence'|'placeholder'|'none'} [opts.evidence='valid']
|
|
68
133
|
* @param {object|null} [opts.runtime] — if set, write .claude/runtime.json
|
|
134
|
+
* @param {object|null} [opts.workflowPolicy] — if set, persist completion obligations
|
|
135
|
+
* @param {string} [opts.phase='closeout'] — persisted lifecycle boundary
|
|
69
136
|
*/
|
|
70
137
|
function makeFixture(opts = {}) {
|
|
71
138
|
const {
|
|
@@ -74,6 +141,8 @@ function makeFixture(opts = {}) {
|
|
|
74
141
|
mdStatus = 'done',
|
|
75
142
|
evidence = 'valid',
|
|
76
143
|
runtime = null,
|
|
144
|
+
workflowPolicy = null,
|
|
145
|
+
phase = 'closeout',
|
|
77
146
|
} = opts;
|
|
78
147
|
const dir = tmpDir();
|
|
79
148
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
@@ -83,14 +152,14 @@ function makeFixture(opts = {}) {
|
|
|
83
152
|
const entry = { status: taskStatus };
|
|
84
153
|
if (completed_at !== null) entry.completed_at = completed_at;
|
|
85
154
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
);
|
|
155
|
+
const spec = {
|
|
156
|
+
status: 'in_progress',
|
|
157
|
+
feature_name: FEATURE,
|
|
158
|
+
current_phase: phase,
|
|
159
|
+
task_registry: { [TASK_REL]: entry },
|
|
160
|
+
};
|
|
161
|
+
if (workflowPolicy) spec.workflow_policy = workflowPolicy;
|
|
162
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify(spec));
|
|
94
163
|
|
|
95
164
|
let evidenceBlock = '';
|
|
96
165
|
if (evidence === 'valid') {
|
|
@@ -101,9 +170,9 @@ function makeFixture(opts = {}) {
|
|
|
101
170
|
'Command: npm test',
|
|
102
171
|
'Exit: 0',
|
|
103
172
|
'Result: PASS',
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
173
|
+
`Base: ${VALID_BASE}`,
|
|
174
|
+
`Head: ${VALID_HEAD}`,
|
|
175
|
+
`Artifact: output/bundle.js (sha256:${FIXTURE_ARTIFACT_DIGEST})`,
|
|
107
176
|
'```',
|
|
108
177
|
'npm test',
|
|
109
178
|
'PASS: 10 tests',
|
|
@@ -111,7 +180,7 @@ function makeFixture(opts = {}) {
|
|
|
111
180
|
'',
|
|
112
181
|
].join('\n');
|
|
113
182
|
} else if (evidence === 'legacy-valid') {
|
|
114
|
-
evidenceBlock =
|
|
183
|
+
evidenceBlock = `## Evidence\n\nVerification: PASS\nCommand: npm test\nExit: 0\nBase: ${VALID_BASE}\nHead: ${VALID_HEAD}\nnpm test — passed, exit code 0\n`;
|
|
115
184
|
} else if (evidence === 'failed') {
|
|
116
185
|
evidenceBlock = '## Evidence\n\nVerification: PASS\n\nFAIL: tests failed, exit code 1\n';
|
|
117
186
|
} else if (evidence === 'fence-only') {
|
|
@@ -149,6 +218,15 @@ function makeFixture(opts = {}) {
|
|
|
149
218
|
);
|
|
150
219
|
}
|
|
151
220
|
|
|
221
|
+
if (evidence === 'valid') {
|
|
222
|
+
fs.mkdirSync(path.join(dir, 'output'), { recursive: true });
|
|
223
|
+
fs.writeFileSync(path.join(dir, 'output', 'bundle.js'), FIXTURE_ARTIFACT);
|
|
224
|
+
}
|
|
225
|
+
if (evidence === 'valid' || evidence === 'legacy-valid') {
|
|
226
|
+
const taskFile = path.join(dir, 'specs', FEATURE, TASK_REL);
|
|
227
|
+
fs.writeFileSync(taskFile, bindFixtureReceipt(dir, fs.readFileSync(taskFile, 'utf8')));
|
|
228
|
+
}
|
|
229
|
+
|
|
152
230
|
return dir;
|
|
153
231
|
}
|
|
154
232
|
|
|
@@ -164,6 +242,57 @@ function parseBlock(stdout) {
|
|
|
164
242
|
}
|
|
165
243
|
}
|
|
166
244
|
|
|
245
|
+
function installClaudeGate(root, policyMode = 'valid') {
|
|
246
|
+
const hooks = path.join(root, '.claude', 'hooks');
|
|
247
|
+
const scripts = path.join(root, '.claude', 'scripts');
|
|
248
|
+
fs.mkdirSync(hooks, { recursive: true });
|
|
249
|
+
fs.mkdirSync(scripts, { recursive: true });
|
|
250
|
+
fs.copyFileSync(HOOK, path.join(hooks, 'spec-gate.cjs'));
|
|
251
|
+
for (const name of [
|
|
252
|
+
'completion-authority-check.cjs', 'completion-authority-state.cjs', 'semantic-review-authority.cjs',
|
|
253
|
+
]) fs.copyFileSync(path.join(__dirname, '..', name), path.join(hooks, name));
|
|
254
|
+
for (const name of ['spec-resolver.cjs', 'spec-receipt.cjs', 'validate-spec-output.cjs', 'spec-ground.cjs', 'spec-semantic-model.cjs', 'spec-final-state.cjs']) {
|
|
255
|
+
fs.copyFileSync(path.join(__dirname, '..', '..', 'scripts', name), path.join(scripts, name));
|
|
256
|
+
}
|
|
257
|
+
if (policyMode === 'valid') {
|
|
258
|
+
fs.copyFileSync(path.join(__dirname, '..', '..', 'scripts', 'workflow-policy.cjs'), path.join(scripts, 'workflow-policy.cjs'));
|
|
259
|
+
fs.copyFileSync(path.join(__dirname, '..', '..', 'scripts', 'provenance.cjs'), path.join(scripts, 'provenance.cjs'));
|
|
260
|
+
} else if (policyMode === 'malformed') {
|
|
261
|
+
fs.writeFileSync(path.join(scripts, 'workflow-policy.cjs'), 'module.exports = {\n');
|
|
262
|
+
}
|
|
263
|
+
return path.join(hooks, 'spec-gate.cjs');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
test('CLAUDE_PROJECT_DIR wins over malicious payload.cwd and stale process cwd', () => {
|
|
267
|
+
const target = makeFixture({ evidence: 'missing-evidence' });
|
|
268
|
+
const stale = tmpDir();
|
|
269
|
+
try {
|
|
270
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
271
|
+
const result = spawnSync(process.execPath, [HOOK], {
|
|
272
|
+
cwd: stale,
|
|
273
|
+
env: {
|
|
274
|
+
...process.env,
|
|
275
|
+
CLAUDE_PROJECT_DIR: target,
|
|
276
|
+
PROJECT_ROOT: stale,
|
|
277
|
+
},
|
|
278
|
+
input: JSON.stringify({
|
|
279
|
+
hook_event_name: 'Stop',
|
|
280
|
+
session_id: 'test',
|
|
281
|
+
stop_hook_active: false,
|
|
282
|
+
cwd: stale,
|
|
283
|
+
}),
|
|
284
|
+
encoding: 'utf8',
|
|
285
|
+
});
|
|
286
|
+
assert.equal(result.status, 0, result.stderr);
|
|
287
|
+
const body = parseBlock(result.stdout.trim());
|
|
288
|
+
assert.equal(body?.decision, 'block');
|
|
289
|
+
assert.match(body.reason, new RegExp(TASK_REL.replace('/', '\\/')));
|
|
290
|
+
} finally {
|
|
291
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
292
|
+
fs.rmSync(stale, { recursive: true, force: true });
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
167
296
|
beforeEach(() => {
|
|
168
297
|
clearCache();
|
|
169
298
|
});
|
|
@@ -193,6 +322,134 @@ test('1. newly-done task WITHOUT receipt → block, reason names task path', ()
|
|
|
193
322
|
}
|
|
194
323
|
});
|
|
195
324
|
|
|
325
|
+
test('installed Claude gate fails closed with a controlled decision when policy is missing or malformed', () => {
|
|
326
|
+
for (const policyMode of ['missing', 'malformed']) {
|
|
327
|
+
const dir = tmpDir();
|
|
328
|
+
const gate = installClaudeGate(dir, policyMode);
|
|
329
|
+
try {
|
|
330
|
+
const result = spawnSync(process.execPath, [gate], {
|
|
331
|
+
cwd: dir,
|
|
332
|
+
env: { ...process.env, PROJECT_ROOT: dir },
|
|
333
|
+
input: JSON.stringify({ cwd: dir, session_id: 'test', stop_hook_active: false }),
|
|
334
|
+
encoding: 'utf8',
|
|
335
|
+
});
|
|
336
|
+
assert.equal(result.status, 0, `${policyMode}: ${result.stderr}`);
|
|
337
|
+
assert.equal(result.stderr, '', `${policyMode} must not leak a stack trace`);
|
|
338
|
+
const block = parseBlock(result.stdout);
|
|
339
|
+
assert.equal(block?.decision, 'block', `${policyMode} must emit a controlled block`);
|
|
340
|
+
assert.match(block.reason, /workflow policy could not be loaded/i);
|
|
341
|
+
assert.ok(fs.existsSync(path.join(dir, '.claude', 'hooks', '.logs', 'hook-log.jsonl')));
|
|
342
|
+
} finally {
|
|
343
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('Claude adapter requires canonical SHA-256 only for a declared task artifact', () => {
|
|
349
|
+
const digest = FIXTURE_ARTIFACT_DIGEST;
|
|
350
|
+
const bodies = [
|
|
351
|
+
{ suffix: '', expected: true, label: 'missing hash' },
|
|
352
|
+
{ suffix: 'sha256: abc123\n', expected: true, label: 'invalid hash' },
|
|
353
|
+
{ suffix: `sha256: ${'0'.repeat(64)}\n`, expected: true, label: 'mismatched hash' },
|
|
354
|
+
{ suffix: `sha256: ${digest}\n`, expected: false, label: 'valid hash' },
|
|
355
|
+
];
|
|
356
|
+
for (const { suffix, expected, label } of bodies) {
|
|
357
|
+
const dir = tmpDir();
|
|
358
|
+
const gate = installClaudeGate(dir);
|
|
359
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
360
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
361
|
+
fs.mkdirSync(path.join(dir, 'output'), { recursive: true });
|
|
362
|
+
fs.writeFileSync(path.join(dir, 'output', 'bundle.js'), FIXTURE_ARTIFACT);
|
|
363
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
364
|
+
status: 'in_progress',
|
|
365
|
+
current_phase: 'closeout',
|
|
366
|
+
feature_name: FEATURE,
|
|
367
|
+
task_registry: {
|
|
368
|
+
[TASK_REL]: {
|
|
369
|
+
status: 'done',
|
|
370
|
+
completed_at: '2026-08-11T00:00:00.000Z',
|
|
371
|
+
artifacts: ['output/bundle.js'],
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
}));
|
|
375
|
+
fs.writeFileSync(path.join(featureDir, TASK_REL), [
|
|
376
|
+
'# Task', '', '**Status:** done', '', '## Evidence', '',
|
|
377
|
+
'Verification: PASS', 'Command: node --test', 'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
378
|
+
'Artifact: output/bundle.js', suffix,
|
|
379
|
+
].join('\n'));
|
|
380
|
+
fs.writeFileSync(
|
|
381
|
+
path.join(featureDir, TASK_REL),
|
|
382
|
+
bindFixtureReceipt(dir, fs.readFileSync(path.join(featureDir, TASK_REL), 'utf8')),
|
|
383
|
+
);
|
|
384
|
+
installFeatureReceipt(dir);
|
|
385
|
+
try {
|
|
386
|
+
const result = spawnSync(process.execPath, [gate], {
|
|
387
|
+
cwd: dir,
|
|
388
|
+
env: { ...process.env, PROJECT_ROOT: dir },
|
|
389
|
+
input: JSON.stringify({ cwd: dir, session_id: 'test', stop_hook_active: false }),
|
|
390
|
+
encoding: 'utf8',
|
|
391
|
+
});
|
|
392
|
+
assert.equal(result.status, 0, `${label}: ${result.stderr}`);
|
|
393
|
+
if (expected) {
|
|
394
|
+
const block = parseBlock(result.stdout);
|
|
395
|
+
assert.equal(block?.decision, 'block', `${label} must block`);
|
|
396
|
+
assert.match(block.reason, /\bartifact_hash\b/);
|
|
397
|
+
} else {
|
|
398
|
+
assert.equal(result.stdout, '', `${label} must pass`);
|
|
399
|
+
}
|
|
400
|
+
} finally {
|
|
401
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('Claude artifact verification rejects traversal and symlink paths', () => {
|
|
407
|
+
const cases = [
|
|
408
|
+
{ artifactPath: '../outside.js', symlink: false },
|
|
409
|
+
{ artifactPath: 'output/link.js', symlink: true },
|
|
410
|
+
];
|
|
411
|
+
for (const { artifactPath, symlink } of cases) {
|
|
412
|
+
const dir = tmpDir();
|
|
413
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
414
|
+
const artifactBytes = Buffer.from(`artifact-${artifactPath}\n`);
|
|
415
|
+
const digest = crypto.createHash('sha256').update(artifactBytes).digest('hex');
|
|
416
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
417
|
+
if (symlink) {
|
|
418
|
+
fs.mkdirSync(path.join(dir, 'output'), { recursive: true });
|
|
419
|
+
const target = path.join(dir, 'artifact-target.js');
|
|
420
|
+
fs.writeFileSync(target, artifactBytes);
|
|
421
|
+
fs.symlinkSync(target, path.join(dir, artifactPath));
|
|
422
|
+
} else {
|
|
423
|
+
fs.writeFileSync(path.join(dir, 'outside.js'), artifactBytes);
|
|
424
|
+
}
|
|
425
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
426
|
+
status: 'in_progress',
|
|
427
|
+
current_phase: 'closeout',
|
|
428
|
+
feature_name: FEATURE,
|
|
429
|
+
task_registry: {
|
|
430
|
+
[TASK_REL]: {
|
|
431
|
+
status: 'done',
|
|
432
|
+
completed_at: '2026-08-11T00:00:00.000Z',
|
|
433
|
+
artifacts: [artifactPath],
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
}));
|
|
437
|
+
fs.writeFileSync(path.join(featureDir, TASK_REL), bindFixtureReceipt(dir, [
|
|
438
|
+
'# Task', '', '**Status:** done', '', '## Evidence', '',
|
|
439
|
+
'Verification: PASS', 'Command: node --test', 'Exit: 0',
|
|
440
|
+
`Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
441
|
+
`Artifact: ${artifactPath} (sha256:${digest})`,
|
|
442
|
+
].join('\n')));
|
|
443
|
+
try {
|
|
444
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
445
|
+
assert.equal(body?.decision, 'block', `${artifactPath} must block`);
|
|
446
|
+
assert.match(body.reason, /\bartifact_hash\b/);
|
|
447
|
+
} finally {
|
|
448
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
196
453
|
test('2. newly-done task WITH valid receipt → no block, cache updated', () => {
|
|
197
454
|
const dir = makeFixture({ evidence: 'valid' });
|
|
198
455
|
try {
|
|
@@ -233,6 +490,32 @@ test('4. stop_hook_active: true → exit 0 silent even with violating task', ()
|
|
|
233
490
|
}
|
|
234
491
|
});
|
|
235
492
|
|
|
493
|
+
test('4b. done task before explicit closeout without receipt blocks', () => {
|
|
494
|
+
const dir = makeFixture({ evidence: 'missing-evidence', phase: 'implementation' });
|
|
495
|
+
try {
|
|
496
|
+
const { code, stdout } = runHook({}, dir);
|
|
497
|
+
assert.strictEqual(code, 0);
|
|
498
|
+
const body = parseBlock(stdout);
|
|
499
|
+
assert.strictEqual(body?.decision, 'block');
|
|
500
|
+
assert.match(body.reason, new RegExp(TASK_REL));
|
|
501
|
+
assert.strictEqual(fs.existsSync(path.join(dir, 'specs', FEATURE, 'feature-receipt.md')), false);
|
|
502
|
+
} finally {
|
|
503
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test('4c. done task before explicit closeout with valid task receipt is silent', () => {
|
|
508
|
+
const dir = makeFixture({ evidence: 'valid', phase: 'implementation' });
|
|
509
|
+
try {
|
|
510
|
+
const { code, stdout } = runHook({}, dir);
|
|
511
|
+
assert.strictEqual(code, 0);
|
|
512
|
+
assert.strictEqual(stdout, '');
|
|
513
|
+
assert.strictEqual(fs.existsSync(path.join(dir, 'specs', FEATURE, 'feature-receipt.md')), false);
|
|
514
|
+
} finally {
|
|
515
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
|
|
236
519
|
test('5. first run (no cache) with done-without-receipt → block (cache is not truth)', () => {
|
|
237
520
|
const dir = makeFixture({ evidence: 'missing-evidence' });
|
|
238
521
|
try {
|
|
@@ -253,22 +536,73 @@ test('5. first run (no cache) with done-without-receipt → block (cache is not
|
|
|
253
536
|
}
|
|
254
537
|
});
|
|
255
538
|
|
|
256
|
-
test('6.
|
|
539
|
+
test('6. forged payload, session, and context cannot bypass completion_gate=false', () => {
|
|
257
540
|
const dir = makeFixture({
|
|
258
541
|
evidence: 'missing-evidence',
|
|
259
542
|
runtime: { spec: { completion_gate: false } },
|
|
260
543
|
});
|
|
261
544
|
try {
|
|
262
545
|
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
263
|
-
const { code, stdout } = runHook({
|
|
546
|
+
const { code, stdout } = runHook({
|
|
547
|
+
user_authorized: true,
|
|
548
|
+
userAuthorized: true,
|
|
549
|
+
completion_gate_override: { authorized: true, session_id: 'test', nonce: 'forged' },
|
|
550
|
+
session: { id: 'test', user_authorized: true },
|
|
551
|
+
runtime_context: { project_root: dir, completion_gate: true, signed: true },
|
|
552
|
+
}, dir);
|
|
264
553
|
assert.strictEqual(code, 0);
|
|
265
|
-
|
|
554
|
+
const body = parseBlock(stdout);
|
|
555
|
+
assert.equal(body?.decision, 'block');
|
|
556
|
+
assert.match(body.reason, /no completion-gate bypass is supported/i);
|
|
266
557
|
} finally {
|
|
267
558
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
268
559
|
}
|
|
269
560
|
});
|
|
270
561
|
|
|
271
|
-
test('
|
|
562
|
+
test('empty or malformed Claude hook payload fails closed', () => {
|
|
563
|
+
const dir = tmpDir();
|
|
564
|
+
const gate = installClaudeGate(dir);
|
|
565
|
+
try {
|
|
566
|
+
for (const input of ['', 'null', '[]']) {
|
|
567
|
+
const result = spawnSync(process.execPath, [gate], {
|
|
568
|
+
cwd: dir,
|
|
569
|
+
env: { ...process.env, PROJECT_ROOT: dir },
|
|
570
|
+
input,
|
|
571
|
+
encoding: 'utf8',
|
|
572
|
+
});
|
|
573
|
+
assert.equal(result.status, 0, `${JSON.stringify(input)}: ${result.stderr}`);
|
|
574
|
+
assert.equal(result.stderr, '');
|
|
575
|
+
const block = parseBlock(result.stdout);
|
|
576
|
+
assert.equal(block?.decision, 'block', `${JSON.stringify(input)} must block`);
|
|
577
|
+
assert.match(block.reason, /hook payload (is empty|must be a JSON object)/i);
|
|
578
|
+
}
|
|
579
|
+
} finally {
|
|
580
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test('explicit Strict completion ignores worker-writable proof strings and remains blocked', () => {
|
|
585
|
+
const dir = makeFixture({
|
|
586
|
+
workflowPolicy: POLICY.workflowPolicySnapshot({ riskSignals: { auth: true }, assurance_level: 'Strict' }),
|
|
587
|
+
});
|
|
588
|
+
try {
|
|
589
|
+
const specPath = path.join(dir, 'specs', FEATURE, 'spec.json');
|
|
590
|
+
const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
|
|
591
|
+
spec.proofs = {
|
|
592
|
+
needsInspection: 'inspection completed',
|
|
593
|
+
needsIndependentAudit: 'PASS',
|
|
594
|
+
needsResearchGrounding: 'research completed',
|
|
595
|
+
};
|
|
596
|
+
fs.writeFileSync(specPath, JSON.stringify(spec));
|
|
597
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
598
|
+
assert.equal(body?.decision, 'block');
|
|
599
|
+
assert.match(body.reason, /needsInspection|needsIndependentAudit|needsResearchGrounding/);
|
|
600
|
+
} finally {
|
|
601
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test('7. Evidence with {{...}} placeholder → blocked (placeholder)', () => {
|
|
272
606
|
const dir = makeFixture({ evidence: 'placeholder' });
|
|
273
607
|
try {
|
|
274
608
|
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
@@ -278,8 +612,8 @@ test('7. Evidence with {{...}} placeholder → blocked (check c)', () => {
|
|
|
278
612
|
assert.ok(body, 'expected block JSON on stdout');
|
|
279
613
|
assert.strictEqual(body.decision, 'block');
|
|
280
614
|
assert.ok(
|
|
281
|
-
body.reason.includes(TASK_REL) && /\
|
|
282
|
-
`reason must cite task path and
|
|
615
|
+
body.reason.includes(TASK_REL) && /\bplaceholder\b/.test(body.reason),
|
|
616
|
+
`reason must cite task path and placeholder; got: ${body.reason}`,
|
|
283
617
|
);
|
|
284
618
|
} finally {
|
|
285
619
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
@@ -292,7 +626,7 @@ test('8. explicit failure cannot pass even when PASS text is present', () => {
|
|
|
292
626
|
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
293
627
|
const body = parseBlock(runHook({}, dir).stdout);
|
|
294
628
|
assert.ok(body);
|
|
295
|
-
assert.match(body.reason, /\
|
|
629
|
+
assert.match(body.reason, /\bverification_state\b/);
|
|
296
630
|
} finally {
|
|
297
631
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
298
632
|
}
|
|
@@ -314,14 +648,14 @@ test('10. completed_at must be a valid ISO timestamp', () => {
|
|
|
314
648
|
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
315
649
|
const body = parseBlock(runHook({}, dir).stdout);
|
|
316
650
|
assert.ok(body);
|
|
317
|
-
assert.match(body.reason, /\
|
|
651
|
+
assert.match(body.reason, /\bcompleted_at\b/);
|
|
318
652
|
} finally {
|
|
319
653
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
320
654
|
}
|
|
321
655
|
});
|
|
322
656
|
|
|
323
|
-
test('11. done → pending cache transition is persisted and
|
|
324
|
-
const dir = makeFixture({ taskStatus: 'pending', mdStatus: 'pending' });
|
|
657
|
+
test('11. done → pending cache transition is persisted and explicit closeout gates re-completion', () => {
|
|
658
|
+
const dir = makeFixture({ taskStatus: 'pending', mdStatus: 'pending', phase: 'implementation' });
|
|
325
659
|
try {
|
|
326
660
|
seedCache({ [FEATURE]: { [TASK_REL]: 'done' } });
|
|
327
661
|
assert.strictEqual(runHook({}, dir).stdout, '');
|
|
@@ -333,6 +667,7 @@ test('11. done → pending cache transition is persisted and can be gated again'
|
|
|
333
667
|
status: 'done',
|
|
334
668
|
completed_at: '2026-07-01T00:00:00Z',
|
|
335
669
|
};
|
|
670
|
+
spec.current_phase = 'closeout';
|
|
336
671
|
fs.writeFileSync(specFile, JSON.stringify(spec));
|
|
337
672
|
const taskFile = path.join(dir, 'specs', FEATURE, TASK_REL);
|
|
338
673
|
fs.writeFileSync(taskFile, '# Task\n\n**Status:** done\n\n## Evidence\n\nFAIL: tests failed\n');
|
|
@@ -360,11 +695,12 @@ test('13. provenance requires both Base and Head - only Base fails', () => {
|
|
|
360
695
|
fs.mkdirSync(tasksDir, { recursive: true });
|
|
361
696
|
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
362
697
|
status: 'in_progress',
|
|
363
|
-
|
|
698
|
+
feature_name: FEATURE,
|
|
699
|
+
current_phase: 'closeout',
|
|
364
700
|
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
365
701
|
}));
|
|
366
702
|
// Only Base, missing Head
|
|
367
|
-
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), [
|
|
703
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), bindFixtureReceipt(dir, [
|
|
368
704
|
'# Task',
|
|
369
705
|
'',
|
|
370
706
|
'**Status:** done',
|
|
@@ -374,17 +710,17 @@ test('13. provenance requires both Base and Head - only Base fails', () => {
|
|
|
374
710
|
'Verification: PASS',
|
|
375
711
|
'Command: npm test',
|
|
376
712
|
'Exit: 0',
|
|
377
|
-
|
|
713
|
+
`Base: ${VALID_BASE}`,
|
|
378
714
|
'```',
|
|
379
715
|
'npm test PASS',
|
|
380
716
|
'```',
|
|
381
|
-
].join('\n'));
|
|
717
|
+
].join('\n')));
|
|
382
718
|
try {
|
|
383
719
|
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
384
720
|
const body = parseBlock(runHook({}, dir).stdout);
|
|
385
721
|
assert.ok(body, 'only Base should block');
|
|
386
722
|
assert.strictEqual(body.decision, 'block');
|
|
387
|
-
assert.match(body.reason, /\
|
|
723
|
+
assert.match(body.reason, /\bprovenance\b/);
|
|
388
724
|
} finally {
|
|
389
725
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
390
726
|
}
|
|
@@ -397,7 +733,8 @@ test('14. provenance requires both Base and Head - only Head fails', () => {
|
|
|
397
733
|
fs.mkdirSync(tasksDir, { recursive: true });
|
|
398
734
|
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
399
735
|
status: 'in_progress',
|
|
400
|
-
|
|
736
|
+
feature_name: FEATURE,
|
|
737
|
+
current_phase: 'closeout',
|
|
401
738
|
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
402
739
|
}));
|
|
403
740
|
// Only Head, missing Base
|
|
@@ -411,7 +748,7 @@ test('14. provenance requires both Base and Head - only Head fails', () => {
|
|
|
411
748
|
'Verification: PASS',
|
|
412
749
|
'Command: npm test',
|
|
413
750
|
'Exit: 0',
|
|
414
|
-
|
|
751
|
+
`Head: ${VALID_HEAD}`,
|
|
415
752
|
'```',
|
|
416
753
|
'npm test PASS',
|
|
417
754
|
'```',
|
|
@@ -421,7 +758,7 @@ test('14. provenance requires both Base and Head - only Head fails', () => {
|
|
|
421
758
|
const body = parseBlock(runHook({}, dir).stdout);
|
|
422
759
|
assert.ok(body, 'only Head should block');
|
|
423
760
|
assert.strictEqual(body.decision, 'block');
|
|
424
|
-
assert.match(body.reason, /\
|
|
761
|
+
assert.match(body.reason, /\bprovenance\b/);
|
|
425
762
|
} finally {
|
|
426
763
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
427
764
|
}
|
|
@@ -434,10 +771,11 @@ test('15. provenance with both Base and Head passes', () => {
|
|
|
434
771
|
fs.mkdirSync(tasksDir, { recursive: true });
|
|
435
772
|
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
436
773
|
status: 'in_progress',
|
|
437
|
-
|
|
774
|
+
feature_name: FEATURE,
|
|
775
|
+
current_phase: 'closeout',
|
|
438
776
|
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
439
777
|
}));
|
|
440
|
-
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), [
|
|
778
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), bindFixtureReceipt(dir, [
|
|
441
779
|
'# Task',
|
|
442
780
|
'',
|
|
443
781
|
'**Status:** done',
|
|
@@ -447,12 +785,12 @@ test('15. provenance with both Base and Head passes', () => {
|
|
|
447
785
|
'Verification: PASS',
|
|
448
786
|
'Command: npm test',
|
|
449
787
|
'Exit: 0',
|
|
450
|
-
|
|
451
|
-
|
|
788
|
+
`Base: ${VALID_BASE}`,
|
|
789
|
+
`Head: ${VALID_HEAD}`,
|
|
452
790
|
'```',
|
|
453
791
|
'npm test PASS',
|
|
454
792
|
'```',
|
|
455
|
-
].join('\n'));
|
|
793
|
+
].join('\n')));
|
|
456
794
|
try {
|
|
457
795
|
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
458
796
|
const { stdout } = runHook({}, dir);
|
|
@@ -481,8 +819,8 @@ test('16. registry task path with ../ escape or sibling-prefix is rejected (chec
|
|
|
481
819
|
'Verification: PASS',
|
|
482
820
|
'Command: npm test',
|
|
483
821
|
'Exit: 0',
|
|
484
|
-
|
|
485
|
-
|
|
822
|
+
`Base: ${VALID_BASE}`,
|
|
823
|
+
`Head: ${VALID_HEAD}`,
|
|
486
824
|
'```',
|
|
487
825
|
'pass',
|
|
488
826
|
'```',
|
|
@@ -491,7 +829,8 @@ test('16. registry task path with ../ escape or sibling-prefix is rejected (chec
|
|
|
491
829
|
const siblingPrefixRel = `tasks/../../${FEATURE}-evil/tasks/task.md`;
|
|
492
830
|
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
493
831
|
status: 'in_progress',
|
|
494
|
-
|
|
832
|
+
feature_name: FEATURE,
|
|
833
|
+
current_phase: 'closeout',
|
|
495
834
|
task_registry: {
|
|
496
835
|
[maliciousRel]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' },
|
|
497
836
|
[siblingPrefixRel]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' },
|
|
@@ -527,8 +866,9 @@ test('17. empty or bare provenance fails — Base:/Head: require non-empty same-
|
|
|
527
866
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
528
867
|
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
529
868
|
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
530
|
-
|
|
531
|
-
|
|
869
|
+
status: 'in_progress',
|
|
870
|
+
feature_name: FEATURE,
|
|
871
|
+
current_phase: 'closeout',
|
|
532
872
|
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
533
873
|
}));
|
|
534
874
|
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
|
|
@@ -536,30 +876,33 @@ test('17. empty or bare provenance fails — Base:/Head: require non-empty same-
|
|
|
536
876
|
const body = parseBlock(runHook({}, dir).stdout);
|
|
537
877
|
assert.ok(body, `empty/bare provenance should block for ${desc}`);
|
|
538
878
|
assert.strictEqual(body.decision, 'block', `empty/bare should block: ${desc}`);
|
|
539
|
-
assert.match(body.reason, /\
|
|
879
|
+
assert.match(body.reason, /\bprovenance\b/, `should fail provenance for ${desc}`);
|
|
540
880
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
541
881
|
}
|
|
542
882
|
});
|
|
543
883
|
|
|
544
884
|
test('18. valid non-empty Base: value and base_sha: value pass', () => {
|
|
545
885
|
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'
|
|
886
|
+
{ evidence: ['Verification: PASS','Command: npm test','Exit: 0',`Base: ${VALID_BASE}`,`Head: ${VALID_HEAD}`].join('\n'), shouldPass: true },
|
|
887
|
+
{ evidence: ['Verification: PASS','Command: npm test','Exit: 0',`base_sha: ${VALID_BASE}`,`head_sha: ${VALID_HEAD}`].join('\n'), shouldPass: true },
|
|
888
|
+
{ evidence: ['Verification: PASS','Command: npm test','Exit: 0',`Base: ${'a'.repeat(40)}`,`Head: ${'b'.repeat(40)}`].join('\n'), shouldPass: false },
|
|
549
889
|
];
|
|
550
|
-
for (const evidence of validCases) {
|
|
890
|
+
for (const { evidence, shouldPass } of validCases) {
|
|
551
891
|
const dir = tmpDir();
|
|
552
892
|
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
553
893
|
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
554
894
|
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
555
895
|
status: 'in_progress',
|
|
556
|
-
|
|
896
|
+
feature_name: FEATURE,
|
|
897
|
+
current_phase: 'closeout',
|
|
557
898
|
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
558
899
|
}));
|
|
559
|
-
|
|
900
|
+
const boundEvidence = shouldPass ? bindFixtureReceipt(dir, evidence) : evidence;
|
|
901
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + boundEvidence,'```','pass','```'].join('\n'));
|
|
560
902
|
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
561
|
-
const
|
|
562
|
-
assert.strictEqual(stdout, '', `valid provenance should not block: ${evidence.slice(0,40)}`);
|
|
903
|
+
const result = runHook({}, dir);
|
|
904
|
+
if (shouldPass) assert.strictEqual(result.stdout, '', `valid provenance should not block: ${evidence.slice(0,40)}`);
|
|
905
|
+
else assert.match(parseBlock(result.stdout).reason, /\bprovenance\b/, 'arbitrary valid-length SHA values must block');
|
|
563
906
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
564
907
|
}
|
|
565
908
|
});
|
|
@@ -594,13 +937,13 @@ test('20. cache-hit: receipt mutation (removed Verification/Command) blocks even
|
|
|
594
937
|
fs.writeFileSync(taskFile, [
|
|
595
938
|
'# Task', '', '**Status:** done', '',
|
|
596
939
|
'## Evidence', '',
|
|
597
|
-
'Exit: 0',
|
|
940
|
+
'Exit: 0', `Base: ${VALID_BASE}`, `Head: ${VALID_HEAD}`,
|
|
598
941
|
'```', 'pass', '```',
|
|
599
942
|
].join('\n'));
|
|
600
943
|
const blocked = parseBlock(runHook({}, dir).stdout);
|
|
601
944
|
assert.ok(blocked, 'mutated receipt missing Verification/Command should block on cache-hit');
|
|
602
945
|
assert.strictEqual(blocked.decision, 'block');
|
|
603
|
-
assert.match(blocked.reason, /\
|
|
946
|
+
assert.match(blocked.reason, /\bverification_state\b|\bcommand\b/);
|
|
604
947
|
// cache must not have been promoted to still-valid done; next run must still block
|
|
605
948
|
const secondBlocked = parseBlock(runHook({}, dir).stdout);
|
|
606
949
|
assert.ok(secondBlocked, 'second hit after mutation must still block');
|
|
@@ -618,21 +961,21 @@ test('21. cache-hit: provenance mutation (removed Head / changed to stale) block
|
|
|
618
961
|
const taskFile = path.join(dir, 'specs', FEATURE, TASK_REL);
|
|
619
962
|
fs.writeFileSync(taskFile, [
|
|
620
963
|
'# Task', '', '**Status:** done', '',
|
|
621
|
-
'## Evidence', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0',
|
|
964
|
+
'## Evidence', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0', `Base: ${VALID_BASE}`,
|
|
622
965
|
'```', 'pass', '```',
|
|
623
966
|
].join('\n'));
|
|
624
967
|
let body = parseBlock(runHook({}, dir).stdout);
|
|
625
968
|
assert.ok(body, 'missing Head provenance should block on cache-hit');
|
|
626
|
-
assert.match(body.reason, /\
|
|
969
|
+
assert.match(body.reason, /\bprovenance\b/);
|
|
627
970
|
// mutate back to include both but with empty Head value (stale)
|
|
628
971
|
fs.writeFileSync(taskFile, [
|
|
629
972
|
'# Task', '', '**Status:** done', '',
|
|
630
|
-
'## Evidence', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0',
|
|
973
|
+
'## Evidence', '', 'Verification: PASS', 'Command: npm test', 'Exit: 0', `Base: ${VALID_BASE}`, 'Head:',
|
|
631
974
|
'```', 'pass', '```',
|
|
632
975
|
].join('\n'));
|
|
633
976
|
body = parseBlock(runHook({}, dir).stdout);
|
|
634
977
|
assert.ok(body, 'empty Head should block as stale provenance');
|
|
635
|
-
assert.match(body.reason, /\
|
|
978
|
+
assert.match(body.reason, /\bprovenance\b/);
|
|
636
979
|
} finally {
|
|
637
980
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
638
981
|
}
|
|
@@ -693,3 +1036,208 @@ test('24. first-run (no cache file) with valid receipt passes and seeds cache',
|
|
|
693
1036
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
694
1037
|
}
|
|
695
1038
|
});
|
|
1039
|
+
|
|
1040
|
+
test('25. placeholder tokens Command: TODO / Base: TBD / artifact sha256 empty must block', () => {
|
|
1041
|
+
const cases = [
|
|
1042
|
+
{ evidence: ['Verification: PASS','Command: TODO','Exit: 0','Base: a','Head: b'].join('\n'), check: /\bcommand\b/, desc: 'Command TODO' },
|
|
1043
|
+
{ evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: TBD','Head: b'].join('\n'), check: /\bprovenance\b/, desc: 'Base TBD' },
|
|
1044
|
+
{ evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: N/A'].join('\n'), check: /\bprovenance\b/, desc: 'Head N/A' },
|
|
1045
|
+
{ evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','base_sha: pending','head_sha: unknown'].join('\n'), check: /\bprovenance\b/, desc: 'base_sha pending' },
|
|
1046
|
+
{ evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','Artifact: bundle','sha256: '].join('\n'), check: /\bartifact_hash\b/, desc: 'artifact empty sha' },
|
|
1047
|
+
{ evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','Artifact: bundle','sha256: TBD'].join('\n'), check: /\bartifact_hash\b/, desc: 'artifact TBD sha' },
|
|
1048
|
+
];
|
|
1049
|
+
for (const { evidence, check, desc } of cases) {
|
|
1050
|
+
const dir = tmpDir();
|
|
1051
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
1052
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
1053
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: FEATURE, current_phase: 'closeout', task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } } }));
|
|
1054
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
|
|
1055
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
1056
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
1057
|
+
assert.ok(body, `placeholder case ${desc} should block`);
|
|
1058
|
+
assert.strictEqual(body.decision, 'block', desc);
|
|
1059
|
+
assert.match(body.reason, check, `check for ${desc}`);
|
|
1060
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1061
|
+
}
|
|
1062
|
+
// Ensure normal todo substring does NOT block
|
|
1063
|
+
const dir2 = tmpDir();
|
|
1064
|
+
const featureDir2 = path.join(dir2, 'specs', FEATURE);
|
|
1065
|
+
fs.mkdirSync(path.join(featureDir2, 'tasks'), { recursive: true });
|
|
1066
|
+
fs.writeFileSync(path.join(featureDir2, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: FEATURE, current_phase: 'closeout', task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } } }));
|
|
1067
|
+
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')));
|
|
1068
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
1069
|
+
assert.strictEqual(runHook({}, dir2).stdout, '', 'command containing todo substring should not block');
|
|
1070
|
+
fs.rmSync(dir2, { recursive: true, force: true });
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
test('26. explicit failure outcomes Tests failed / Result FAIL and multiple Results must block', () => {
|
|
1074
|
+
const cases = [
|
|
1075
|
+
{ evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','Tests failed: 1'].join('\n'), desc: 'Tests failed: 1' },
|
|
1076
|
+
{ evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','Result: FAIL','Result: PASS'].join('\n'), desc: 'Result FAIL then PASS' },
|
|
1077
|
+
{ evidence: ['Verification: PASS','Command: pnpm test','Exit: 0','Base: a','Head: b','0 tests passed'].join('\n'), desc: '0 tests' },
|
|
1078
|
+
];
|
|
1079
|
+
for (const { evidence, desc } of cases) {
|
|
1080
|
+
const dir = tmpDir();
|
|
1081
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
1082
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
1083
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: FEATURE, current_phase: 'closeout', task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } } }));
|
|
1084
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
|
|
1085
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
1086
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
1087
|
+
assert.ok(body, `explicit failure ${desc} should block`);
|
|
1088
|
+
assert.strictEqual(body.decision, 'block');
|
|
1089
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
test('27. task markdown symlink outside feature must be rejected (check a)', () => {
|
|
1094
|
+
const dir = tmpDir();
|
|
1095
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
1096
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
1097
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: FEATURE, current_phase: 'closeout', task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } } }));
|
|
1098
|
+
const outside = path.join(dir, 'outside.md');
|
|
1099
|
+
fs.writeFileSync(outside, '# Task\n\n**Status:** done\n\n## Evidence\n\nVerification: PASS\nCommand: pnpm test\nExit: 0\nBase: a\nHead: b\n');
|
|
1100
|
+
const taskPath = path.join(dir, 'specs', FEATURE, TASK_REL);
|
|
1101
|
+
try { fs.unlinkSync(taskPath); } catch {}
|
|
1102
|
+
fs.symlinkSync(outside, taskPath);
|
|
1103
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
1104
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
1105
|
+
assert.ok(body, 'symlink task outside should block');
|
|
1106
|
+
assert.strictEqual(body.decision, 'block');
|
|
1107
|
+
assert.match(body.reason, /\ba\b/);
|
|
1108
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
test('28. malformed spec.json must block with invalid_specs', () => {
|
|
1112
|
+
const dir = tmpDir();
|
|
1113
|
+
fs.mkdirSync(path.join(dir, 'specs', 'good'), { recursive: true });
|
|
1114
|
+
fs.mkdirSync(path.join(dir, 'specs', 'bad'), { recursive: true });
|
|
1115
|
+
fs.writeFileSync(path.join(dir, 'specs', 'good', 'spec.json'), JSON.stringify({ status: 'in_progress', feature_name: 'good', task_registry: {} }));
|
|
1116
|
+
fs.writeFileSync(path.join(dir, 'specs', 'bad', 'spec.json'), '{ malformed');
|
|
1117
|
+
seedCache({});
|
|
1118
|
+
const { stdout } = runHook({}, dir);
|
|
1119
|
+
const body = parseBlock(stdout);
|
|
1120
|
+
assert.ok(body, 'malformed spec should block');
|
|
1121
|
+
assert.strictEqual(body.decision, 'block');
|
|
1122
|
+
assert.match(body.reason, /invalid spec/i);
|
|
1123
|
+
assert.match(body.reason, /bad/);
|
|
1124
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
test('29. specs/linked symlink outside must be explicit_malformed and non-explicit scan must not count it', () => {
|
|
1128
|
+
const dir = tmpDir();
|
|
1129
|
+
const specsDir = path.join(dir, 'specs');
|
|
1130
|
+
fs.mkdirSync(specsDir, { recursive: true });
|
|
1131
|
+
const outside = path.join(dir, 'outside-feat');
|
|
1132
|
+
fs.mkdirSync(outside, { recursive: true });
|
|
1133
|
+
fs.writeFileSync(path.join(outside, 'spec.json'), JSON.stringify({ status: 'in_progress' }));
|
|
1134
|
+
const link = path.join(specsDir, 'linked');
|
|
1135
|
+
fs.symlinkSync(outside, link);
|
|
1136
|
+
// non-explicit should block with invalid_specs (fail-closed, not phantom no-active)
|
|
1137
|
+
seedCache({});
|
|
1138
|
+
const nonExplicit = parseBlock(runHook({}, dir).stdout);
|
|
1139
|
+
assert.ok(nonExplicit, 'non-explicit symlink outside should block with invalid_specs');
|
|
1140
|
+
assert.strictEqual(nonExplicit.decision, 'block');
|
|
1141
|
+
assert.match(nonExplicit.reason, /invalid spec/i);
|
|
1142
|
+
assert.match(nonExplicit.reason, /linked/);
|
|
1143
|
+
// explicit should block explicit_malformed
|
|
1144
|
+
const body = parseBlock(runHook({ featureName: 'linked' }, dir).stdout);
|
|
1145
|
+
assert.ok(body);
|
|
1146
|
+
assert.strictEqual(body.decision, 'block');
|
|
1147
|
+
assert.match(body.reason, /explicit_malformed/);
|
|
1148
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
test('30. phantom vectors via gate evidence must block with verification_state (r3)', () => {
|
|
1152
|
+
const vectors = [
|
|
1153
|
+
'ℹ tests 0',
|
|
1154
|
+
'Tests: 0 total',
|
|
1155
|
+
'0 tests',
|
|
1156
|
+
'collected 0 items',
|
|
1157
|
+
'No tests found.',
|
|
1158
|
+
'ℹ cancelled 1',
|
|
1159
|
+
'cancelled 1',
|
|
1160
|
+
'1 cancelled',
|
|
1161
|
+
'Test Suites: 1 failed, 1 total',
|
|
1162
|
+
'FAIL ./foo.test.js',
|
|
1163
|
+
'FAIL\tpackage',
|
|
1164
|
+
'FAIL\texample.test/probe\t0.431s',
|
|
1165
|
+
'--- FAIL: TestName (0.00s)',
|
|
1166
|
+
'FAILED tests/test_demo.py::test_foo - assert 1 == 2',
|
|
1167
|
+
'FAILED tests/test_demo.py',
|
|
1168
|
+
'# fail 1',
|
|
1169
|
+
'ℹ fail 1',
|
|
1170
|
+
'not ok 1 - test',
|
|
1171
|
+
'1 error in 0.12s',
|
|
1172
|
+
'ERROR collecting tests/test_demo.py',
|
|
1173
|
+
'Found 2 errors',
|
|
1174
|
+
'Tests run: 5, Failures: 1, Errors: 0',
|
|
1175
|
+
'[ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0',
|
|
1176
|
+
'[ERROR] Tests run: 5, Failures: 0, Errors: 1, Skipped: 0',
|
|
1177
|
+
'[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.2.5:test',
|
|
1178
|
+
'5 tests completed, 1 failed',
|
|
1179
|
+
];
|
|
1180
|
+
for (const v of vectors) {
|
|
1181
|
+
const dir = tmpDir();
|
|
1182
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
1183
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
1184
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
1185
|
+
status: 'in_progress',
|
|
1186
|
+
feature_name: FEATURE,
|
|
1187
|
+
current_phase: 'closeout',
|
|
1188
|
+
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
1189
|
+
}));
|
|
1190
|
+
const evidence = bindFixtureReceipt(dir, ['Verification: PASS','Command: npm test','Exit: 0',`Base: ${VALID_BASE}`,`Head: ${VALID_HEAD}`, v].join('\n'));
|
|
1191
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
|
|
1192
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
1193
|
+
const body = parseBlock(runHook({}, dir).stdout);
|
|
1194
|
+
assert.ok(body, `vector should block: ${v}`);
|
|
1195
|
+
assert.strictEqual(body.decision, 'block', `vector ${v} should block`);
|
|
1196
|
+
assert.match(body.reason, /\bverification_state\b/, `vector ${v} should fail verification_state`);
|
|
1197
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1198
|
+
}
|
|
1199
|
+
// positive controls must not block
|
|
1200
|
+
const passes = [
|
|
1201
|
+
'Tests failed: 0',
|
|
1202
|
+
'fail 0',
|
|
1203
|
+
'Tests: 0 failed',
|
|
1204
|
+
'cancelled 0',
|
|
1205
|
+
'Test Suites: 0 failed',
|
|
1206
|
+
'5 tests completed, 0 failed',
|
|
1207
|
+
'errors 0',
|
|
1208
|
+
'Found 0 errors',
|
|
1209
|
+
'collected 1 item',
|
|
1210
|
+
'[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
|
|
1211
|
+
'[ERROR] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0',
|
|
1212
|
+
'[ERROR] Error handling is documented',
|
|
1213
|
+
'Error handling is documented',
|
|
1214
|
+
'FAILURE mode analysis',
|
|
1215
|
+
'Notes: tests failed previously but now fixed',
|
|
1216
|
+
'Notes: supports zero-test parsing',
|
|
1217
|
+
'# tests 1',
|
|
1218
|
+
'# suites 0',
|
|
1219
|
+
'# pass 1',
|
|
1220
|
+
'# fail 0',
|
|
1221
|
+
'# cancelled 0',
|
|
1222
|
+
'# skipped 0',
|
|
1223
|
+
'# todo 0',
|
|
1224
|
+
'# duration_ms 114.203625',
|
|
1225
|
+
];
|
|
1226
|
+
for (const v of passes) {
|
|
1227
|
+
const dir = tmpDir();
|
|
1228
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
1229
|
+
fs.mkdirSync(path.join(featureDir, 'tasks'), { recursive: true });
|
|
1230
|
+
fs.writeFileSync(path.join(featureDir, 'spec.json'), JSON.stringify({
|
|
1231
|
+
status: 'in_progress',
|
|
1232
|
+
feature_name: FEATURE,
|
|
1233
|
+
current_phase: 'closeout',
|
|
1234
|
+
task_registry: { [TASK_REL]: { status: 'done', completed_at: '2026-07-01T00:00:00Z' } },
|
|
1235
|
+
}));
|
|
1236
|
+
const evidence = bindFixtureReceipt(dir, ['Verification: PASS','Command: npm test','Exit: 0',`Base: ${VALID_BASE}`,`Head: ${VALID_HEAD}`, v].join('\n'));
|
|
1237
|
+
fs.writeFileSync(path.join(dir, 'specs', FEATURE, TASK_REL), ['# Task','','**Status:** done','','## Evidence','','' + evidence,'```','pass','```'].join('\n'));
|
|
1238
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
1239
|
+
const { stdout } = runHook({}, dir);
|
|
1240
|
+
assert.strictEqual(stdout, '', `positive control should not block: ${v}`);
|
|
1241
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1242
|
+
}
|
|
1243
|
+
});
|