@dzhechkov/p-replicator 1.13.2 → 1.13.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/.dz-manifest.json +56 -24
- package/bin/cli.js +0 -0
- package/package.json +11 -10
- package/sbom.json +103 -23
- package/scripts/check-pipeline-gaps.sh +0 -0
- package/src/utils.js +1 -0
- 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-external-deps.cjs +18 -3
- 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 +3 -3
- 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/tests/dz-availability.js +34 -0
- package/tests/e2e/packed-insights-writer.test.js +5 -1
- package/tests/npm-cli-resolver.js +366 -0
- package/tests/npm-resolver-cases.json +128 -0
- package/tests/snapshot/baseline.json +13 -12
- package/tests/unit/capture-source-path.test.js +73 -24
- package/tests/unit/check-dangling-refs.test.js +91 -0
- package/tests/unit/check-external-deps.test.js +26 -2
- package/tests/unit/detection-ladder-contract.test.js +20 -10
- package/tests/unit/guard-honest-input-meta.test.js +49 -0
- package/tests/unit/honest-failure-rules.test.js +23 -1
- package/tests/unit/insights-writer.test.js +5 -1
- package/tests/unit/negative-conclusion-gate.test.js +3 -3
- package/tests/unit/npm-cli-resolver.test.js +314 -0
- package/tests/unit/optional-doc-idiom.test.js +83 -0
- package/tests/unit/quote-provenance.test.js +4 -0
- package/tests/unit/traceability-negative-fixture.test.js +19 -5
- package/tests/unit/verdict-vocabulary.test.js +72 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The case table IS the contract — feature `repo-sweep-honesty`, T4 (FR-3, FR-4, ADR-001).
|
|
5
|
+
*
|
|
6
|
+
* This is the CommonJS half of a twin pair. It drives `tests/npm-resolver-cases.json`, which is a
|
|
7
|
+
* byte-identical copy of the table that harness-cli's TypeScript twin drives; the two copies are
|
|
8
|
+
* held in step by a guard in harness-core. Neither implementation may grow a behaviour the other
|
|
9
|
+
* lacks, because neither test decides what to check — the table does.
|
|
10
|
+
*
|
|
11
|
+
* The placeholders are resolved WITHOUT the resolver under test (npm through `npm root -g`, pnpm
|
|
12
|
+
* through `which`), so a bug in the resolver cannot also supply its own fixture.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { test } = require('node:test');
|
|
16
|
+
const assert = require('node:assert/strict');
|
|
17
|
+
const { execFileSync } = require('node:child_process');
|
|
18
|
+
const NodeModule = require('node:module');
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
NPM_CLI_RELATIVE_PATH,
|
|
25
|
+
NPM_IDENTITY_MODULE,
|
|
26
|
+
ambientGlobalRoots,
|
|
27
|
+
announceNpmSkip,
|
|
28
|
+
defaultGlobalRoots,
|
|
29
|
+
isNpmCli,
|
|
30
|
+
npmConfigPrefix,
|
|
31
|
+
npmPackageRoot,
|
|
32
|
+
resolveNpmCli,
|
|
33
|
+
resolveNpmCliPath,
|
|
34
|
+
} = require('../npm-cli-resolver.js');
|
|
35
|
+
|
|
36
|
+
const TABLE = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'npm-resolver-cases.json'), 'utf8'));
|
|
37
|
+
|
|
38
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-npm-resolver-'));
|
|
39
|
+
const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-npm-empty-root-'));
|
|
40
|
+
const decoyCli = path.join(scratch, 'decoy-cli.js');
|
|
41
|
+
fs.writeFileSync(decoyCli,
|
|
42
|
+
'// An existing file that is not npm. An existsSync check accepts it; identity does not.\nmodule.exports = {};\n');
|
|
43
|
+
const missingPath = path.join(scratch, 'definitely-absent', NPM_CLI_RELATIVE_PATH);
|
|
44
|
+
|
|
45
|
+
process.on('exit', () => {
|
|
46
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
47
|
+
fs.rmSync(emptyRoot, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** npm, located independently of the code under test. */
|
|
51
|
+
function locateRealNpmCli() {
|
|
52
|
+
try {
|
|
53
|
+
const globalRoot = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
54
|
+
const candidate = path.join(globalRoot, NPM_CLI_RELATIVE_PATH);
|
|
55
|
+
return fs.existsSync(candidate) ? candidate : null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** pnpm, located independently of the code under test — an existing file that is NOT npm. */
|
|
62
|
+
function locateRealPnpmCli() {
|
|
63
|
+
try {
|
|
64
|
+
const which = process.platform === 'win32' ? 'where' : 'which';
|
|
65
|
+
const found = execFileSync(which, ['pnpm'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
66
|
+
.split(/\r?\n/)[0];
|
|
67
|
+
if (!found || found.trim() === '') return null;
|
|
68
|
+
const real = fs.realpathSync(found.trim());
|
|
69
|
+
return fs.existsSync(real) ? real : null;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A file that is NOT npm, sitting in a tree where `libnpmpack` DOES resolve — the fixture a
|
|
77
|
+
* resolution-only identity check accepts (review r1, HIGH-5), built without touching the real npm.
|
|
78
|
+
*/
|
|
79
|
+
function buildIntruder() {
|
|
80
|
+
const tree = path.join(scratch, 'intruder-tree');
|
|
81
|
+
const fake = path.join(tree, 'node_modules', NPM_IDENTITY_MODULE);
|
|
82
|
+
fs.mkdirSync(fake, { recursive: true });
|
|
83
|
+
fs.writeFileSync(path.join(fake, 'package.json'), '{"name":"libnpmpack","version":"0.0.0","main":"index.js"}\n');
|
|
84
|
+
fs.writeFileSync(path.join(fake, 'index.js'), 'module.exports = {};\n');
|
|
85
|
+
const intruder = path.join(tree, 'intruder-cli.js');
|
|
86
|
+
fs.writeFileSync(intruder, '// Not npm. Only its NEIGHBOURHOOD looks like npm.\nmodule.exports = {};\n');
|
|
87
|
+
return intruder;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A symlink to the real npm-cli.js — how a genuine npm is normally reached. */
|
|
91
|
+
function buildNpmSymlink(realNpm) {
|
|
92
|
+
if (realNpm === null) return null;
|
|
93
|
+
const link = path.join(scratch, 'npm-link.js');
|
|
94
|
+
try {
|
|
95
|
+
fs.symlinkSync(realNpm, link);
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return link;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A PATH-style bin directory whose `npm` entry is a symlink to the real CLI. */
|
|
103
|
+
function buildNpmBinDir(realNpm) {
|
|
104
|
+
if (realNpm === null) return null;
|
|
105
|
+
const dir = path.join(scratch, 'fake-bin');
|
|
106
|
+
try {
|
|
107
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
108
|
+
fs.symlinkSync(realNpm, path.join(dir, 'npm'));
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return dir;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** npm's install prefix, derived from `npm root -g` — again, without the resolver under test. */
|
|
116
|
+
function locateNpmPrefix() {
|
|
117
|
+
try {
|
|
118
|
+
const globalRoot = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
119
|
+
if (globalRoot === '') return null;
|
|
120
|
+
const parent = path.dirname(globalRoot);
|
|
121
|
+
return path.basename(globalRoot) === 'node_modules' && path.basename(parent) === 'lib'
|
|
122
|
+
? path.dirname(parent)
|
|
123
|
+
: parent;
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A HOME directory whose `.npmrc` carries `prefix=<npmPrefix>` — npm's own user config. */
|
|
130
|
+
function buildNpmrcHome(prefix) {
|
|
131
|
+
if (prefix === null) return null;
|
|
132
|
+
const home = path.join(scratch, 'npmrc-home');
|
|
133
|
+
fs.mkdirSync(home, { recursive: true });
|
|
134
|
+
fs.writeFileSync(path.join(home, '.npmrc'), `; a user config, exactly as npm writes one\nprefix=${prefix}\n`);
|
|
135
|
+
return home;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const realNpmCli = locateRealNpmCli();
|
|
139
|
+
const npmPrefix = locateNpmPrefix();
|
|
140
|
+
|
|
141
|
+
const PLACEHOLDERS = {
|
|
142
|
+
npmCli: realNpmCli,
|
|
143
|
+
pnpmCli: locateRealPnpmCli(),
|
|
144
|
+
decoyCli,
|
|
145
|
+
intruderCli: buildIntruder(),
|
|
146
|
+
npmCliSymlink: buildNpmSymlink(realNpmCli),
|
|
147
|
+
npmPrefix,
|
|
148
|
+
npmrcHome: buildNpmrcHome(npmPrefix),
|
|
149
|
+
npmBinDir: buildNpmBinDir(realNpmCli),
|
|
150
|
+
missingPath,
|
|
151
|
+
emptyRoot,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
function expand(name) {
|
|
155
|
+
if (!(name in PLACEHOLDERS)) throw new Error(`case table uses an unknown placeholder '${name}'`);
|
|
156
|
+
if (PLACEHOLDERS[name] === null) throw new Error(`placeholder '${name}' is not available on this machine`);
|
|
157
|
+
return PLACEHOLDERS[name];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function missingRequirements(entry) {
|
|
161
|
+
return entry.requires.filter((name) => !(name in PLACEHOLDERS) || PLACEHOLDERS[name] === null);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
test('the table is a real table: it has cases, and every placeholder it names is documented', () => {
|
|
165
|
+
assert.ok(TABLE.cases.length > 0, 'the shared case table is empty');
|
|
166
|
+
for (const entry of TABLE.cases) {
|
|
167
|
+
assert.ok(entry.why, `case ${entry.id} must say why it exists`);
|
|
168
|
+
for (const name of entry.requires) {
|
|
169
|
+
assert.ok(name in TABLE.placeholders, `case ${entry.id} requires undocumented placeholder ${name}`);
|
|
170
|
+
}
|
|
171
|
+
if (entry.given.npmExecPath !== null) {
|
|
172
|
+
assert.ok(entry.given.npmExecPath in PLACEHOLDERS,
|
|
173
|
+
`case ${entry.id} names a placeholder this twin cannot build: ${entry.given.npmExecPath}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
for (const entry of TABLE.cases) {
|
|
179
|
+
test(`${entry.id} — ${entry.why}`, (t) => {
|
|
180
|
+
const missing = missingRequirements(entry);
|
|
181
|
+
if (missing.length > 0) {
|
|
182
|
+
// FR-4: the skip is audible and names the tool that could not be found.
|
|
183
|
+
console.log(`SKIPPED — case '${entry.id}' needs ${missing.join(', ')}, which this machine does not`
|
|
184
|
+
+ ` provide (looked for ${NPM_CLI_RELATIVE_PATH} / pnpm).`);
|
|
185
|
+
t.skip(`missing ${missing.join(', ')}`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const env = {};
|
|
190
|
+
if (entry.given.npmExecPath !== null) env.npm_execpath = expand(entry.given.npmExecPath);
|
|
191
|
+
for (const [name, placeholder] of Object.entries(entry.given.env || {})) {
|
|
192
|
+
env[name] = expand(placeholder);
|
|
193
|
+
}
|
|
194
|
+
const options = entry.given.globalRoots === 'defaultRoots'
|
|
195
|
+
? {}
|
|
196
|
+
: { globalRoots: entry.given.globalRoots.map(expand) };
|
|
197
|
+
|
|
198
|
+
// LOW-7: the null-argument case exercises the SIGNATURE, so it must not be handed an object.
|
|
199
|
+
const result = entry.given.nullArguments === true ? resolveNpmCli(null, null) : resolveNpmCli(env, options);
|
|
200
|
+
|
|
201
|
+
assert.equal(result.kind, entry.expect.kind, `case ${entry.id}: ${JSON.stringify(result)}`);
|
|
202
|
+
if (result.kind === 'npm-cli') {
|
|
203
|
+
if (entry.expect.source !== undefined) {
|
|
204
|
+
assert.equal(result.source, entry.expect.source, `case ${entry.id} source`);
|
|
205
|
+
}
|
|
206
|
+
if (entry.expect.path !== undefined) assert.equal(result.path, expand(entry.expect.path));
|
|
207
|
+
// Whatever was chosen must actually BE npm — the property, re-checked at the outcome.
|
|
208
|
+
assert.equal(isNpmCli(result.path), true, `case ${entry.id}: chosen path is not npm`);
|
|
209
|
+
} else {
|
|
210
|
+
for (const fragment of entry.expect.reasonIncludes || []) {
|
|
211
|
+
assert.ok(result.reason.includes(fragment),
|
|
212
|
+
`case ${entry.id}: reason does not name '${fragment}': ${result.reason}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
test('the identity check rejects an existing non-npm file that an existence check would accept', () => {
|
|
219
|
+
assert.equal(fs.existsSync(decoyCli), true);
|
|
220
|
+
assert.equal(isNpmCli(decoyCli), false);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('global roots are derived from process.execPath, not only Module.globalPaths', () => {
|
|
224
|
+
// MEASURED 2026-09-18: Module.globalPaths here omits /usr/lib/node_modules, where npm lives.
|
|
225
|
+
assert.equal(ambientGlobalRoots('/usr/bin/node', {})[0], '/usr/lib/node_modules');
|
|
226
|
+
// The composed list keeps the ambient roots first and adds the env-derived ones after.
|
|
227
|
+
assert.equal(defaultGlobalRoots('/usr/bin/node', {})[0], '/usr/lib/node_modules');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('the identity check rejects an intruder whose NEIGHBOURHOOD resolves libnpmpack', () => {
|
|
231
|
+
// review r1, HIGH-5. The fixture genuinely passed the old check: libnpmpack really resolves from
|
|
232
|
+
// here. What it is not is npm.
|
|
233
|
+
const intruder = PLACEHOLDERS.intruderCli;
|
|
234
|
+
assert.ok(intruder, 'the intruder fixture was not built');
|
|
235
|
+
assert.doesNotThrow(() => NodeModule.createRequire(intruder).resolve(NPM_IDENTITY_MODULE));
|
|
236
|
+
assert.equal(isNpmCli(intruder), false);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('a symlink to npm is dereferenced before it is judged, and the REAL path is returned', (t) => {
|
|
240
|
+
const link = PLACEHOLDERS.npmCliSymlink;
|
|
241
|
+
const real = PLACEHOLDERS.npmCli;
|
|
242
|
+
if (link === null || real === null) {
|
|
243
|
+
console.log('SKIPPED — no installed npm to symlink on this machine; nothing to dereference.');
|
|
244
|
+
t.skip('no npm to symlink');
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
assert.throws(() => NodeModule.createRequire(link).resolve(NPM_IDENTITY_MODULE));
|
|
248
|
+
assert.equal(isNpmCli(link), true);
|
|
249
|
+
assert.equal(resolveNpmCliPath(link), real);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('npmPackageRoot names npm own package directory, and refuses anything else', (t) => {
|
|
253
|
+
const real = PLACEHOLDERS.npmCli;
|
|
254
|
+
if (real === null) {
|
|
255
|
+
console.log('SKIPPED — no installed npm on this machine to take a package root from.');
|
|
256
|
+
t.skip('no npm installed');
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
assert.equal(npmPackageRoot(real), path.join(path.dirname(real), '..'));
|
|
260
|
+
assert.equal(npmPackageRoot(decoyCli), null);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('npm configured prefix is read from the environment, never from the ambient home directory', () => {
|
|
264
|
+
assert.equal(npmConfigPrefix({}), undefined);
|
|
265
|
+
assert.equal(npmConfigPrefix({ npm_config_prefix: '/opt/somewhere' }), '/opt/somewhere');
|
|
266
|
+
if (PLACEHOLDERS.npmrcHome !== null) {
|
|
267
|
+
assert.equal(npmConfigPrefix({ HOME: PLACEHOLDERS.npmrcHome }), PLACEHOLDERS.npmPrefix);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('explicit null arguments behave like omitted ones (twin parity, LOW-7)', () => {
|
|
272
|
+
assert.doesNotThrow(() => resolveNpmCli(null, null));
|
|
273
|
+
assert.doesNotThrow(() => resolveNpmCli(undefined, undefined));
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('FR-4 — an unavailable npm is announced with a line naming the missing tool', () => {
|
|
277
|
+
const unavailable = resolveNpmCli({ npm_execpath: decoyCli }, { globalRoots: [emptyRoot] });
|
|
278
|
+
assert.equal(unavailable.kind, 'unavailable');
|
|
279
|
+
const lines = [];
|
|
280
|
+
const line = announceNpmSkip(unavailable, (value) => lines.push(value));
|
|
281
|
+
assert.deepEqual(lines, [line]);
|
|
282
|
+
assert.ok(line.includes('SKIPPED'), line);
|
|
283
|
+
assert.ok(line.includes('npm'), line);
|
|
284
|
+
assert.ok(line.includes(NPM_IDENTITY_MODULE), line);
|
|
285
|
+
assert.ok(line.includes(NPM_CLI_RELATIVE_PATH), line);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('FR-4 — an explicit null writer behaves exactly like an omitted one (LOW-7)', () => {
|
|
289
|
+
const unavailable = resolveNpmCli({ npm_execpath: decoyCli }, { globalRoots: [emptyRoot] });
|
|
290
|
+
const seen = [];
|
|
291
|
+
const original = console.log;
|
|
292
|
+
console.log = (...args) => { seen.push(args.map(String).join(' ')); };
|
|
293
|
+
try {
|
|
294
|
+
announceNpmSkip(unavailable, null);
|
|
295
|
+
} finally {
|
|
296
|
+
console.log = original;
|
|
297
|
+
}
|
|
298
|
+
assert.equal(seen.length, 1);
|
|
299
|
+
assert.ok(seen[0].includes('SKIPPED'), seen[0]);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('FR-4 — the announcement writes somewhere by default, so omission cannot silence it', () => {
|
|
303
|
+
const unavailable = resolveNpmCli({}, { globalRoots: [emptyRoot] });
|
|
304
|
+
const seen = [];
|
|
305
|
+
const original = console.log;
|
|
306
|
+
console.log = (...args) => { seen.push(args.map(String).join(' ')); };
|
|
307
|
+
try {
|
|
308
|
+
announceNpmSkip(unavailable);
|
|
309
|
+
} finally {
|
|
310
|
+
console.log = original;
|
|
311
|
+
}
|
|
312
|
+
assert.equal(seen.length, 1);
|
|
313
|
+
assert.ok(seen[0].includes('npm not found'), seen[0]);
|
|
314
|
+
});
|
|
@@ -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();
|
|
@@ -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
|
+
});
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 dzhechko
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|