@dzhechkov/p-replicator 1.13.1 → 1.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +93 -25
- package/README.md +19 -1
- package/package.json +5 -4
- package/sbom.json +194 -24
- package/scripts/check-pipeline-gaps.sh +510 -21
- package/src/utils.js +2 -0
- package/templates/.claude/commands/feature.md +43 -1
- package/templates/.claude/commands/replicate.md +9 -1
- package/templates/.claude/hooks/check-dangling-refs.cjs +89 -0
- package/templates/.claude/hooks/check-docs-complete.cjs +7 -0
- package/templates/.claude/hooks/check-review-contract.cjs +205 -0
- package/templates/.claude/hooks/statusline.cjs +1 -1
- package/templates/.claude/rules/cost-of-detection-ladder.md +37 -4
- package/templates/.claude/rules/docker-ports.md +28 -0
- package/templates/.claude/rules/feature-lifecycle.md +21 -0
- package/templates/.claude/rules/replicate-pipeline.md +5 -4
- package/templates/.claude/skills/brutal-honesty-review/resources/assessment-rubrics.md +12 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/security-patterns-library.md +45 -0
- package/templates/.claude/skills/requirements-validator/SKILL.md +18 -5
- package/templates/.claude/skills/requirements-validator/references/feature-report-contracts.md +67 -0
- package/templates/.claude/skills/requirements-validator/references/scoring-system.md +15 -6
- package/tests/e2e/feature-contour.test.js +176 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/01_specification.md +15 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/02_pseudocode.md +19 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/03_architecture.md +3 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/04_refinement.md +3 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/05_completion.md +7 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/review-report.md +11 -0
- package/tests/fixtures/feature-contour/docs/features/demo-gate/validation-report.md +10 -0
- package/tests/fixtures/feature-contour/tests/demo.test.js +16 -0
- package/tests/snapshot/baseline.json +17 -14
- package/tests/unit/capture-source-path.test.js +73 -24
- package/tests/unit/check-dangling-refs.test.js +91 -0
- package/tests/unit/check-review-contract.test.js +181 -0
- package/tests/unit/guard-honest-input-meta.test.js +49 -0
- package/tests/unit/honest-failure-rules.test.js +40 -3
- package/tests/unit/negative-conclusion-gate.test.js +3 -3
- package/tests/unit/optional-doc-idiom.test.js +83 -0
- package/tests/unit/quote-provenance.test.js +4 -0
- package/tests/unit/sync-templates-guard.test.js +46 -3
- package/tests/unit/traceability-completion-gate.test.js +267 -0
- package/tests/unit/traceability-negative-fixture.test.js +19 -5
- package/tests/unit/verdict-vocabulary.test.js +72 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A document may be retired from the required set only WITH A DATED MEASUREMENT beside it.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS IS A TEST AND NOT A CONVENTION. Retiring a document is the cheapest way to make a
|
|
7
|
+
* pipeline look faster, and it is invisible afterwards: nobody notices a check that stopped
|
|
8
|
+
* running. The package already ships the honest form of this move — `Final_Summary.md` carries
|
|
9
|
+
* `{ optional: true, expected: true }` plus a comment naming WHAT was measured, WHEN, and WHY the
|
|
10
|
+
* evidence is not yet enough to decide. That is reversible and it leaves a receipt.
|
|
11
|
+
*
|
|
12
|
+
* This test makes the receipt mandatory. `optional: true` without a `MEASURED YYYY-MM-DD` comment
|
|
13
|
+
* above it turns red, so the next person who wants to drop a document must either measure or argue
|
|
14
|
+
* in the open. The guard is deliberately about the RECEIPT, not about which documents are optional:
|
|
15
|
+
* deciding that is a design call, and this file does not pretend to make it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { test, describe } = require('node:test');
|
|
19
|
+
const assert = require('node:assert/strict');
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
const HOOK = path.join(__dirname, '..', '..', 'templates', '.claude', 'hooks', 'check-docs-complete.cjs');
|
|
24
|
+
const DATE = /MEASURED \d{4}-\d{2}-\d{2}/;
|
|
25
|
+
|
|
26
|
+
/** Lines of the DOCS array, paired with the comment block immediately above each entry. */
|
|
27
|
+
function optionalEntriesWithContext(source) {
|
|
28
|
+
const lines = source.split('\n');
|
|
29
|
+
const out = [];
|
|
30
|
+
lines.forEach((line, i) => {
|
|
31
|
+
// Match an ENTRY of the DOCS array, not any prose that happens to contain the words. The first
|
|
32
|
+
// version of this parser matched a doc-comment at :29 that merely EXPLAINS the flag, which
|
|
33
|
+
// would have made the guard permanently red for a reason unrelated to any real receipt.
|
|
34
|
+
if (!/^\s*\{\s*file:\s*'[^']+'.*optional:\s*true/.test(line)) return;
|
|
35
|
+
// Walk up through the contiguous comment block directly above this entry.
|
|
36
|
+
let j = i - 1;
|
|
37
|
+
const comment = [];
|
|
38
|
+
while (j >= 0 && /^\s*\/\//.test(lines[j])) { comment.unshift(lines[j]); j -= 1; }
|
|
39
|
+
out.push({ line: i + 1, entry: line.trim(), comment: comment.join('\n') });
|
|
40
|
+
});
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('retiring a document leaves a dated receipt', () => {
|
|
45
|
+
test('every `optional: true` entry carries a MEASURED date above it', () => {
|
|
46
|
+
const source = fs.readFileSync(HOOK, 'utf8');
|
|
47
|
+
const entries = optionalEntriesWithContext(source);
|
|
48
|
+
assert.ok(entries.length > 0, 'the fixture assumes at least one optional document exists');
|
|
49
|
+
|
|
50
|
+
const undated = entries.filter((e) => !DATE.test(e.comment));
|
|
51
|
+
assert.deepEqual(
|
|
52
|
+
undated.map((e) => `${HOOK}:${e.line} ${e.entry}`),
|
|
53
|
+
[],
|
|
54
|
+
'a document was made optional without a dated measurement — say what was measured and when',
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('the guard fires on an injected undated entry', () => {
|
|
59
|
+
// Discrimination: without this, a guard that found nothing would look identical to a guard
|
|
60
|
+
// that cannot see anything. Inject the exact shape it must catch.
|
|
61
|
+
const injected = [
|
|
62
|
+
"const DOCS = [",
|
|
63
|
+
" { file: 'Honest.md' },",
|
|
64
|
+
" // a comment with no date at all",
|
|
65
|
+
" { file: 'Sneaky.md', optional: true },",
|
|
66
|
+
"];",
|
|
67
|
+
].join('\n');
|
|
68
|
+
const found = optionalEntriesWithContext(injected).filter((e) => !DATE.test(e.comment));
|
|
69
|
+
assert.equal(found.length, 1, 'the parser must catch an undated optional entry');
|
|
70
|
+
assert.match(found[0].entry, /Sneaky\.md/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a dated entry passes, so the guard is not simply always-red', () => {
|
|
74
|
+
const injected = [
|
|
75
|
+
"const DOCS = [",
|
|
76
|
+
" // MEASURED 2026-08-27 against a real project: produced 8 of 9 promised documents.",
|
|
77
|
+
" { file: 'Final_Summary.md', optional: true, expected: true },",
|
|
78
|
+
"];",
|
|
79
|
+
].join('\n');
|
|
80
|
+
const found = optionalEntriesWithContext(injected).filter((e) => !DATE.test(e.comment));
|
|
81
|
+
assert.deepEqual(found, [], 'a dated entry must pass');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -15,6 +15,10 @@ function runPython(source, args = []) {
|
|
|
15
15
|
const result = spawnSync("python3", ["-c", source, ...args], {
|
|
16
16
|
cwd: scriptsDir,
|
|
17
17
|
encoding: "utf8",
|
|
18
|
+
// PYTHONDONTWRITEBYTECODE: a test run must not leave __pycache__/*.pyc inside templates/.
|
|
19
|
+
// Measured 2026-09-02: six .pyc files were signed into a package manifest and packed for
|
|
20
|
+
// publication because `npm test` spawned python3 here without it.
|
|
21
|
+
env: { ...process.env, PYTHONDONTWRITEBYTECODE: "1" },
|
|
18
22
|
});
|
|
19
23
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
20
24
|
return result.stdout.trim();
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// scripts/sync-templates.js copies <found root>/.claude/{skills,commands,agents,rules,hooks} over
|
|
4
4
|
// templates/.claude/ — the directory that becomes the npm tarball. It finds its source by walking up
|
|
5
|
-
// to five parents
|
|
6
|
-
// MONOREPO, whose .claude/skills holds a whole
|
|
5
|
+
// to five parents, stopping at the package repository boundary, and taking the FIRST one containing
|
|
6
|
+
// .claude/skills, which inside a monorepo is the MONOREPO, whose .claude/skills holds a whole
|
|
7
|
+
// different toolkit.
|
|
7
8
|
//
|
|
8
9
|
// Nothing had fired only because package.json's prepublishOnly points at the publish gate, not here.
|
|
9
10
|
// "Safe because dead" is not a safety property: a dead script can be revived by anyone who does not
|
|
@@ -61,7 +62,11 @@ const DECLARATION = 'p-replicator-sync-source: v1';
|
|
|
61
62
|
* with its own templates/.claude tree. `marker` decides whether the root claims to be the source. */
|
|
62
63
|
function fixture(opts) {
|
|
63
64
|
const o = opts || {};
|
|
64
|
-
const
|
|
65
|
+
const parent = o.parent || os.tmpdir();
|
|
66
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(parent, 'p-rep-sync-')));
|
|
67
|
+
const boundary = o.boundary === undefined ? 'dir' : o.boundary;
|
|
68
|
+
if (boundary === 'dir') fs.mkdirSync(path.join(root, '.git'));
|
|
69
|
+
if (boundary === 'file') fs.writeFileSync(path.join(root, '.git'), 'gitdir: /nonexistent\n');
|
|
65
70
|
if (o.rootHasClaude !== false) {
|
|
66
71
|
fs.mkdirSync(path.join(root, '.claude', 'skills', 'intruder'), { recursive: true });
|
|
67
72
|
fs.writeFileSync(path.join(root, '.claude', 'skills', 'intruder', 'SKILL.md'),
|
|
@@ -208,6 +213,44 @@ describe.skip = describe.skip || (() => {});
|
|
|
208
213
|
} finally { cleanup(f.root); }
|
|
209
214
|
});
|
|
210
215
|
|
|
216
|
+
test('P10 — a .claude/skills ABOVE the repository boundary is not a candidate root', () => {
|
|
217
|
+
const grandparent = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-intruder-')));
|
|
218
|
+
fs.mkdirSync(path.join(grandparent, '.claude', 'skills', 'intruder'), { recursive: true });
|
|
219
|
+
fs.writeFileSync(path.join(grandparent, '.claude', 'skills', 'intruder', 'SKILL.md'),
|
|
220
|
+
'# outside the fixture repository\n');
|
|
221
|
+
try {
|
|
222
|
+
const bounded = fixture({ parent: grandparent, rootHasClaude: false, boundary: 'dir' });
|
|
223
|
+
try {
|
|
224
|
+
const r = runScript(bounded.pkg);
|
|
225
|
+
assert.equal(r.code, 0, 'the repository boundary must stop the walk: ' + r.out);
|
|
226
|
+
assert.match(r.out, /Not in repo context — skipping sync/);
|
|
227
|
+
} finally { cleanup(bounded.root); }
|
|
228
|
+
|
|
229
|
+
const unbounded = fixture({ parent: grandparent, rootHasClaude: false, boundary: false });
|
|
230
|
+
try {
|
|
231
|
+
const r = runScript(unbounded.pkg);
|
|
232
|
+
assert.equal(r.code, 1, 'without a boundary the outside candidate must still be found');
|
|
233
|
+
assert.match(r.out, /REFUSING to sync/,
|
|
234
|
+
'the contrast proves the boundary, rather than the host /tmp state, caused the skip');
|
|
235
|
+
} finally { cleanup(unbounded.root); }
|
|
236
|
+
} finally { cleanup(grandparent); }
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('P11 — a .git FILE (worktree) is a boundary too', () => {
|
|
240
|
+
const grandparent = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-intruder-')));
|
|
241
|
+
fs.mkdirSync(path.join(grandparent, '.claude', 'skills', 'intruder'), { recursive: true });
|
|
242
|
+
fs.writeFileSync(path.join(grandparent, '.claude', 'skills', 'intruder', 'SKILL.md'),
|
|
243
|
+
'# outside the fixture worktree\n');
|
|
244
|
+
try {
|
|
245
|
+
const f = fixture({ parent: grandparent, rootHasClaude: false, boundary: 'file' });
|
|
246
|
+
try {
|
|
247
|
+
const r = runScript(f.pkg);
|
|
248
|
+
assert.equal(r.code, 0, 'a worktree .git file must stop the walk: ' + r.out);
|
|
249
|
+
assert.match(r.out, /Not in repo context — skipping sync/);
|
|
250
|
+
} finally { cleanup(f.root); }
|
|
251
|
+
} finally { cleanup(grandparent); }
|
|
252
|
+
});
|
|
253
|
+
|
|
211
254
|
test('P5 — no helper tells a reader to run it', () => {
|
|
212
255
|
// The helper printed "Run prepublishOnly first: node scripts/sync-templates.js" when templates/
|
|
213
256
|
// was missing: a live invitation to the hazardous command, in the one situation where a reader
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const os = require('node:os');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const { spawnSync } = require('node:child_process');
|
|
10
|
+
|
|
11
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..', '..');
|
|
12
|
+
const CHECKER = path.join(PACKAGE_ROOT, 'scripts', 'check-pipeline-gaps.sh');
|
|
13
|
+
const TRACEABILITY_FIXTURE = path.join(PACKAGE_ROOT, 'tests', 'fixtures', 'prep-traceability-fixture');
|
|
14
|
+
const FEATURE_TEMPLATE = path.join(PACKAGE_ROOT, 'templates', '.claude', 'commands', 'feature.md');
|
|
15
|
+
const PROJECT_TEMPLATE = path.join(
|
|
16
|
+
PACKAGE_ROOT, 'templates', '.claude', 'skills', 'sparc-prd-mini', 'SKILL.md',
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const ROLES = {
|
|
20
|
+
specification: '01_specification.md',
|
|
21
|
+
pseudocode: '02_pseudocode.md',
|
|
22
|
+
architecture: '03_architecture.md',
|
|
23
|
+
refinement: '04_refinement.md',
|
|
24
|
+
completion: '05_completion.md',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function write(root, relative, body) {
|
|
28
|
+
const target = path.join(root, relative);
|
|
29
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
30
|
+
fs.writeFileSync(target, body);
|
|
31
|
+
return target;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function roleMap(root, relative, heading) {
|
|
35
|
+
const rows = Object.entries(ROLES).map(([role, target]) => ` ${role}: ${target}`);
|
|
36
|
+
return write(root, relative,
|
|
37
|
+
`${heading}\n\n\`\`\`yaml\nDOCUMENT_ROLE_MAP:\n${rows.join('\n')}\n\`\`\`\n`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function temp(t, prefix = 'traceability-completion-') {
|
|
41
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
42
|
+
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
43
|
+
return root;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createContour(root, options = {}) {
|
|
47
|
+
const slug = options.slug || 'demo';
|
|
48
|
+
const prefix = path.join('docs', 'features', slug);
|
|
49
|
+
const specification = options.specification || [
|
|
50
|
+
`# ${slug} specification`,
|
|
51
|
+
'',
|
|
52
|
+
`### FR-${slug}-1 — feature requirement`,
|
|
53
|
+
'',
|
|
54
|
+
`### AC-${slug}-1 — accepts a valid request`,
|
|
55
|
+
'',
|
|
56
|
+
`### AC-${slug}-2 — rejects an invalid request`,
|
|
57
|
+
'',
|
|
58
|
+
].join('\n');
|
|
59
|
+
const digest = crypto.createHash('sha256').update(specification).digest('hex');
|
|
60
|
+
write(root, path.join(prefix, ROLES.specification), specification);
|
|
61
|
+
write(root, path.join(prefix, ROLES.pseudocode), [
|
|
62
|
+
`### Algorithm: ${slug}`,
|
|
63
|
+
`REQUIREMENT: \`FR-${slug}-1\``,
|
|
64
|
+
`REQUIREMENT: \`AC-${slug}-1\``,
|
|
65
|
+
`REQUIREMENT: \`AC-${slug}-2\``,
|
|
66
|
+
'',
|
|
67
|
+
].join('\n'));
|
|
68
|
+
write(root, path.join(prefix, ROLES.architecture), '# Architecture\n');
|
|
69
|
+
write(root, path.join(prefix, ROLES.refinement), '# Refinement\n');
|
|
70
|
+
write(root, path.join(prefix, ROLES.completion), options.completion || [
|
|
71
|
+
'# Completion',
|
|
72
|
+
'',
|
|
73
|
+
'## Criterion coverage',
|
|
74
|
+
'| Criterion | Test file | Test title |',
|
|
75
|
+
'|-----------|-----------|------------|',
|
|
76
|
+
`| AC-${slug}-1 | tests/${slug}.test.js | accepts a valid request |`,
|
|
77
|
+
`| AC-${slug}-2 | tests/${slug}.test.js | rejects an invalid request |`,
|
|
78
|
+
'',
|
|
79
|
+
].join('\n'));
|
|
80
|
+
write(root, path.join(prefix, 'validation-report.md'), options.validation || [
|
|
81
|
+
'# Validation report',
|
|
82
|
+
`Spec revision: sha256:${digest}`,
|
|
83
|
+
'',
|
|
84
|
+
'## Criterion scenarios',
|
|
85
|
+
'| Criterion | Scenario |',
|
|
86
|
+
'|-----------|----------|',
|
|
87
|
+
`| AC-${slug}-1 | accepts a valid request |`,
|
|
88
|
+
`| AC-${slug}-2 | rejects an invalid request |`,
|
|
89
|
+
'',
|
|
90
|
+
].join('\n'));
|
|
91
|
+
write(root, path.join('tests', `${slug}.test.js`), options.testBody || [
|
|
92
|
+
"test('accepts a valid request', () => {});",
|
|
93
|
+
"test('rejects an invalid request', () => {});",
|
|
94
|
+
'',
|
|
95
|
+
].join('\n'));
|
|
96
|
+
return { slug, prefix, digest, specification };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function maps(root) {
|
|
100
|
+
return {
|
|
101
|
+
feature: roleMap(root, 'feature-map.md', '### Phase 1 document role map'),
|
|
102
|
+
project: roleMap(root, 'project-map.md', '### Project-level default'),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function runChecker(root, flags, options = {}) {
|
|
107
|
+
const roleMaps = options.maps || maps(root);
|
|
108
|
+
const result = spawnSync('bash', [options.checker || CHECKER, root, ...flags,
|
|
109
|
+
'--role-map-source', roleMaps.feature,
|
|
110
|
+
'--project-role-map-source', roleMaps.project,
|
|
111
|
+
], { cwd: options.cwd || PACKAGE_ROOT, encoding: 'utf8', timeout: 10000 });
|
|
112
|
+
return {
|
|
113
|
+
status: result.status,
|
|
114
|
+
signal: result.signal,
|
|
115
|
+
stdout: result.stdout || '',
|
|
116
|
+
stderr: result.stderr || '',
|
|
117
|
+
output: `${result.stdout || ''}${result.stderr || ''}`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe('traceability completion and report gates', () => {
|
|
122
|
+
test('P1 — clean contour: --completion and --report-revision exit 0 with PASS verdicts', (t) => {
|
|
123
|
+
const root = temp(t);
|
|
124
|
+
createContour(root);
|
|
125
|
+
const result = runChecker(root, ['--completion', '--report-revision']);
|
|
126
|
+
assert.equal(result.status, 0, result.output);
|
|
127
|
+
assert.match(result.output, /VERDICT completion=PASS features=1 gaps=0 inconclusive=0/);
|
|
128
|
+
assert.match(result.output, /VERDICT report-revision=PASS features=1 gaps=0 inconclusive=0/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('P2 — an AC with no coverage row is a named gap (exit 1)', (t) => {
|
|
132
|
+
const root = temp(t);
|
|
133
|
+
const contour = createContour(root);
|
|
134
|
+
const completion = path.join(root, contour.prefix, ROLES.completion);
|
|
135
|
+
fs.writeFileSync(completion, fs.readFileSync(completion, 'utf8')
|
|
136
|
+
.replace(/^\| AC-demo-2 .*\n/m, ''));
|
|
137
|
+
const result = runChecker(root, ['--completion']);
|
|
138
|
+
assert.equal(result.status, 1, result.output);
|
|
139
|
+
assert.match(result.output,
|
|
140
|
+
/GAP contour=demo completion AC-demo-2 has no row in Criterion coverage/);
|
|
141
|
+
assert.match(result.output, /VERDICT completion=FAIL/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('P3 — a row whose id is not in the specification is a named gap', (t) => {
|
|
145
|
+
const root = temp(t);
|
|
146
|
+
const contour = createContour(root);
|
|
147
|
+
const completion = path.join(root, contour.prefix, ROLES.completion);
|
|
148
|
+
fs.appendFileSync(completion,
|
|
149
|
+
'| AC-demo-9 | tests/demo.test.js | accepts a valid request |\n');
|
|
150
|
+
const result = runChecker(root, ['--completion']);
|
|
151
|
+
assert.equal(result.status, 1, result.output);
|
|
152
|
+
assert.match(result.output,
|
|
153
|
+
/GAP contour=demo completion row AC-demo-9 is not declared in the specification/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('P4 — a row whose test file lacks the title is a named gap; escaping and symlinked paths are refused', (t) => {
|
|
157
|
+
const root = temp(t);
|
|
158
|
+
const contour = createContour(root, { testBody: "test('another title', () => {});\n" });
|
|
159
|
+
const completion = path.join(root, contour.prefix, ROLES.completion);
|
|
160
|
+
let result = runChecker(root, ['--completion']);
|
|
161
|
+
assert.equal(result.status, 1, result.output);
|
|
162
|
+
assert.match(result.output,
|
|
163
|
+
/AC-demo-1 test file tests\/demo\.test\.js does not contain title "accepts a valid request"/);
|
|
164
|
+
|
|
165
|
+
fs.writeFileSync(completion, fs.readFileSync(completion, 'utf8')
|
|
166
|
+
.replace('tests/demo.test.js | accepts a valid request',
|
|
167
|
+
'../outside.test.js | accepts a valid request'));
|
|
168
|
+
result = runChecker(root, ['--completion']);
|
|
169
|
+
assert.equal(result.status, 1, result.output);
|
|
170
|
+
assert.match(result.output, /test file \.\.\/outside\.test\.js escapes the project root/);
|
|
171
|
+
|
|
172
|
+
const outside = temp(t, 'traceability-completion-outside-');
|
|
173
|
+
write(outside, 'linked.test.js', "test('accepts a valid request', () => {});\n");
|
|
174
|
+
fs.symlinkSync(path.join(outside, 'linked.test.js'), path.join(root, 'tests', 'linked.test.js'));
|
|
175
|
+
fs.writeFileSync(completion, fs.readFileSync(completion, 'utf8')
|
|
176
|
+
.replace('../outside.test.js', 'tests/linked.test.js'));
|
|
177
|
+
result = runChecker(root, ['--completion']);
|
|
178
|
+
assert.equal(result.status, 1, result.output);
|
|
179
|
+
assert.match(result.output, /test file tests\/linked\.test\.js uses a symlink/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('P5 — a stale revision line exits 1 naming both prefixes; a missing line exits 2', (t) => {
|
|
183
|
+
const root = temp(t);
|
|
184
|
+
const contour = createContour(root);
|
|
185
|
+
const report = path.join(root, contour.prefix, 'validation-report.md');
|
|
186
|
+
const stale = 'a'.repeat(64);
|
|
187
|
+
fs.writeFileSync(report, fs.readFileSync(report, 'utf8')
|
|
188
|
+
.replace(contour.digest, stale));
|
|
189
|
+
let result = runChecker(root, ['--report-revision']);
|
|
190
|
+
assert.equal(result.status, 1, result.output);
|
|
191
|
+
assert.match(result.output,
|
|
192
|
+
new RegExp(`sha256:${stale.slice(0, 12)}… != specification sha256:${contour.digest.slice(0, 12)}…`));
|
|
193
|
+
|
|
194
|
+
fs.writeFileSync(report, fs.readFileSync(report, 'utf8')
|
|
195
|
+
.replace(/^Spec revision:.*\n/m, ''));
|
|
196
|
+
result = runChecker(root, ['--report-revision']);
|
|
197
|
+
assert.equal(result.status, 2, result.output);
|
|
198
|
+
assert.match(result.output,
|
|
199
|
+
/NOT-ESTABLISHED contour=demo report-revision line missing in validation-report\.md/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('P5b — --criterion-scenarios names missing and undeclared rows; a missing table exits 2', (t) => {
|
|
203
|
+
const root = temp(t);
|
|
204
|
+
const contour = createContour(root);
|
|
205
|
+
const report = path.join(root, contour.prefix, 'validation-report.md');
|
|
206
|
+
fs.writeFileSync(report, fs.readFileSync(report, 'utf8')
|
|
207
|
+
.replace(/^\| AC-demo-2 .*\n/m, '')
|
|
208
|
+
.replace('| AC-demo-1 | accepts a valid request |', [
|
|
209
|
+
'| AC-demo-1 | accepts a valid request |',
|
|
210
|
+
'| AC-demo-9 | undeclared scenario |',
|
|
211
|
+
].join('\n')));
|
|
212
|
+
let result = runChecker(root, ['--criterion-scenarios']);
|
|
213
|
+
assert.equal(result.status, 1, result.output);
|
|
214
|
+
assert.match(result.output,
|
|
215
|
+
/GAP contour=demo criterion-scenarios AC-demo-2 has no scenario row/);
|
|
216
|
+
assert.match(result.output,
|
|
217
|
+
/GAP contour=demo criterion-scenarios row AC-demo-9 is not declared in the specification/);
|
|
218
|
+
|
|
219
|
+
fs.writeFileSync(report, [
|
|
220
|
+
'# Validation report',
|
|
221
|
+
`Spec revision: sha256:${contour.digest}`,
|
|
222
|
+
'',
|
|
223
|
+
'## Different section',
|
|
224
|
+
'',
|
|
225
|
+
].join('\n'));
|
|
226
|
+
result = runChecker(root, ['--criterion-scenarios']);
|
|
227
|
+
assert.equal(result.status, 2, result.output);
|
|
228
|
+
assert.match(result.output, /NOT-ESTABLISHED.*criterion-scenarios table missing or malformed/);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('P6 — flags compose and the worst status wins', (t) => {
|
|
232
|
+
const root = temp(t);
|
|
233
|
+
const contour = createContour(root);
|
|
234
|
+
const completion = path.join(root, contour.prefix, ROLES.completion);
|
|
235
|
+
const report = path.join(root, contour.prefix, 'validation-report.md');
|
|
236
|
+
fs.writeFileSync(completion, fs.readFileSync(completion, 'utf8')
|
|
237
|
+
.replace(/^\| AC-demo-2 .*\n/m, ''));
|
|
238
|
+
fs.writeFileSync(report, fs.readFileSync(report, 'utf8')
|
|
239
|
+
.replace(/^Spec revision:.*\n/m, ''));
|
|
240
|
+
const result = runChecker(root, [
|
|
241
|
+
'--traceability', '--completion', '--report-revision', '--criterion-scenarios',
|
|
242
|
+
]);
|
|
243
|
+
assert.equal(result.status, 2, result.output);
|
|
244
|
+
assert.match(result.output, /VERDICT traceability=PASS/);
|
|
245
|
+
assert.match(result.output, /VERDICT completion=FAIL/);
|
|
246
|
+
assert.match(result.output, /VERDICT report-revision=NOT-ESTABLISHED/);
|
|
247
|
+
assert.match(result.output, /VERDICT criterion-scenarios=PASS/);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('P7 — --traceability output is byte-identical to before on the existing fixture', (t) => {
|
|
251
|
+
const root = temp(t, 'traceability-legacy-checker-');
|
|
252
|
+
const oldScript = path.join(root, 'check-pipeline-gaps.sh');
|
|
253
|
+
const shown = spawnSync('git', ['show', 'HEAD:./scripts/check-pipeline-gaps.sh'], {
|
|
254
|
+
cwd: PACKAGE_ROOT, encoding: 'utf8', timeout: 10000,
|
|
255
|
+
});
|
|
256
|
+
assert.equal(shown.status, 0, `${shown.stdout}\n${shown.stderr}`);
|
|
257
|
+
fs.writeFileSync(oldScript, shown.stdout, { mode: 0o755 });
|
|
258
|
+
const roleMaps = { feature: FEATURE_TEMPLATE, project: PROJECT_TEMPLATE };
|
|
259
|
+
const before = runChecker(TRACEABILITY_FIXTURE, ['--traceability'], {
|
|
260
|
+
checker: oldScript, maps: roleMaps,
|
|
261
|
+
});
|
|
262
|
+
const after = runChecker(TRACEABILITY_FIXTURE, ['--traceability'], { maps: roleMaps });
|
|
263
|
+
assert.equal(after.status, before.status, after.output);
|
|
264
|
+
assert.equal(after.stdout, before.stdout);
|
|
265
|
+
assert.equal(after.stderr, before.stderr);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -230,14 +230,28 @@ describe('the per-feature traceability gap is a named negative fixture (PR-021)'
|
|
|
230
230
|
const pkg = JSON.parse(read(PACKAGE_JSON));
|
|
231
231
|
const unitFiles = fs.readdirSync(path.join(PACKAGE_ROOT, 'tests', 'unit'));
|
|
232
232
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
233
|
+
// Registration is checked against the UNION of the declared lanes, not against `test:unit`
|
|
234
|
+
// alone. A unit file may legitimately live in a slower lane — `test:browser` drives a real
|
|
235
|
+
// browser and ran for over 6m40s inside the 9m20s `npm test`, pushing the whole suite against
|
|
236
|
+
// the 10-minute call ceiling (measured 2026-09-03). What must NEVER happen is a file that runs
|
|
237
|
+
// in NO lane, because that is how "we made the suite fast" turns into "we stopped testing it".
|
|
238
|
+
// So the union is what the guard accepts, and the lane list itself is asserted below: dropping
|
|
239
|
+
// `test:browser` from package.json turns this test red rather than silently shrinking coverage.
|
|
240
|
+
const LANES = ['test:unit', 'test:browser'];
|
|
241
|
+
for (const lane of LANES) {
|
|
242
|
+
assert.ok(typeof pkg.scripts[lane] === 'string' && pkg.scripts[lane].length > 0,
|
|
243
|
+
`lane ${lane} must exist in package.json — a lane that vanishes takes its tests with it`);
|
|
244
|
+
}
|
|
245
|
+
const unionScript = LANES.map((l) => pkg.scripts[l]).join(' ');
|
|
246
|
+
|
|
247
|
+
assert.deepEqual(unregisteredUnitTests(unitFiles, unionScript), [],
|
|
248
|
+
'a unit file on disk runs in NO declared lane');
|
|
249
|
+
assert.deepEqual(unregisteredUnitTests(unitFiles, `${pkg.scripts.test} ${pkg.scripts['test:browser']}`), [],
|
|
250
|
+
'a unit file runs in neither `test` nor `test:browser`');
|
|
237
251
|
|
|
238
252
|
const probe = '__unregistered-probe.test.js';
|
|
239
253
|
assert.deepEqual(
|
|
240
|
-
unregisteredUnitTests([...unitFiles, probe],
|
|
254
|
+
unregisteredUnitTests([...unitFiles, probe], unionScript),
|
|
241
255
|
[probe],
|
|
242
256
|
'the registration guard must fire on a real injected unregistered filename',
|
|
243
257
|
);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* «НЕ УСТАНОВЛЕНО» / «НЕ ИЗМЕРЕНО» mean REFUSAL here, and no future text may reuse them to mean
|
|
5
|
+
* "carry on".
|
|
6
|
+
*
|
|
7
|
+
* WHY A TEST AND NOT A STYLE NOTE. The toolkit already ships a three-valued verdict whose third
|
|
8
|
+
* value BLOCKS: `commands/feature.md` («`2` ПРОВЕРКА НЕ ВЫПОЛНЕНА, и это никогда не «всё чисто»»),
|
|
9
|
+
* `hooks/capture-source-path.cjs` («единственная дверь к коду 2»), `hooks/check-look-trace.cjs`.
|
|
10
|
+
* A proposal reviewed on 2026-09-02 reused the SAME words for a passing outcome — a row written by
|
|
11
|
+
* the author saying "not established, moving on". Two opposite meanings behind one phrase is worse
|
|
12
|
+
* than a new phrase: a reader who learned the blocking sense would read a pass as a refusal, and a
|
|
13
|
+
* reader who learned the passing sense would ignore a real block.
|
|
14
|
+
*
|
|
15
|
+
* The guard is deliberately narrow. It does NOT try to parse intent. It asserts that every file
|
|
16
|
+
* introducing these terms as a VERDICT also contains a non-zero exit, i.e. the vocabulary and the
|
|
17
|
+
* refusal live together. Prose that merely mentions the phrase in passing is not a verdict and is
|
|
18
|
+
* excluded by requiring the term to appear in a verdict-shaped context.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { test, describe } = require('node:test');
|
|
22
|
+
const assert = require('node:assert/strict');
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const HOOKS = path.join(__dirname, '..', '..', 'templates', '.claude', 'hooks');
|
|
27
|
+
const TERMS = /НЕ УСТАНОВЛЕНО|НЕ ИЗМЕРЕНО|NOT-ESTABLISHED/;
|
|
28
|
+
// Three shapes of refusal, all of them real in this package and the first version of this predicate
|
|
29
|
+
// only saw the first. `check-dangling-refs.cjs` computes its code in `main()` and calls
|
|
30
|
+
// `process.exit(main(argv))`; the guard called that "cannot refuse" and fired on a hook that
|
|
31
|
+
// refuses perfectly well. A guard whose predicate is narrower than the thing it guards produces
|
|
32
|
+
// false accusations, which cost more trust than the misses they were meant to prevent.
|
|
33
|
+
const NONZERO_EXIT = new RegExp([
|
|
34
|
+
'process\\.exit\\(\\s*[1-9]\\d*\\s*\\)', // literal: process.exit(2)
|
|
35
|
+
'exitCode\\s*=\\s*[1-9]', // assigned: process.exitCode = 1
|
|
36
|
+
'process\\.exit\\(\\s*[A-Za-z_$]', // computed: process.exit(main(argv))
|
|
37
|
+
].join('|'));
|
|
38
|
+
|
|
39
|
+
function hookFiles() {
|
|
40
|
+
return fs.readdirSync(HOOKS).filter((f) => f.endsWith('.cjs')).map((f) => path.join(HOOKS, f));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('the refusal vocabulary always sits next to a refusal', () => {
|
|
44
|
+
test('every hook using the terms can also exit non-zero', () => {
|
|
45
|
+
const offenders = [];
|
|
46
|
+
for (const file of hookFiles()) {
|
|
47
|
+
const src = fs.readFileSync(file, 'utf8');
|
|
48
|
+
if (!TERMS.test(src)) continue;
|
|
49
|
+
if (!NONZERO_EXIT.test(src)) offenders.push(path.basename(file));
|
|
50
|
+
}
|
|
51
|
+
assert.deepEqual(offenders, [],
|
|
52
|
+
'a hook speaks the refusal vocabulary but has no way to refuse — the words promise a block the code cannot deliver');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('the guard fires on a hook that speaks the words without refusing', () => {
|
|
56
|
+
// Discrimination on the predicate itself: a guard that found nothing must be shown to be
|
|
57
|
+
// capable of finding something. Without this, an empty offender list is indistinguishable
|
|
58
|
+
// from a broken matcher.
|
|
59
|
+
const speaksButCannotRefuse = "// НЕ ИЗМЕРЕНО\nconsole.log('ok');\nprocess.exit(0);\n";
|
|
60
|
+
assert.equal(TERMS.test(speaksButCannotRefuse), true, 'the term matcher must see the phrase');
|
|
61
|
+
assert.equal(NONZERO_EXIT.test(speaksButCannotRefuse), false, 'exit(0) is not a refusal');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('at least one hook really does carry both, so the guard is not vacuous', () => {
|
|
65
|
+
const both = hookFiles().filter((f) => {
|
|
66
|
+
const src = fs.readFileSync(f, 'utf8');
|
|
67
|
+
return TERMS.test(src) && NONZERO_EXIT.test(src);
|
|
68
|
+
});
|
|
69
|
+
assert.ok(both.length >= 2,
|
|
70
|
+
'the fixture assumes the toolkit really does use this vocabulary with real refusals');
|
|
71
|
+
});
|
|
72
|
+
});
|