GitNexus/gitnexus/test/unit/resolve-invocation.test.ts

559 lines
22 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('node:child_process', () => ({
execFileSync: vi.fn(),
}));
import { execFileSync } from 'node:child_process';
import {
getNpmMajorVersion,
warnIfNpm11NpxRisk,
NPX_REF,
} from '../../src/cli/resolve-invocation.js';
import { readFileSync, mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import os from 'node:os';
const mockedExec = vi.mocked(execFileSync);
const cjsRequire = createRequire(import.meta.url);
const CANONICAL_CJS = path.resolve(
__dirname,
'..',
'..',
'hooks',
'claude',
'resolve-analyze-cmd.cjs',
);
const PLUGIN_CJS = path.resolve(
__dirname,
'..',
'..',
'..',
'gitnexus-claude-plugin',
'hooks',
'resolve-analyze-cmd.cjs',
);
interface CjsModule {
formatAnalyzeCommand: (
o?: { embeddings?: boolean },
deps?: { npmMajor?: number | null; pnpmMajor?: number | null; pnpmMinor?: number | null },
) => string;
formatDocumentationDlxCommand: (args: string, o?: { embeddings?: boolean }) => string;
formatPnpmAllowBuildArgs: (
o?: { embeddings?: boolean; alwaysAllowBuild?: boolean },
deps?: { pnpmMajor?: number | null; pnpmMinor?: number | null },
) => string[];
resolveInvocationMode: (
probe?: (command: string, gitnexusWrapper?: boolean) => string | null,
deps?: { npmMajor?: number | null; pnpmMajor?: number | null; pnpmPresent?: boolean },
) => 'gitnexus' | 'pnpm' | 'npx';
resolveOnPath: (
command: string,
preferExecExt?: boolean,
opts?: { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv },
) => string | null;
buildRunnerArgv: (
mode: 'gitnexus' | 'pnpm' | 'npx',
gitnexusArgs: string[],
deps?: { pnpmMajor?: number | null; pnpmMinor?: number | null },
) => { program: string; args: string[] };
NPX_REF: string;
}
// Require the real shipped artifact — the hook runtime loads this exact file, so
// the tests exercise production code, not a TypeScript mirror of it.
//
// Determinism invariant: createRequire bypasses vitest's node:child_process mock,
// so the only live subprocess this module can run is probeVersion (`npm`/`pnpm
// --version`). resolveOnPath is now spawn-free — a pure PATH scan — so tests pin
// it by passing an injected `{ platform, env }` (never the host PATH). Mode tests
// inject a fake `probe` or force GITNEXUS_INVOCATION; version tests inject `deps`.
// Keep new tests on one of those paths so results never depend on the host.
const cjs = cjsRequire(CANONICAL_CJS) as CjsModule;
describe('resolve-analyze-cmd.cjs (canonical invocation resolver)', () => {
afterEach(() => {
delete process.env.GITNEXUS_INVOCATION;
});
it('standardizes the invocation ref on gitnexus@latest', () => {
expect(cjs.NPX_REF).toBe('gitnexus@latest');
});
it('formats each forced mode, with and without --embeddings', () => {
const allow = '--allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter';
const allowEmb =
'--allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter --allow-build=onnxruntime-node';
const cases = [
['gitnexus', 'gitnexus analyze', 'gitnexus analyze --embeddings'],
[
'pnpm',
`pnpm ${allow} dlx ${cjs.NPX_REF} analyze`,
`pnpm ${allowEmb} dlx ${cjs.NPX_REF} analyze --embeddings`,
],
['npx', `npx ${cjs.NPX_REF} analyze`, `npx ${cjs.NPX_REF} analyze --embeddings`],
] as const;
for (const [mode, plain, withEmbeddings] of cases) {
process.env.GITNEXUS_INVOCATION = mode;
expect(cjs.formatAnalyzeCommand(undefined, { pnpmMajor: 11 })).toBe(plain);
expect(cjs.formatAnalyzeCommand({ embeddings: true }, { pnpmMajor: 11 })).toBe(
withEmbeddings,
);
}
});
it('auto-selects global gitnexus first', () => {
expect(cjs.resolveInvocationMode(() => '/usr/local/bin/gitnexus')).toBe('gitnexus');
});
it('auto-selects pnpm on npm 11+ when pnpm is on PATH', () => {
const probe = (c: string) => (c === 'pnpm' ? '/usr/local/bin/pnpm' : null);
expect(cjs.resolveInvocationMode(probe, { npmMajor: 11 })).toBe('pnpm');
});
it('auto-selects npx on npm 10 even when pnpm is on PATH', () => {
const probe = (c: string) => (c === 'pnpm' ? '/usr/local/bin/pnpm' : null);
expect(cjs.resolveInvocationMode(probe, { npmMajor: 10 })).toBe('npx');
});
it('auto-selects pnpm when npm is absent (null injected) but pnpm is on PATH', () => {
// npmMajor:null means "npm absent" and must be honored via the `in` seam —
// not fall through to the host's real npm (npm 10.x on CI → would route npx).
const probe = (c: string) => (c === 'pnpm' ? '/usr/local/bin/pnpm' : null);
expect(cjs.resolveInvocationMode(probe, { npmMajor: null })).toBe('pnpm');
});
it('falls back to npx when npm is null-absent and pnpm is also absent', () => {
expect(cjs.resolveInvocationMode(() => null, { npmMajor: null })).toBe('npx');
});
it('falls back to npx when neither global gitnexus nor pnpm is available', () => {
expect(cjs.resolveInvocationMode(() => null, { npmMajor: 11 })).toBe('npx');
});
it('honors pnpmPresent:true — a present-but-unparseable pnpm selects pnpm, not the npx crash path', () => {
// Windows headline regression guard: when probeVersion cannot read the
// version (timeout / Corepack banner) but pnpm is on PATH, formatAnalyzeCommand
// sets pnpmPresent:true so npm-11 users still get pnpm rather than the npx crash.
expect(cjs.resolveInvocationMode(() => null, { npmMajor: 11, pnpmPresent: true })).toBe('pnpm');
});
it('honors pnpmPresent:false as explicit absence (overrides a PATH hit)', () => {
const probe = (c: string) => (c === 'pnpm' ? '/usr/local/bin/pnpm' : null);
expect(cjs.resolveInvocationMode(probe, { npmMajor: 11, pnpmPresent: false })).toBe('npx');
});
it('omits --allow-build on pnpm 9 (scripts run by default)', () => {
process.env.GITNEXUS_INVOCATION = 'pnpm';
expect(cjs.formatAnalyzeCommand(undefined, { pnpmMajor: 9 })).toBe(
`pnpm dlx ${cjs.NPX_REF} analyze`,
);
});
it('includes --allow-build (pre-dlx) on pnpm 10.x with unknown minor (conservative)', () => {
// No pnpmMinor injected → minor is null → the gate cannot prove < 10.2, so
// it conservatively emits the flags. This is the unknown-minor fallback, NOT
// real pnpm 10.0 (which reports minor=0 and is covered separately below).
process.env.GITNEXUS_INVOCATION = 'pnpm';
const allow = '--allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter';
expect(cjs.formatAnalyzeCommand(undefined, { pnpmMajor: 10 })).toBe(
`pnpm ${allow} dlx ${cjs.NPX_REF} analyze`,
);
});
it('omits --allow-build on pnpm 10.0 (the flag did not exist until 10.2)', () => {
process.env.GITNEXUS_INVOCATION = 'pnpm';
expect(cjs.formatAnalyzeCommand(undefined, { pnpmMajor: 10, pnpmMinor: 0 })).toBe(
`pnpm dlx ${cjs.NPX_REF} analyze`,
);
});
it('omits --allow-build on pnpm 10.1 (the flag was added in 10.2)', () => {
process.env.GITNEXUS_INVOCATION = 'pnpm';
expect(cjs.formatAnalyzeCommand(undefined, { pnpmMajor: 10, pnpmMinor: 1 })).toBe(
`pnpm dlx ${cjs.NPX_REF} analyze`,
);
});
it('includes --allow-build on pnpm 10.2 (the first minor that accepts the flag)', () => {
process.env.GITNEXUS_INVOCATION = 'pnpm';
const allow = '--allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter';
expect(cjs.formatAnalyzeCommand(undefined, { pnpmMajor: 10, pnpmMinor: 2 })).toBe(
`pnpm ${allow} dlx ${cjs.NPX_REF} analyze`,
);
});
it('emits --allow-build when the pnpm major is null-injected (absent/unknown)', () => {
expect(cjs.formatPnpmAllowBuildArgs({}, { pnpmMajor: null })).toEqual([
'--allow-build=@ladybugdb/core',
'--allow-build=gitnexus',
'--allow-build=tree-sitter',
]);
});
it('formatDocumentationDlxCommand always includes allow-build for committed docs', () => {
expect(cjs.formatDocumentationDlxCommand('analyze')).toContain('--allow-build=@ladybugdb/core');
expect(cjs.formatDocumentationDlxCommand('analyze')).toContain('gitnexus@latest analyze');
});
it('lets GITNEXUS_INVOCATION override the probe without consulting it', () => {
process.env.GITNEXUS_INVOCATION = 'pnpm';
const probe = vi.fn(() => '/usr/local/bin/gitnexus');
expect(cjs.resolveInvocationMode(probe)).toBe('pnpm');
expect(probe).not.toHaveBeenCalled();
});
});
describe('warnIfNpm11NpxRisk (#1939 npm-11 nudge)', () => {
afterEach(() => {
vi.clearAllMocks();
delete process.env.GITNEXUS_INVOCATION;
});
it('exposes the resolver contract the load-time guard enforces', () => {
// The module's createRequire guard throws at load if the cjs export shape
// drifts; that this module imported at all (and these hold) proves it passed.
expect(typeof NPX_REF).toBe('string');
expect(typeof getNpmMajorVersion).toBe('function');
expect(typeof warnIfNpm11NpxRisk).toBe('function');
});
it('parses the npm major version', () => {
mockedExec.mockReturnValue('11.5.2\n');
expect(getNpmMajorVersion()).toBe(11);
});
it('handles edge npm --version output (pre-release / empty / non-numeric)', () => {
mockedExec.mockReturnValue('12.0.0-pre\n');
expect(getNpmMajorVersion()).toBe(12);
mockedExec.mockReturnValue('\n');
expect(getNpmMajorVersion()).toBeNull();
mockedExec.mockReturnValue('not-a-version\n');
expect(getNpmMajorVersion()).toBeNull();
});
it('tolerates a Corepack/notice banner line before the version', () => {
mockedExec.mockReturnValue(
'Corepack is about to download https://registry.npmjs.org/npm/-/npm-11.0.0.tgz\n11.0.0\n',
);
expect(getNpmMajorVersion()).toBe(11);
});
it('passes a shell on Windows so the .cmd npm shim resolves (load-bearing for the warning)', () => {
const orig = Object.getOwnPropertyDescriptor(process, 'platform')!;
try {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
mockedExec.mockReturnValue('11.0.0\n');
getNpmMajorVersion();
expect(mockedExec).toHaveBeenCalledWith(
'npm',
['--version'],
expect.objectContaining({ shell: true }),
);
} finally {
Object.defineProperty(process, 'platform', orig);
}
});
it('uses no shell on POSIX (direct PATH lookup)', () => {
const orig = Object.getOwnPropertyDescriptor(process, 'platform')!;
try {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
mockedExec.mockReturnValue('11.0.0\n');
getNpmMajorVersion();
expect(mockedExec).toHaveBeenCalledWith(
'npm',
['--version'],
expect.objectContaining({ shell: false }),
);
} finally {
Object.defineProperty(process, 'platform', orig);
}
});
it('warns on the npm 11+ npx path', () => {
process.env.GITNEXUS_INVOCATION = 'npx';
mockedExec.mockReturnValue('11.0.0\n');
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
warnIfNpm11NpxRisk();
expect(write).toHaveBeenCalledTimes(1);
expect(String(write.mock.calls[0]?.[0])).toContain('node.target is null');
expect(String(write.mock.calls[0]?.[0])).toContain('--allow-build=@ladybugdb/core');
expect(String(write.mock.calls[0]?.[0])).toContain(`gitnexus@latest analyze`);
write.mockRestore();
});
it('does not warn when a global gitnexus or pnpm is preferred', () => {
process.env.GITNEXUS_INVOCATION = 'pnpm';
mockedExec.mockReturnValue('11.0.0\n');
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
warnIfNpm11NpxRisk();
expect(write).not.toHaveBeenCalled();
write.mockRestore();
});
it('does not warn when a global gitnexus is preferred', () => {
process.env.GITNEXUS_INVOCATION = 'gitnexus';
mockedExec.mockReturnValue('11.0.0\n');
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
warnIfNpm11NpxRisk();
expect(write).not.toHaveBeenCalled();
write.mockRestore();
});
it('does not warn when npm is older than 11', () => {
process.env.GITNEXUS_INVOCATION = 'npx';
mockedExec.mockReturnValue('10.9.0\n');
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
warnIfNpm11NpxRisk();
expect(write).not.toHaveBeenCalled();
write.mockRestore();
});
it('does not warn when npm is absent', () => {
process.env.GITNEXUS_INVOCATION = 'npx';
mockedExec.mockImplementation(() => {
throw new Error('missing');
});
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
warnIfNpm11NpxRisk();
expect(write).not.toHaveBeenCalled();
write.mockRestore();
});
});
describe('buildRunnerArgv (project-local runner exec, #1945)', () => {
it('passes gitnexus args straight through for the global-binary mode', () => {
expect(cjs.buildRunnerArgv('gitnexus', ['group', 'list'])).toEqual({
program: 'gitnexus',
args: ['group', 'list'],
});
});
it('prefixes the registry ref for npx mode', () => {
expect(cjs.buildRunnerArgv('npx', ['analyze'])).toEqual({
program: 'npx',
args: ['gitnexus@latest', 'analyze'],
});
});
it('builds the pre-`dlx` --allow-build invocation for pnpm mode', () => {
// Inject a pnpm version >= 10.2 so the allow-build flags are emitted without
// a live `pnpm --version` probe.
const { program, args } = cjs.buildRunnerArgv('pnpm', ['analyze'], {
pnpmMajor: 10,
pnpmMinor: 14,
});
expect(program).toBe('pnpm');
// Flags must precede `dlx` (ERR_PNPM_SPEC_NOT_SUPPORTED otherwise, #1939).
const dlxIdx = args.indexOf('dlx');
expect(dlxIdx).toBeGreaterThan(0);
expect(args.slice(0, dlxIdx)).toEqual([
'--allow-build=@ladybugdb/core',
'--allow-build=gitnexus',
'--allow-build=tree-sitter',
]);
expect(args.slice(dlxIdx)).toEqual(['dlx', 'gitnexus@latest', 'analyze']);
});
it('widens the pnpm allow-build set when --embeddings is requested', () => {
const { args } = cjs.buildRunnerArgv('pnpm', ['analyze', '--embeddings'], {
pnpmMajor: 10,
pnpmMinor: 14,
});
expect(args).toContain('--allow-build=onnxruntime-node');
});
it('widens the allow-build set for the --embeddings=N equals form too', () => {
const { args } = cjs.buildRunnerArgv('pnpm', ['analyze', '--embeddings=5000'], {
pnpmMajor: 10,
pnpmMinor: 14,
});
expect(args).toContain('--allow-build=onnxruntime-node');
});
it('omits onnxruntime-node when --embeddings is absent', () => {
const { args } = cjs.buildRunnerArgv('pnpm', ['analyze'], { pnpmMajor: 10, pnpmMinor: 14 });
expect(args).not.toContain('--allow-build=onnxruntime-node');
});
});
describe('resolveOnPath — pure-Node PATH scan (#1938, all-OS, spawn-free)', () => {
const tmpDirs: string[] = [];
const mkBinDir = (): string => {
const dir = mkdtempSync(path.join(os.tmpdir(), 'resolve-path-'));
tmpDirs.push(dir);
return dir;
};
afterEach(() => {
while (tmpDirs.length) rmSync(tmpDirs.pop() as string, { recursive: true, force: true });
});
it('finds an executable launcher on a POSIX PATH', () => {
const dir = mkBinDir();
const bin = path.join(dir, 'gitnexus');
writeFileSync(bin, '#!/bin/sh\nexit 0\n');
chmodSync(bin, 0o755);
expect(cjs.resolveOnPath('gitnexus', true, { platform: 'linux', env: { PATH: dir } })).toBe(
bin,
);
});
// X_OK is meaningless on Windows (every file reads as accessible), so this
// POSIX-only guarantee can only be asserted on a POSIX host.
it.skipIf(process.platform === 'win32')(
'skips a non-executable file on POSIX (requires X_OK)',
() => {
const dir = mkBinDir();
writeFileSync(path.join(dir, 'gitnexus'), 'not executable'); // intentionally no chmod +x
expect(
cjs.resolveOnPath('gitnexus', true, { platform: 'linux', env: { PATH: dir } }),
).toBeNull();
},
);
it('returns null when the launcher is absent or PATH is empty', () => {
const dir = mkBinDir();
expect(
cjs.resolveOnPath('gitnexus', true, { platform: 'linux', env: { PATH: dir } }),
).toBeNull();
expect(cjs.resolveOnPath('gitnexus', true, { platform: 'linux', env: {} })).toBeNull();
});
it('honors PATHEXT on Windows (a .cmd shim is detected)', () => {
const dir = mkBinDir();
const bin = path.join(dir, 'gitnexus.cmd');
writeFileSync(bin, '@echo off\r\n');
// The PATHEXT entry case matches the fixture so the assertion is deterministic
// on case-sensitive CI filesystems; real Windows is case-insensitive, so the
// casing of PATHEXT vs the on-disk shim never matters there.
expect(
cjs.resolveOnPath('gitnexus', true, {
platform: 'win32',
env: { PATH: dir, PATHEXT: '.COM;.EXE;.BAT;.cmd' },
}),
).toBe(bin);
});
it('does not treat a .ps1-only shim as on PATH when PATHEXT excludes .PS1', () => {
// A .ps1 is not launchable as `gitnexus` without a shell and is absent from
// default PATHEXT, so mirroring `where`/cmd.exe (PATHEXT-driven) avoids a hint
// that would fail when run.
const dir = mkBinDir();
writeFileSync(path.join(dir, 'gitnexus.ps1'), 'exit 0');
expect(
cjs.resolveOnPath('gitnexus', true, {
platform: 'win32',
env: { PATH: dir, PATHEXT: '.COM;.EXE;.BAT;.CMD' },
}),
).toBeNull();
});
it('on Windows ignores a bare extensionless file and returns the PATHEXT shim', () => {
// Windows matches PATHEXT extensions only — an extensionless `gitnexus` is not
// launchable as `gitnexus` from a shell, so when both exist the .cmd shim wins
// and the bare file is never the result (it would be an un-spawnable hint).
const dir = mkBinDir();
writeFileSync(path.join(dir, 'gitnexus'), 'not a shim');
const cmd = path.join(dir, 'gitnexus.cmd');
writeFileSync(cmd, '@echo off\r\n');
expect(
cjs.resolveOnPath('gitnexus', true, {
platform: 'win32',
env: { PATH: dir, PATHEXT: '.COM;.EXE;.BAT;.cmd' },
}),
).toBe(cmd);
});
it('on Windows returns null for an extensionless-only file (not in PATHEXT)', () => {
const dir = mkBinDir();
writeFileSync(path.join(dir, 'gitnexus'), 'not a shim');
expect(
cjs.resolveOnPath('gitnexus', true, {
platform: 'win32',
env: { PATH: dir, PATHEXT: '.COM;.EXE;.BAT;.CMD' },
}),
).toBeNull();
});
it('with preferExecExt, prefers a .cmd/.exe shim over an exotic .COM hit, but accepts .COM alone', () => {
// preferExecExt mirrors the old `where` wrapper preference: a recognized
// .cmd/.bat/.exe wins over a .COM, yet a lone .COM is still detected (better a
// resolvable hint than none). Fixture/PATHEXT cases match for CI determinism.
const both = mkBinDir();
writeFileSync(path.join(both, 'gitnexus.com'), 'x');
const cmd = path.join(both, 'gitnexus.cmd');
writeFileSync(cmd, '@echo off\r\n');
expect(
cjs.resolveOnPath('gitnexus', true, {
platform: 'win32',
env: { PATH: both, PATHEXT: '.com;.cmd' },
}),
).toBe(cmd);
const comOnly = mkBinDir();
const com = path.join(comOnly, 'gitnexus.com');
writeFileSync(com, 'x');
expect(
cjs.resolveOnPath('gitnexus', true, {
platform: 'win32',
env: { PATH: comOnly, PATHEXT: '.com;.cmd' },
}),
).toBe(com);
});
});
describe('formatAnalyzeCommand end-to-end via the pure scan (#1938)', () => {
// Exercises the public entry through resolveOnPath against a real PATH (no
// mocks, no GITNEXUS_INVOCATION): with a launcher on PATH the hint resolves to
// `gitnexus analyze`. Because resolveOnPath is now spawn-free, this works
// identically on every OS — there is no `where`/`which` reachability caveat.
const savedPath = process.env.PATH;
let binDir: string | undefined;
afterEach(() => {
if (savedPath === undefined) delete process.env.PATH;
else process.env.PATH = savedPath;
if (binDir) rmSync(binDir, { recursive: true, force: true });
binDir = undefined;
delete process.env.GITNEXUS_INVOCATION;
});
it('resolves `gitnexus analyze` when a launcher is the only thing on PATH', () => {
binDir = mkdtempSync(path.join(os.tmpdir(), 'gn-e2e-'));
const isWin = process.platform === 'win32';
const launcher = path.join(binDir, isWin ? 'gitnexus.cmd' : 'gitnexus');
writeFileSync(launcher, isWin ? '@echo off\r\nexit /b 0\r\n' : '#!/bin/sh\nexit 0\n');
if (!isWin) chmodSync(launcher, 0o755);
// PATH reduced to just the launcher dir — the former `where`/`which` resolver
// would have ENOENT'd here; the pure scan finds the launcher directly.
process.env.PATH = binDir;
expect(cjs.formatAnalyzeCommand()).toBe('gitnexus analyze');
});
});
describe('resolve-analyze-cmd.cjs parity', () => {
it('keeps the two CJS hook copies byte-identical', () => {
expect(readFileSync(CANONICAL_CJS, 'utf-8')).toBe(readFileSync(PLUGIN_CJS, 'utf-8'));
});
});
describe('CLI module-load posture (R3/R4 regression guard)', () => {
const cliDir = path.resolve(__dirname, '..', '..', 'src', 'cli');
it('does not probe invocation hints at index.ts module load (#207/#1383)', () => {
const indexSrc = readFileSync(path.join(cliDir, 'index.ts'), 'utf-8');
// Every command — including the `gitnexus mcp` stdio server — pays index.ts
// module load. warnIfNpm11NpxRisk()/PATH probing must stay out of module
// scope, or it reintroduces the startup-spawn regression (#207, #1383).
expect(indexSrc).not.toMatch(/warnIfNpm11NpxRisk/);
expect(indexSrc).not.toMatch(/resolve-invocation/);
});
it('wires the npm-11 warning into the analyze command instead', () => {
const analyzeSrc = readFileSync(path.join(cliDir, 'analyze.ts'), 'utf-8');
expect(analyzeSrc).toMatch(/warnIfNpm11NpxRisk\(\)/);
});
});