@boyingliu01/xp-gate 0.21.0 → 0.22.0

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.
@@ -0,0 +1,403 @@
1
+ /**
2
+ * @test REQ-A-05 (#447) xp-gate arch subcommand
3
+ * @intent `xp-gate arch` works again: the default path runs the PINNED local
4
+ * @archlinter/cli with built-in rules (never `npx -y`), with baseline-ratchet
5
+ * verdicts when .architecture-baseline.json exists (Gate 6 parity) and
6
+ * zero-tolerance otherwise; `--use-config` is the opt-in legacy config path.
7
+ * Anti-noop: the integration tests run the REAL archlint binary and prove a
8
+ * planted high-severity violation flips the verdict to non-zero — an exit 0
9
+ * is a real scan, not a hardcoded pass.
10
+ * @covers AC-A-05-01, AC-A-05-02
11
+ */
12
+ const fs = require('node:fs');
13
+ const os = require('node:os');
14
+ const path = require('node:path');
15
+ const { spawnSync } = require('node:child_process');
16
+
17
+ const archModule = require('../arch.js');
18
+ const {
19
+ findProjectRoot,
20
+ binEntryFromPackageDir,
21
+ resolveArchlint,
22
+ verdictFromScanJson,
23
+ quoteForShell,
24
+ stripExtendedPrefix,
25
+ } = archModule.__internals;
26
+
27
+ const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
28
+
29
+ // Verified against @archlinter/cli 0.16.0: a ~198-token duplicated block is
30
+ // reported as a High-severity CodeClone (this repo's four High smells are all
31
+ // large clones, token_count 112-149). Smaller clones only reach Medium, which
32
+ // the high/critical blocking threshold deliberately ignores.
33
+ const HIGH_CLONE_BLOCK = [
34
+ 'function processAlphaRecords(records) {',
35
+ ' const out = [];',
36
+ ' for (const r of records) {',
37
+ ' if (!r || typeof r !== "object") continue;',
38
+ ' const score = (r.alpha || 0) * 2 + (r.beta || 0) * 3 + (r.gamma || 0) * 5;',
39
+ ' const tier = score > 100 ? "platinum" : score > 50 ? "gold" : score > 20 ? "silver" : "bronze";',
40
+ ' const weighted = score * (r.weight || 1) + (r.penalty || 0);',
41
+ ' const bucket = weighted > 200 ? "A" : weighted > 120 ? "B" : weighted > 60 ? "C" : "D";',
42
+ ' out.push({ id: r.id, score, weighted, tier, bucket, tags: (r.tags || []).map((t) => t.toLowerCase()) });',
43
+ ' }',
44
+ ' return out.sort((a, b) => b.weighted - a.weighted);',
45
+ '}',
46
+ '',
47
+ 'function summarizeAlphaBuckets(rows) {',
48
+ ' const acc = { A: 0, B: 0, C: 0, D: 0 };',
49
+ ' for (const row of rows) {',
50
+ ' acc[row.bucket] = (acc[row.bucket] || 0) + 1;',
51
+ ' }',
52
+ ' return { counts: acc, total: rows.length, average: rows.reduce((s, r) => s + r.weighted, 0) / (rows.length || 1) };',
53
+ '}',
54
+ '',
55
+ ].join('\n');
56
+
57
+ let sandbox;
58
+
59
+ beforeEach(() => {
60
+ sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-arch-'));
61
+ });
62
+
63
+ afterEach(() => {
64
+ fs.rmSync(sandbox, { recursive: true, force: true });
65
+ });
66
+
67
+ function writeCleanSandbox() {
68
+ fs.mkdirSync(path.join(sandbox, 'src'), { recursive: true });
69
+ fs.writeFileSync(path.join(sandbox, 'src', 'a.js'), 'module.exports = { a: 1 };\n');
70
+ fs.writeFileSync(
71
+ path.join(sandbox, 'src', 'b.js'),
72
+ 'const { a } = require("./a");\nmodule.exports = { b: a + 1 };\n',
73
+ );
74
+ }
75
+
76
+ function plantHighClone() {
77
+ fs.mkdirSync(path.join(sandbox, 'src', 'domain'), { recursive: true });
78
+ fs.mkdirSync(path.join(sandbox, 'src', 'infrastructure'), { recursive: true });
79
+ fs.writeFileSync(path.join(sandbox, 'src', 'domain', 'user.js'), 'const u = 1;\n' + HIGH_CLONE_BLOCK);
80
+ fs.writeFileSync(path.join(sandbox, 'src', 'infrastructure', 'db.js'), 'const d = 2;\n' + HIGH_CLONE_BLOCK);
81
+ }
82
+
83
+ /**
84
+ * Run `arch(args)` in a child node process with cwd set to the sandbox.
85
+ * Worker threads forbid process.chdir (ERR_WORKER_UNSUPPORTED_OPERATION), so
86
+ * cwd-sensitive behavior is exercised the same way gate-runner.test.js does it:
87
+ * a real child process whose exit code IS arch's return code.
88
+ */
89
+ function runArchInChild(cwd, args) {
90
+ const archPath = path.join(__dirname, '..', 'arch.js');
91
+ const script = [
92
+ `const { arch } = require(${JSON.stringify(archPath)});`,
93
+ `arch(${JSON.stringify(args)}).then((code) => process.exit(code));`,
94
+ ].join('\n');
95
+ const result = spawnSync(process.execPath, ['-e', script], {
96
+ cwd,
97
+ encoding: 'utf8',
98
+ maxBuffer: 64 * 1024 * 1024,
99
+ });
100
+ return { status: result.status, stdout: result.stdout || '', stderr: result.stderr || '' };
101
+ }
102
+
103
+ /** Run the REAL pinned archlint (xp-gate's own dependency) inside a sandbox. */
104
+ function runRealArchlint(args, cwd) {
105
+ const resolved = resolveArchlint(sandbox);
106
+ return spawnSync(process.execPath, [resolved.script, ...args], { cwd, encoding: 'utf8' });
107
+ }
108
+
109
+ describe('findProjectRoot', () => {
110
+ it('walks up to the directory containing .git', () => {
111
+ fs.mkdirSync(path.join(sandbox, '.git'), { recursive: true });
112
+ const nested = path.join(sandbox, 'a', 'b', 'c');
113
+ fs.mkdirSync(nested, { recursive: true });
114
+ // The walk is purely lexical (dirname chain), so the sandbox path itself
115
+ // comes back unchanged — no realpath normalization is applied.
116
+ expect(findProjectRoot(nested)).toBe(sandbox);
117
+ });
118
+
119
+ it('falls back to the start directory when no .git marker exists', () => {
120
+ // os.tmpdir() is outside any repository on CI and dev machines alike.
121
+ expect(findProjectRoot(sandbox)).toBe(sandbox);
122
+ });
123
+ });
124
+
125
+ describe('binEntryFromPackageDir', () => {
126
+ function fakePackage(binValue, { writeEntry = true } = {}) {
127
+ const pkgDir = path.join(sandbox, 'node_modules', '@archlinter', 'cli');
128
+ fs.mkdirSync(pkgDir, { recursive: true });
129
+ fs.writeFileSync(
130
+ path.join(pkgDir, 'package.json'),
131
+ JSON.stringify({ name: '@archlinter/cli', version: '0.16.0', bin: binValue }),
132
+ );
133
+ if (writeEntry) {
134
+ const entryRel = typeof binValue === 'string' ? binValue : binValue.archlint;
135
+ fs.mkdirSync(path.dirname(path.join(pkgDir, entryRel)), { recursive: true });
136
+ fs.writeFileSync(path.join(pkgDir, entryRel), '// entry\n');
137
+ }
138
+ return pkgDir;
139
+ }
140
+
141
+ it('resolves the archlint JS entry from a bin map', () => {
142
+ const pkgDir = fakePackage({ archlint: 'bin/archlint.js' });
143
+ expect(binEntryFromPackageDir(pkgDir)).toBe(path.join(pkgDir, 'bin', 'archlint.js'));
144
+ });
145
+
146
+ it('supports the string form of bin', () => {
147
+ const pkgDir = fakePackage('bin/archlint.js');
148
+ expect(binEntryFromPackageDir(pkgDir)).toBe(path.join(pkgDir, 'bin', 'archlint.js'));
149
+ });
150
+
151
+ it('returns null when the package is not installed', () => {
152
+ expect(binEntryFromPackageDir(path.join(sandbox, 'node_modules', '@archlinter', 'cli'))).toBeNull();
153
+ });
154
+
155
+ it('returns null when the bin entry file is missing on disk', () => {
156
+ const pkgDir = fakePackage({ archlint: 'bin/archlint.js' }, { writeEntry: false });
157
+ expect(binEntryFromPackageDir(pkgDir)).toBeNull();
158
+ });
159
+
160
+ it('returns null for a manifest without a usable bin', () => {
161
+ const pkgDir = path.join(sandbox, 'node_modules', '@archlinter', 'cli');
162
+ fs.mkdirSync(pkgDir, { recursive: true });
163
+ fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name: '@archlinter/cli' }));
164
+ expect(binEntryFromPackageDir(pkgDir)).toBeNull();
165
+ });
166
+ });
167
+
168
+ describe('resolveArchlint', () => {
169
+ it('prefers xp-gate\'s own pinned dependency (never the registry latest)', () => {
170
+ const resolved = resolveArchlint(sandbox);
171
+ expect(resolved.kind).toBe('node-script');
172
+ expect(fs.existsSync(resolved.script)).toBe(true);
173
+ const pkgDir = path.dirname(path.dirname(resolved.script));
174
+ const version = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')).version;
175
+ expect(version).toMatch(/^0\.16\./);
176
+ });
177
+
178
+ it('falls back to the project\'s local node_modules when the own dependency is skipped', () => {
179
+ const pkgDir = path.join(sandbox, 'node_modules', '@archlinter', 'cli');
180
+ fs.mkdirSync(path.join(pkgDir, 'bin'), { recursive: true });
181
+ fs.writeFileSync(
182
+ path.join(pkgDir, 'package.json'),
183
+ JSON.stringify({ name: '@archlinter/cli', version: '9.9.9', bin: { archlint: 'bin/archlint.js' } }),
184
+ );
185
+ fs.writeFileSync(path.join(pkgDir, 'bin', 'archlint.js'), '// entry\n');
186
+ const resolved = resolveArchlint(sandbox, { includeOwnDependency: false });
187
+ expect(resolved).toEqual({
188
+ kind: 'node-script',
189
+ script: path.join(pkgDir, 'bin', 'archlint.js'),
190
+ source: 'project node_modules',
191
+ });
192
+ });
193
+
194
+ it('falls back to PATH when no node_modules install is reachable', () => {
195
+ const binDir = path.join(sandbox, 'fakebin');
196
+ fs.mkdirSync(binDir, { recursive: true });
197
+ const originalPath = process.env.PATH;
198
+ try {
199
+ if (process.platform === 'win32') {
200
+ fs.writeFileSync(path.join(binDir, 'archlint.bat'), '@echo off\r\nexit /b 0\r\n');
201
+ } else {
202
+ const sh = path.join(binDir, 'archlint');
203
+ fs.writeFileSync(sh, '#!/bin/sh\nexit 0\n');
204
+ fs.chmodSync(sh, 0o755);
205
+ }
206
+ // Prepend so `where`/`which` themselves still resolve, but archlint
207
+ // resolves to the fake first (PATH order).
208
+ process.env.PATH = binDir + path.delimiter + originalPath;
209
+ const resolved = resolveArchlint(sandbox, { includeOwnDependency: false });
210
+ expect(resolved.kind).toBe('binary');
211
+ expect(resolved.bin.toLowerCase()).toContain('archlint');
212
+ } finally {
213
+ process.env.PATH = originalPath;
214
+ }
215
+ });
216
+
217
+ it('falls back to npx --no-install as the last resort (never npx -y)', () => {
218
+ // System dirs only: the lookup binaries resolve, archlint does not.
219
+ const systemDir =
220
+ process.platform === 'win32'
221
+ ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32')
222
+ : '/usr/bin:/bin';
223
+ const originalPath = process.env.PATH;
224
+ try {
225
+ process.env.PATH = systemDir;
226
+ const resolved = resolveArchlint(sandbox, { includeOwnDependency: false });
227
+ expect(resolved).toEqual({ kind: 'npx', source: 'npx --no-install (local only)' });
228
+ } finally {
229
+ process.env.PATH = originalPath;
230
+ }
231
+ });
232
+ });
233
+
234
+ describe('verdictFromScanJson', () => {
235
+ const smell = (severity, type = 'SomeSmell') => ({ severity, type, files: ['src/x.js'] });
236
+
237
+ it('counts high/critical as blocking and ignores medium/low', () => {
238
+ const json = JSON.stringify({
239
+ smells: [smell('High'), smell('critical'), smell('Medium'), smell('low'), smell('Medium')],
240
+ });
241
+ const verdict = verdictFromScanJson(json);
242
+ expect(verdict.parseError).toBeNull();
243
+ expect(verdict.total).toBe(5);
244
+ expect(verdict.blocking).toBe(2);
245
+ expect(verdict.blockingDetails).toHaveLength(2);
246
+ });
247
+
248
+ it('matches severity case-insensitively', () => {
249
+ const json = JSON.stringify({ smells: [smell('HIGH'), smell('Critical')] });
250
+ expect(verdictFromScanJson(json).blocking).toBe(2);
251
+ });
252
+
253
+ it('treats a missing smells array as schema drift — parseError, never a silent clean (FC-12)', () => {
254
+ const verdict = verdictFromScanJson(JSON.stringify({ summary: {} }));
255
+ expect(verdict.parseError).toBeTruthy();
256
+ expect(verdict.total).toBe(0);
257
+ expect(verdict.blocking).toBe(0);
258
+ });
259
+
260
+ it('accepts an explicitly empty smells array as a clean scan', () => {
261
+ const verdict = verdictFromScanJson(JSON.stringify({ smells: [] }));
262
+ expect(verdict.parseError).toBeNull();
263
+ expect(verdict.total).toBe(0);
264
+ expect(verdict.blocking).toBe(0);
265
+ });
266
+
267
+ it('reports unparseable output as an error (fail-closed, never "clean")', () => {
268
+ const verdict = verdictFromScanJson('not json at all');
269
+ expect(verdict.parseError).toBeTruthy();
270
+ expect(verdict.blocking).toBe(0);
271
+ });
272
+
273
+ it('caps the blocking preview at 10 entries', () => {
274
+ const json = JSON.stringify({ smells: Array.from({ length: 25 }, () => smell('High')) });
275
+ const verdict = verdictFromScanJson(json);
276
+ expect(verdict.blocking).toBe(25);
277
+ expect(verdict.blockingDetails).toHaveLength(10);
278
+ });
279
+ });
280
+
281
+ describe('quoteForShell', () => {
282
+ it('leaves whitespace-free arguments untouched', () => {
283
+ expect(quoteForShell('--fail-on')).toBe('--fail-on');
284
+ });
285
+
286
+ it('quotes arguments containing whitespace for cmd.exe', () => {
287
+ expect(quoteForShell('C:\\my configs\\arch.yaml')).toBe('"C:\\my configs\\arch.yaml"');
288
+ });
289
+
290
+ it('doubles embedded quotes and wraps, so a quote cannot terminate the quoting early (FC-07)', () => {
291
+ expect(quoteForShell('a"b.yaml')).toBe('"a""b.yaml"');
292
+ expect(quoteForShell('C:\\my configs\\a"b.yaml')).toBe('"C:\\my configs\\a""b.yaml"');
293
+ });
294
+
295
+ it('wraps a quote-only argument even without whitespace', () => {
296
+ expect(quoteForShell('a"b')).toBe('"a""b"');
297
+ });
298
+ });
299
+
300
+ describe('stripExtendedPrefix', () => {
301
+ it('strips the plain extended-length prefix', () => {
302
+ expect(stripExtendedPrefix('\\\\?\\C:\\repo\\src\\x.js')).toBe('C:\\repo\\src\\x.js');
303
+ });
304
+
305
+ it('converts the UNC extended prefix to a plain share path (FC-13)', () => {
306
+ expect(stripExtendedPrefix('\\\\?\\UNC\\server\\share\\x.js')).toBe('\\\\server\\share\\x.js');
307
+ });
308
+
309
+ it('leaves ordinary paths untouched', () => {
310
+ expect(stripExtendedPrefix('/repo/src/x.js')).toBe('/repo/src/x.js');
311
+ expect(stripExtendedPrefix('C:\\repo\\x.js')).toBe('C:\\repo\\x.js');
312
+ });
313
+ });
314
+
315
+ describe('arch — real archlint integration (anti-noop)', () => {
316
+ it('exits 0 on a clean sandbox (zero-tolerance mode, no baseline)', () => {
317
+ writeCleanSandbox();
318
+ const run = runArchInChild(sandbox, []);
319
+ expect(run.status).toBe(0);
320
+ expect(run.stdout).toContain('Zero-tolerance mode');
321
+ expect(run.stdout).toContain('✅ arch: no high/critical architecture findings');
322
+ }, 60000);
323
+
324
+ it('BLOCKS a planted high-severity violation — exit 0 is a real scan, not a no-op', () => {
325
+ writeCleanSandbox();
326
+ plantHighClone();
327
+ const run = runArchInChild(sandbox, []);
328
+ expect(run.status).toBe(1);
329
+ expect(run.stderr).toContain('BLOCKED');
330
+ expect(run.stderr).toContain('CodeClone');
331
+ }, 60000);
332
+
333
+ it('baseline ratchet: snapshot of a clean tree then re-scan exits 0', () => {
334
+ writeCleanSandbox();
335
+ const snapshot = runRealArchlint(['snapshot', '-o', path.join(sandbox, '.architecture-baseline.json'), '-p', sandbox], sandbox);
336
+ expect(snapshot.status).toBe(0);
337
+ const run = runArchInChild(sandbox, []);
338
+ expect(run.status).toBe(0);
339
+ expect(run.stdout).toContain('Baseline ratchet mode');
340
+ }, 120000);
341
+
342
+ it('baseline ratchet: a NEW high finding against the baseline blocks', () => {
343
+ writeCleanSandbox();
344
+ const snapshot = runRealArchlint(['snapshot', '-o', path.join(sandbox, '.architecture-baseline.json'), '-p', sandbox], sandbox);
345
+ expect(snapshot.status).toBe(0);
346
+ plantHighClone();
347
+ const run = runArchInChild(sandbox, []);
348
+ expect(run.status).toBe(1);
349
+ expect(run.stderr).toContain('BLOCKED');
350
+ }, 120000);
351
+
352
+ it('exits 0 on this repository (AC-A-05-01: baseline present, no new high findings)', () => {
353
+ // Guarded: needs both the committed baseline and the pinned archlint,
354
+ // i.e. a full dev/CI checkout (npm ci installs @archlinter/cli).
355
+ const baseline = path.join(REPO_ROOT, '.architecture-baseline.json');
356
+ if (!fs.existsSync(baseline) || !fs.existsSync(path.join(REPO_ROOT, 'node_modules', '@archlinter', 'cli'))) {
357
+ return;
358
+ }
359
+ const run = runArchInChild(REPO_ROOT, []);
360
+ expect(run.status).toBe(0);
361
+ expect(run.stdout).toContain('Baseline ratchet mode');
362
+ }, 180000);
363
+ });
364
+
365
+ describe('arch — opt-in config path (--use-config)', () => {
366
+ it('passes a valid new-schema config through to archlint (exit 0)', () => {
367
+ writeCleanSandbox();
368
+ const init = runRealArchlint(['init', '--no-interactive'], sandbox);
369
+ expect(init.status).toBe(0);
370
+ expect(fs.existsSync(path.join(sandbox, '.archlint.yaml'))).toBe(true);
371
+ const run = runArchInChild(sandbox, ['--use-config', '.archlint.yaml']);
372
+ expect(run.status).toBe(0);
373
+ }, 60000);
374
+
375
+ it('accepts --config as a deprecated alias of --use-config', () => {
376
+ writeCleanSandbox();
377
+ runRealArchlint(['init', '--no-interactive'], sandbox);
378
+ const run = runArchInChild(sandbox, ['--config', '.archlint.yaml']);
379
+ expect(run.status).toBe(0);
380
+ expect(run.stderr).toContain('deprecated');
381
+ }, 60000);
382
+
383
+ it("surfaces archlint's schema rejection for the repo's pre-0.16 architecture.yaml (no silent pass)", () => {
384
+ const run = runArchInChild(REPO_ROOT, ['--use-config', 'architecture.yaml']);
385
+ expect(run.status).not.toBe(0);
386
+ }, 60000);
387
+
388
+ it('rejects a missing config file before spawning archlint', () => {
389
+ const run = runArchInChild(sandbox, ['--use-config', 'nope.yaml']);
390
+ expect(run.status).toBe(1);
391
+ expect(run.stderr).toContain('config file not found');
392
+ });
393
+
394
+ it('rejects --use-config without a value', () => {
395
+ const run = runArchInChild(sandbox, ['--use-config']);
396
+ expect(run.status).toBe(1);
397
+ });
398
+
399
+ it('rejects passing both --use-config and --config', () => {
400
+ const run = runArchInChild(sandbox, ['--use-config', 'a.yaml', '--config', 'b.yaml']);
401
+ expect(run.status).toBe(1);
402
+ });
403
+ });
@@ -0,0 +1,200 @@
1
+ /**
2
+ * @test REQ-469 xp-gate doctor probes tools in the HOOK environment and reads
3
+ * the registry PATH fail-closed
4
+ * @intent doctor 曾用 `where` / cmd.exe 直执探测 CLI 工具,而 pre-commit/pre-push
5
+ * 钩子运行在 `bash -c` 里——两个环境对同一工具可以给出相反的答案,doctor 的
6
+ * "✓ All checks passed" 因此不构成对钩子运行环境的承诺(#469)。本套件固化
7
+ * 三层修复:hook-env 探测为权威层、registry PATH 陈旧检测(fail-closed
8
+ * WARN)、`--refresh-env` 对新装工具的可见性恢复。
9
+ * @covers AC-469-01 hook-env probe authoritative, AC-469-02 parseRegQueryPath,
10
+ * AC-469-03 stale-entry detection, AC-469-04 fail-closed WARN,
11
+ * AC-469-05 buildRefreshedPath keeps process PATH first,
12
+ * AC-469-06 --refresh-env flag contract
13
+ */
14
+ const isWindows = process.platform === 'win32';
15
+ const describeWin = isWindows ? describe : describe.skip;
16
+
17
+ // Walkthrough FC-02: a bare `return` inside it() silently reports PASS on a
18
+ // runner without bash — a false-green in the very suite that guards against
19
+ // false greens. Bash-gated tests must SKIP loudly instead.
20
+ const { execSync } = require('child_process');
21
+ const BASH_AVAILABLE = (() => {
22
+ try { execSync('bash --version', { stdio: 'ignore' }); return true; } catch { return false; }
23
+ })();
24
+ const itWithBash = BASH_AVAILABLE ? it : it.skip;
25
+
26
+ describe('doctor hook-env probing (#469)', () => {
27
+ function mod() {
28
+ return require('../doctor');
29
+ }
30
+
31
+ itWithBash('AC-469-01: a tool the hook env sees is reported with its hook-env path', async () => {
32
+ const { probeToolInHookEnv } = mod();
33
+ const bashPath = isWindows ? 'bash' : '/bin/sh';
34
+ const p = await probeToolInHookEnv(isWindows ? 'node' : 'sh', bashPath);
35
+ expect(typeof p).toBe('string');
36
+ expect(p.length).toBeGreaterThan(0);
37
+ });
38
+
39
+ itWithBash('AC-469-01: a tool absent from the hook env returns null — no fall-through', async () => {
40
+ const { detectCliToolAsync } = mod();
41
+ const result = await detectCliToolAsync('definitely-not-a-real-tool-xyz-469', isWindows, {
42
+ bashPath: 'bash',
43
+ });
44
+ // With bash present there is NO fall-through: `where` finding nothing is
45
+ // irrelevant here, the hook env verdict is final.
46
+ expect(result.available).toBe(false);
47
+ });
48
+
49
+ it('AC-469-01: tool names outside the allowlist are rejected fail-closed (FC-04)', async () => {
50
+ const { probeToolInHookEnv } = mod();
51
+ // Injection guard: shell metacharacters never reach a command string.
52
+ expect(await probeToolInHookEnv('node; rm -rf /', 'bash')).toBeNull();
53
+ expect(await probeToolInHookEnv('node$(evil)', 'bash')).toBeNull();
54
+ expect(await probeToolInHookEnv('', 'bash')).toBeNull();
55
+ });
56
+
57
+ itWithBash('AC-469-01: an installed tool is visible through the hook-env tier', async () => {
58
+ const { detectCliToolAsync } = mod();
59
+ const result = await detectCliToolAsync('node', true, { bashPath: 'bash' });
60
+ expect(result.available).toBe(true);
61
+ // The hook-env path is POSIX form on Windows — cmd.exe cannot run it.
62
+ if (isWindows) expect(result.path.startsWith('/')).toBe(true);
63
+ });
64
+
65
+ it('AC-469-02: parseRegQueryPath extracts the value from a reg query line', () => {
66
+ const { parseRegQueryPath } = mod();
67
+ const stdout = [
68
+ '',
69
+ 'HKEY_CURRENT_USER\\Environment',
70
+ ' Path REG_EXPAND_SZ C:\\tools\\bin;C:\\other',
71
+ '',
72
+ ].join('\r\n');
73
+ expect(parseRegQueryPath(stdout)).toBe('C:\\tools\\bin;C:\\other');
74
+ expect(parseRegQueryPath(' Path REG_SZ /usr/local/bin')).toBe('/usr/local/bin');
75
+ });
76
+
77
+ it('AC-469-02: parseRegQueryPath returns null when no Path value line exists', () => {
78
+ const { parseRegQueryPath } = mod();
79
+ expect(parseRegQueryPath('')).toBeNull();
80
+ expect(parseRegQueryPath('ERROR: The system was unable to find the specified registry key or value.')).toBeNull();
81
+ // A Path-like line without a value column is not evidence.
82
+ expect(parseRegQueryPath(' Path REG_SZ')).toBeNull();
83
+ });
84
+
85
+ it('AC-469-03: splitPathEntries and findStalePathEntries agree on normalization', () => {
86
+ const { findStalePathEntries, splitPathEntries } = mod();
87
+ expect(splitPathEntries('a;b;;c')).toEqual(['a', 'b', 'c']);
88
+ // Registry-only entries are stale; entries the process sees are not.
89
+ const stale = findStalePathEntries('C:\\tools\\bin', ['C:\\tools\\bin', 'C:\\fresh\\install']);
90
+ expect(stale).toEqual(['C:\\fresh\\install']);
91
+ // Case and trailing-separator differences are the same directory.
92
+ expect(findStalePathEntries('c:\\tools\\bin\\', ['C:\\TOOLS\\BIN'])).toEqual([]);
93
+ });
94
+
95
+ it('AC-469-05: buildRefreshedPath keeps the process PATH FIRST and appends registry-only entries', () => {
96
+ const { buildRefreshedPath } = mod();
97
+ const processPath = 'C:\\git\\bin;C:\\windows';
98
+ const registry = {
99
+ machine: 'C:\\windows;C:\\new-machine-tool',
100
+ user: 'C:\\new-user-tool',
101
+ };
102
+ const out = buildRefreshedPath(processPath, registry);
103
+ const entries = out.split(';');
104
+ // The process environment keeps priority — the shell that resolves
105
+ // `bash` must be the one this doctor run diagnosed, not a re-resolution
106
+ // through the registry (registry-first broke probe: WSL stub won).
107
+ expect(entries.slice(0, 2)).toEqual(['C:\\git\\bin', 'C:\\windows']);
108
+ expect(entries).toContain('C:\\new-machine-tool');
109
+ expect(entries).toContain('C:\\new-user-tool');
110
+ // No duplicates: the registry copies of process entries are dropped.
111
+ expect(entries.filter((e) => e === 'C:\\windows')).toHaveLength(1);
112
+ });
113
+ });
114
+
115
+ describeWin('doctor PATH staleness (#469, Windows-only)', () => {
116
+ function mod() {
117
+ return require('../doctor');
118
+ }
119
+
120
+ it('AC-469-04: an unreadable registry WARNs instead of claiming freshness', async () => {
121
+ const { diagnosePathStaleness } = mod();
122
+ const checks = [];
123
+ await diagnosePathStaleness(checks, {
124
+ registry: { supported: true, user: [], machine: [], userError: 'reg query failed for HKCU\\Environment', machineError: 'reg query failed for HKLM', error: 'combined' },
125
+ });
126
+ expect(checks).toHaveLength(1);
127
+ expect(checks[0].name).toBe('PATH freshness');
128
+ expect(checks[0].status).toBe('WARN');
129
+ expect(checks[0].detail).toMatch(/staleness unknown|unreadable/);
130
+ });
131
+
132
+ it('AC-469-04: a readable hive still counts when only the other half fails (FC-07)', async () => {
133
+ const { diagnosePathStaleness } = mod();
134
+ // Machine hive readable and matching; user hive unreadable. The verdict
135
+ // must be WARN (the user half is unknown) but the detail must carry the
136
+ // machine-half evidence instead of discarding it.
137
+ const processPath = process.env.PATH || '';
138
+ const matchingMachine = processPath.split(';').slice(0, 2).join(';');
139
+ const checks = [];
140
+ await diagnosePathStaleness(checks, {
141
+ registry: { supported: true, user: [], machine: matchingMachine.split(';'), userError: 'reg query failed for HKCU\\Environment', machineError: null, error: 'reg query failed for HKCU\\Environment' },
142
+ });
143
+ expect(checks).toHaveLength(1);
144
+ expect(checks[0].status).toBe('WARN');
145
+ expect(checks[0].detail).toMatch(/User hive unreadable/);
146
+ expect(checks[0].detail).toMatch(/Machine/);
147
+ });
148
+
149
+ it('AC-469-03: a matching PATH PASSes with no staleness claim', async () => {
150
+ const { execSync } = require('child_process');
151
+ // Real registry read; on a healthy terminal this must PASS. If the
152
+ // sandbox blocks reg query the check must WARN (fail-closed), not PASS.
153
+ let regOk = true;
154
+ try { execSync('reg query "HKCU\\Environment" /v Path', { stdio: 'ignore' }); } catch { regOk = false; }
155
+ const { diagnosePathStaleness } = mod();
156
+ const checks = [];
157
+ await diagnosePathStaleness(checks, {});
158
+ expect(checks).toHaveLength(1);
159
+ if (!regOk) {
160
+ expect(checks[0].status).toBe('WARN');
161
+ return;
162
+ }
163
+ // reg works: the verdict must be evidence-based. A stale terminal WARNs;
164
+ // a fresh one PASSes — both are legal, a missing check is not.
165
+ expect(['PASS', 'WARN']).toContain(checks[0].status);
166
+ expect(checks[0].name).toBe('PATH freshness');
167
+ });
168
+
169
+ it('AC-469-03: stale registry entries WARN and mention --refresh-env', async () => {
170
+ const { diagnosePathStaleness } = mod();
171
+ // Inject a registry whose user PATH carries a directory this process
172
+ // cannot see — the canonical "installed after this terminal opened".
173
+ const { execSync } = require('child_process');
174
+ const sandbox = 'C:\\__xpgate_469_nonexistent_dir__';
175
+ const checks = [];
176
+ await diagnosePathStaleness(checks, {
177
+ registry: { supported: true, machine: [], user: [sandbox], error: null },
178
+ });
179
+ // A nonexistent dir cannot contain a tool, so the invisible-tool scan
180
+ // adds nothing, but the staleness WARN itself must fire.
181
+ try { execSync('bash --version', { stdio: 'ignore' }); } catch {
182
+ // no bash: invisible-tool scan skipped, WARN still fires
183
+ }
184
+ expect(checks[0].status).toBe('WARN');
185
+ expect(checks[0].detail).toMatch(/stale|--refresh-env/);
186
+ });
187
+ });
188
+
189
+ describe('doctor --refresh-env flag contract (#469)', () => {
190
+ it('AC-469-06: --refresh-env is a known flag and reaches doctorFlagContract output', () => {
191
+ const { doctorFlagContract } = require('../doctor');
192
+ const ok = doctorFlagContract(['--refresh-env']);
193
+ expect(ok.errors).toEqual([]);
194
+ expect(ok.refreshEnv).toBe(true);
195
+ const plain = doctorFlagContract([]);
196
+ expect(plain.refreshEnv).toBe(false);
197
+ const bad = doctorFlagContract(['--refresh-env-typo']);
198
+ expect(bad.errors.length).toBeGreaterThan(0);
199
+ });
200
+ });