@dzhechkov/p-replicator 1.13.3 → 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 +24 -8
- package/bin/cli.js +0 -0
- package/package.json +11 -11
- package/sbom.json +47 -7
- package/scripts/check-pipeline-gaps.sh +0 -0
- package/templates/.claude/hooks/check-external-deps.cjs +18 -3
- 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 +2 -2
- package/tests/unit/check-external-deps.test.js +26 -2
- package/tests/unit/detection-ladder-contract.test.js +20 -10
- package/tests/unit/insights-writer.test.js +5 -1
- package/tests/unit/npm-cli-resolver.test.js +314 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which npm may a test use? — CommonJS twin of
|
|
5
|
+
* `packages/@dzhechkov/harness-cli/test/npm-cli-resolver.ts` (ADR-001 of feature
|
|
6
|
+
* `repo-sweep-honesty`, FR-3 + FR-4).
|
|
7
|
+
*
|
|
8
|
+
* WHY A TWIN AND NOT A SHARED HELPER. This package PUBLISHES its `tests/` directory (see the
|
|
9
|
+
* `files` field of its package.json), so an import reaching outside the package would travel into
|
|
10
|
+
* the tarball as a broken reference to a file the consumer never receives. The two packages also
|
|
11
|
+
* live in different module systems — harness-cli is `type: "module"` TypeScript, this one is
|
|
12
|
+
* CommonJS JavaScript — so one file fit for both does not exist. What is shared instead is the
|
|
13
|
+
* CONTRACT: `npm-resolver-cases.json`, one byte-identical copy per package, held in step by
|
|
14
|
+
* `packages/@dzhechkov/harness-core/test/npm-resolver-cases-twin-drift.test.ts`.
|
|
15
|
+
*
|
|
16
|
+
* THE DEFECT THIS REPLACES. The previous fallback here read
|
|
17
|
+
* `npm_execpath && fs.existsSync(npm_execpath) ? npm_execpath : <global npm>`. Under `pnpm test`,
|
|
18
|
+
* `npm_execpath` is pnpm.cjs; that file EXISTS; so the fallback never fired in the one case it was
|
|
19
|
+
* written for, and pnpm was loaded as if it were the npm CLI — `Unknown option: 'dry-run'`
|
|
20
|
+
* (MEASURED 2026-09-18). The question is therefore identity, not existence.
|
|
21
|
+
*
|
|
22
|
+
* WHAT "IDENTITY" MEANS HERE — three facts a look-alike cannot borrow together (review r1, HIGH-5):
|
|
23
|
+
* 1. the candidate is DEREFERENCED first (`realpath`), because a genuine npm is normally reached
|
|
24
|
+
* through a symlink (`/usr/bin/npm` -> `../lib/node_modules/npm/bin/npm-cli.js`, MEASURED) and
|
|
25
|
+
* resolving modules from `/usr/bin` finds nothing — the real npm was being REJECTED;
|
|
26
|
+
* 2. the nearest enclosing `package.json` says `"name": "npm"`, because module resolution answers
|
|
27
|
+
* a question about a DIRECTORY, so without this any intruder dropped into a tree that contains
|
|
28
|
+
* `libnpmpack` was ACCEPTED;
|
|
29
|
+
* 3. `libnpmpack` resolves from the dereferenced file and lives in the SAME installation.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const Module = require('node:module');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
|
|
36
|
+
/** The module whose resolvability IS npm's identity. Nothing else about npm is checked. */
|
|
37
|
+
const NPM_IDENTITY_MODULE = 'libnpmpack';
|
|
38
|
+
|
|
39
|
+
/** Where npm's CLI entry point sits inside a global module root. */
|
|
40
|
+
const NPM_CLI_RELATIVE_PATH = path.join('npm', 'bin', 'npm-cli.js');
|
|
41
|
+
|
|
42
|
+
/** The name the enclosing package.json must carry for a file to be part of npm. */
|
|
43
|
+
const NPM_PACKAGE_NAME = 'npm';
|
|
44
|
+
|
|
45
|
+
/** How far up the tree the enclosing `package.json` is looked for. npm's CLI is 2 levels deep. */
|
|
46
|
+
const PACKAGE_ROOT_SEARCH_DEPTH = 8;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `true` when `child` is `parent` itself or lies underneath it.
|
|
50
|
+
* @param {string} parent
|
|
51
|
+
* @param {string} child
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
function isInside(parent, child) {
|
|
55
|
+
if (!parent || !child) return false;
|
|
56
|
+
const base = parent.endsWith(path.sep) ? parent.slice(0, -path.sep.length) : parent;
|
|
57
|
+
return child === base || child.startsWith(base + path.sep);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {string} candidate
|
|
62
|
+
* @returns {string|null}
|
|
63
|
+
*/
|
|
64
|
+
function realPathOrNull(candidate) {
|
|
65
|
+
try {
|
|
66
|
+
return fs.realpathSync(candidate);
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The directory of the npm PACKAGE that contains `realCandidate`, or `null` when the nearest
|
|
74
|
+
* enclosing `package.json` belongs to something else (or there is none at all).
|
|
75
|
+
*
|
|
76
|
+
* @param {string} realCandidate
|
|
77
|
+
* @returns {string|null}
|
|
78
|
+
*/
|
|
79
|
+
function npmPackageRoot(realCandidate) {
|
|
80
|
+
let dir = path.dirname(realCandidate);
|
|
81
|
+
for (let depth = 0; depth < PACKAGE_ROOT_SEARCH_DEPTH; depth += 1) {
|
|
82
|
+
const manifest = path.join(dir, 'package.json');
|
|
83
|
+
if (fs.existsSync(manifest)) {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(fs.readFileSync(manifest, 'utf8'));
|
|
86
|
+
return parsed && typeof parsed === 'object' && parsed.name === NPM_PACKAGE_NAME ? dir : null;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const parent = path.dirname(dir);
|
|
92
|
+
if (parent === dir) return null;
|
|
93
|
+
dir = parent;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The dereferenced path of `candidate` when it really is npm's CLI, otherwise `null`.
|
|
100
|
+
* @param {string|undefined|null} candidate
|
|
101
|
+
* @returns {string|null}
|
|
102
|
+
*/
|
|
103
|
+
function resolveNpmCliPath(candidate) {
|
|
104
|
+
if (typeof candidate !== 'string' || candidate === '') return null;
|
|
105
|
+
if (!fs.existsSync(candidate)) return null;
|
|
106
|
+
const real = realPathOrNull(candidate);
|
|
107
|
+
if (real === null) return null;
|
|
108
|
+
|
|
109
|
+
const packageRoot = npmPackageRoot(real);
|
|
110
|
+
if (packageRoot === null) return null;
|
|
111
|
+
|
|
112
|
+
let resolved;
|
|
113
|
+
try {
|
|
114
|
+
resolved = Module.createRequire(real).resolve(NPM_IDENTITY_MODULE);
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const realResolved = realPathOrNull(resolved) || resolved;
|
|
119
|
+
// The identity module must belong to the SAME installation: either inside npm's own package, or
|
|
120
|
+
// hoisted into the `node_modules` directory that holds it.
|
|
121
|
+
const sameInstall = isInside(packageRoot, realResolved) || isInside(path.dirname(packageRoot), realResolved);
|
|
122
|
+
return sameInstall ? real : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Is the file at `candidate` npm itself? The boolean face of {@link resolveNpmCliPath}.
|
|
127
|
+
* @param {string|undefined|null} candidate
|
|
128
|
+
* @returns {boolean}
|
|
129
|
+
*/
|
|
130
|
+
function isNpmCli(candidate) {
|
|
131
|
+
return resolveNpmCliPath(candidate) !== null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {string[]} roots
|
|
136
|
+
* @returns {string[]}
|
|
137
|
+
*/
|
|
138
|
+
function dedupe(roots) {
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
const unique = [];
|
|
141
|
+
for (const root of roots) {
|
|
142
|
+
if (!root) continue;
|
|
143
|
+
const normalised = path.resolve(root);
|
|
144
|
+
if (seen.has(normalised)) continue;
|
|
145
|
+
seen.add(normalised);
|
|
146
|
+
unique.push(normalised);
|
|
147
|
+
}
|
|
148
|
+
return unique;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {NodeJS.ProcessEnv} env
|
|
153
|
+
* @returns {string[]}
|
|
154
|
+
*/
|
|
155
|
+
function pathEntries(env) {
|
|
156
|
+
const raw = typeof env.PATH === 'string' ? env.PATH : (typeof env.Path === 'string' ? env.Path : '');
|
|
157
|
+
return raw.split(path.delimiter).filter((entry) => entry !== '');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Module roots that come from this MACHINE.
|
|
162
|
+
*
|
|
163
|
+
* MEASURED 2026-09-18 and load-bearing: `Module.globalPaths` on this machine is
|
|
164
|
+
* ['/root/.node_modules', '/root/.node_libraries', '/usr/lib/node'] and does NOT contain
|
|
165
|
+
* '/usr/lib/node_modules', where npm actually lives. A lookup that trusted `globalPaths` alone
|
|
166
|
+
* could report "npm not installed" on a machine that has npm, so the layout derived from
|
|
167
|
+
* `process.execPath` is tried first.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} [execPath]
|
|
170
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
171
|
+
* @returns {string[]}
|
|
172
|
+
*/
|
|
173
|
+
function ambientGlobalRoots(execPath, env) {
|
|
174
|
+
const exec = typeof execPath === 'string' && execPath !== '' ? execPath : process.execPath;
|
|
175
|
+
const environment = env || process.env;
|
|
176
|
+
const binDir = path.dirname(exec);
|
|
177
|
+
const nodePath = typeof environment.NODE_PATH === 'string' ? environment.NODE_PATH : '';
|
|
178
|
+
return dedupe([
|
|
179
|
+
path.join(binDir, '..', 'lib', 'node_modules'),
|
|
180
|
+
path.join(binDir, '..', '..', 'lib', 'node_modules'),
|
|
181
|
+
path.join(binDir, 'node_modules'),
|
|
182
|
+
...nodePath.split(path.delimiter).filter((entry) => entry !== ''),
|
|
183
|
+
...Module.globalPaths,
|
|
184
|
+
]);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* npm's configured install prefix — the same value `npm config get prefix` prints, obtained WITHOUT
|
|
189
|
+
* spawning npm.
|
|
190
|
+
*
|
|
191
|
+
* Why not spawn it: running npm to find npm is circular (on the machine where the answer matters
|
|
192
|
+
* most the spawn is exactly what fails), and a subprocess inside a resolver every test calls is a
|
|
193
|
+
* hang risk on a loaded runner. The sources are npm's own, in npm's own order: the
|
|
194
|
+
* `npm_config_prefix` environment variable, then the user config file (`$npm_config_userconfig`,
|
|
195
|
+
* else `$HOME/.npmrc`).
|
|
196
|
+
*
|
|
197
|
+
* HONEST GAP: npm's builtin and global config files are not read, so a prefix set ONLY in
|
|
198
|
+
* `/usr/etc/npmrc` is not seen. The env is the only source consulted, never the ambient
|
|
199
|
+
* `homedir()`, so a caller handing in a bare env gets a deterministic answer.
|
|
200
|
+
*
|
|
201
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
202
|
+
* @returns {string|undefined}
|
|
203
|
+
*/
|
|
204
|
+
function npmConfigPrefix(env) {
|
|
205
|
+
const environment = env || process.env;
|
|
206
|
+
const direct = environment.npm_config_prefix;
|
|
207
|
+
if (typeof direct === 'string' && direct !== '') return direct;
|
|
208
|
+
|
|
209
|
+
const home = typeof environment.HOME === 'string' && environment.HOME !== ''
|
|
210
|
+
? environment.HOME
|
|
211
|
+
: (typeof environment.USERPROFILE === 'string' ? environment.USERPROFILE : '');
|
|
212
|
+
const userConfig = typeof environment.npm_config_userconfig === 'string' && environment.npm_config_userconfig !== ''
|
|
213
|
+
? environment.npm_config_userconfig
|
|
214
|
+
: (home ? path.join(home, '.npmrc') : '');
|
|
215
|
+
if (!userConfig || !fs.existsSync(userConfig)) return undefined;
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
for (const rawLine of fs.readFileSync(userConfig, 'utf8').split(/\r?\n/)) {
|
|
219
|
+
const line = rawLine.trim();
|
|
220
|
+
if (line === '' || line.startsWith('#') || line.startsWith(';')) continue;
|
|
221
|
+
const match = /^prefix\s*=\s*(.+)$/.exec(line);
|
|
222
|
+
if (match && match[1] !== undefined) return match[1].trim().replace(/^["']|["']$/g, '');
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Module roots under npm's configured prefix (both the POSIX and the Windows layout).
|
|
232
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
233
|
+
* @returns {string[]}
|
|
234
|
+
*/
|
|
235
|
+
function npmPrefixRoots(env) {
|
|
236
|
+
const prefix = npmConfigPrefix(env);
|
|
237
|
+
if (prefix === undefined) return [];
|
|
238
|
+
return dedupe([path.join(prefix, 'lib', 'node_modules'), path.join(prefix, 'node_modules')]);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Module roots derived from the directories on PATH. A machine whose npm lives under an unrelated
|
|
243
|
+
* prefix (nvm, asdf, Homebrew, a per-user install) is invisible to `process.execPath` and to
|
|
244
|
+
* `globalPaths` alike, but its `bin` directory is on PATH by construction.
|
|
245
|
+
*
|
|
246
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
247
|
+
* @returns {string[]}
|
|
248
|
+
*/
|
|
249
|
+
function pathRoots(env) {
|
|
250
|
+
const environment = env || process.env;
|
|
251
|
+
const roots = [];
|
|
252
|
+
for (const entry of pathEntries(environment)) {
|
|
253
|
+
roots.push(path.join(entry, '..', 'lib', 'node_modules'), path.join(entry, 'node_modules'));
|
|
254
|
+
}
|
|
255
|
+
return dedupe(roots);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The `npm` executables sitting on PATH. Each is DEREFERENCED and identity-checked, which is what
|
|
260
|
+
* makes the usual `bin/npm -> ../lib/node_modules/npm/bin/npm-cli.js` symlink a usable answer.
|
|
261
|
+
*
|
|
262
|
+
* `npm.cmd` / `npm.ps1` on Windows are batch wrappers, not JavaScript; they are not candidates for
|
|
263
|
+
* a resolver whose answer is fed to `node <path>`, and they are deliberately not listed.
|
|
264
|
+
*
|
|
265
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
266
|
+
* @returns {string[]}
|
|
267
|
+
*/
|
|
268
|
+
function pathNpmExecutables(env) {
|
|
269
|
+
return pathEntries(env || process.env).map((entry) => path.join(entry, 'npm'));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Every root this resolver would search for a given machine + environment, in order.
|
|
274
|
+
* @param {string} [execPath]
|
|
275
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
276
|
+
* @returns {string[]}
|
|
277
|
+
*/
|
|
278
|
+
function defaultGlobalRoots(execPath, env) {
|
|
279
|
+
return dedupe([...ambientGlobalRoots(execPath, env), ...npmPrefixRoots(env), ...pathRoots(env)]);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The ladder, in the one order ADR-001 fixes:
|
|
284
|
+
* npm_execpath (only if it IS npm) -> the installed npm -> an honest, named unavailability.
|
|
285
|
+
*
|
|
286
|
+
* `options.globalRoots` replaces the AMBIENT roots only; the env-derived roots (npm's configured
|
|
287
|
+
* prefix, PATH) always apply, because they describe the environment the caller handed in.
|
|
288
|
+
*
|
|
289
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
290
|
+
* @param {{ globalRoots?: string[] }} [options]
|
|
291
|
+
* @returns {{kind:'npm-cli', path:string, source:'npm_execpath'|'global-install'}|{kind:'unavailable', reason:string}}
|
|
292
|
+
*/
|
|
293
|
+
function resolveNpmCli(env, options) {
|
|
294
|
+
const environment = env || process.env;
|
|
295
|
+
const settings = options || {};
|
|
296
|
+
const handedOver = environment.npm_execpath;
|
|
297
|
+
|
|
298
|
+
const handedOverReal = resolveNpmCliPath(handedOver);
|
|
299
|
+
if (handedOverReal !== null) {
|
|
300
|
+
return { kind: 'npm-cli', path: handedOverReal, source: 'npm_execpath' };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const roots = dedupe([
|
|
304
|
+
...(Array.isArray(settings.globalRoots) ? settings.globalRoots : ambientGlobalRoots(process.execPath, environment)),
|
|
305
|
+
...npmPrefixRoots(environment),
|
|
306
|
+
...pathRoots(environment),
|
|
307
|
+
]);
|
|
308
|
+
for (const root of roots) {
|
|
309
|
+
const found = resolveNpmCliPath(path.join(root, NPM_CLI_RELATIVE_PATH));
|
|
310
|
+
if (found !== null) {
|
|
311
|
+
return { kind: 'npm-cli', path: found, source: 'global-install' };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const executables = pathNpmExecutables(environment);
|
|
316
|
+
for (const executable of executables) {
|
|
317
|
+
const found = resolveNpmCliPath(executable);
|
|
318
|
+
if (found !== null) {
|
|
319
|
+
return { kind: 'npm-cli', path: found, source: 'global-install' };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const handedOverNote = typeof handedOver !== 'string' || handedOver === ''
|
|
324
|
+
? 'npm_execpath was not set'
|
|
325
|
+
: `npm_execpath=${handedOver} is not npm (module '${NPM_IDENTITY_MODULE}' does not resolve from it)`;
|
|
326
|
+
return {
|
|
327
|
+
kind: 'unavailable',
|
|
328
|
+
reason: `npm not found: ${handedOverNote}; `
|
|
329
|
+
+ `no '${NPM_CLI_RELATIVE_PATH}' whose '${NPM_IDENTITY_MODULE}' resolves was found under `
|
|
330
|
+
+ `[${roots.join(', ')}]`
|
|
331
|
+
+ `, nor an 'npm' executable on PATH [${executables.join(', ')}]`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* FR-4 — a skip must be AUDIBLE. A silent `skip` is how a test stops proving anything without
|
|
337
|
+
* anyone noticing, so the reason (which names the missing tool) is written to the suite's output
|
|
338
|
+
* before the test steps aside.
|
|
339
|
+
*
|
|
340
|
+
* @param {{kind:'unavailable', reason:string}} resolution
|
|
341
|
+
* @param {((line: string) => void)|null} [write]
|
|
342
|
+
* @returns {string}
|
|
343
|
+
*/
|
|
344
|
+
function announceNpmSkip(resolution, write) {
|
|
345
|
+
const emit = typeof write === 'function' ? write : (line) => console.log(line);
|
|
346
|
+
const line = `SKIPPED — this test needs the real npm packer. ${resolution.reason}`;
|
|
347
|
+
emit(line);
|
|
348
|
+
return line;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
module.exports = {
|
|
352
|
+
NPM_IDENTITY_MODULE,
|
|
353
|
+
NPM_CLI_RELATIVE_PATH,
|
|
354
|
+
NPM_PACKAGE_NAME,
|
|
355
|
+
ambientGlobalRoots,
|
|
356
|
+
announceNpmSkip,
|
|
357
|
+
defaultGlobalRoots,
|
|
358
|
+
isNpmCli,
|
|
359
|
+
npmConfigPrefix,
|
|
360
|
+
npmPackageRoot,
|
|
361
|
+
npmPrefixRoots,
|
|
362
|
+
pathNpmExecutables,
|
|
363
|
+
pathRoots,
|
|
364
|
+
resolveNpmCli,
|
|
365
|
+
resolveNpmCliPath,
|
|
366
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
{
|
|
2
|
+
"contract": "resolveNpmCli(env, options) — ADR-001 of feature repo-sweep-honesty. A test that needs the real npm packer must check the IDENTITY of the tool it was handed, never the existence of a path: under pnpm, npm_execpath points at pnpm.cjs, that file exists, and an existsSync fallback therefore never fires in the one case it was written for (MEASURED 2026-09-18).",
|
|
3
|
+
"identity": [
|
|
4
|
+
"A candidate is npm only if ALL THREE hold (review r1, HIGH-5 — the first and third were missing):",
|
|
5
|
+
"1. the candidate is DEREFERENCED first (realpath), because a genuine npm is normally reached through a symlink (/usr/bin/npm -> ../lib/node_modules/npm/bin/npm-cli.js) and resolving modules from /usr/bin finds nothing;",
|
|
6
|
+
"2. the nearest enclosing package.json says name == 'npm', because module resolution answers a question about a DIRECTORY — without this, any intruder dropped into a tree that contains libnpmpack passes;",
|
|
7
|
+
"3. 'libnpmpack' resolves from the dereferenced file AND the module it resolves to lives in the SAME npm installation (inside npm's own package directory, or in the node_modules that holds it)."
|
|
8
|
+
],
|
|
9
|
+
"ladder": [
|
|
10
|
+
"1. npm_execpath is used only if it passes the identity check above (the answer is the DEREFERENCED path).",
|
|
11
|
+
"2. otherwise the installed npm is looked up in the global module roots: the ambient machine roots (process.execPath layout, NODE_PATH, module.globalPaths), then npm's configured prefix, then the directories on PATH.",
|
|
12
|
+
"3. otherwise an 'npm' executable on PATH is dereferenced and identity-checked.",
|
|
13
|
+
"4. otherwise the answer is 'unavailable' carrying a reason that NAMES the tool that was not found and everywhere it was looked for."
|
|
14
|
+
],
|
|
15
|
+
"twins": [
|
|
16
|
+
"packages/@dzhechkov/harness-cli/test/npm-resolver-cases.json",
|
|
17
|
+
"packages/@dzhechkov/p-replicator/tests/npm-resolver-cases.json"
|
|
18
|
+
],
|
|
19
|
+
"twinGuard": "packages/@dzhechkov/harness-core/test/npm-resolver-cases-twin-drift.test.ts keeps the two copies byte-identical. Two implementations exist because p-replicator PUBLISHES its tests/ directory, so it may not import from the repo root; the contract, not the code, is what is shared.",
|
|
20
|
+
"givenFields": {
|
|
21
|
+
"npmExecPath": "placeholder name for env.npm_execpath, or null to leave it unset",
|
|
22
|
+
"globalRoots": "'defaultRoots' to let the resolver pick its own AMBIENT roots, or a list of placeholder names that replaces them",
|
|
23
|
+
"env": "optional map of extra environment variables; each value is a placeholder name. Env-derived roots (npm's prefix, PATH) apply even when globalRoots is overridden, because they describe the environment handed in rather than the machine",
|
|
24
|
+
"nullArguments": "optional; when true the resolver is called with explicit nulls for BOTH optional arguments instead of an env object"
|
|
25
|
+
},
|
|
26
|
+
"placeholders": {
|
|
27
|
+
"npmCli": "absolute path to a real npm-cli.js, located WITHOUT the resolver under test",
|
|
28
|
+
"pnpmCli": "absolute path to a real pnpm.cjs — an existing file that is NOT npm",
|
|
29
|
+
"decoyCli": "a freshly written .js file that exists and is NOT npm; the fixture that an existence check would wrongly accept",
|
|
30
|
+
"intruderCli": "a freshly written .js file that is NOT npm but sits in a tree where 'libnpmpack' DOES resolve; the fixture that a resolution-only check would wrongly accept",
|
|
31
|
+
"npmCliSymlink": "a symlink pointing at the real npm-cli.js; the fixture a resolver that does not dereference would wrongly reject",
|
|
32
|
+
"npmPrefix": "npm's install prefix, derived from `npm root -g` without the resolver under test",
|
|
33
|
+
"npmrcHome": "a directory holding a .npmrc whose `prefix=` names npmPrefix",
|
|
34
|
+
"npmBinDir": "a directory holding a symlink named 'npm' that points at the real npm-cli.js, as a bin directory on PATH does",
|
|
35
|
+
"missingPath": "an absolute path that does not exist",
|
|
36
|
+
"emptyRoot": "an existing directory holding no npm installation",
|
|
37
|
+
"defaultRoots": "the resolver's own ambient global-root list, whatever it is on this machine"
|
|
38
|
+
},
|
|
39
|
+
"cases": [
|
|
40
|
+
{
|
|
41
|
+
"id": "execpath-that-is-npm-is-used",
|
|
42
|
+
"why": "the happy path the whole ladder exists to preserve: a real npm hand-off is honoured",
|
|
43
|
+
"given": { "npmExecPath": "npmCli", "globalRoots": "defaultRoots" },
|
|
44
|
+
"requires": ["npmCli"],
|
|
45
|
+
"expect": { "kind": "npm-cli", "source": "npm_execpath", "path": "npmCli" }
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"id": "execpath-that-is-pnpm-is-rejected",
|
|
49
|
+
"why": "the measured defect: pnpm.cjs exists, so an existence check accepts it and the caller then loads pnpm as if it were npm",
|
|
50
|
+
"given": { "npmExecPath": "pnpmCli", "globalRoots": "defaultRoots" },
|
|
51
|
+
"requires": ["npmCli", "pnpmCli"],
|
|
52
|
+
"expect": { "kind": "npm-cli", "source": "global-install", "path": "npmCli" }
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"id": "execpath-that-is-an-existing-decoy-is-rejected",
|
|
56
|
+
"why": "machine-independent proof that IDENTITY is checked and not EXISTENCE; this file always exists",
|
|
57
|
+
"given": { "npmExecPath": "decoyCli", "globalRoots": "defaultRoots" },
|
|
58
|
+
"requires": ["npmCli"],
|
|
59
|
+
"expect": { "kind": "npm-cli", "source": "global-install", "path": "npmCli" }
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"id": "intruder-in-a-tree-where-libnpmpack-resolves-is-rejected",
|
|
63
|
+
"why": "review r1 HIGH-5: resolving libnpmpack proves something about the DIRECTORY, not about the file; without the enclosing-package check any file dropped beside a node_modules/libnpmpack passed as npm",
|
|
64
|
+
"given": { "npmExecPath": "intruderCli", "globalRoots": "defaultRoots" },
|
|
65
|
+
"requires": ["npmCli", "intruderCli"],
|
|
66
|
+
"expect": { "kind": "npm-cli", "source": "global-install", "path": "npmCli" }
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"id": "symlinked-npm-is-dereferenced-and-accepted",
|
|
70
|
+
"why": "review r1 HIGH-5, the other half: a genuine npm is normally reached through a symlink, and resolving from the symlink's own directory finds nothing — the real npm was being rejected",
|
|
71
|
+
"given": { "npmExecPath": "npmCliSymlink", "globalRoots": "defaultRoots" },
|
|
72
|
+
"requires": ["npmCli", "npmCliSymlink"],
|
|
73
|
+
"expect": { "kind": "npm-cli", "source": "npm_execpath", "path": "npmCli" }
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"id": "execpath-that-does-not-exist-is-rejected",
|
|
77
|
+
"why": "a stale hand-off must fall through the ladder rather than crash the caller",
|
|
78
|
+
"given": { "npmExecPath": "missingPath", "globalRoots": "defaultRoots" },
|
|
79
|
+
"requires": ["npmCli"],
|
|
80
|
+
"expect": { "kind": "npm-cli", "source": "global-install", "path": "npmCli" }
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"id": "no-execpath-falls-through-to-the-installed-npm",
|
|
84
|
+
"why": "direct runs (npx vitest run / node --test) set no npm_execpath at all",
|
|
85
|
+
"given": { "npmExecPath": null, "globalRoots": "defaultRoots" },
|
|
86
|
+
"requires": ["npmCli"],
|
|
87
|
+
"expect": { "kind": "npm-cli", "source": "global-install", "path": "npmCli" }
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"id": "npm-config-prefix-from-the-environment-is-searched",
|
|
91
|
+
"why": "review r1 HIGH-5: an npm installed under an unrelated prefix is invisible to process.execPath and to globalPaths, and was reported absent on a machine that has it",
|
|
92
|
+
"given": { "npmExecPath": "decoyCli", "globalRoots": ["emptyRoot"], "env": { "npm_config_prefix": "npmPrefix" } },
|
|
93
|
+
"requires": ["npmCli", "npmPrefix"],
|
|
94
|
+
"expect": { "kind": "npm-cli", "source": "global-install", "path": "npmCli" }
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"id": "npm-config-prefix-from-the-user-npmrc-is-searched",
|
|
98
|
+
"why": "the same question `npm config get prefix` answers, read from npm's own user config instead of by spawning npm (spawning the tool you are looking for is circular)",
|
|
99
|
+
"given": { "npmExecPath": "decoyCli", "globalRoots": ["emptyRoot"], "env": { "HOME": "npmrcHome" } },
|
|
100
|
+
"requires": ["npmCli", "npmrcHome"],
|
|
101
|
+
"expect": { "kind": "npm-cli", "source": "global-install", "path": "npmCli" }
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"id": "an-npm-on-PATH-is-found-when-every-root-is-empty",
|
|
105
|
+
"why": "review r1 HIGH-5: the fallback ignored PATH, yet a bin directory on PATH is how the user runs npm at all; the entry there is a symlink, so this also proves the dereferencing",
|
|
106
|
+
"given": { "npmExecPath": "decoyCli", "globalRoots": ["emptyRoot"], "env": { "PATH": "npmBinDir" } },
|
|
107
|
+
"requires": ["npmCli", "npmBinDir"],
|
|
108
|
+
"expect": { "kind": "npm-cli", "source": "global-install", "path": "npmCli" }
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"id": "explicit-null-arguments-behave-like-omitted-ones",
|
|
112
|
+
"why": "review r1 LOW-7: a default parameter fires only on undefined, so the TypeScript twin threw on resolveNpmCli(null) while the CommonJS twin fell back to process.env — two twins that disagree on a value a caller can pass are not twins",
|
|
113
|
+
"given": { "npmExecPath": null, "globalRoots": "defaultRoots", "nullArguments": true },
|
|
114
|
+
"requires": ["npmCli"],
|
|
115
|
+
"expect": { "kind": "npm-cli" }
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"id": "no-npm-anywhere-is-an-honest-unavailable",
|
|
119
|
+
"why": "FR-4: absence is reported with a NAMED reason, never as a pass and never as a crash",
|
|
120
|
+
"given": { "npmExecPath": "decoyCli", "globalRoots": ["emptyRoot"] },
|
|
121
|
+
"requires": [],
|
|
122
|
+
"expect": {
|
|
123
|
+
"kind": "unavailable",
|
|
124
|
+
"reasonIncludes": ["npm", "libnpmpack", "npm/bin/npm-cli.js"]
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
]
|
|
128
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedAt": "2026-09-
|
|
2
|
+
"generatedAt": "2026-09-20T09:34:29.101Z",
|
|
3
3
|
"totalFiles": 167,
|
|
4
4
|
"files": {
|
|
5
5
|
".claude/agents/doc-validator.md": "e57950758cd4764b2cb54b3f1905567e9dbc1ed20ac6ecd30998614894689f4f",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
".claude/hooks/check-dangling-refs.cjs": "8f012876b1af110a1093319d36a2f84208a15780dbb8abb5a43eb4aeb8f76865",
|
|
26
26
|
".claude/hooks/check-docs-complete.cjs": "0f516df0882c9da53d166a1b4e30ff1fc37bd5a2027c7e5fbdab6cba116ca828",
|
|
27
27
|
".claude/hooks/check-embed-contract.cjs": "9e963a07c12350b760264133021e75fb699439b1f060332e1ccdd962b645b4a0",
|
|
28
|
-
".claude/hooks/check-external-deps.cjs": "
|
|
28
|
+
".claude/hooks/check-external-deps.cjs": "d671353b2e73e0790206cf963ee85f1ff6b97d56d975b9eb3ad59a14d7239e2a",
|
|
29
29
|
".claude/hooks/check-file-ownership.cjs": "c6273e7b0817d15e375586131924a08095722aff7d0e6a6a04c857a480788e1f",
|
|
30
30
|
".claude/hooks/check-growth-trace.cjs": "91994fc109c8440306c9dd3d5eea176c533eb0818fcdb09bac2b309397d9908a",
|
|
31
31
|
".claude/hooks/check-handoff-manifest.cjs": "768cd0befc0da3adcddcded86ed6d1bd1ac4f25afb563ae578002060a54968cf",
|
|
@@ -52,8 +52,12 @@ const check = (files) => run(CHECK, files);
|
|
|
52
52
|
const GOOD_EVIDENCE = 'https://docs.example.test/email · checked 2026-08-30 · '
|
|
53
53
|
+ '«The API reports hard and soft bounces via webhook»';
|
|
54
54
|
|
|
55
|
+
const EN_HEADER = '| Capability needed | Provider / API | Evidence | Verdict | Requirements relying on it |';
|
|
56
|
+
/** The package writes its documents in Russian, so a Russian header is the TYPICAL case, not a corner. */
|
|
57
|
+
const RU_HEADER = '| Возможность | Провайдер | Свидетельство | Вердикт | Требования |';
|
|
58
|
+
|
|
55
59
|
/** An Architecture.md whose inventory says what the case needs. */
|
|
56
|
-
function arch({ rows, before = '## Technology Stack\n\n| Layer | Technology | Rationale |\n|---|---|---|\n| Backend | Node | привычен команде |\n', section = true, prose = '' } = {}) {
|
|
60
|
+
function arch({ rows, header = EN_HEADER, before = '## Technology Stack\n\n| Layer | Technology | Rationale |\n|---|---|---|\n| Backend | Node | привычен команде |\n', section = true, prose = '' } = {}) {
|
|
57
61
|
const body = rows === undefined
|
|
58
62
|
? [['отправка писем', 'Postmark', GOOD_EVIDENCE, 'CONFIRMED', 'FR-010']]
|
|
59
63
|
: rows;
|
|
@@ -61,7 +65,7 @@ function arch({ rows, before = '## Technology Stack\n\n| Layer | Technology | Ra
|
|
|
61
65
|
if (section) {
|
|
62
66
|
out += '## External Dependencies\n\n' + (prose ? prose + '\n\n' : '');
|
|
63
67
|
if (body.length) {
|
|
64
|
-
out +=
|
|
68
|
+
out += header + '\n'
|
|
65
69
|
+ '|---|---|---|---|---|\n'
|
|
66
70
|
+ body.map((r) => '| ' + r.join(' | ') + ' |').join('\n') + '\n';
|
|
67
71
|
}
|
|
@@ -154,6 +158,26 @@ describe('инвентарь внешних зависимостей — the det
|
|
|
154
158
|
assert.match(r.out, /FR-011/, 'the dependent requirements must be named, not counted');
|
|
155
159
|
});
|
|
156
160
|
|
|
161
|
+
test('P9b - RU/EN twin: the header is recognised by POSITION, so a Russian header is never a data row', () => {
|
|
162
|
+
// MEASURED 2026-09-02 (backlog b8a7669d): the same table body under a Russian header produced a
|
|
163
|
+
// FALSE finding «строка инвентаря без вердикта • Возможность» — the header itself was read as a
|
|
164
|
+
// row — and that false finding fired FIRST, masking the real one. One variable differs between
|
|
165
|
+
// the twins: the header line. Every outcome below must be identical for both.
|
|
166
|
+
const clean = [['отправка писем', 'Postmark', GOOD_EVIDENCE, 'CONFIRMED', 'FR-010']];
|
|
167
|
+
for (const header of [EN_HEADER, RU_HEADER]) {
|
|
168
|
+
const r = check({ [ARCH]: arch({ rows: clean, header }) });
|
|
169
|
+
assert.equal(r.code, 0, header + '\n' + r.out);
|
|
170
|
+
assert.doesNotMatch(r.out, /Возможность|Capability needed/, 'the header must not surface as a row: ' + r.out);
|
|
171
|
+
}
|
|
172
|
+
const noVerdict = [['отправка писем', 'Postmark', GOOD_EVIDENCE, '', 'FR-010']];
|
|
173
|
+
const twins = [EN_HEADER, RU_HEADER].map((header) => check({ [ARCH]: arch({ rows: noVerdict, header }) }));
|
|
174
|
+
for (const r of twins) {
|
|
175
|
+
assert.equal(r.code, 1, r.out);
|
|
176
|
+
assert.match(r.out, /отправка писем/, 'the REAL defective row must be the one named: ' + r.out);
|
|
177
|
+
assert.doesNotMatch(r.out, /Возможность|Capability needed/, 'the header must not be the finding: ' + r.out);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
157
181
|
test('P10 - THE second fixture: CONFIRMED without a verbatim quote fails, WITH THE ROW NAMED', () => {
|
|
158
182
|
const r = check({ [ARCH]: arch({ rows: [
|
|
159
183
|
['отправка писем', 'Postmark', 'https://docs.example.test/email · checked 2026-08-30', 'CONFIRMED', 'FR-010'],
|
|
@@ -4,11 +4,12 @@ const { test } = require('node:test');
|
|
|
4
4
|
const assert = require('node:assert/strict');
|
|
5
5
|
const { execFileSync } = require('node:child_process');
|
|
6
6
|
const fs = require('node:fs');
|
|
7
|
-
const Module = require('node:module');
|
|
8
7
|
const os = require('node:os');
|
|
9
8
|
const path = require('node:path');
|
|
10
9
|
const { Worker } = require('node:worker_threads');
|
|
11
10
|
|
|
11
|
+
const { announceNpmSkip, resolveNpmCli } = require('../npm-cli-resolver.js');
|
|
12
|
+
|
|
12
13
|
const PKG_DIR = path.resolve(__dirname, '..', '..');
|
|
13
14
|
const RULE_DIR = path.join(PKG_DIR, 'templates', '.claude', 'rules');
|
|
14
15
|
const RULE_FILE = 'cost-of-detection-ladder.md';
|
|
@@ -18,13 +19,12 @@ const CLI = path.join(PKG_DIR, 'bin', 'cli.js');
|
|
|
18
19
|
|
|
19
20
|
const read = (file) => fs.readFileSync(file, 'utf8');
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
'npm-cli.js');
|
|
22
|
+
// FR-3 / ADR-001. The previous line here asked whether `npm_execpath` EXISTS. Under `pnpm test` it
|
|
23
|
+
// points at pnpm.cjs, that file exists, so the fallback never fired in the one case it was written
|
|
24
|
+
// for — and pnpm was then loaded as the npm CLI: `Unknown option: 'dry-run'` (MEASURED 2026-09-18).
|
|
25
|
+
// The resolver asks instead whether the thing at that path IS npm, and falls through to the
|
|
26
|
+
// installed npm when it is not.
|
|
27
|
+
function npmPackDryRunFiles(npmCli) {
|
|
28
28
|
const cache = fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-npm-pack-cache-'));
|
|
29
29
|
|
|
30
30
|
return new Promise((resolve, reject) => {
|
|
@@ -288,14 +288,24 @@ test('P5 — package runner and scope guards reject omitted and out-of-scope inp
|
|
|
288
288
|
'a real out-of-scope package path must fire the scope safeguard');
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
-
test('A2 — package files include templates and the actual npm pack listing contains the ladder', async () => {
|
|
291
|
+
test('A2 — package files include templates and the actual npm pack listing contains the ladder', async (t) => {
|
|
292
292
|
const pkg = JSON.parse(read(path.join(PKG_DIR, 'package.json')));
|
|
293
293
|
assert.deepEqual(packagingProblems(pkg), []);
|
|
294
294
|
const omitted = JSON.parse(JSON.stringify(pkg));
|
|
295
295
|
omitted.files = omitted.files.filter((entry) => entry !== 'templates/');
|
|
296
296
|
assert.deepEqual(packagingProblems(omitted), ['package files[] omits templates/'],
|
|
297
297
|
'a cloned files[] without templates/ must fire the distribution safeguard');
|
|
298
|
-
|
|
298
|
+
|
|
299
|
+
// FR-4: no npm anywhere is a NAMED skip printed into the suite output, never a silent pass and
|
|
300
|
+
// never a crash. This test exists to exercise the REAL packer; a faked one would prove nothing.
|
|
301
|
+
const npmCli = resolveNpmCli(process.env);
|
|
302
|
+
if (npmCli.kind !== 'npm-cli') {
|
|
303
|
+
announceNpmSkip(npmCli);
|
|
304
|
+
t.skip(npmCli.reason);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const packedFiles = await npmPackDryRunFiles(npmCli.path);
|
|
299
309
|
assert.ok(
|
|
300
310
|
packedFiles.includes(`templates/.claude/rules/${RULE_FILE}`),
|
|
301
311
|
`actual npm pack listing omits templates/.claude/rules/${RULE_FILE}`);
|
|
@@ -6,6 +6,7 @@ const { spawnSync } = require('node:child_process');
|
|
|
6
6
|
const fs = require('node:fs');
|
|
7
7
|
const os = require('node:os');
|
|
8
8
|
const path = require('node:path');
|
|
9
|
+
const { skipWhenDzAbsent } = require('../dz-availability.js');
|
|
9
10
|
|
|
10
11
|
const PKG = path.resolve(__dirname, '..', '..');
|
|
11
12
|
const TPL = path.join(PKG, 'templates', '.claude');
|
|
@@ -258,7 +259,7 @@ describe('PR-022 harvest insight persistence', () => {
|
|
|
258
259
|
} finally { cleanup(broken); }
|
|
259
260
|
});
|
|
260
261
|
|
|
261
|
-
test('P16b - duplicate replay imports one learned row', () => {
|
|
262
|
+
test('P16b - duplicate replay imports one learned row', (t) => {
|
|
262
263
|
const { writeInsight, stableTeachText, normalizePayload } = requireWriter();
|
|
263
264
|
const root = tempProject('p-rep-real-dz-teach-');
|
|
264
265
|
try {
|
|
@@ -266,6 +267,9 @@ describe('PR-022 harvest insight persistence', () => {
|
|
|
266
267
|
const first = writeInsight(root, record);
|
|
267
268
|
const replay = writeInsight(root, { ...record, date: '2026-08-31' });
|
|
268
269
|
assert.equal(first.status, 'created');
|
|
270
|
+
// Backlog 1b4857a6: this case queries the learned rows dz itself stores, so a missing dz is
|
|
271
|
+
// an audible skip and a broken dz stays red — the rule lives once, in tests/dz-availability.js.
|
|
272
|
+
if (skipWhenDzAbsent(t, first.teach.state, 'it reads back the learned rows dz stores')) return;
|
|
269
273
|
assert.equal(first.teach.state, 'ok');
|
|
270
274
|
assert.equal(replay.status, 'duplicate');
|
|
271
275
|
assert.equal(replay.teach.state, 'ok');
|