@haposoft/cafekit 0.16.0-rc.4 → 0.16.0-rc.5
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__/change-firewall.semantic-firewall.test.js +2134 -0
- package/bin/__tests__/codex-hooks.test.js +136 -64
- package/bin/__tests__/develop-contract.test.js +55 -17
- package/bin/__tests__/finalizer-pass-gate.semantic-firewall.test.js +342 -0
- package/bin/__tests__/package-inventory.test.js +29 -11
- package/bin/__tests__/release-preflight.semantic-firewall.test.js +578 -0
- package/bin/__tests__/review-receipt-c2.semantic-firewall.test.js +378 -0
- package/bin/__tests__/specs-v2-execution-closeout.test.js +59 -12
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +78 -14
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +222 -42
- package/bin/__tests__/validator-grounding.test.js +103 -4
- package/package.json +5 -2
- package/src/claude/hooks/__tests__/completion-authority.test.js +31 -1
- package/src/claude/hooks/__tests__/privacy-block.test.js +111 -29
- package/src/claude/hooks/privacy-block.cjs +38 -14
- package/src/claude/scripts/change-firewall.cjs +1748 -0
- package/src/claude/scripts/spec-authoring-digest.cjs +151 -0
- package/src/claude/scripts/spec-authoring-validation.cjs +126 -0
- package/src/claude/scripts/spec-final-state.cjs +46 -0
- package/src/claude/scripts/spec-ground.cjs +46 -10
- package/src/claude/scripts/spec-readiness.cjs +324 -17
- package/src/claude/scripts/spec-scaffold.cjs +268 -28
- package/src/claude/scripts/validate-spec-output.cjs +262 -10
- package/src/claude/skills/specs/references/review.md +14 -2
- package/src/claude/skills/specs/templates/spec-state.json +14 -2
- package/src/codex/hooks/lib/privacy-state.cjs +23 -22
- package/src/codex/hooks/privacy-approval.cjs +7 -3
- package/src/codex/hooks/privacy-block.cjs +45 -22
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// R1-01 owned proof: frozen C2 SemanticReviewReceipt (verdict vs
|
|
4
|
+
// lifecycle_disposition, structural-only checks per R3.4), C13
|
|
5
|
+
// SemanticReviewHistory (exact keys, whole-lineage digest dedupe), and C16
|
|
6
|
+
// AuthoringValidationReceipt (the sole spec-authoring-validation.cjs atomic
|
|
7
|
+
// writer, fail-closed freshness in validate-spec-output.cjs).
|
|
8
|
+
|
|
9
|
+
const assert = require('node:assert/strict');
|
|
10
|
+
const crypto = require('node:crypto');
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const os = require('node:os');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { spawnSync } = require('node:child_process');
|
|
15
|
+
const test = require('node:test');
|
|
16
|
+
|
|
17
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '../..');
|
|
18
|
+
const SCAFFOLD = path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-scaffold.cjs');
|
|
19
|
+
const VALIDATOR = path.join(PACKAGE_ROOT, 'src/claude/scripts/validate-spec-output.cjs');
|
|
20
|
+
const AUTHORING_VALIDATOR = path.join(PACKAGE_ROOT, 'src/claude/scripts/spec-authoring-validation.cjs');
|
|
21
|
+
|
|
22
|
+
function exec(root, script, args) {
|
|
23
|
+
return spawnSync(process.execPath, [script, ...args], { cwd: root, encoding: 'utf8' });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function output(result) { return `${result.stdout}\n${result.stderr}`; }
|
|
27
|
+
|
|
28
|
+
function writeSpec(specPath, spec) {
|
|
29
|
+
fs.writeFileSync(specPath, `${JSON.stringify(spec, null, 2)}\n`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readSpec(specPath) {
|
|
33
|
+
return JSON.parse(fs.readFileSync(specPath, 'utf8'));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function task(id, title, ownedPath, action, criterion, taskRole) {
|
|
37
|
+
return `# Task ${id}: ${title}
|
|
38
|
+
**Status:** pending
|
|
39
|
+
|
|
40
|
+
## Outcome
|
|
41
|
+
Deliver observable ${title.toLowerCase()} behavior through the declared entrypoint.
|
|
42
|
+
|
|
43
|
+
## Scope
|
|
44
|
+
- **In scope:** Exact behavior for ${ownedPath}.
|
|
45
|
+
- **Out of scope:** Unrelated runtime behavior.
|
|
46
|
+
|
|
47
|
+
## Anchors and Ownership
|
|
48
|
+
| ID | Type | Target | Role | Access | Action |
|
|
49
|
+
|---|---|---|---|---|---|
|
|
50
|
+
| A-${id}-01 | file | \`${ownedPath}\` | owner | write | ${action} |
|
|
51
|
+
| A-${id}-02 | artifact | \`artifacts/${id}.json\` | verifier | write | create |
|
|
52
|
+
|
|
53
|
+
## Changes
|
|
54
|
+
${criterion ? `- [ ] Implement the owned behavior. _Requirements: ${criterion.slice(1)}_` : '- [ ] Verify the subject implementation through the canonical proof boundary.'}
|
|
55
|
+
|
|
56
|
+
## Acceptance
|
|
57
|
+
${criterion ? `- **${criterion}:** The command returns the concrete expected state.` : '- The verifier produces an observable proof artifact without claiming the subject acceptance criterion.'}
|
|
58
|
+
|
|
59
|
+
## Dependencies
|
|
60
|
+
- none
|
|
61
|
+
|
|
62
|
+
## Verification Plan
|
|
63
|
+
- **Verification ref:** V1
|
|
64
|
+
- **Task role:** ${taskRole}
|
|
65
|
+
- **Command:** \`node --test test/service.test.js\`
|
|
66
|
+
- **Expected:** Exit code 0 and exact state enabled.
|
|
67
|
+
- **Negative path:** Invalid input returns the specified failure state.
|
|
68
|
+
- **Reachability:** \`src/entry.js\`
|
|
69
|
+
`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A ready, coordinator-validated fixture. C16's own receipt is produced by
|
|
73
|
+
// the real spec-authoring-validation.cjs coordinator, never hand-written, so
|
|
74
|
+
// every test here starts from a byte-accurate receipt.
|
|
75
|
+
function makeReadyFixture() {
|
|
76
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-c2-'));
|
|
77
|
+
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
78
|
+
fs.mkdirSync(path.join(root, 'test'), { recursive: true });
|
|
79
|
+
fs.mkdirSync(path.join(root, 'artifacts'), { recursive: true });
|
|
80
|
+
fs.writeFileSync(path.join(root, 'src/entry.js'), 'module.exports = { enabled: true };\n');
|
|
81
|
+
fs.writeFileSync(path.join(root, 'src/service.js'), 'module.exports = { enabled: true };\n');
|
|
82
|
+
fs.writeFileSync(path.join(root, 'test/service.test.js'), 'require("node:test")("service",()=>{});\n');
|
|
83
|
+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ scripts: { test: 'node --test' } }));
|
|
84
|
+
const boundaries = [{
|
|
85
|
+
id: 'B-OWN', type: 'ownership', tasks: ['R1-01', 'R1-02'],
|
|
86
|
+
write_sets: {
|
|
87
|
+
'R1-01': ['src/entry.js', 'artifacts/R1-01.json'],
|
|
88
|
+
'R1-02': ['src/service.js', 'artifacts/R1-02.json'],
|
|
89
|
+
},
|
|
90
|
+
}, {
|
|
91
|
+
id: 'B-V', type: 'proof', subject: 'R1-01', verifier: 'R1-02',
|
|
92
|
+
verification_ref: 'V1', artifact_anchor: 'A-R1-02-02',
|
|
93
|
+
}];
|
|
94
|
+
const scaffold = exec(root, SCAFFOLD, ['feature', '--tasks', 'R1-01-entry,R1-02-verify', '--boundaries', JSON.stringify(boundaries)]);
|
|
95
|
+
assert.equal(scaffold.status, 0, scaffold.stderr);
|
|
96
|
+
const specDir = path.join(root, 'specs/feature');
|
|
97
|
+
fs.writeFileSync(path.join(specDir, 'requirements.md'), `# Requirements
|
|
98
|
+
|
|
99
|
+
## Requirements
|
|
100
|
+
### Requirement 1: Entry behavior
|
|
101
|
+
- **R1.1**: When valid input arrives, the entry shall return enabled state.
|
|
102
|
+
- **R1.2**: The verifier shall produce a separate observable proof artifact for the enabled state.
|
|
103
|
+
`);
|
|
104
|
+
fs.writeFileSync(path.join(root, 'src/design-boundary.js'), 'module.exports = {};\n');
|
|
105
|
+
fs.writeFileSync(path.join(specDir, 'design.md'), `# Design
|
|
106
|
+
|
|
107
|
+
## Architecture
|
|
108
|
+
The entry delegates to the service and emits a verifier-owned artifact.
|
|
109
|
+
|
|
110
|
+
## Typed Anchors
|
|
111
|
+
| ID | Type | Target | Role | Access | Action |
|
|
112
|
+
|---|---|---|---|---|---|
|
|
113
|
+
| A-D-01 | file | \`src/design-boundary.js\` | design boundary | read | read |
|
|
114
|
+
|
|
115
|
+
## Canonical Contracts & Invariants
|
|
116
|
+
### D1 — Entry decision
|
|
117
|
+
The entry always returns enabled true on valid input.
|
|
118
|
+
### I1 — Invalid-state invariant
|
|
119
|
+
Invalid input never returns enabled state.
|
|
120
|
+
### C1 — Result contract
|
|
121
|
+
The result contains an enabled boolean.
|
|
122
|
+
|
|
123
|
+
## Verification Definitions
|
|
124
|
+
- **V1**: Criteria R1.1; Owner R1-01; Proof criteria R1.2; Proof owner R1-02; Evidence anchor A-R1-02-02; Decision refs D1, I1, C1; Method command \`node --test test/service.test.js\`; Expected exit 0 with enabled state and verifier artifact; Negative/failure invalid input returns rejected state without an enabled result; Reachability/grounding entrypoint \`src/entry.js\` via A-D-01, A-R1-02-02.
|
|
125
|
+
`);
|
|
126
|
+
fs.writeFileSync(path.join(specDir, 'tasks/task-R1-01-entry.md'), task('R1-01', 'Entry', 'src/entry.js', 'modify', 'R1.1', 'subject'));
|
|
127
|
+
fs.writeFileSync(path.join(specDir, 'tasks/task-R1-02-verify.md'), task('R1-02', 'Verify', 'src/service.js', 'modify', 'R1.2', 'verifier'));
|
|
128
|
+
const promoted = exec(root, SCAFFOLD, ['feature', '--sync-semantic-model']);
|
|
129
|
+
assert.equal(promoted.status, 0, output(promoted));
|
|
130
|
+
const specPath = path.join(specDir, 'spec.json');
|
|
131
|
+
const coordinated = exec(root, AUTHORING_VALIDATOR, [specDir]);
|
|
132
|
+
assert.equal(coordinated.status, 0, output(coordinated));
|
|
133
|
+
const spec = readSpec(specPath);
|
|
134
|
+
spec.validation.status = 'completed';
|
|
135
|
+
spec.validation.semantic_review = {
|
|
136
|
+
status: 'completed',
|
|
137
|
+
semantic_digest: null,
|
|
138
|
+
verdict: 'PASS',
|
|
139
|
+
lifecycle_disposition: 'CONTINUE',
|
|
140
|
+
findings: [],
|
|
141
|
+
unresolved_decisions: [],
|
|
142
|
+
graph_coverage: [
|
|
143
|
+
'criterion_local', 'cross_criterion', 'runtime_path', 'assumption_provenance', 'compatibility_migration',
|
|
144
|
+
].map((surface) => ({ surface, covered: true, notes: 'Fixture coverage.' })),
|
|
145
|
+
repair_round: 0,
|
|
146
|
+
reviewed_criteria: ['R1.1', 'R1.2'],
|
|
147
|
+
counterexamples: [{
|
|
148
|
+
criterion: 'R1.1', case_kind: 'failure',
|
|
149
|
+
scenario: 'The entry receives an invalid input object without the required discriminator.',
|
|
150
|
+
expected: 'The entry returns rejected state and never emits enabled true.',
|
|
151
|
+
decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
|
|
152
|
+
}, {
|
|
153
|
+
criterion: 'R1.2', case_kind: 'adversarial',
|
|
154
|
+
scenario: 'The implementation reports enabled state without producing the verifier-owned artifact.',
|
|
155
|
+
expected: 'Verification fails because the separate proof artifact is absent.',
|
|
156
|
+
decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
|
|
157
|
+
}],
|
|
158
|
+
reviewer_evidence: { assurance: 'Routine', summary: 'Fixture reviewer evidence for structural contract test.' },
|
|
159
|
+
};
|
|
160
|
+
writeSpec(specPath, spec);
|
|
161
|
+
const digest = exec(root, VALIDATOR, [specDir, '--semantic-digest']);
|
|
162
|
+
assert.equal(digest.status, 0, output(digest));
|
|
163
|
+
const refreshed = readSpec(specPath);
|
|
164
|
+
refreshed.validation.semantic_review.semantic_digest = digest.stdout.trim();
|
|
165
|
+
refreshed.ready_for_implementation = true;
|
|
166
|
+
writeSpec(specPath, refreshed);
|
|
167
|
+
return { root, specDir, specPath };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function cleanup(fixture) {
|
|
171
|
+
fs.rmSync(fixture.root, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function sha256Tag(bytes) {
|
|
175
|
+
return `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
test('C2 receipt freezes the exact key set with separate verdict and lifecycle_disposition', () => {
|
|
179
|
+
const fixture = makeReadyFixture();
|
|
180
|
+
try {
|
|
181
|
+
const clean = exec(fixture.root, VALIDATOR, [fixture.specDir]);
|
|
182
|
+
assert.equal(clean.status, 0, output(clean));
|
|
183
|
+
// Snapshot the known-clean baseline once; every mutation below clones it
|
|
184
|
+
// fresh so one case's leftover mutation never contaminates the next.
|
|
185
|
+
const baseline = readSpec(fixture.specPath);
|
|
186
|
+
|
|
187
|
+
const missingLifecycle = JSON.parse(JSON.stringify(baseline));
|
|
188
|
+
missingLifecycle.validation.semantic_review.lifecycle_disposition = null;
|
|
189
|
+
writeSpec(fixture.specPath, missingLifecycle);
|
|
190
|
+
const result1 = exec(fixture.root, VALIDATOR, [fixture.specDir]);
|
|
191
|
+
assert.equal(result1.status, 1, output(result1));
|
|
192
|
+
assert.match(output(result1), /lifecycle_disposition: must be CONTINUE or BLOCKED when completed/);
|
|
193
|
+
|
|
194
|
+
const extraKey = JSON.parse(JSON.stringify(baseline));
|
|
195
|
+
extraKey.validation.semantic_review.reviewer_note = 'not a canonical C2 field';
|
|
196
|
+
writeSpec(fixture.specPath, extraKey);
|
|
197
|
+
const result2 = exec(fixture.root, VALIDATOR, [fixture.specDir]);
|
|
198
|
+
assert.equal(result2.status, 1, output(result2));
|
|
199
|
+
assert.match(output(result2), /semantic_review: schema 2\.1 fields must be exactly/);
|
|
200
|
+
|
|
201
|
+
const conflated = JSON.parse(JSON.stringify(baseline));
|
|
202
|
+
conflated.validation.semantic_review.verdict = 'FAIL';
|
|
203
|
+
conflated.validation.semantic_review.lifecycle_disposition = 'CONTINUE';
|
|
204
|
+
writeSpec(fixture.specPath, conflated);
|
|
205
|
+
const result3 = exec(fixture.root, VALIDATOR, [fixture.specDir]);
|
|
206
|
+
// R3.4: structural validator enforces shape/type/enum/cardinality only —
|
|
207
|
+
// it does not judge whether a FAIL+CONTINUE pairing is a meaningful
|
|
208
|
+
// review outcome (that policy belongs to the R1-02 finalizer).
|
|
209
|
+
assert.equal(result3.status, 0, output(result3));
|
|
210
|
+
} finally { cleanup(fixture); }
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('sync migrates an exact legacy review into a fresh C2/C13/C16 lifecycle even when the semantic model is unchanged', () => {
|
|
214
|
+
const fixture = makeReadyFixture();
|
|
215
|
+
try {
|
|
216
|
+
const legacy = readSpec(fixture.specPath);
|
|
217
|
+
legacy.validation = {
|
|
218
|
+
status: 'completed',
|
|
219
|
+
semantic_review: {
|
|
220
|
+
status: 'completed',
|
|
221
|
+
semantic_digest: legacy.validation.semantic_review.semantic_digest,
|
|
222
|
+
reviewed_criteria: legacy.validation.semantic_review.reviewed_criteria,
|
|
223
|
+
counterexamples: legacy.validation.semantic_review.counterexamples,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
legacy.ready_for_implementation = true;
|
|
227
|
+
writeSpec(fixture.specPath, legacy);
|
|
228
|
+
|
|
229
|
+
const migrated = exec(fixture.root, SCAFFOLD, ['feature', '--sync-semantic-model']);
|
|
230
|
+
assert.equal(migrated.status, 0, output(migrated));
|
|
231
|
+
const next = readSpec(fixture.specPath);
|
|
232
|
+
assert.equal(next.ready_for_implementation, false);
|
|
233
|
+
assert.equal(next.validation.status, 'not-run');
|
|
234
|
+
assert.deepEqual(Object.keys(next.validation).sort(), [
|
|
235
|
+
'authoring_validation', 'semantic_review', 'semantic_review_history', 'status',
|
|
236
|
+
]);
|
|
237
|
+
assert.equal(next.validation.semantic_review.status, 'not-run');
|
|
238
|
+
assert.deepEqual(next.validation.semantic_review_history.entries, []);
|
|
239
|
+
assert.equal(next.validation.authoring_validation, null);
|
|
240
|
+
assert.equal(next.authoring.requirements, 'draft');
|
|
241
|
+
assert.equal(next.authoring.design, 'draft');
|
|
242
|
+
assert.equal(next.authoring.tasks, 'draft');
|
|
243
|
+
} finally { cleanup(fixture); }
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('C2 findings/unresolved_decisions/graph_coverage/reviewer_evidence enforce exact shape', () => {
|
|
247
|
+
const fixture = makeReadyFixture();
|
|
248
|
+
try {
|
|
249
|
+
const badFinding = readSpec(fixture.specPath);
|
|
250
|
+
badFinding.validation.semantic_review.findings = [{
|
|
251
|
+
id: 'F1', severity: 'Severe', location: 'design.md', summary: 'A concrete finding summary.', blocking: false, disposition: 'open',
|
|
252
|
+
}];
|
|
253
|
+
writeSpec(fixture.specPath, badFinding);
|
|
254
|
+
const result1 = exec(fixture.root, VALIDATOR, [fixture.specDir]);
|
|
255
|
+
assert.equal(result1.status, 1, output(result1));
|
|
256
|
+
assert.match(output(result1), /findings\[0\]\.severity: must be Critical, High, Medium, or Low/);
|
|
257
|
+
|
|
258
|
+
const badCoverage = readSpec(fixture.specPath);
|
|
259
|
+
badCoverage.validation.semantic_review.graph_coverage = badCoverage.validation.semantic_review.graph_coverage.slice(0, 4);
|
|
260
|
+
writeSpec(fixture.specPath, badCoverage);
|
|
261
|
+
const result2 = exec(fixture.root, VALIDATOR, [fixture.specDir]);
|
|
262
|
+
assert.equal(result2.status, 1, output(result2));
|
|
263
|
+
assert.match(output(result2), /graph_coverage: missing required surface/);
|
|
264
|
+
|
|
265
|
+
const badEvidence = readSpec(fixture.specPath);
|
|
266
|
+
badEvidence.validation.semantic_review.reviewer_evidence = { assurance: 'Overkill', summary: 'A concrete reviewer summary.' };
|
|
267
|
+
writeSpec(fixture.specPath, badEvidence);
|
|
268
|
+
const result3 = exec(fixture.root, VALIDATOR, [fixture.specDir]);
|
|
269
|
+
assert.equal(result3.status, 1, output(result3));
|
|
270
|
+
assert.match(output(result3), /reviewer_evidence\.assurance: must be Routine, Elevated, or Strict/);
|
|
271
|
+
} finally { cleanup(fixture); }
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('C13 semantic_review_history freezes exact keys and dedupes across the whole lineage', () => {
|
|
275
|
+
const fixture = makeReadyFixture();
|
|
276
|
+
try {
|
|
277
|
+
const spec = readSpec(fixture.specPath);
|
|
278
|
+
const receiptDigestA = sha256Tag(Buffer.from('receipt-a'));
|
|
279
|
+
const receiptDigestB = sha256Tag(Buffer.from('receipt-b'));
|
|
280
|
+
const semanticDigest = spec.validation.semantic_review.semantic_digest;
|
|
281
|
+
spec.validation.semantic_review_history.entries = [
|
|
282
|
+
{
|
|
283
|
+
sequence: 0, semantic_digest: semanticDigest, review_receipt_digest: receiptDigestA,
|
|
284
|
+
verdict: 'FAIL', lifecycle_disposition: 'CONTINUE', blocking_count: 1, attempt_index: 0, review_epoch: 0,
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
sequence: 1, semantic_digest: semanticDigest, review_receipt_digest: receiptDigestB,
|
|
288
|
+
verdict: 'PASS', lifecycle_disposition: 'CONTINUE', blocking_count: 0, attempt_index: 1, review_epoch: 0,
|
|
289
|
+
},
|
|
290
|
+
];
|
|
291
|
+
writeSpec(fixture.specPath, spec);
|
|
292
|
+
const clean = exec(fixture.root, VALIDATOR, [fixture.specDir]);
|
|
293
|
+
assert.equal(clean.status, 0, output(clean));
|
|
294
|
+
// Snapshot the known-clean 2-entry baseline once; every mutation below
|
|
295
|
+
// clones it fresh so one case's leftover mutation never contaminates the
|
|
296
|
+
// next (each case must independently isolate the single defect it proves).
|
|
297
|
+
const baseline = readSpec(fixture.specPath);
|
|
298
|
+
|
|
299
|
+
const missingEpoch = JSON.parse(JSON.stringify(baseline));
|
|
300
|
+
delete missingEpoch.validation.semantic_review_history.entries[0].review_epoch;
|
|
301
|
+
writeSpec(fixture.specPath, missingEpoch);
|
|
302
|
+
const result1 = exec(fixture.root, VALIDATOR, [fixture.specDir]);
|
|
303
|
+
assert.equal(result1.status, 1, output(result1));
|
|
304
|
+
assert.match(output(result1), /entries\[0\]: fields must be exactly/);
|
|
305
|
+
|
|
306
|
+
// Whole-lineage dedupe: a third entry replaying entry 0's digest is a
|
|
307
|
+
// replay even though entry 0 is not the latest entry.
|
|
308
|
+
const replay = JSON.parse(JSON.stringify(baseline));
|
|
309
|
+
replay.validation.semantic_review_history.entries.push({
|
|
310
|
+
sequence: 2, semantic_digest: semanticDigest, review_receipt_digest: receiptDigestA,
|
|
311
|
+
verdict: 'PASS', lifecycle_disposition: 'CONTINUE', blocking_count: 0, attempt_index: 2, review_epoch: 0,
|
|
312
|
+
});
|
|
313
|
+
writeSpec(fixture.specPath, replay);
|
|
314
|
+
const result2 = exec(fixture.root, VALIDATOR, [fixture.specDir]);
|
|
315
|
+
assert.equal(result2.status, 1, output(result2));
|
|
316
|
+
assert.match(output(result2), /review_receipt_digest: replays an existing entry's digest anywhere in the lineage/);
|
|
317
|
+
|
|
318
|
+
const badLineage = JSON.parse(JSON.stringify(baseline));
|
|
319
|
+
badLineage.validation.semantic_review_history.lineage_id = sha256Tag(Buffer.from('wrong'));
|
|
320
|
+
writeSpec(fixture.specPath, badLineage);
|
|
321
|
+
const result3 = exec(fixture.root, VALIDATOR, [fixture.specDir]);
|
|
322
|
+
assert.equal(result3.status, 1, output(result3));
|
|
323
|
+
assert.match(output(result3), /lineage_id: must be sha256 of feature_name and created_at/);
|
|
324
|
+
} finally { cleanup(fixture); }
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('C16 coordinator is the sole atomic writer: clean pass writes, a failing pass leaves spec.json untouched', () => {
|
|
328
|
+
const fixture = makeReadyFixture();
|
|
329
|
+
try {
|
|
330
|
+
const before = readSpec(fixture.specPath);
|
|
331
|
+
assert.equal(before.authoring.requirements, 'validated');
|
|
332
|
+
assert.equal(before.authoring.design, 'validated');
|
|
333
|
+
assert.equal(before.authoring.tasks, 'validated');
|
|
334
|
+
assert.ok(before.validation.authoring_validation);
|
|
335
|
+
assert.match(before.validation.authoring_validation.requirements.digest, /^sha256:[a-f0-9]{64}$/);
|
|
336
|
+
|
|
337
|
+
// Re-running the coordinator on already-fresh bytes is a clean no-op pass.
|
|
338
|
+
const rerun = exec(fixture.root, AUTHORING_VALIDATOR, [fixture.specDir]);
|
|
339
|
+
assert.equal(rerun.status, 0, output(rerun));
|
|
340
|
+
|
|
341
|
+
// Break grounding (dangling design anchor target) so the coordinator's
|
|
342
|
+
// own validate+ground pass fails; spec.json bytes must stay identical.
|
|
343
|
+
const designPath = path.join(fixture.specDir, 'design.md');
|
|
344
|
+
fs.writeFileSync(designPath, fs.readFileSync(designPath, 'utf8').replace(
|
|
345
|
+
'| A-D-01 | file | `src/design-boundary.js` | design boundary | read | read |',
|
|
346
|
+
'| A-D-01 | file | `src/does-not-exist.js` | design boundary | read | read |',
|
|
347
|
+
));
|
|
348
|
+
const beforeBytes = fs.readFileSync(fixture.specPath, 'utf8');
|
|
349
|
+
const failing = exec(fixture.root, AUTHORING_VALIDATOR, [fixture.specDir]);
|
|
350
|
+
assert.notEqual(failing.status, 0, output(failing));
|
|
351
|
+
const afterBytes = fs.readFileSync(fixture.specPath, 'utf8');
|
|
352
|
+
assert.equal(afterBytes, beforeBytes, 'a failed coordinator run must leave spec.json completely unchanged');
|
|
353
|
+
} finally { cleanup(fixture); }
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('C16/I20 fail-closed freshness: an edit after validation reverts the stage to draft-equivalent structurally', () => {
|
|
357
|
+
const fixture = makeReadyFixture();
|
|
358
|
+
try {
|
|
359
|
+
const clean = exec(fixture.root, VALIDATOR, [fixture.specDir]);
|
|
360
|
+
assert.equal(clean.status, 0, output(clean));
|
|
361
|
+
|
|
362
|
+
// Edit requirements.md after the coordinator already ran; do not rerun the
|
|
363
|
+
// coordinator. authoring.requirements still reads validated, but its
|
|
364
|
+
// receipt digest no longer matches the current bytes.
|
|
365
|
+
fs.appendFileSync(path.join(fixture.specDir, 'requirements.md'), '\nA later edit changes the authored requirement text.\n');
|
|
366
|
+
const stale = exec(fixture.root, VALIDATOR, [fixture.specDir]);
|
|
367
|
+
assert.equal(stale.status, 1, output(stale));
|
|
368
|
+
assert.match(output(stale), /spec\.json\.authoring\.requirements: reads validated but validation\.authoring_validation.*digest-mismatched/);
|
|
369
|
+
|
|
370
|
+
// An absent receipt with a validated reading is refused identically.
|
|
371
|
+
const missingReceipt = readSpec(fixture.specPath);
|
|
372
|
+
missingReceipt.validation.authoring_validation = null;
|
|
373
|
+
writeSpec(fixture.specPath, missingReceipt);
|
|
374
|
+
const absent = exec(fixture.root, VALIDATOR, [fixture.specDir]);
|
|
375
|
+
assert.equal(absent.status, 1, output(absent));
|
|
376
|
+
assert.match(output(absent), /spec\.json\.authoring\.requirements: reads validated but validation\.authoring_validation is absent/);
|
|
377
|
+
} finally { cleanup(fixture); }
|
|
378
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const assert = require('node:assert/strict');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
4
5
|
const fs = require('node:fs');
|
|
5
6
|
const os = require('node:os');
|
|
6
7
|
const path = require('node:path');
|
|
@@ -14,6 +15,8 @@ const PROVENANCE = require(path.join(SCRIPTS, 'provenance.cjs'));
|
|
|
14
15
|
const RECEIPT = require(path.join(SCRIPTS, 'spec-receipt.cjs'));
|
|
15
16
|
const SCAFFOLD = path.join(SCRIPTS, 'spec-scaffold.cjs');
|
|
16
17
|
const VALIDATOR = path.join(SCRIPTS, 'validate-spec-output.cjs');
|
|
18
|
+
const AUTHORING_VALIDATION = path.join(SCRIPTS, 'spec-authoring-validation.cjs');
|
|
19
|
+
const RUNTIME_CLOSURE = require('./test-runtime-dependency-closure.cjs');
|
|
17
20
|
const SEMANTIC_MODEL = require(path.join(SCRIPTS, 'spec-semantic-model.cjs'));
|
|
18
21
|
const CLAUDE_CHECK = require(path.join(ROOT, 'src/claude/hooks/completion-authority-check.cjs'));
|
|
19
22
|
const CODEX_CHECK = require(path.join(ROOT, 'src/codex/hooks/completion-authority-check.cjs'));
|
|
@@ -143,12 +146,41 @@ Only a validated and grounded current digest reaches closeout.
|
|
|
143
146
|
R1.1 and R1.2 are verified by V1 against D1, I1, and C1.
|
|
144
147
|
`);
|
|
145
148
|
const specPath = path.join(featureDir, 'spec.json');
|
|
149
|
+
const seed = JSON.parse(fs.readFileSync(specPath, 'utf8'));
|
|
150
|
+
const seedProjection = SEMANTIC_MODEL.modelFromMarkdown(featureDir, seed);
|
|
151
|
+
assert.deepEqual(seedProjection.errors, []);
|
|
152
|
+
seed.semantic_model = seedProjection.model;
|
|
153
|
+
fs.writeFileSync(specPath, `${JSON.stringify(seed, null, 2)}\n`);
|
|
154
|
+
// I21: only the C16 coordinator may set an authoring stage to `validated`,
|
|
155
|
+
// and only together with the digest receipt that proves it fresh. The fixture
|
|
156
|
+
// runs the real command instead of hand-authoring the enum.
|
|
157
|
+
const authored = spawnSync(
|
|
158
|
+
process.execPath,
|
|
159
|
+
[AUTHORING_VALIDATION, featureDir, '--root', root],
|
|
160
|
+
{ cwd: root, encoding: 'utf8' },
|
|
161
|
+
);
|
|
162
|
+
assert.equal(authored.status, 0, `${authored.stdout}\n${authored.stderr}`);
|
|
146
163
|
const spec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
|
|
147
|
-
spec.authoring.requirements
|
|
148
|
-
spec.authoring.design
|
|
164
|
+
assert.equal(spec.authoring.requirements, 'validated');
|
|
165
|
+
assert.equal(spec.authoring.design, 'validated');
|
|
149
166
|
spec.validation.status = 'completed';
|
|
150
167
|
spec.validation.semantic_review = {
|
|
151
|
-
status: 'completed',
|
|
168
|
+
status: 'completed',
|
|
169
|
+
semantic_digest: null,
|
|
170
|
+
verdict: 'PASS',
|
|
171
|
+
lifecycle_disposition: 'CONTINUE',
|
|
172
|
+
findings: [],
|
|
173
|
+
unresolved_decisions: [],
|
|
174
|
+
graph_coverage: [
|
|
175
|
+
'criterion_local', 'cross_criterion', 'runtime_path',
|
|
176
|
+
'assumption_provenance', 'compatibility_migration',
|
|
177
|
+
].map((surface) => ({ surface, covered: true, notes: `${surface} reviewed for the closeout fixture.` })),
|
|
178
|
+
repair_round: 0,
|
|
179
|
+
reviewer_evidence: {
|
|
180
|
+
assurance: 'Routine',
|
|
181
|
+
summary: 'Closeout fixture reviewer confirmed both criteria against the grounded final-state boundary.',
|
|
182
|
+
},
|
|
183
|
+
reviewed_criteria: ['R1.1', 'R1.2'],
|
|
152
184
|
counterexamples: [{
|
|
153
185
|
criterion: 'R1.1', case_kind: 'failure',
|
|
154
186
|
scenario: 'A requirements artifact changes after the reviewed digest was recorded.',
|
|
@@ -161,13 +193,24 @@ R1.1 and R1.2 are verified by V1 against D1, I1, and C1.
|
|
|
161
193
|
decision_refs: ['D1', 'I1', 'C1'], verification_ref: 'V1',
|
|
162
194
|
}],
|
|
163
195
|
};
|
|
164
|
-
const projection = SEMANTIC_MODEL.modelFromMarkdown(featureDir, spec);
|
|
165
|
-
assert.deepEqual(projection.errors, []);
|
|
166
|
-
spec.semantic_model = projection.model;
|
|
167
196
|
fs.writeFileSync(specPath, `${JSON.stringify(spec, null, 2)}\n`);
|
|
168
197
|
const digest = spawnSync(process.execPath, [VALIDATOR, featureDir, '--semantic-digest'], { cwd: root, encoding: 'utf8' });
|
|
169
198
|
assert.equal(digest.status, 0, `${digest.stdout}\n${digest.stderr}`);
|
|
170
199
|
spec.validation.semantic_review.semantic_digest = digest.stdout.trim();
|
|
200
|
+
// C13: a completed receipt is only terminal when the durable history records
|
|
201
|
+
// it. The entry binds the same semantic digest, the receipt's own canonical
|
|
202
|
+
// digest, and the attempt index the receipt reports as repair_round.
|
|
203
|
+
spec.validation.semantic_review_history.entries = [{
|
|
204
|
+
sequence: 0,
|
|
205
|
+
semantic_digest: spec.validation.semantic_review.semantic_digest,
|
|
206
|
+
review_receipt_digest: `sha256:${crypto.createHash('sha256')
|
|
207
|
+
.update(SEMANTIC_MODEL.stableJson(spec.validation.semantic_review), 'utf8').digest('hex')}`,
|
|
208
|
+
verdict: 'PASS',
|
|
209
|
+
lifecycle_disposition: 'CONTINUE',
|
|
210
|
+
blocking_count: 0,
|
|
211
|
+
attempt_index: spec.validation.semantic_review.repair_round,
|
|
212
|
+
review_epoch: 0,
|
|
213
|
+
}];
|
|
171
214
|
spec.ready_for_implementation = true;
|
|
172
215
|
spec.status = specStatus === 'complete' ? 'done' : specStatus;
|
|
173
216
|
delete spec.current_phase;
|
|
@@ -212,12 +255,16 @@ function installGate(root, kind) {
|
|
|
212
255
|
fs.mkdirSync(path.join(runtimeDir, 'hooks'), { recursive: true });
|
|
213
256
|
fs.mkdirSync(path.join(runtimeDir, 'hooks', 'lib'), { recursive: true });
|
|
214
257
|
fs.mkdirSync(path.join(runtimeDir, 'scripts'), { recursive: true });
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
258
|
+
// Copy the real transitive require closure rather than a hand-listed subset.
|
|
259
|
+
// A hand-listed subset silently rots the moment one of these entrypoints
|
|
260
|
+
// gains a new local dependency: the fixture keeps building, and the gate only
|
|
261
|
+
// fails later with a bare MODULE_NOT_FOUND from inside the installed runtime.
|
|
262
|
+
RUNTIME_CLOSURE.copyClaudeTestRuntime(ROOT, runtimeDir, [
|
|
263
|
+
'scripts/workflow-policy.cjs', 'scripts/provenance.cjs', 'scripts/spec-resolver.cjs',
|
|
264
|
+
'scripts/spec-receipt.cjs', 'scripts/validate-spec-output.cjs', 'scripts/spec-ground.cjs',
|
|
265
|
+
'scripts/spec-semantic-model.cjs', 'scripts/spec-final-state.cjs',
|
|
266
|
+
'hooks/lib/runtime-path-safety.cjs',
|
|
267
|
+
]);
|
|
221
268
|
if (kind === 'claude') {
|
|
222
269
|
for (const name of [
|
|
223
270
|
'spec-gate.cjs', 'completion-authority-check.cjs', 'completion-authority-state.cjs',
|
|
@@ -32,6 +32,25 @@ function readSpec(root, feature) {
|
|
|
32
32
|
return JSON.parse(fs.readFileSync(path.join(root, 'specs', feature, 'spec.json'), 'utf8'));
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// A canonical 2.1 scaffold always emits the C2 semantic_review shape plus the
|
|
36
|
+
// C13 history and C16 receipt slots. A genuine schema 2.0 record predates all
|
|
37
|
+
// three, so a fixture that simulates legacy migration input must strip them —
|
|
38
|
+
// relabelling schema_version alone produces a record that never existed and
|
|
39
|
+
// trips the 2.0 allowlist before the behavior under test is ever reached.
|
|
40
|
+
function downgradeToLegacy20(spec) {
|
|
41
|
+
spec.schema_version = '2.0';
|
|
42
|
+
delete spec.validation.semantic_review_history;
|
|
43
|
+
delete spec.validation.authoring_validation;
|
|
44
|
+
const review = spec.validation.semantic_review;
|
|
45
|
+
spec.validation.semantic_review = {
|
|
46
|
+
status: review.status,
|
|
47
|
+
semantic_digest: review.semantic_digest,
|
|
48
|
+
reviewed_criteria: review.reviewed_criteria,
|
|
49
|
+
counterexamples: review.counterexamples,
|
|
50
|
+
};
|
|
51
|
+
return spec;
|
|
52
|
+
}
|
|
53
|
+
|
|
35
54
|
function ownershipBoundary() {
|
|
36
55
|
return JSON.stringify([{
|
|
37
56
|
id: 'B-OWN', type: 'ownership', tasks: ['R1-01', 'R1-02'],
|
|
@@ -250,8 +269,7 @@ test('durable transaction recovery covers every fresh and replacement rename bou
|
|
|
250
269
|
]);
|
|
251
270
|
assert.equal(initial.status, 0, output(initial));
|
|
252
271
|
const specPath = path.join(root, 'specs', feature, 'spec.json');
|
|
253
|
-
const spec = readSpec(root, feature);
|
|
254
|
-
spec.schema_version = '2.0';
|
|
272
|
+
const spec = downgradeToLegacy20(readSpec(root, feature));
|
|
255
273
|
fs.writeFileSync(specPath, `${JSON.stringify(spec, null, 2)}\n`);
|
|
256
274
|
},
|
|
257
275
|
invoke(root, feature, env) { return runMatchingTasksOnly(root, feature, env); },
|
|
@@ -362,23 +380,37 @@ test('tasks-only normalization is table-driven, conservative, and idempotent', (
|
|
|
362
380
|
]);
|
|
363
381
|
assert.equal(Object.hasOwn(spec.task_registry['tasks/task-R1-01-one.md'], 'legacy_owner'), false);
|
|
364
382
|
assert.deepEqual(Object.keys(spec.workflow_policy), CANONICAL_POLICY_FIELDS);
|
|
383
|
+
// The legacy input asserts requirements: 'validated' with no C16 receipt
|
|
384
|
+
// behind it. Migration downgrades it to draft rather than carrying the
|
|
385
|
+
// unprovable claim forward: only spec-authoring-validation.cjs may write
|
|
386
|
+
// `validated`, and only together with a matching digest receipt (I21).
|
|
365
387
|
assert.deepEqual(spec.authoring, {
|
|
366
|
-
requirements: '
|
|
388
|
+
requirements: 'draft', design: 'draft', research: 'absent', tasks: 'draft',
|
|
367
389
|
});
|
|
368
390
|
assert.equal(spec.ready_for_implementation, false);
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
391
|
+
// A legacy validation block never satisfies the canonical 2.1 shape, so
|
|
392
|
+
// migration replaces it with the fresh C2 review, a seeded C13 lineage,
|
|
393
|
+
// and an empty C16 slot — the legacy digest/criteria are not carried
|
|
394
|
+
// forward as if they had been reviewed under the current contract.
|
|
395
|
+
assert.deepEqual(Object.keys(spec.validation).sort(), [
|
|
396
|
+
'authoring_validation', 'semantic_review', 'semantic_review_history', 'status',
|
|
397
|
+
]);
|
|
398
|
+
assert.equal(spec.validation.status, 'not-run');
|
|
399
|
+
assert.deepEqual(spec.validation.semantic_review, {
|
|
400
|
+
status: 'not-run', semantic_digest: null, verdict: null, lifecycle_disposition: null,
|
|
401
|
+
findings: [], unresolved_decisions: [], graph_coverage: [], repair_round: 0,
|
|
402
|
+
reviewed_criteria: [], counterexamples: [], reviewer_evidence: null,
|
|
374
403
|
});
|
|
404
|
+
assert.deepEqual(spec.validation.semantic_review_history.entries, []);
|
|
405
|
+
assert.match(spec.validation.semantic_review_history.lineage_id, /^sha256:[0-9a-f]{64}$/);
|
|
406
|
+
assert.equal(spec.validation.authoring_validation, null);
|
|
375
407
|
},
|
|
376
408
|
},
|
|
377
409
|
{
|
|
378
410
|
name: 'legacy research pointer and valid done lifecycle',
|
|
379
411
|
research: true,
|
|
380
412
|
mutate(spec) {
|
|
381
|
-
spec
|
|
413
|
+
downgradeToLegacy20(spec);
|
|
382
414
|
spec.research = { path: 'research.md' };
|
|
383
415
|
delete spec.authoring.research;
|
|
384
416
|
const task = spec.task_registry['tasks/task-R1-01-one.md'];
|
|
@@ -402,7 +434,7 @@ test('tasks-only normalization is table-driven, conservative, and idempotent', (
|
|
|
402
434
|
{
|
|
403
435
|
name: 'legacy nested inert receipt read migration',
|
|
404
436
|
mutate(spec) {
|
|
405
|
-
spec
|
|
437
|
+
downgradeToLegacy20(spec);
|
|
406
438
|
spec.workflow_policy.override_receipt = null;
|
|
407
439
|
},
|
|
408
440
|
verify(spec) {
|
|
@@ -522,6 +554,7 @@ test('tasks-only migration rejects invalid evidence without any write', () => {
|
|
|
522
554
|
{
|
|
523
555
|
name: 'ambiguous top level inert receipt',
|
|
524
556
|
mutate(root, feature, spec) {
|
|
557
|
+
downgradeToLegacy20(spec);
|
|
525
558
|
delete spec.schema_version;
|
|
526
559
|
spec.override_receipt = null;
|
|
527
560
|
fs.writeFileSync(path.join(root, 'specs', feature, 'spec.json'), `${JSON.stringify(spec, null, 2)}\n`);
|
|
@@ -531,7 +564,7 @@ test('tasks-only migration rejects invalid evidence without any write', () => {
|
|
|
531
564
|
{
|
|
532
565
|
name: 'legacy top level non inert receipt',
|
|
533
566
|
mutate(root, feature, spec) {
|
|
534
|
-
spec
|
|
567
|
+
downgradeToLegacy20(spec);
|
|
535
568
|
spec.override_receipt = { issuer: 'unsupported' };
|
|
536
569
|
fs.writeFileSync(path.join(root, 'specs', feature, 'spec.json'), `${JSON.stringify(spec, null, 2)}\n`);
|
|
537
570
|
},
|
|
@@ -587,15 +620,46 @@ test('schema 2.0 migration rejects unknown authority fields instead of projectio
|
|
|
587
620
|
const feature = `legacy-unknown-${name}`;
|
|
588
621
|
assert.equal(run(root, [feature, '--tasks', 'R1-01-one,R1-02-two', '--boundaries', ownershipBoundary()]).status, 0);
|
|
589
622
|
const specPath = path.join(root, 'specs', feature, 'spec.json');
|
|
590
|
-
const spec = readSpec(root, feature);
|
|
591
|
-
spec.schema_version = '2.0';
|
|
623
|
+
const spec = downgradeToLegacy20(readSpec(root, feature));
|
|
592
624
|
mutate(spec);
|
|
593
625
|
fs.writeFileSync(specPath, `${JSON.stringify(spec, null, 2)}\n`);
|
|
594
626
|
const before = readTree(path.dirname(specPath));
|
|
595
627
|
const result = runMatchingTasksOnly(root, feature);
|
|
596
628
|
assert.equal(result.status, 2, `${name}: ${output(result)}`);
|
|
597
|
-
|
|
629
|
+
// Must name the injected dead_authority field specifically: before the
|
|
630
|
+
// legacy downgrade above, this matched on the 2.1-only validation records
|
|
631
|
+
// instead and passed without exercising the mutation under test.
|
|
632
|
+
assert.match(output(result), /unsupported legacy authority field.*dead_authority/);
|
|
598
633
|
assert.deepEqual(readTree(path.dirname(specPath)), before);
|
|
599
634
|
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
600
635
|
}
|
|
601
636
|
});
|
|
637
|
+
|
|
638
|
+
test('--help and --version answer on stdout before any precondition runs', () => {
|
|
639
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-scaffold-info-flags-'));
|
|
640
|
+
try {
|
|
641
|
+
for (const flag of ['--help', '-h']) {
|
|
642
|
+
const result = run(root, [flag]);
|
|
643
|
+
assert.equal(result.status, 0, `${flag}: ${output(result)}`);
|
|
644
|
+
assert.match(result.stdout, /^Usage: node spec-scaffold\.cjs/, `${flag} must print usage on stdout`);
|
|
645
|
+
assert.equal(result.stderr, '', `${flag} must not write to stderr`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
for (const flag of ['--version', '-v']) {
|
|
649
|
+
const result = run(root, [flag]);
|
|
650
|
+
assert.equal(result.status, 0, `${flag}: ${output(result)}`);
|
|
651
|
+
// Prerelease suffixes are normal on this package (e.g. 0.16.0-rc.4).
|
|
652
|
+
assert.match(result.stdout.trim(), /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/, `${flag} must print a SemVer version`);
|
|
653
|
+
assert.equal(result.stderr, '', `${flag} must not write to stderr`);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// A help flag must never be read as a feature name, and must not create a spec.
|
|
657
|
+
assert.deepEqual(fs.readdirSync(root), [], 'info flags must not touch the work tree');
|
|
658
|
+
|
|
659
|
+
// The no-argument path keeps usage-as-diagnostic: stderr, exit 2.
|
|
660
|
+
const bare = run(root, []);
|
|
661
|
+
assert.equal(bare.status, 2, output(bare));
|
|
662
|
+
assert.equal(bare.stdout, '', 'bare invocation must not print usage on stdout');
|
|
663
|
+
assert.match(bare.stderr, /^Usage: node spec-scaffold\.cjs/);
|
|
664
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
665
|
+
});
|