diff --git a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md index cd9a83be00..989c082775 100644 --- a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md @@ -5,14 +5,16 @@ description: "Use when the user needs to run GitNexus CLI commands like analyze/ # GitNexus CLI Commands -All commands work via `npx` — no global install required. +Commands below use `node .gitnexus/run.cjs ` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required. + +> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939). ## Commands ### analyze — Build or refresh the index ```bash -npx gitnexus analyze +node .gitnexus/run.cjs analyze ``` Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files. @@ -28,7 +30,7 @@ Run from the project root. This parses all source files, builds the knowledge gr ### status — Check index freshness ```bash -npx gitnexus status +node .gitnexus/run.cjs status ``` Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed. @@ -36,7 +38,7 @@ Shows whether the current repo has a GitNexus index, when it was last updated, a ### clean — Delete the index ```bash -npx gitnexus clean +node .gitnexus/run.cjs clean ``` Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project. @@ -49,7 +51,7 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi ### wiki — Generate documentation from the graph ```bash -npx gitnexus wiki +node .gitnexus/run.cjs wiki ``` Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). @@ -66,7 +68,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir ### list — Show all indexed repos ```bash -npx gitnexus list +node .gitnexus/run.cjs list ``` Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information. diff --git a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md index 9510b97ac3..937b5e2a41 100644 --- a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md @@ -22,7 +22,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking 4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed ``` -> If "Index is stale" → run `npx gitnexus analyze` in terminal. +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklist diff --git a/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md index 927a4e4b64..2dcf7b578d 100644 --- a/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md @@ -23,7 +23,7 @@ description: "Use when the user asks how code works, wants to understand archite 5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow ``` -> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal. +> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklist diff --git a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md index 937ac73d16..b81900b5ef 100644 --- a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md @@ -15,7 +15,7 @@ For any task involving code understanding, debugging, impact analysis, or refact 2. **Match your task to a skill below** and **read that skill file** 3. **Follow the skill's workflow and checklist** -> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first. +> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first. ## Skills diff --git a/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md index e19af280c1..7206ca5062 100644 --- a/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md @@ -23,7 +23,7 @@ description: "Use when the user wants to know what will break if they change som 4. Assess risk and report to user ``` -> If "Index is stale" → run `npx gitnexus analyze` in terminal. +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklist diff --git a/.claude/skills/gitnexus/gitnexus-pr-review/SKILL.md b/.claude/skills/gitnexus/gitnexus-pr-review/SKILL.md index e112f47baa..319c063f92 100644 --- a/.claude/skills/gitnexus/gitnexus-pr-review/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-pr-review/SKILL.md @@ -26,7 +26,7 @@ description: "Use when the user wants to review a pull request, understand what 6. Summarize findings with risk assessment ``` -> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing. +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal before reviewing. ## Checklist diff --git a/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md index f48cc01bd5..c749eb3841 100644 --- a/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md @@ -22,7 +22,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru 4. Plan update order: interfaces → implementations → callers → tests ``` -> If "Index is stale" → run `npx gitnexus analyze` in terminal. +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklists diff --git a/AGENTS.md b/AGENTS.md index 5b0fb162de..286fbc14f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ commits, or posts. This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. +> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939). ## Always Do diff --git a/CLAUDE.md b/CLAUDE.md index 70b3f1a363..e3815af765 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ See the ` … ` block in **[AGENTS.m This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. +> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939). ## Always Do diff --git a/README.md b/README.md index e6f50950ac..60f15861cd 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,14 @@ npx gitnexus analyze That's it. This indexes the codebase, installs agent skills, registers Claude Code hooks, and creates `AGENTS.md` / `CLAUDE.md` context files — all in one command. +> **On npm 11.x?** `npx` can crash during install with `Cannot destructure property 'package' of 'node.target'` (an npm/arborist bug, before GitNexus runs). Use pnpm instead — it builds the native deps explicitly: +> +> ```bash +> pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze +> ``` +> +> Or install globally (`npm install -g gitnexus@latest`) and run `gitnexus analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939). + To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below. > **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip vendored grammar materialize/build (`tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`). Dart/Proto/Swift files won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild. diff --git a/gitnexus-claude-plugin/hooks/gitnexus-hook.js b/gitnexus-claude-plugin/hooks/gitnexus-hook.js index 91c20c9d22..e3c62c7695 100644 --- a/gitnexus-claude-plugin/hooks/gitnexus-hook.js +++ b/gitnexus-claude-plugin/hooks/gitnexus-hook.js @@ -16,6 +16,7 @@ const path = require('path'); const { spawnSync } = require('child_process'); const { acquireHookSlot } = require('./hook-lock.js'); const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs'); +const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs'); /** * Read JSON input from stdin synchronously. @@ -345,7 +346,7 @@ function handlePostToolUse(input) { // If HEAD matches last indexed commit, no reindex needed if (currentHead && currentHead === lastCommit) return; - const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`; + const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings }); sendHookResponse( 'PostToolUse', `GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` + diff --git a/gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs b/gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs new file mode 100644 index 0000000000..cf87c3bd2b --- /dev/null +++ b/gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs @@ -0,0 +1,295 @@ +/** + * Single source of truth for how docs, hooks, and warnings invoke gitnexus. + * + * Automatically selects a working invocation path: + * 1. Global `gitnexus` on PATH (best — no install step) + * 2. npm 11+ with pnpm on PATH → `pnpm --allow-build=… dlx` (avoids the npx + * arborist crash *and* pnpm 10+ ignored-build-script failures, #1939) + * 3. npm < 11 with npm on PATH → `npx` (works; simpler than pnpm dlx) + * 4. pnpm-only → `pnpm --allow-build=… dlx` + * 5. Last resort → `npx` (warned on npm 11+ from analyze.ts) + * + * The `--allow-build` flags MUST precede the `dlx` token. pnpm < 10.14 keeps + * `dlx` in its argv escape list, so flags placed *after* `dlx` are parsed as + * package specs (ERR_PNPM_SPEC_NOT_SUPPORTED). The pre-`dlx` position parses + * into dlx's allow-build option and has been honored since pnpm 10.2.0 (#1939). + * + * This stays self-contained CJS because the Claude/Antigravity hooks run as + * standalone files copied into the user's hook dir, where no package import is + * available. The CLI reuses this module from src/cli/resolve-invocation.ts via + * createRequire rather than re-implementing it. Two committed copies must stay + * byte-identical (enforced by resolve-invocation.test.ts) — edit both together: + * gitnexus/hooks/claude/ (the canonical copy the CLI and `gitnexus setup` read) + * and gitnexus-claude-plugin/hooks/. A THIRD copy is written at runtime to + * `/.gitnexus/run.cjs` by `gitnexus analyze` (ai-context.ts) so docs can + * reference it directly via the `require.main === module` exec tail below; that + * copy is gitignored and refreshed on every analyze, so it cannot drift for long. + */ + +const { execFileSync } = require('child_process'); + +const NPX_REF = 'gitnexus@latest'; + +// Native packages whose postinstall must run under pnpm 10+ (blocked by default). +const PNPM_ALLOW_BUILD_BASE = ['@ladybugdb/core', 'gitnexus', 'tree-sitter']; +const PNPM_ALLOW_BUILD_EMBEDDINGS = ['onnxruntime-node']; + +// Probe timeout, kept under Claude Code's 10s hook budget. In a linked worktree +// the stale-index hook first runs `git rev-parse --git-common-dir` (~2s) and +// `git rev-parse HEAD` (~3s); the pnpm path then adds up to four 1s probes +// (which gitnexus, npm --version, which pnpm, pnpm --version), so the worst case +// is ~9s — within budget but tight. A healthy `which`/`where`/`--version` +// returns in well under a second, so the realistic cost is far lower. +const PROBE_TIMEOUT_MS = 1000; + +/** + * Pick the best match from `where`/`which` output. A global `gitnexus` may be a + * `.cmd`/`.bat` (npm), a `.exe`, or an extensionless shim (Volta, scoop), so on + * Windows we prefer a recognized executable extension but accept any hit — the + * emitted hint is `gitnexus analyze` regardless of which shim resolves it. Pure + * and exported so the shim-matching can be unit-tested without spawning. + */ +function pickPathMatch(output, { isWin, gitnexusWrapper } = {}) { + const lines = output + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + if (isWin && gitnexusWrapper) { + return lines.find((l) => /\.(cmd|bat|exe)$/i.test(l)) || lines[0] || null; + } + return lines[0] || null; +} + +/** Absolute path to `command` on PATH, or null. `gitnexusWrapper` enables the Windows shim match. */ +function resolveOnPath(command, gitnexusWrapper = false) { + const isWin = process.platform === 'win32'; + try { + const output = execFileSync(isWin ? 'where' : 'which', [command], { + encoding: 'utf-8', + timeout: PROBE_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }); + return pickPathMatch(output, { isWin, gitnexusWrapper }); + } catch { + return null; + } +} + +// One spawn of ` --version` → { major, minor } (each null when +// unreadable). Version injection happens at the resolver seam (getNpmMajorVersion +// / formatPnpmAllowBuildArgs), so this stays a pure real-process probe. +function probeVersion(command) { + try { + const output = execFileSync(command, ['--version'], { + encoding: 'utf-8', + timeout: PROBE_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + // On Windows, npm/pnpm resolve to `.cmd` shims; execFileSync does no + // PATHEXT resolution and Node refuses to spawn `.cmd`/`.bat` without a + // shell (CVE-2024-27980), so a bare ` --version` ENOENTs and the + // probe would wrongly report a present tool as absent. A shell lets the OS + // resolve the shim. POSIX needs no shell (direct PATH lookup works). + shell: process.platform === 'win32', + }); + // Find the first line that starts with a version token (`MAJOR.MINOR`, + // optional `v` prefix) rather than splitting the whole output — pnpm/npm + // under Corepack or with an update notice can print a banner line on stdout + // before the version (stderr is already dropped via the stdio config). + const versionLine = output + .split('\n') + .map((l) => l.trim()) + .find((l) => /^v?\d+\.\d+/.test(l)); + const match = versionLine ? versionLine.match(/^v?(\d+)\.(\d+)/) : null; + return { + major: match ? Number(match[1]) : null, + minor: match ? Number(match[2]) : null, + }; + } catch { + return { major: null, minor: null }; + } +} + +// `deps` is the single injection seam: an explicitly provided key — including a +// `null` value, detected via `in` — is honored as-is so tests can simulate an +// absent tool without spawning; an absent key falls through to the real probe. +function getNpmMajorVersion(deps = {}) { + return 'npmMajor' in deps ? deps.npmMajor : probeVersion('npm').major; +} + +/** + * `--allow-build` flags for the pre-`dlx` position. Emitted for pnpm >= 10.2 + * (where the flag exists, and pnpm 10+ blocks build scripts by default). Omitted + * below 10.2: pnpm < 10 runs build scripts anyway, and pnpm 10.0/10.1 lack the + * flag (it would be rejected as an unknown option). `alwaysAllowBuild` forces the + * flags for committed documentation, which cannot probe the reader's pnpm. + */ +function formatPnpmAllowBuildArgs(options = {}, deps = {}) { + if (!options.alwaysAllowBuild) { + const { major, minor } = + 'pnpmMajor' in deps + ? { major: deps.pnpmMajor, minor: 'pnpmMinor' in deps ? deps.pnpmMinor : null } + : probeVersion('pnpm'); + const lacksAllowBuild = + major !== null && (major < 10 || (major === 10 && minor !== null && minor < 2)); + if (lacksAllowBuild) return []; + } + const pkgs = [...PNPM_ALLOW_BUILD_BASE]; + if (options.embeddings) pkgs.push(...PNPM_ALLOW_BUILD_EMBEDDINGS); + return pkgs.map((p) => `--allow-build=${p}`); +} + +/** Fixed install-free command for committed AGENTS.md / SKILL.md (pnpm >= 10.2). */ +function formatDocumentationDlxCommand(gitnexusArgs, options = {}) { + const flags = formatPnpmAllowBuildArgs({ ...options, alwaysAllowBuild: true }).join(' '); + const prefix = flags ? `${flags} ` : ''; + return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`; +} + +/** + * Resolve `gitnexus` | `pnpm` | `npx`. `GITNEXUS_INVOCATION` forces a mode + * (test/escape hatch). `probe` is injectable so the preference order can be + * unit-tested without spawning; it defaults to the real PATH probe. `deps` can + * inject `{ npmMajor, pnpmMajor }` for tests. + */ +function resolveInvocationMode(probe = resolveOnPath, deps = {}) { + const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase(); + if (forced === 'gitnexus' || forced === 'pnpm' || forced === 'npx') { + return forced; + } + if (probe('gitnexus', true)) return 'gitnexus'; + + const npmMajor = getNpmMajorVersion(deps); + // pnpm presence: prefer an explicit `pnpmPresent` flag (set by + // formatAnalyzeCommand, which falls back to a PATH probe when the version is + // unreadable) so a present-but-unparseable pnpm — slow probe, Corepack + // banner — still selects pnpm instead of the npx crash path. Otherwise an + // injected version (a successful `pnpm --version` proves presence) + // short-circuits the `which pnpm` probe; failing both, fall back to PATH. + const hasPnpm = + 'pnpmPresent' in deps + ? deps.pnpmPresent + : 'pnpmMajor' in deps + ? deps.pnpmMajor !== null + : Boolean(probe('pnpm')); + + // npm 11+ npx install crash (#1939) — prefer pnpm dlx when available. + if (hasPnpm && npmMajor !== null && npmMajor >= 11) return 'pnpm'; + // npm 10 and earlier: npx works; prefer it over pnpm dlx when npm is present. + if (npmMajor !== null && npmMajor < 11) return 'npx'; + // npm absent or unreadable — use pnpm if present (with allow-build flags). + if (hasPnpm) return 'pnpm'; + + return 'npx'; +} + +function formatPnpmDlxCommand(gitnexusArgs, options = {}, deps = {}) { + const flags = formatPnpmAllowBuildArgs(options, deps).join(' '); + const prefix = flags ? `${flags} ` : ''; + return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`; +} + +function formatAnalyzeCommand(options = {}, deps = {}) { + const suffix = options.embeddings ? ' --embeddings' : ''; + // Keep the stale-index hook budget tight by querying each tool at most once. + // A memoized PATH probe is shared with resolveInvocationMode (so `gitnexus` + // isn't probed twice), and pnpm's version is captured by a single + // `pnpm --version` that proves both presence (for mode resolution) and + // version (for the allow-build gate) — replacing the former `which pnpm` + + // `pnpm --version` double spawn. Injected deps (tests) and forced/global + // modes skip the pnpm probe. + const cache = new Map(); + const probe = (command, gitnexusWrapper) => { + const key = `${command}:${gitnexusWrapper ? 1 : 0}`; + if (!cache.has(key)) cache.set(key, resolveOnPath(command, gitnexusWrapper)); + return cache.get(key); + }; + let resolved = deps; + if (!('pnpmMajor' in deps)) { + const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase(); + // pnpm is only consulted when no non-pnpm mode is already certain: forced + // gitnexus/npx never use pnpm, and a present global gitnexus wins outright. + const mightUsePnpm = forced === 'pnpm' || (forced !== 'gitnexus' && forced !== 'npx'); + if (mightUsePnpm && (forced === 'pnpm' || !probe('gitnexus', true))) { + const { major, minor } = probeVersion('pnpm'); + // Carry presence separately from version: when the version probe fails + // (timeout, Corepack banner) but pnpm is on PATH, still treat it as + // present so mode resolution picks pnpm over the npx crash path. The + // PATH probe is memoized and only runs when the version is unreadable. + const pnpmPresent = major !== null || Boolean(probe('pnpm')); + resolved = { ...deps, pnpmMajor: major, pnpmMinor: minor, pnpmPresent }; + } + } + const mode = resolveInvocationMode(probe, resolved); + if (mode === 'gitnexus') return `gitnexus analyze${suffix}`; + if (mode === 'pnpm') return `${formatPnpmDlxCommand(`analyze${suffix}`, options, resolved)}`; + return `npx ${NPX_REF} analyze${suffix}`; +} + +/** + * Resolve `mode` into a concrete { program, args } pair for a set of gitnexus + * subcommand arguments. Shared by the direct-exec entrypoint below; pure (no + * spawn) so it is unit-testable. `--embeddings` widens the pnpm allow-build set. + */ +function buildRunnerArgv(mode, gitnexusArgs, deps = {}) { + // Match both the space form (`--embeddings`) and the equals form + // (`--embeddings=5000`) Commander accepts, so the pnpm allow-build set still + // widens to onnxruntime-node when a user hand-types the equals form. + const embeddings = gitnexusArgs.some( + (a) => a === '--embeddings' || a.startsWith('--embeddings='), + ); + if (mode === 'gitnexus') return { program: 'gitnexus', args: [...gitnexusArgs] }; + if (mode === 'pnpm') { + return { + program: 'pnpm', + args: [...formatPnpmAllowBuildArgs({ embeddings }, deps), 'dlx', NPX_REF, ...gitnexusArgs], + }; + } + return { program: 'npx', args: [NPX_REF, ...gitnexusArgs] }; +} + +module.exports = { + formatAnalyzeCommand, + formatDocumentationDlxCommand, + formatPnpmAllowBuildArgs, + formatPnpmDlxCommand, + resolveInvocationMode, + buildRunnerArgv, + pickPathMatch, + getNpmMajorVersion, + NPX_REF, + PNPM_ALLOW_BUILD_BASE, +}; + +// Direct-exec entrypoint (#1945): `node run.cjs ` resolves the +// best available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time and +// runs it, inheriting stdio and propagating the child's exit code. This lets the +// committed skills and generated AGENTS.md/CLAUDE.md reference ONE stable, +// CLI-neutral command without baking in a package-manager assumption. `gitnexus +// analyze` drops a copy of this file at `.gitnexus/run.cjs`. Skipped on require() +// (the CLI and tests reuse the exports above), so it runs only when invoked as a +// script. +if (require.main === module) { + const gitnexusArgs = process.argv.slice(2); + const { program, args } = buildRunnerArgv(resolveInvocationMode(), gitnexusArgs); + try { + execFileSync(program, args, { + stdio: 'inherit', + windowsHide: true, + // On Windows, `npx`/`pnpm`/`gitnexus` resolve to `.cmd`/`.ps1`/`.exe` + // shims (npm, Volta, Corepack, scoop). execFileSync does not do PATHEXT + // resolution and Node refuses to spawn `.cmd`/`.bat` without a shell + // (CVE-2024-27980), so a bare program name ENOENTs. A shell lets the OS + // resolve the shim; POSIX needs no shell (direct PATH lookup works). + shell: process.platform === 'win32', + }); + } catch (err) { + // Make spawn failures (resolved program absent from PATH) self-explanatory + // instead of a silent exit 1, then propagate the runner's own exit code. + if (typeof err.status !== 'number') { + process.stderr.write(`gitnexus runner: could not launch \`${program}\` — ${err.message}\n`); + } + process.exit(typeof err.status === 'number' ? err.status : 1); + } +} diff --git a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md index d0ac08de55..de7a2e2b87 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md @@ -5,14 +5,16 @@ description: "Use when the user needs to run GitNexus CLI commands like analyze/ # GitNexus CLI Commands -All commands work via `npx` — no global install required. +Commands below use `node .gitnexus/run.cjs ` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required. + +> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939). ## Commands ### analyze — Build or refresh the index ```bash -npx gitnexus analyze +node .gitnexus/run.cjs analyze ``` Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files. @@ -28,7 +30,7 @@ Run from the project root. This parses all source files, builds the knowledge gr ### status — Check index freshness ```bash -npx gitnexus status +node .gitnexus/run.cjs status ``` Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed. @@ -36,7 +38,7 @@ Shows whether the current repo has a GitNexus index, when it was last updated, a ### clean — Delete the index ```bash -npx gitnexus clean +node .gitnexus/run.cjs clean ``` Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project. @@ -49,7 +51,7 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi ### wiki — Generate documentation from the graph ```bash -npx gitnexus wiki +node .gitnexus/run.cjs wiki ``` Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). @@ -68,7 +70,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir ### list — Show all indexed repos ```bash -npx gitnexus list +node .gitnexus/run.cjs list ``` Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information. diff --git a/gitnexus-claude-plugin/skills/gitnexus-debugging/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-debugging/SKILL.md index 9510b97ac3..937b5e2a41 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-debugging/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-debugging/SKILL.md @@ -22,7 +22,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking 4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed ``` -> If "Index is stale" → run `npx gitnexus analyze` in terminal. +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklist diff --git a/gitnexus-claude-plugin/skills/gitnexus-exploring/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-exploring/SKILL.md index 927a4e4b64..2dcf7b578d 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-exploring/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-exploring/SKILL.md @@ -23,7 +23,7 @@ description: "Use when the user asks how code works, wants to understand archite 5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow ``` -> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal. +> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklist diff --git a/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md index 937ac73d16..b81900b5ef 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md @@ -15,7 +15,7 @@ For any task involving code understanding, debugging, impact analysis, or refact 2. **Match your task to a skill below** and **read that skill file** 3. **Follow the skill's workflow and checklist** -> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first. +> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first. ## Skills diff --git a/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md index e19af280c1..7206ca5062 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md @@ -23,7 +23,7 @@ description: "Use when the user wants to know what will break if they change som 4. Assess risk and report to user ``` -> If "Index is stale" → run `npx gitnexus analyze` in terminal. +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklist diff --git a/gitnexus-claude-plugin/skills/gitnexus-pr-review/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-pr-review/SKILL.md index e112f47baa..319c063f92 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-pr-review/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-pr-review/SKILL.md @@ -26,7 +26,7 @@ description: "Use when the user wants to review a pull request, understand what 6. Summarize findings with risk assessment ``` -> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing. +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal before reviewing. ## Checklist diff --git a/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md index f48cc01bd5..c749eb3841 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md @@ -22,7 +22,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru 4. Plan update order: interfaces → implementations → callers → tests ``` -> If "Index is stale" → run `npx gitnexus analyze` in terminal. +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklists diff --git a/gitnexus-cursor-integration/README.md b/gitnexus-cursor-integration/README.md index 6545bacec9..948757eca6 100644 --- a/gitnexus-cursor-integration/README.md +++ b/gitnexus-cursor-integration/README.md @@ -40,7 +40,7 @@ If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array ### Verify -1. Index the project: `npx gitnexus analyze` +1. Index the project: `npx gitnexus analyze` (on npm 11.x, `npx` can crash during install — use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze` instead; see [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939)) 2. Reload the Cursor window so it picks up the new hook config. 3. Ask the agent something that triggers `Read` / `Grep` / `Shell rg`. You should see a `[GitNexus]` block appended to the tool result. 4. Diagnose silent no-ops by setting `GITNEXUS_DEBUG=1` in your shell environment — the hook will write Cursor's raw event payload to stderr so you can verify field names. diff --git a/gitnexus-cursor-integration/skills/gitnexus-debugging/SKILL.md b/gitnexus-cursor-integration/skills/gitnexus-debugging/SKILL.md index 3b945835bd..a7e2506476 100644 --- a/gitnexus-cursor-integration/skills/gitnexus-debugging/SKILL.md +++ b/gitnexus-cursor-integration/skills/gitnexus-debugging/SKILL.md @@ -21,7 +21,7 @@ description: Trace bugs through call chains using knowledge graph 4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed ``` -> If "Index is stale" → run `npx gitnexus analyze` in terminal. +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklist diff --git a/gitnexus-cursor-integration/skills/gitnexus-exploring/SKILL.md b/gitnexus-cursor-integration/skills/gitnexus-exploring/SKILL.md index 2214c289cd..4549505e54 100644 --- a/gitnexus-cursor-integration/skills/gitnexus-exploring/SKILL.md +++ b/gitnexus-cursor-integration/skills/gitnexus-exploring/SKILL.md @@ -22,7 +22,7 @@ description: Navigate unfamiliar code using GitNexus knowledge graph 5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow ``` -> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal. +> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklist diff --git a/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md b/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md index bb5f51fcc1..0733b09ac2 100644 --- a/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md +++ b/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md @@ -22,7 +22,7 @@ description: Analyze blast radius before making code changes 4. Assess risk and report to user ``` -> If "Index is stale" → run `npx gitnexus analyze` in terminal. +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklist diff --git a/gitnexus-cursor-integration/skills/gitnexus-pr-review/SKILL.md b/gitnexus-cursor-integration/skills/gitnexus-pr-review/SKILL.md index e112f47baa..319c063f92 100644 --- a/gitnexus-cursor-integration/skills/gitnexus-pr-review/SKILL.md +++ b/gitnexus-cursor-integration/skills/gitnexus-pr-review/SKILL.md @@ -26,7 +26,7 @@ description: "Use when the user wants to review a pull request, understand what 6. Summarize findings with risk assessment ``` -> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing. +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal before reviewing. ## Checklist diff --git a/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md b/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md index 23f4d11301..a49b58be4a 100644 --- a/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md +++ b/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md @@ -21,7 +21,7 @@ description: Plan safe refactors using blast radius and dependency mapping 4. Plan update order: interfaces → implementations → callers → tests ``` -> If "Index is stale" → run `npx gitnexus analyze` in terminal. +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklists diff --git a/gitnexus/README.md b/gitnexus/README.md index 8320dc134a..7544508bad 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -24,6 +24,14 @@ npx gitnexus analyze That's it. This indexes the codebase, installs agent skills, registers Claude Code hooks, and creates `AGENTS.md` / `CLAUDE.md` context files — all in one command. +> **On npm 11.x?** `npx` can crash during install (`Cannot destructure property 'package' of 'node.target'`). Use the pnpm form instead: +> +> ```bash +> pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze +> ``` +> +> See [Troubleshooting → `npx gitnexus` crashes with `node.target is null` (npm 11)](#cannot-destructure-property-package-of-nodetarget-as-it-is-null) for the full matrix (global install, npm downgrade). + To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below. `gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once. @@ -273,22 +281,27 @@ for the full list; stable `latest` is unaffected. ### `Cannot destructure property 'package' of 'node.target' as it is null` -This crash was caused by a dependency URL format that is incompatible with -certain npm/arborist versions ([npm/cli#8126](https://github.com/npm/cli/issues/8126)). -It is fixed in **gitnexus v1.6.2+**. Upgrade to the latest version: +This error comes from **npm 11.x's arborist** while installing gitnexus (often via `npx`), before gitnexus code runs. It is triggered by platform-filtered `optionalDependencies` in native packages such as `onnxruntime-node` / `@huggingface/transformers` (used when indexing with `--embeddings`). GitNexus cannot catch it at runtime — use one of these workarounds: ```bash -npx gitnexus@latest analyze # always uses the newest release -# — or — -npm install -g gitnexus@latest # upgrade a global install +pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze # auto-selected when pnpm + npm 11+ +npm install -g gitnexus@latest # global install avoids per-run npx reify +gitnexus analyze # if already installed globally ``` -If you still hit npm install issues after upgrading, these generic workarounds -may help: +On **pnpm 10+**, lifecycle scripts are blocked unless explicitly allowed — the resolver adds `--allow-build` for `@ladybugdb/core`, `gitnexus`, and `tree-sitter` automatically when it picks `pnpm dlx`. + +If you must stay on npm 11.x without pnpm, downgrade npm toolchain-wide (last resort): ```bash -npm install -g npm@latest # update npm itself -npm cache clean --force # clear a possibly corrupt cache +npm install -g npm@10.9.0 +``` + +See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939) and the original [#819](https://github.com/abhigyanpatwari/GitNexus/issues/819) thread. An older variant of this crash (tree-sitter-dart tarball URL) was fixed in gitnexus v1.6.2+ ([#820](https://github.com/abhigyanpatwari/GitNexus/pull/820)); if you still see install failures after upgrading, clear cache: + +```bash +npm cache clean --force +npx gitnexus@latest analyze ``` ### `ERR_DLOPEN_FAILED` / `lbugjs.node` missing (pnpm dlx, pnpx) diff --git a/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs b/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs index 4bd807631d..bbfccb92ec 100755 --- a/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs +++ b/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs @@ -25,6 +25,7 @@ const path = require('path'); const { spawnSync } = require('child_process'); const { acquireHookSlot } = require('./hook-lock.cjs'); const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs'); +const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs'); function readInput() { try { @@ -315,7 +316,7 @@ function buildStaleIndexHint(gitNexusDir, cwd) { if (currentHead === lastCommit) return ''; - const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`; + const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings }); return ( `[GitNexus] index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` + `Run \`${analyzeCmd}\` to refresh the knowledge graph.` diff --git a/gitnexus/hooks/claude/gitnexus-hook.cjs b/gitnexus/hooks/claude/gitnexus-hook.cjs index 9793bd7bc8..8bfa49381e 100755 --- a/gitnexus/hooks/claude/gitnexus-hook.cjs +++ b/gitnexus/hooks/claude/gitnexus-hook.cjs @@ -16,6 +16,7 @@ const path = require('path'); const { spawnSync } = require('child_process'); const { acquireHookSlot } = require('./hook-lock.cjs'); const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs'); +const { formatAnalyzeCommand } = require('./resolve-analyze-cmd.cjs'); /** * Read JSON input from stdin synchronously. @@ -340,7 +341,7 @@ function handlePostToolUse(input) { // If HEAD matches last indexed commit, no reindex needed if (currentHead && currentHead === lastCommit) return; - const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`; + const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings }); sendHookResponse( 'PostToolUse', `GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` + diff --git a/gitnexus/hooks/claude/resolve-analyze-cmd.cjs b/gitnexus/hooks/claude/resolve-analyze-cmd.cjs new file mode 100644 index 0000000000..cf87c3bd2b --- /dev/null +++ b/gitnexus/hooks/claude/resolve-analyze-cmd.cjs @@ -0,0 +1,295 @@ +/** + * Single source of truth for how docs, hooks, and warnings invoke gitnexus. + * + * Automatically selects a working invocation path: + * 1. Global `gitnexus` on PATH (best — no install step) + * 2. npm 11+ with pnpm on PATH → `pnpm --allow-build=… dlx` (avoids the npx + * arborist crash *and* pnpm 10+ ignored-build-script failures, #1939) + * 3. npm < 11 with npm on PATH → `npx` (works; simpler than pnpm dlx) + * 4. pnpm-only → `pnpm --allow-build=… dlx` + * 5. Last resort → `npx` (warned on npm 11+ from analyze.ts) + * + * The `--allow-build` flags MUST precede the `dlx` token. pnpm < 10.14 keeps + * `dlx` in its argv escape list, so flags placed *after* `dlx` are parsed as + * package specs (ERR_PNPM_SPEC_NOT_SUPPORTED). The pre-`dlx` position parses + * into dlx's allow-build option and has been honored since pnpm 10.2.0 (#1939). + * + * This stays self-contained CJS because the Claude/Antigravity hooks run as + * standalone files copied into the user's hook dir, where no package import is + * available. The CLI reuses this module from src/cli/resolve-invocation.ts via + * createRequire rather than re-implementing it. Two committed copies must stay + * byte-identical (enforced by resolve-invocation.test.ts) — edit both together: + * gitnexus/hooks/claude/ (the canonical copy the CLI and `gitnexus setup` read) + * and gitnexus-claude-plugin/hooks/. A THIRD copy is written at runtime to + * `/.gitnexus/run.cjs` by `gitnexus analyze` (ai-context.ts) so docs can + * reference it directly via the `require.main === module` exec tail below; that + * copy is gitignored and refreshed on every analyze, so it cannot drift for long. + */ + +const { execFileSync } = require('child_process'); + +const NPX_REF = 'gitnexus@latest'; + +// Native packages whose postinstall must run under pnpm 10+ (blocked by default). +const PNPM_ALLOW_BUILD_BASE = ['@ladybugdb/core', 'gitnexus', 'tree-sitter']; +const PNPM_ALLOW_BUILD_EMBEDDINGS = ['onnxruntime-node']; + +// Probe timeout, kept under Claude Code's 10s hook budget. In a linked worktree +// the stale-index hook first runs `git rev-parse --git-common-dir` (~2s) and +// `git rev-parse HEAD` (~3s); the pnpm path then adds up to four 1s probes +// (which gitnexus, npm --version, which pnpm, pnpm --version), so the worst case +// is ~9s — within budget but tight. A healthy `which`/`where`/`--version` +// returns in well under a second, so the realistic cost is far lower. +const PROBE_TIMEOUT_MS = 1000; + +/** + * Pick the best match from `where`/`which` output. A global `gitnexus` may be a + * `.cmd`/`.bat` (npm), a `.exe`, or an extensionless shim (Volta, scoop), so on + * Windows we prefer a recognized executable extension but accept any hit — the + * emitted hint is `gitnexus analyze` regardless of which shim resolves it. Pure + * and exported so the shim-matching can be unit-tested without spawning. + */ +function pickPathMatch(output, { isWin, gitnexusWrapper } = {}) { + const lines = output + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + if (isWin && gitnexusWrapper) { + return lines.find((l) => /\.(cmd|bat|exe)$/i.test(l)) || lines[0] || null; + } + return lines[0] || null; +} + +/** Absolute path to `command` on PATH, or null. `gitnexusWrapper` enables the Windows shim match. */ +function resolveOnPath(command, gitnexusWrapper = false) { + const isWin = process.platform === 'win32'; + try { + const output = execFileSync(isWin ? 'where' : 'which', [command], { + encoding: 'utf-8', + timeout: PROBE_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }); + return pickPathMatch(output, { isWin, gitnexusWrapper }); + } catch { + return null; + } +} + +// One spawn of ` --version` → { major, minor } (each null when +// unreadable). Version injection happens at the resolver seam (getNpmMajorVersion +// / formatPnpmAllowBuildArgs), so this stays a pure real-process probe. +function probeVersion(command) { + try { + const output = execFileSync(command, ['--version'], { + encoding: 'utf-8', + timeout: PROBE_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + // On Windows, npm/pnpm resolve to `.cmd` shims; execFileSync does no + // PATHEXT resolution and Node refuses to spawn `.cmd`/`.bat` without a + // shell (CVE-2024-27980), so a bare ` --version` ENOENTs and the + // probe would wrongly report a present tool as absent. A shell lets the OS + // resolve the shim. POSIX needs no shell (direct PATH lookup works). + shell: process.platform === 'win32', + }); + // Find the first line that starts with a version token (`MAJOR.MINOR`, + // optional `v` prefix) rather than splitting the whole output — pnpm/npm + // under Corepack or with an update notice can print a banner line on stdout + // before the version (stderr is already dropped via the stdio config). + const versionLine = output + .split('\n') + .map((l) => l.trim()) + .find((l) => /^v?\d+\.\d+/.test(l)); + const match = versionLine ? versionLine.match(/^v?(\d+)\.(\d+)/) : null; + return { + major: match ? Number(match[1]) : null, + minor: match ? Number(match[2]) : null, + }; + } catch { + return { major: null, minor: null }; + } +} + +// `deps` is the single injection seam: an explicitly provided key — including a +// `null` value, detected via `in` — is honored as-is so tests can simulate an +// absent tool without spawning; an absent key falls through to the real probe. +function getNpmMajorVersion(deps = {}) { + return 'npmMajor' in deps ? deps.npmMajor : probeVersion('npm').major; +} + +/** + * `--allow-build` flags for the pre-`dlx` position. Emitted for pnpm >= 10.2 + * (where the flag exists, and pnpm 10+ blocks build scripts by default). Omitted + * below 10.2: pnpm < 10 runs build scripts anyway, and pnpm 10.0/10.1 lack the + * flag (it would be rejected as an unknown option). `alwaysAllowBuild` forces the + * flags for committed documentation, which cannot probe the reader's pnpm. + */ +function formatPnpmAllowBuildArgs(options = {}, deps = {}) { + if (!options.alwaysAllowBuild) { + const { major, minor } = + 'pnpmMajor' in deps + ? { major: deps.pnpmMajor, minor: 'pnpmMinor' in deps ? deps.pnpmMinor : null } + : probeVersion('pnpm'); + const lacksAllowBuild = + major !== null && (major < 10 || (major === 10 && minor !== null && minor < 2)); + if (lacksAllowBuild) return []; + } + const pkgs = [...PNPM_ALLOW_BUILD_BASE]; + if (options.embeddings) pkgs.push(...PNPM_ALLOW_BUILD_EMBEDDINGS); + return pkgs.map((p) => `--allow-build=${p}`); +} + +/** Fixed install-free command for committed AGENTS.md / SKILL.md (pnpm >= 10.2). */ +function formatDocumentationDlxCommand(gitnexusArgs, options = {}) { + const flags = formatPnpmAllowBuildArgs({ ...options, alwaysAllowBuild: true }).join(' '); + const prefix = flags ? `${flags} ` : ''; + return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`; +} + +/** + * Resolve `gitnexus` | `pnpm` | `npx`. `GITNEXUS_INVOCATION` forces a mode + * (test/escape hatch). `probe` is injectable so the preference order can be + * unit-tested without spawning; it defaults to the real PATH probe. `deps` can + * inject `{ npmMajor, pnpmMajor }` for tests. + */ +function resolveInvocationMode(probe = resolveOnPath, deps = {}) { + const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase(); + if (forced === 'gitnexus' || forced === 'pnpm' || forced === 'npx') { + return forced; + } + if (probe('gitnexus', true)) return 'gitnexus'; + + const npmMajor = getNpmMajorVersion(deps); + // pnpm presence: prefer an explicit `pnpmPresent` flag (set by + // formatAnalyzeCommand, which falls back to a PATH probe when the version is + // unreadable) so a present-but-unparseable pnpm — slow probe, Corepack + // banner — still selects pnpm instead of the npx crash path. Otherwise an + // injected version (a successful `pnpm --version` proves presence) + // short-circuits the `which pnpm` probe; failing both, fall back to PATH. + const hasPnpm = + 'pnpmPresent' in deps + ? deps.pnpmPresent + : 'pnpmMajor' in deps + ? deps.pnpmMajor !== null + : Boolean(probe('pnpm')); + + // npm 11+ npx install crash (#1939) — prefer pnpm dlx when available. + if (hasPnpm && npmMajor !== null && npmMajor >= 11) return 'pnpm'; + // npm 10 and earlier: npx works; prefer it over pnpm dlx when npm is present. + if (npmMajor !== null && npmMajor < 11) return 'npx'; + // npm absent or unreadable — use pnpm if present (with allow-build flags). + if (hasPnpm) return 'pnpm'; + + return 'npx'; +} + +function formatPnpmDlxCommand(gitnexusArgs, options = {}, deps = {}) { + const flags = formatPnpmAllowBuildArgs(options, deps).join(' '); + const prefix = flags ? `${flags} ` : ''; + return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`; +} + +function formatAnalyzeCommand(options = {}, deps = {}) { + const suffix = options.embeddings ? ' --embeddings' : ''; + // Keep the stale-index hook budget tight by querying each tool at most once. + // A memoized PATH probe is shared with resolveInvocationMode (so `gitnexus` + // isn't probed twice), and pnpm's version is captured by a single + // `pnpm --version` that proves both presence (for mode resolution) and + // version (for the allow-build gate) — replacing the former `which pnpm` + + // `pnpm --version` double spawn. Injected deps (tests) and forced/global + // modes skip the pnpm probe. + const cache = new Map(); + const probe = (command, gitnexusWrapper) => { + const key = `${command}:${gitnexusWrapper ? 1 : 0}`; + if (!cache.has(key)) cache.set(key, resolveOnPath(command, gitnexusWrapper)); + return cache.get(key); + }; + let resolved = deps; + if (!('pnpmMajor' in deps)) { + const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase(); + // pnpm is only consulted when no non-pnpm mode is already certain: forced + // gitnexus/npx never use pnpm, and a present global gitnexus wins outright. + const mightUsePnpm = forced === 'pnpm' || (forced !== 'gitnexus' && forced !== 'npx'); + if (mightUsePnpm && (forced === 'pnpm' || !probe('gitnexus', true))) { + const { major, minor } = probeVersion('pnpm'); + // Carry presence separately from version: when the version probe fails + // (timeout, Corepack banner) but pnpm is on PATH, still treat it as + // present so mode resolution picks pnpm over the npx crash path. The + // PATH probe is memoized and only runs when the version is unreadable. + const pnpmPresent = major !== null || Boolean(probe('pnpm')); + resolved = { ...deps, pnpmMajor: major, pnpmMinor: minor, pnpmPresent }; + } + } + const mode = resolveInvocationMode(probe, resolved); + if (mode === 'gitnexus') return `gitnexus analyze${suffix}`; + if (mode === 'pnpm') return `${formatPnpmDlxCommand(`analyze${suffix}`, options, resolved)}`; + return `npx ${NPX_REF} analyze${suffix}`; +} + +/** + * Resolve `mode` into a concrete { program, args } pair for a set of gitnexus + * subcommand arguments. Shared by the direct-exec entrypoint below; pure (no + * spawn) so it is unit-testable. `--embeddings` widens the pnpm allow-build set. + */ +function buildRunnerArgv(mode, gitnexusArgs, deps = {}) { + // Match both the space form (`--embeddings`) and the equals form + // (`--embeddings=5000`) Commander accepts, so the pnpm allow-build set still + // widens to onnxruntime-node when a user hand-types the equals form. + const embeddings = gitnexusArgs.some( + (a) => a === '--embeddings' || a.startsWith('--embeddings='), + ); + if (mode === 'gitnexus') return { program: 'gitnexus', args: [...gitnexusArgs] }; + if (mode === 'pnpm') { + return { + program: 'pnpm', + args: [...formatPnpmAllowBuildArgs({ embeddings }, deps), 'dlx', NPX_REF, ...gitnexusArgs], + }; + } + return { program: 'npx', args: [NPX_REF, ...gitnexusArgs] }; +} + +module.exports = { + formatAnalyzeCommand, + formatDocumentationDlxCommand, + formatPnpmAllowBuildArgs, + formatPnpmDlxCommand, + resolveInvocationMode, + buildRunnerArgv, + pickPathMatch, + getNpmMajorVersion, + NPX_REF, + PNPM_ALLOW_BUILD_BASE, +}; + +// Direct-exec entrypoint (#1945): `node run.cjs ` resolves the +// best available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time and +// runs it, inheriting stdio and propagating the child's exit code. This lets the +// committed skills and generated AGENTS.md/CLAUDE.md reference ONE stable, +// CLI-neutral command without baking in a package-manager assumption. `gitnexus +// analyze` drops a copy of this file at `.gitnexus/run.cjs`. Skipped on require() +// (the CLI and tests reuse the exports above), so it runs only when invoked as a +// script. +if (require.main === module) { + const gitnexusArgs = process.argv.slice(2); + const { program, args } = buildRunnerArgv(resolveInvocationMode(), gitnexusArgs); + try { + execFileSync(program, args, { + stdio: 'inherit', + windowsHide: true, + // On Windows, `npx`/`pnpm`/`gitnexus` resolve to `.cmd`/`.ps1`/`.exe` + // shims (npm, Volta, Corepack, scoop). execFileSync does not do PATHEXT + // resolution and Node refuses to spawn `.cmd`/`.bat` without a shell + // (CVE-2024-27980), so a bare program name ENOENTs. A shell lets the OS + // resolve the shim; POSIX needs no shell (direct PATH lookup works). + shell: process.platform === 'win32', + }); + } catch (err) { + // Make spawn failures (resolved program absent from PATH) self-explanatory + // instead of a silent exit 1, then propagate the runner's own exit code. + if (typeof err.status !== 'number') { + process.stderr.write(`gitnexus runner: could not launch \`${program}\` — ${err.message}\n`); + } + process.exit(typeof err.status === 'number' ? err.status : 1); + } +} diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 4a648c033c..364eac683f 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -30,6 +30,7 @@ const PLATFORM_LOGIC = [ 'test/unit/setup-jsonc.test.ts', 'test/unit/setup-codex.test.ts', 'test/unit/setup-antigravity.test.ts', + 'test/unit/resolve-invocation.test.ts', 'test/unit/platform-capabilities.test.ts', 'test/unit/worker-pool-windows-quarantine.test.ts', 'test/unit/lbug-pool-win-fts-probe.test.ts', @@ -84,6 +85,7 @@ const SPAWN_CLI = [ 'test/integration/setup-antigravity.test.ts', 'test/integration/antigravity-hook-e2e.test.ts', 'test/unit/local-cli-subprocess.test.ts', + 'test/unit/runner-exec-tail.test.ts', ]; // Worker threads tests — exercise real worker_threads which have diff --git a/gitnexus/skills/gitnexus-cli.md b/gitnexus/skills/gitnexus-cli.md index cd9a83be00..989c082775 100644 --- a/gitnexus/skills/gitnexus-cli.md +++ b/gitnexus/skills/gitnexus-cli.md @@ -5,14 +5,16 @@ description: "Use when the user needs to run GitNexus CLI commands like analyze/ # GitNexus CLI Commands -All commands work via `npx` — no global install required. +Commands below use `node .gitnexus/run.cjs ` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required. + +> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939). ## Commands ### analyze — Build or refresh the index ```bash -npx gitnexus analyze +node .gitnexus/run.cjs analyze ``` Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files. @@ -28,7 +30,7 @@ Run from the project root. This parses all source files, builds the knowledge gr ### status — Check index freshness ```bash -npx gitnexus status +node .gitnexus/run.cjs status ``` Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed. @@ -36,7 +38,7 @@ Shows whether the current repo has a GitNexus index, when it was last updated, a ### clean — Delete the index ```bash -npx gitnexus clean +node .gitnexus/run.cjs clean ``` Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project. @@ -49,7 +51,7 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi ### wiki — Generate documentation from the graph ```bash -npx gitnexus wiki +node .gitnexus/run.cjs wiki ``` Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). @@ -66,7 +68,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir ### list — Show all indexed repos ```bash -npx gitnexus list +node .gitnexus/run.cjs list ``` Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information. diff --git a/gitnexus/skills/gitnexus-debugging.md b/gitnexus/skills/gitnexus-debugging.md index 9510b97ac3..937b5e2a41 100644 --- a/gitnexus/skills/gitnexus-debugging.md +++ b/gitnexus/skills/gitnexus-debugging.md @@ -22,7 +22,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking 4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed ``` -> If "Index is stale" → run `npx gitnexus analyze` in terminal. +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklist diff --git a/gitnexus/skills/gitnexus-exploring.md b/gitnexus/skills/gitnexus-exploring.md index 927a4e4b64..2dcf7b578d 100644 --- a/gitnexus/skills/gitnexus-exploring.md +++ b/gitnexus/skills/gitnexus-exploring.md @@ -23,7 +23,7 @@ description: "Use when the user asks how code works, wants to understand archite 5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow ``` -> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal. +> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklist diff --git a/gitnexus/skills/gitnexus-guide.md b/gitnexus/skills/gitnexus-guide.md index 937ac73d16..b81900b5ef 100644 --- a/gitnexus/skills/gitnexus-guide.md +++ b/gitnexus/skills/gitnexus-guide.md @@ -15,7 +15,7 @@ For any task involving code understanding, debugging, impact analysis, or refact 2. **Match your task to a skill below** and **read that skill file** 3. **Follow the skill's workflow and checklist** -> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first. +> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first. ## Skills diff --git a/gitnexus/skills/gitnexus-impact-analysis.md b/gitnexus/skills/gitnexus-impact-analysis.md index e19af280c1..7206ca5062 100644 --- a/gitnexus/skills/gitnexus-impact-analysis.md +++ b/gitnexus/skills/gitnexus-impact-analysis.md @@ -23,7 +23,7 @@ description: "Use when the user wants to know what will break if they change som 4. Assess risk and report to user ``` -> If "Index is stale" → run `npx gitnexus analyze` in terminal. +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklist diff --git a/gitnexus/skills/gitnexus-pr-review.md b/gitnexus/skills/gitnexus-pr-review.md index e112f47baa..319c063f92 100644 --- a/gitnexus/skills/gitnexus-pr-review.md +++ b/gitnexus/skills/gitnexus-pr-review.md @@ -26,7 +26,7 @@ description: "Use when the user wants to review a pull request, understand what 6. Summarize findings with risk assessment ``` -> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing. +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal before reviewing. ## Checklist diff --git a/gitnexus/skills/gitnexus-refactoring.md b/gitnexus/skills/gitnexus-refactoring.md index f48cc01bd5..c749eb3841 100644 --- a/gitnexus/skills/gitnexus-refactoring.md +++ b/gitnexus/skills/gitnexus-refactoring.md @@ -22,7 +22,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru 4. Plan update order: interfaces → implementations → callers → tests ``` -> If "Index is stale" → run `npx gitnexus analyze` in terminal. +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklists diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 1eecc00b47..34de638b48 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -89,13 +89,17 @@ async function findGroupsContainingRegistryName(registryName: string): Promise 0 @@ -127,13 +131,22 @@ function generateGitNexusContent( |------|---------------------| ${tableBody}` : ''; + // Docs reference the project-local runner `gitnexus analyze` writes (#1945): + // a single, CLI-neutral, machine-independent command (no per-machine churn, + // #1706) that auto-selects the available runner at call time. Kept terse to + // stay under the CLAUDE.md block token budget (#856); the cli skill carries the + // full bootstrap + npm-11 fallback (`node.target is null` npx install crash). + const runner = `node ${runnerPath}`; + const bootstrapNote = + `No \`${runnerPath}\` yet? \`npx gitnexus analyze\` ` + + '(npm 11 crash → `npm i -g gitnexus`; #1939).'; return `${GITNEXUS_START_MARKER} # GitNexus — Code Intelligence This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows)`}. Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. -> If any GitNexus tool warns the index is stale, run \`npx gitnexus analyze\` in terminal first. +> Index stale? Run \`${runner} analyze\` from the project root — it auto-selects an available runner. ${bootstrapNote} ## Always Do @@ -163,7 +176,7 @@ ${ groupNames && groupNames.length > 0 ? `## Cross-Repo Groups -This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}** (see \`~/.gitnexus/groups/\`). For cross-repo analysis, use MCP tools \`impact\`, \`query\`, and \`context\` with \`repo\` set to \`@\` or \`@/\` (paths match keys in that group’s \`group.yaml\`). Use \`group_list\` / \`group_sync\` for membership and sync. From the terminal: \`npx gitnexus group list\`, \`npx gitnexus group sync \`, \`npx gitnexus group impact --target --repo \`. +This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}** (see \`~/.gitnexus/groups/\`). For cross-repo analysis, use MCP tools \`impact\`, \`query\`, and \`context\` with \`repo\` set to \`@\` or \`@/\` (paths match keys in that group’s \`group.yaml\`). Use \`group_list\` / \`group_sync\` for membership and sync. From the project root: \`${runner} group list\`, \`${runner} group sync \`, \`${runner} group impact --target --repo \` (the \`${runnerPath}\` path is repo-root-relative). ` : '' @@ -379,13 +392,36 @@ Use GitNexus tools to accomplish this task. */ export async function generateAIContextFiles( repoPath: string, - _storagePath: string, + storagePath: string, projectName: string, stats: RepoStats, generatedSkills?: GeneratedSkillInfo[], options?: AIContextOptions, ): Promise<{ files: string[] }> { const groupNames = await findGroupsContainingRegistryName(projectName); + + // Drop a project-local runner next to the index (#1945) so the generated docs + // can reference one CLI-neutral command that resolves the available runner at + // call time. It is a copy of the canonical self-contained resolver, which the + // CLI and hooks already share; failure to copy is non-fatal (docs carry a + // bootstrap fallback). `runnerPath` is project-relative with POSIX separators + // so the emitted command is identical across platforms. + const runnerPath = path.relative(repoPath, path.join(storagePath, 'run.cjs')).replace(/\\/g, '/'); + try { + const runnerSrc = path.join( + __dirname, + '..', + '..', + 'hooks', + 'claude', + 'resolve-analyze-cmd.cjs', + ); + await fs.mkdir(storagePath, { recursive: true }); + await fs.copyFile(runnerSrc, path.join(storagePath, 'run.cjs')); + } catch (err) { + logger.warn(`Could not write GitNexus runner to ${runnerPath}: ${String(err)}`); + } + const content = generateGitNexusContent( projectName, stats, @@ -393,6 +429,7 @@ export async function generateAIContextFiles( groupNames, options?.noStats, options?.skipSkills, + runnerPath, ); const createdFiles: string[] = []; diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 18d4bd6410..6ea2d43772 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -35,6 +35,7 @@ import fs from 'fs/promises'; import { cliError } from './cli-message.js'; import { formatElapsed } from './format-elapsed.js'; import { isHfDownloadFailure } from '../core/embeddings/hf-env.js'; +import { warnIfNpm11NpxRisk } from './resolve-invocation.js'; // Capture stderr.write at module load BEFORE anything (LadybugDB native // init, progress bar, console redirection) can monkey-patch it. The @@ -617,6 +618,11 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption // a stack trace and a non-zero exit code instead of a silent exit 0. installFatalHandlers(); + // npm-11 npx-crash nudge (#1939). Runs here, after the heap re-exec guard, + // so it fires once in the working process and never on the lazy-startup path + // of other commands (e.g. `gitnexus mcp`). + warnIfNpm11NpxRisk(); + // Snapshot the GITNEXUS_* env vars that the impl writes for downstream // consumption, so they don't leak across `analyzeCommand` invocations in // programmatic callers (tests, long-running hosts). `process.exit(0)` on diff --git a/gitnexus/src/cli/resolve-invocation.ts b/gitnexus/src/cli/resolve-invocation.ts new file mode 100644 index 0000000000..66e641d718 --- /dev/null +++ b/gitnexus/src/cli/resolve-invocation.ts @@ -0,0 +1,98 @@ +/** + * npm 11.x npx-install-crash nudge for the `analyze` command (#1939). + * + * The gitnexus/pnpm/npx selection itself lives in the canonical hook helper + * (hooks/claude/resolve-analyze-cmd.cjs) — self-contained CJS because the copied + * hook runtime cannot import from the package. We reuse it here via createRequire + * instead of re-implementing it, so there is one source of truth for the + * invocation decision. This module adds only the npm-version probe and the + * warning, which are CLI-only. The relative path resolves identically from + * src/cli/ (tsx, vitest) and dist/cli/ (shipped), since both sit one level under + * the package root and `hooks/` is published. + */ + +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; + +type InvocationMode = 'gitnexus' | 'pnpm' | 'npx'; + +interface InvocationResolver { + // `probe` is injectable in the cjs (defaults to the real PATH probe) so the + // preference order is unit-testable without spawning; the CLI calls it with + // no argument. + resolveInvocationMode: ( + probe?: (command: string, gitnexusWrapper?: boolean) => string | null, + ) => InvocationMode; + formatDocumentationDlxCommand: ( + gitnexusArgs: string, + options?: { embeddings?: boolean }, + ) => string; + NPX_REF: string; +} + +const { resolveInvocationMode, formatDocumentationDlxCommand, NPX_REF } = createRequire( + import.meta.url, + // `require()` returns `any`; go through `unknown` so the cast reads as an + // explicit narrowing to the subset this module uses, not a claim that the + // cjs's full export shape is known here. The drift guard below verifies it. +)('../../hooks/claude/resolve-analyze-cmd.cjs') as unknown as InvocationResolver; + +// Fail loud at module load if the canonical cjs export shape drifts (e.g. a +// renamed export), rather than as a late TypeError inside warnIfNpm11NpxRisk. +if ( + typeof resolveInvocationMode !== 'function' || + typeof formatDocumentationDlxCommand !== 'function' || + typeof NPX_REF !== 'string' +) { + throw new Error( + 'resolve-analyze-cmd.cjs must export resolveInvocationMode (function), formatDocumentationDlxCommand (function), and NPX_REF (string)', + ); +} + +export { NPX_REF }; + +// Re-implemented here (rather than reusing the cjs export) so vitest's +// `vi.mock('node:child_process')` intercepts it — the cjs uses bare +// `require('child_process')`, which the mock cannot reach. Timeout matches the +// cjs PROBE_TIMEOUT_MS (1s) so this CLI probe shares the same hook-budget cap; +// `npm --version` is a sub-second local call. +export function getNpmMajorVersion(): number | null { + try { + const output = execFileSync('npm', ['--version'], { + encoding: 'utf-8', + timeout: 1000, + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + // Windows `npm` is a `.cmd` shim; without a shell execFileSync ENOENTs + // (CVE-2024-27980) and the npm-11 npx-crash warning below would never + // fire on Windows. Mirrors probeVersion in resolve-analyze-cmd.cjs. + shell: process.platform === 'win32', + }); + // Read the first version-shaped line so a Corepack/update banner on stdout + // doesn't defeat the parse (mirrors the cjs probeVersion hardening). + const major = output + .split('\n') + .map((l) => l.trim()) + .find((l) => /^v?\d+\./.test(l)) + ?.match(/^v?(\d+)\./); + return major ? Number(major[1]) : null; + } catch { + return null; + } +} + +/** + * One-line stderr nudge when an npm 11+ user is on the npx install path (#1939). + * Skipped when a global `gitnexus` or `pnpm` is already preferred, so it never + * nags users who are not exposed to the npx/arborist crash. + */ +export function warnIfNpm11NpxRisk(): void { + if (resolveInvocationMode() !== 'npx') return; + const major = getNpmMajorVersion(); + if (major === null || major < 11) return; + process.stderr.write( + `Warning: npm ${major}.x can crash while installing gitnexus via npx ` + + `(npm/arborist "node.target is null"). Prefer: ${formatDocumentationDlxCommand('analyze')} ` + + `or npm install -g ${NPX_REF}. See https://github.com/abhigyanpatwari/GitNexus/issues/1939\n`, + ); +} diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 9b6c0944a6..0aa112e6a5 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -33,7 +33,44 @@ if (typeof _pkg.version !== 'string' || !_pkg.version) { 'gitnexus/package.json#version is missing or not a string — cannot generate MCP fallback config.', ); } -const NPX_REF = `gitnexus@${_pkg.version}`; +// Version-pinned ref for the persisted MCP entry — deliberately distinct from +// the cjs's exported `gitnexus@latest` hint ref (resolve-analyze-cmd.cjs); the +// two are not unified (see the comment above and that file's MCP_PINNED_REF). +const MCP_PINNED_REF = `gitnexus@${_pkg.version}`; + +/** + * Build the `command` string written into an editor's hook settings, which the + * editor shell-evaluates. `hookPath` is already forward-slash-normalized. + * + * On POSIX, single-quote the path: a single-quoted shell string expands nothing, + * so spaces and metacharacters ($, backtick, ;, |, &, newline, parens) in the + * install path cannot run as commands. The only character needing escaping + * inside single quotes is the single quote, via the standard `'\''` idiom + * (close, literal-quote, reopen). The previous double-quoted `node "..."` form + * left $/backtick live — a code-execution risk for an adversarial $HOME. + * + * On Windows, filenames cannot contain these POSIX metacharacters and the path + * is forward-slashed, so keep the double-quoted form with backslash-then-quote + * escaping (CodeQL js/incomplete-sanitization safe ordering). + */ +export function formatHookCommand( + hookPath: string, + isWindows = process.platform === 'win32', +): string { + if (isWindows) { + const escaped = hookPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `node "${escaped}"`; + } + return `node '${hookPath.replace(/'/g, "'\\''")}'`; +} + +// The exact source line each hook adapter ships, rewritten at install time to +// point cliPath at the installed CLI. Kept as a named constant so the install +// patch and its drift guard reference one string — if the adapter source ever +// changes this literal, the guard records an actionable error instead of +// silently shipping a hook with an unresolved relative cliPath. +const CLI_PATH_SOURCE_LITERAL = + "let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');"; interface SetupResult { configured: string[]; @@ -99,12 +136,12 @@ function getMcpEntry() { if (process.platform === 'win32') { return { command: 'cmd', - args: ['/c', 'npx', '-y', NPX_REF, 'mcp'], + args: ['/c', 'npx', '-y', MCP_PINNED_REF, 'mcp'], }; } return { command: 'npx', - args: ['-y', NPX_REF, 'mcp'], + args: ['-y', MCP_PINNED_REF, 'mcp'], }; } @@ -120,9 +157,9 @@ function getOpenCodeMcpEntry() { } if (process.platform === 'win32') { - return { type: 'local', command: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'] }; + return { type: 'local', command: ['cmd', '/c', 'npx', '-y', MCP_PINNED_REF, 'mcp'] }; } - return { type: 'local', command: ['npx', '-y', NPX_REF, 'mcp'] }; + return { type: 'local', command: ['npx', '-y', MCP_PINNED_REF, 'mcp'] }; } /** @@ -334,6 +371,48 @@ async function mergeHooksJsonc( return true; } +const HOOK_HELPERS = [ + 'hook-lock.cjs', + 'hook-db-lock-probe.cjs', + 'win-rm-list-json.ps1', + 'resolve-analyze-cmd.cjs', +] as const; + +// win-rm-list-json.ps1 is best-effort: it is read (not require()'d) by +// hook-db-lock-probe.cjs only on Windows, and that probe fails open when the +// script is absent. Every other helper is top-level require()'d by the adapters, +// so its absence crashes the installed hook — those are the ones a failed copy +// must gate hook registration on (see copyHookHelpers' return value). +const BEST_EFFORT_HOOK_HELPERS = new Set(['win-rm-list-json.ps1']); + +/** + * Copy the shared hook helpers from `srcDir` into `destDir`. The adapters + * top-level `require()` the `.cjs` helpers, so a missing required helper makes + * the installed hook crash with MODULE_NOT_FOUND. A failed copy is recorded as a + * setup error, and the names of any failed REQUIRED helpers are returned so the + * caller can fail closed (skip hook registration) instead of registering a hook + * that crashes at runtime. `win-rm-list-json.ps1` is best-effort — its absence is + * recorded but does not gate registration. Both the Claude and Antigravity + * install paths copy this same list from hooks/claude/ (the canonical source). + */ +export async function copyHookHelpers( + srcDir: string, + destDir: string, + label: string, + result: SetupResult, +): Promise { + const failedRequired: string[] = []; + for (const helper of HOOK_HELPERS) { + try { + await fs.copyFile(path.join(srcDir, helper), path.join(destDir, helper)); + } catch { + result.errors.push(`${label}: failed to copy ${helper} — hook may crash at runtime`); + if (!BEST_EFFORT_HOOK_HELPERS.has(helper)) failedRequired.push(helper); + } + } + return failedRequired; +} + /** * Install GitNexus hooks to ~/.claude/settings.json for Claude Code. * Merges hook config without overwriting existing hooks, preserving @@ -361,49 +440,44 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js'); const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/'); const jsonCli = JSON.stringify(normalizedCli); - content = content.replace( - "let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');", - `let cliPath = ${jsonCli};`, - ); + if (!content.includes(CLI_PATH_SOURCE_LITERAL)) { + result.errors.push( + 'Claude Code hooks: gitnexus-hook.cjs no longer contains the cliPath literal to patch — the installed hook may fail to resolve the CLI. Update CLI_PATH_SOURCE_LITERAL in setup.ts.', + ); + } + content = content.replace(CLI_PATH_SOURCE_LITERAL, `let cliPath = ${jsonCli};`); await fs.writeFile(dest, content, 'utf-8'); } catch { // Script not found in source — skip } + // Fail closed: registering the hook without its adapter would crash on every + // tool invocation. Mirrors the Antigravity adapter guard below (this path + // previously registered regardless of whether the adapter wrote). try { - await fs.copyFile( - path.join(pluginHooksPath, 'hook-lock.cjs'), - path.join(destHooksDir, 'hook-lock.cjs'), - ); + await fs.access(dest); } catch { - // Helper not found in source — skip + result.errors.push( + 'Claude Code hooks: adapter script was not installed — skipping hook registration', + ); + return; } - try { - await fs.copyFile( - path.join(pluginHooksPath, 'hook-db-lock-probe.cjs'), - path.join(destHooksDir, 'hook-db-lock-probe.cjs'), + const failedRequired = await copyHookHelpers( + pluginHooksPath, + destHooksDir, + 'Claude Code hooks', + result, + ); + if (failedRequired.length > 0) { + result.errors.push( + `Claude Code hooks: required helper(s) ${failedRequired.join(', ')} failed to copy — skipping hook registration`, ); - } catch { - // Helper not found in source — skip - } - - try { - await fs.copyFile( - path.join(pluginHooksPath, 'win-rm-list-json.ps1'), - path.join(destHooksDir, 'win-rm-list-json.ps1'), - ); - } catch { - // Helper not found in source — skip + return; } const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/'); - // Escape backslashes FIRST, then quotes (CodeQL js/incomplete-sanitization). - // The previous shape `replace(/"/g, '\\"')` alone would let `path\with"quote` - // become `path\with\"quote`, where the trailing `\` before `"` could - // unescape the quote inside the surrounding double-quoted shell context. - const escapedHookPath = hookPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - const hookCmd = `node "${escapedHookPath}"`; + const hookCmd = formatHookCommand(hookPath); // Check which hook events need entries (idempotent: skip if already registered) const parsed = await (async () => { @@ -566,10 +640,12 @@ async function installAntigravityHooks(result: SetupResult): Promise { const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js'); const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/'); const jsonCli = JSON.stringify(normalizedCli); - content = content.replace( - "let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');", - `let cliPath = ${jsonCli};`, - ); + if (!content.includes(CLI_PATH_SOURCE_LITERAL)) { + result.errors.push( + 'Antigravity hooks: gitnexus-antigravity-hook.cjs no longer contains the cliPath literal to patch — the installed hook may fail to resolve the CLI. Update CLI_PATH_SOURCE_LITERAL in setup.ts.', + ); + } + content = content.replace(CLI_PATH_SOURCE_LITERAL, `let cliPath = ${jsonCli};`); await fs.writeFile(adapterDest, content, 'utf-8'); } catch { // Adapter not found in source — skip @@ -591,19 +667,21 @@ async function installAntigravityHooks(result: SetupResult): Promise { // required by hook-db-lock-probe.cjs on Windows — without it, the MCP // server ownership probe silently fails open and the hook may contend // with the MCP server on the LadybugDB. - for (const helper of ['hook-lock.cjs', 'hook-db-lock-probe.cjs', 'win-rm-list-json.ps1']) { - try { - await fs.copyFile(path.join(pluginClaudeDir, helper), path.join(destHooksDir, helper)); - } catch { - result.errors.push( - `Antigravity hooks: failed to copy ${helper} — hook may crash at runtime`, - ); - } + const failedRequired = await copyHookHelpers( + pluginClaudeDir, + destHooksDir, + 'Antigravity hooks', + result, + ); + if (failedRequired.length > 0) { + result.errors.push( + `Antigravity hooks: required helper(s) ${failedRequired.join(', ')} failed to copy — skipping hook registration`, + ); + return; } const hookPath = path.join(destHooksDir, 'gitnexus-antigravity-hook.cjs').replace(/\\/g, '/'); - const escapedHookPath = hookPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - const hookCmd = `node "${escapedHookPath}"`; + const hookCmd = formatHookCommand(hookPath); const parsed = await (async () => { try { diff --git a/gitnexus/test/integration/antigravity-hook-e2e.test.ts b/gitnexus/test/integration/antigravity-hook-e2e.test.ts index 617214c0c1..0150d1f065 100644 --- a/gitnexus/test/integration/antigravity-hook-e2e.test.ts +++ b/gitnexus/test/integration/antigravity-hook-e2e.test.ts @@ -63,7 +63,12 @@ beforeAll(async () => { if (!fs.existsSync(installedHook)) { throw new Error(`Antigravity adapter was not installed at ${installedHook}`); } - for (const helper of ['hook-lock.cjs', 'hook-db-lock-probe.cjs', 'win-rm-list-json.ps1']) { + for (const helper of [ + 'hook-lock.cjs', + 'hook-db-lock-probe.cjs', + 'win-rm-list-json.ps1', + 'resolve-analyze-cmd.cjs', + ]) { const helperPath = path.join(path.dirname(installedHook), helper); if (!fs.existsSync(helperPath)) { throw new Error(`Helper not installed: ${helperPath}`); @@ -97,19 +102,24 @@ describe('antigravity hook adapter e2e', () => { JSON.stringify({ lastCommit: 'a'.repeat(40), stats: {} }), ); - const result = runHook(installedHook, { - hook_event_name: 'AfterTool', - tool_name: 'run_shell_command', - tool_input: { command: 'git commit -m "test"' }, - tool_response: { llmContent: '[committed]' }, - cwd: tmpDir, - }); + const result = runHook( + installedHook, + { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + tool_input: { command: 'git commit -m "test"' }, + tool_response: { llmContent: '[committed]' }, + cwd: tmpDir, + }, + tmpDir, + { env: { ...process.env, GITNEXUS_INVOCATION: 'npx' } }, + ); const output = parseHookOutput(result.stdout); expect(output).not.toBeNull(); expect(output!.hookEventName).toBe('AfterTool'); expect(output!.additionalContext).toContain('index is stale'); - expect(output!.additionalContext).toContain('npx gitnexus analyze'); + expect(output!.additionalContext).toContain('npx gitnexus@latest analyze'); // Mirror to stderr so terminal users see the hint even when the agent // discards additionalContext @@ -148,17 +158,22 @@ describe('antigravity hook adapter e2e', () => { }), ); - const result = runHook(installedHook, { - hook_event_name: 'AfterTool', - tool_name: 'run_shell_command', - tool_input: { command: 'git commit -m "x"' }, - tool_response: { llmContent: '[ok]' }, - cwd: tmpDir, - }); + const result = runHook( + installedHook, + { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + tool_input: { command: 'git commit -m "x"' }, + tool_response: { llmContent: '[ok]' }, + cwd: tmpDir, + }, + tmpDir, + { env: { ...process.env, GITNEXUS_INVOCATION: 'npx' } }, + ); const output = parseHookOutput(result.stdout); expect(output).not.toBeNull(); - expect(output!.additionalContext).toContain('--embeddings'); + expect(output!.additionalContext).toContain('npx gitnexus@latest analyze --embeddings'); }); it('treats missing meta.json as stale', () => { diff --git a/gitnexus/test/integration/hooks-e2e.test.ts b/gitnexus/test/integration/hooks-e2e.test.ts index 6d3b79172d..ea802ed3a8 100644 --- a/gitnexus/test/integration/hooks-e2e.test.ts +++ b/gitnexus/test/integration/hooks-e2e.test.ts @@ -66,18 +66,48 @@ describe.each(HOOKS)('hooks e2e ($name)', ({ name, path: hookPath }) => { JSON.stringify({ lastCommit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', stats: {} }), ); - const result = runHook(hookPath, { - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'git commit -m "test"' }, - tool_output: { exit_code: 0 }, - cwd: tmpDir, - }); + const result = runHook( + hookPath, + { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: tmpDir, + }, + tmpDir, + { env: { ...process.env, GITNEXUS_INVOCATION: 'npx' } }, + ); const output = parseHookOutput(result.stdout); expect(output).not.toBeNull(); expect(output!.additionalContext).toContain('stale'); - expect(output!.additionalContext).toContain('npx gitnexus analyze'); + expect(output!.additionalContext).toContain('npx gitnexus@latest analyze'); + }); + + it('prefers pnpm dlx when GITNEXUS_INVOCATION=pnpm', () => { + fs.writeFileSync( + path.join(gitNexusDir, 'meta.json'), + JSON.stringify({ lastCommit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', stats: {} }), + ); + + const result = runHook( + hookPath, + { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: tmpDir, + }, + tmpDir, + { env: { ...process.env, GITNEXUS_INVOCATION: 'pnpm' } }, + ); + + const output = parseHookOutput(result.stdout); + expect(output).not.toBeNull(); + expect(output!.additionalContext).toContain('--allow-build=@ladybugdb/core'); + expect(output!.additionalContext).toContain('gitnexus@latest analyze'); }); it('stays silent when meta.json lastCommit matches HEAD', () => { @@ -116,17 +146,22 @@ describe.each(HOOKS)('hooks e2e ($name)', ({ name, path: hookPath }) => { }), ); - const result = runHook(hookPath, { - hook_event_name: 'PostToolUse', - tool_name: 'Bash', - tool_input: { command: 'git commit -m "test"' }, - tool_output: { exit_code: 0 }, - cwd: tmpDir, - }); + const result = runHook( + hookPath, + { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_input: { command: 'git commit -m "test"' }, + tool_output: { exit_code: 0 }, + cwd: tmpDir, + }, + tmpDir, + { env: { ...process.env, GITNEXUS_INVOCATION: 'npx' } }, + ); const output = parseHookOutput(result.stdout); expect(output).not.toBeNull(); - expect(output!.additionalContext).toContain('--embeddings'); + expect(output!.additionalContext).toContain('npx gitnexus@latest analyze --embeddings'); }); it('treats missing meta.json as stale', () => { diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index b0f17ae61c..b0f48e1680 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import fs from 'fs/promises'; import path from 'path'; import os from 'os'; -import { generateAIContextFiles } from '../../src/cli/ai-context.js'; +import { generateAIContextFiles, generateGitNexusContent } from '../../src/cli/ai-context.js'; describe('generateAIContextFiles', () => { let tmpDir: string; @@ -93,6 +93,80 @@ describe('generateAIContextFiles', () => { } }); + it('emits the project-local runner command and drops .gitnexus/run.cjs regardless of mode (#1945)', async () => { + const subDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-analyze-cmd-test-')); + const subStorage = path.join(subDir, '.gitnexus'); + await fs.mkdir(subStorage, { recursive: true }); + const prior = process.env.GITNEXUS_INVOCATION; + try { + // Force a mode whose machine-resolved command (`gitnexus analyze`) differs + // from the emitted string, so this fails loudly if generation ever goes + // back to resolving the command per-machine instead of pointing at the + // fixed, CLI-neutral project-local runner. + process.env.GITNEXUS_INVOCATION = 'gitnexus'; + const stats = { nodes: 50, edges: 100, processes: 5 }; + await generateAIContextFiles(subDir, subStorage, 'CmdProject', stats); + + // The runner is copied next to the index so the emitted command resolves. + const runner = await fs.readFile(path.join(subStorage, 'run.cjs'), 'utf-8'); + expect(runner).toContain('buildRunnerArgv'); // it's the real resolver copy + + for (const f of ['CLAUDE.md', 'AGENTS.md']) { + const content = await fs.readFile(path.join(subDir, f), 'utf-8'); + // Primary command is the fixed project-local runner, not machine-resolved. + expect(content).toContain('`node .gitnexus/run.cjs analyze`'); + expect(content).not.toContain('run `gitnexus analyze`'); // no machine-resolved leak + // Bootstrap path (for a not-yet-analyzed checkout) + npm-11 escape hatch. + expect(content).toContain('npx gitnexus analyze'); + expect(content).toContain('1939'); + } + } finally { + if (prior === undefined) delete process.env.GITNEXUS_INVOCATION; + else process.env.GITNEXUS_INVOCATION = prior; + await fs.rm(subDir, { recursive: true, force: true }); + } + }); + + it('emits Cross-Repo Groups commands through the project-local runner (#1945)', () => { + // Exercise the groupNames>0 branch directly — the no-group path cannot + // catch a group-command regression because the block is not emitted. + const content = generateGitNexusContent( + 'TestProject', + { nodes: 50, edges: 100, processes: 5 }, + undefined, + ['TeamGroup'], + ); + expect(content).toContain('## Cross-Repo Groups'); + expect(content).toContain('node .gitnexus/run.cjs group list'); + expect(content).toContain('node .gitnexus/run.cjs group sync'); + expect(content).toContain('node .gitnexus/run.cjs group impact'); + // Group commands must not hardcode a package manager. + expect(content).not.toMatch(/dlx gitnexus@latest group/); + expect(content).not.toMatch(/npx gitnexus group/); + }); + + it('degrades gracefully when the runner copy fails (#1945)', async () => { + // A read-only/full-disk storage dir must not abort generation. The copy is + // best-effort + logged; the generated docs still carry the inline bootstrap + // (`npx gitnexus analyze`) so a reader hitting the absent runner has a path. + const subDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-copyfail-')); + const subStorage = path.join(subDir, '.gitnexus'); + await fs.mkdir(subStorage, { recursive: true }); + const spy = vi.spyOn(fs, 'copyFile').mockRejectedValueOnce(new Error('EACCES: read-only')); + try { + const stats = { nodes: 50, edges: 100, processes: 5 }; + // Must not throw despite the copy failure. + await generateAIContextFiles(subDir, subStorage, 'CopyFail', stats); + const content = await fs.readFile(path.join(subDir, 'CLAUDE.md'), 'utf-8'); + expect(content).toContain('npx gitnexus analyze'); // bootstrap survives + // The runner was not written, so the file is absent. + await expect(fs.access(path.join(subStorage, 'run.cjs'))).rejects.toThrow(); + } finally { + spy.mockRestore(); + await fs.rm(subDir, { recursive: true, force: true }); + } + }); + it('keeps the load-bearing repo-specific sections in the CLAUDE.md block (#856)', async () => { // The trimmed block must still contain everything that is genuinely // unique per repo or load-bearing for the agent: the freshness warning, @@ -104,7 +178,7 @@ describe('generateAIContextFiles', () => { const content = await fs.readFile(path.join(tmpDir, 'CLAUDE.md'), 'utf-8'); - expect(content).toContain('If any GitNexus tool warns the index is stale'); + expect(content).toContain('Index stale? Run `node .gitnexus/run.cjs analyze`'); expect(content).toContain('## Always Do'); expect(content).toContain('## Never Do'); expect(content).toContain('## Resources'); diff --git a/gitnexus/test/unit/hooks.test.ts b/gitnexus/test/unit/hooks.test.ts index 892a19483c..ef8f9afefd 100644 --- a/gitnexus/test/unit/hooks.test.ts +++ b/gitnexus/test/unit/hooks.test.ts @@ -28,6 +28,23 @@ import { runHook, parseHookOutput } from '../utils/hook-test-helpers.js'; const CJS_HOOK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'gitnexus-hook.cjs'); const CJS_HOOK_LOCK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'hook-lock.cjs'); +const RESOLVE_CJS = path.resolve( + __dirname, + '..', + '..', + 'hooks', + 'claude', + 'resolve-analyze-cmd.cjs', +); +const RESOLVE_PLUGIN_CJS = path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-claude-plugin', + 'hooks', + 'resolve-analyze-cmd.cjs', +); const PLUGIN_HOOK = path.resolve( __dirname, '..', @@ -202,6 +219,8 @@ describe('Shell injection regression', () => { for (const [label, hookPath] of [ ['CJS', CJS_HOOK], ['Plugin', PLUGIN_HOOK], + ['Resolve CJS', RESOLVE_CJS], + ['Resolve Plugin', RESOLVE_PLUGIN_CJS], ] as const) { it(`${label} hook has no shell: true in spawnSync calls`, () => { const source = fs.readFileSync(hookPath, 'utf-8'); @@ -254,6 +273,8 @@ describe('windowsHide regression', () => { // Hook-layer files. Adding a new hook file MUST be reflected here. const HOOK_FILES: Array = [ ['gitnexus/hooks/claude/gitnexus-hook.cjs', CJS_HOOK], + ['gitnexus/hooks/claude/resolve-analyze-cmd.cjs', RESOLVE_CJS], + ['gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs', RESOLVE_PLUGIN_CJS], [ 'gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs', path.resolve(__dirname, '..', '..', 'hooks', 'antigravity', 'gitnexus-antigravity-hook.cjs'), diff --git a/gitnexus/test/unit/resolve-invocation.test.ts b/gitnexus/test/unit/resolve-invocation.test.ts new file mode 100644 index 0000000000..4511611b90 --- /dev/null +++ b/gitnexus/test/unit/resolve-invocation.test.ts @@ -0,0 +1,451 @@ +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 } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +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'; + pickPathMatch: ( + output: string, + opts?: { isWin?: boolean; gitnexusWrapper?: boolean }, + ) => 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 this module's resolveOnPath() would spawn a real `which`/`where`. Every test +// below avoids the live probe — by forcing GITNEXUS_INVOCATION, injecting a fake +// `probe`, or calling the pure pickPathMatch() — so results never depend on the +// host PATH. Keep new tests on one of those three paths. +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('pickPathMatch — Windows global-shim detection', () => { + it('detects a .exe-only shim (Volta/scoop)', () => { + expect( + cjs.pickPathMatch('C:\\Users\\me\\AppData\\Local\\Volta\\bin\\gitnexus.exe\r\n', { + isWin: true, + gitnexusWrapper: true, + }), + ).toBe('C:\\Users\\me\\AppData\\Local\\Volta\\bin\\gitnexus.exe'); + }); + + it('detects an extensionless shim', () => { + expect( + cjs.pickPathMatch('C:\\tools\\gitnexus\r\n', { isWin: true, gitnexusWrapper: true }), + ).toBe('C:\\tools\\gitnexus'); + }); + + it('prefers a .cmd over an extensionless sibling', () => { + expect( + cjs.pickPathMatch('C:\\npm\\gitnexus\r\nC:\\npm\\gitnexus.cmd\r\n', { + isWin: true, + gitnexusWrapper: true, + }), + ).toBe('C:\\npm\\gitnexus.cmd'); + }); + + it('strips the CRLF carriage return from the chosen path', () => { + const bin = cjs.pickPathMatch('C:\\npm\\gitnexus.cmd\r\n', { + isWin: true, + gitnexusWrapper: true, + }); + expect(bin).not.toMatch(/\r/); + expect(bin).toBe('C:\\npm\\gitnexus.cmd'); + }); + + it('returns the first hit on non-Windows / non-wrapper lookups, null on empty', () => { + expect(cjs.pickPathMatch('/usr/local/bin/pnpm\n', { isWin: false })).toBe( + '/usr/local/bin/pnpm', + ); + expect(cjs.pickPathMatch('', { isWin: true, gitnexusWrapper: true })).toBeNull(); + }); + + it('returns the first hit for a Windows non-wrapper lookup (pnpm probe)', () => { + expect( + cjs.pickPathMatch('C:\\npm\\pnpm.cmd\r\n', { isWin: true, gitnexusWrapper: false }), + ).toBe('C:\\npm\\pnpm.cmd'); + }); +}); + +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('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\(\)/); + }); +}); diff --git a/gitnexus/test/unit/runner-exec-tail.test.ts b/gitnexus/test/unit/runner-exec-tail.test.ts new file mode 100644 index 0000000000..99e7d376a8 --- /dev/null +++ b/gitnexus/test/unit/runner-exec-tail.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, chmodSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// Exercises the `require.main === module` direct-exec entrypoint of the runner +// (#1945) end-to-end: the pure `buildRunnerArgv` is covered in +// resolve-invocation.test.ts, but the spawn + exit-code propagation + missing- +// runner diagnostic only live in the exec tail. We spawn a real child `node` +// against the canonical run.cjs with GITNEXUS_INVOCATION forcing a mode (so no +// live PATH probe) and a fake `gitnexus` on PATH. +// +// The POSIX cases stage a shebang shell script; the Windows case below stages a +// `.cmd` shim to exercise the Windows-specific branch (shell:true so cmd.exe +// resolves `.cmd`/`.ps1` shims via PATHEXT). This file is registered in +// scripts/cross-platform-tests.ts (SPAWN_CLI) so the windows-latest CI job +// actually runs the Windows case; the POSIX cases self-skip there. +const CANONICAL_CJS = path.resolve( + __dirname, + '..', + '..', + 'hooks', + 'claude', + 'resolve-analyze-cmd.cjs', +); +const onPosix = process.platform !== 'win32'; + +describe('run.cjs direct-exec entrypoint (#1945)', () => { + it.skipIf(!onPosix)( + 'execs the resolved runner, passes args through, and propagates its exit code', + () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'gn-runner-')); + const fake = path.join(dir, 'gitnexus'); + // Fake global `gitnexus` that echoes its argv and exits 42. + writeFileSync(fake, '#!/bin/sh\necho "fake-gitnexus $@"\nexit 42\n'); + chmodSync(fake, 0o755); + + const res = spawnSync(process.execPath, [CANONICAL_CJS, 'analyze', '--foo'], { + env: { + ...process.env, + GITNEXUS_INVOCATION: 'gitnexus', + PATH: `${dir}:${process.env.PATH}`, + }, + encoding: 'utf-8', + }); + + expect(res.status).toBe(42); // exit code propagated, not swallowed + expect(res.stdout).toContain('fake-gitnexus analyze --foo'); // args passthrough + inherited stdio + }, + ); + + it.skipIf(!onPosix)( + 'prints a diagnostic and exits 1 when the resolved runner is absent from PATH', + () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'gn-runner-empty-')); + const res = spawnSync(process.execPath, [CANONICAL_CJS, 'analyze'], { + // Force gitnexus mode but give an empty PATH so the spawn ENOENTs. + env: { ...process.env, GITNEXUS_INVOCATION: 'gitnexus', PATH: dir }, + encoding: 'utf-8', + }); + + expect(res.status).toBe(1); + expect(res.stderr).toContain('could not launch'); + }, + ); + + it.skipIf(onPosix)( + 'resolves a .cmd shim via the Windows shell branch, passing args and exit code', + () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'gn-runner-win-')); + const fake = path.join(dir, 'gitnexus.cmd'); + // Fake global `gitnexus` .cmd shim: echoes its argv and exits 42. The exec + // tail's `shell: process.platform === 'win32'` routes through cmd.exe, + // which resolves bare `gitnexus` → `gitnexus.cmd` via PATHEXT. + writeFileSync(fake, '@echo off\r\necho fake-gitnexus %*\r\nexit /b 42\r\n'); + + const res = spawnSync(process.execPath, [CANONICAL_CJS, 'analyze', '--foo'], { + env: { + ...process.env, + GITNEXUS_INVOCATION: 'gitnexus', + PATH: `${dir};${process.env.PATH}`, + }, + encoding: 'utf-8', + }); + + expect(res.status).toBe(42); // exit code propagated through the shell + expect(res.stdout).toContain('fake-gitnexus analyze --foo'); // args passthrough + }, + ); +}); diff --git a/gitnexus/test/unit/setup-antigravity.test.ts b/gitnexus/test/unit/setup-antigravity.test.ts index 9a7c6c7dc1..73a1cb3059 100644 --- a/gitnexus/test/unit/setup-antigravity.test.ts +++ b/gitnexus/test/unit/setup-antigravity.test.ts @@ -246,6 +246,9 @@ describe('setupAntigravity', () => { // Required by hook-db-lock-probe.cjs on Windows; without it the MCP // server ownership probe silently fails open. await expect(fs.access(path.join(destDir, 'win-rm-list-json.ps1'))).resolves.toBeUndefined(); + // The adapter top-level require()s this; the production install path must + // co-locate it next to the adapter (symmetric with the Claude install). + await expect(fs.access(path.join(destDir, 'resolve-analyze-cmd.cjs'))).resolves.toBeUndefined(); }); it('installs skills under ~/.gemini/antigravity/skills//SKILL.md', async () => { @@ -294,6 +297,7 @@ const ADAPTER_SRC = path.join( const LOCK_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'hook-lock.cjs'); const PROBE_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'hook-db-lock-probe.cjs'); const WIN_RM_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'win-rm-list-json.ps1'); +const RESOLVE_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'resolve-analyze-cmd.cjs'); async function stageAdapter(): Promise { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-adapter-')); @@ -304,6 +308,9 @@ async function stageAdapter(): Promise { // the lock probe silently fails open and the adapter's Windows DB-lock path // would be untested in child-process smoke tests. await fs.copyFile(WIN_RM_SRC, path.join(tmp, 'win-rm-list-json.ps1')); + // The adapter top-level `require('./resolve-analyze-cmd.cjs')`s this helper; + // without staging it the spawned adapter crashes with MODULE_NOT_FOUND. + await fs.copyFile(RESOLVE_SRC, path.join(tmp, 'resolve-analyze-cmd.cjs')); return path.join(tmp, 'gitnexus-antigravity-hook.cjs'); } @@ -311,17 +318,27 @@ function runAdapter( hookPath: string, input: Record, cwd?: string, + env?: Record, ): { stdout: string; stderr: string; status: number | null } { const result = spawnSync(process.execPath, [hookPath], { input: JSON.stringify(input), encoding: 'utf-8', timeout: 10000, cwd, + env: env ? { ...process.env, ...env } : process.env, stdio: ['pipe', 'pipe', 'pipe'], }); return { stdout: result.stdout || '', stderr: result.stderr || '', status: result.status }; } +// A staged adapter that fails to load (e.g. a missing sibling helper) exits +// non-zero and prints MODULE_NOT_FOUND — a state that otherwise masquerades as +// "no stdout" in the silent-path tests below. Assert the process actually ran. +function expectAdapterLoaded(stderr: string, status: number | null): void { + expect(status).toBe(0); + expect(stderr).not.toMatch(/MODULE_NOT_FOUND|Cannot find module/); +} + describe('gitnexus-antigravity-hook adapter', () => { let adapter: string; let workdir: string; @@ -337,7 +354,7 @@ describe('gitnexus-antigravity-hook adapter', () => { }); it('AfterTool with no .gitnexus/ produces no stdout', async () => { - const { stdout } = runAdapter( + const { stdout, stderr, status } = runAdapter( adapter, { hook_event_name: 'AfterTool', @@ -349,10 +366,11 @@ describe('gitnexus-antigravity-hook adapter', () => { workdir, ); expect(stdout.trim()).toBe(''); + expectAdapterLoaded(stderr, status); }); it('AfterTool ignores unrelated tools silently', async () => { - const { stdout, stderr } = runAdapter( + const { stdout, stderr, status } = runAdapter( adapter, { hook_event_name: 'AfterTool', @@ -365,6 +383,7 @@ describe('gitnexus-antigravity-hook adapter', () => { ); expect(stdout.trim()).toBe(''); expect(stderr).not.toMatch(/\[GitNexus\]/); + expectAdapterLoaded(stderr, status); }); it('AfterTool ignores non-git run_shell_command silently', async () => { @@ -376,7 +395,7 @@ describe('gitnexus-antigravity-hook adapter', () => { 'utf-8', ); - const { stdout, stderr } = runAdapter( + const { stdout, stderr, status } = runAdapter( adapter, { hook_event_name: 'AfterTool', @@ -389,6 +408,7 @@ describe('gitnexus-antigravity-hook adapter', () => { ); expect(stdout.trim()).toBe(''); expect(stderr).not.toMatch(/\[GitNexus\]/); + expectAdapterLoaded(stderr, status); }); it('AfterTool emits stale-index hint after a successful git commit', async () => { @@ -418,6 +438,10 @@ describe('gitnexus-antigravity-hook adapter', () => { cwd: workdir, }, workdir, + // Force a deterministic invocation mode: the emitted analyze command + // varies by what's installed on each CI runner (gitnexus/pnpm/npx), and + // only the `gitnexus` mode yields the bare `gitnexus analyze` form. + { GITNEXUS_INVOCATION: 'gitnexus' }, ); // Hint surfaces both via the agent-visible channel and stderr (terminal). @@ -438,7 +462,7 @@ describe('gitnexus-antigravity-hook adapter', () => { 'utf-8', ); - const { stdout } = runAdapter( + const { stdout, stderr, status } = runAdapter( adapter, { hook_event_name: 'AfterTool', @@ -450,6 +474,7 @@ describe('gitnexus-antigravity-hook adapter', () => { workdir, ); expect(stdout.trim()).toBe(''); + expectAdapterLoaded(stderr, status); }); it('ignores unknown tool names without crashing', async () => { diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts index ee43d97d71..bee3f19a23 100644 --- a/gitnexus/test/unit/setup.test.ts +++ b/gitnexus/test/unit/setup.test.ts @@ -8,7 +8,7 @@ import { createRequire } from 'module'; // so the test never goes stale on a release bump. const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string }) .version; -const NPX_REF = `gitnexus@${PKG_VERSION}`; +const MCP_PINNED_REF = `gitnexus@${PKG_VERSION}`; const execFileMock = vi.fn((...args: any[]) => { const callback = args.at(-1); @@ -82,7 +82,7 @@ describe('setupClaudeCode', () => { expect(config.mcpServers.gitnexus).toEqual({ command: 'cmd', - args: ['/c', 'npx', '-y', NPX_REF, 'mcp'], + args: ['/c', 'npx', '-y', MCP_PINNED_REF, 'mcp'], }); }); @@ -97,7 +97,7 @@ describe('setupClaudeCode', () => { expect(config.mcpServers.gitnexus).toEqual({ command: 'npx', - args: ['-y', NPX_REF, 'mcp'], + args: ['-y', MCP_PINNED_REF, 'mcp'], }); }); @@ -189,7 +189,7 @@ describe('setupClaudeCode', () => { expect(config.mcpServers.gitnexus).toEqual({ command: 'npx', - args: ['-y', NPX_REF, 'mcp'], + args: ['-y', MCP_PINNED_REF, 'mcp'], }); }); @@ -267,19 +267,96 @@ describe('setupClaudeCode', () => { }); }); - it('copies hook-db-lock-probe.cjs and win-rm-list-json.ps1 to ~/.claude/hooks/gitnexus/', async () => { + it('copies shared hook helpers (incl. resolve-analyze-cmd.cjs) to ~/.claude/hooks/gitnexus/', async () => { setPlatform('linux'); const { setupCommand } = await import('../../src/cli/setup.js'); await setupCommand(); const destHooksDir = path.join(tempHome, '.claude', 'hooks', 'gitnexus'); + await expect(fs.access(path.join(destHooksDir, 'hook-lock.cjs'))).resolves.toBeUndefined(); await expect( fs.access(path.join(destHooksDir, 'hook-db-lock-probe.cjs')), ).resolves.toBeUndefined(); await expect( fs.access(path.join(destHooksDir, 'win-rm-list-json.ps1')), ).resolves.toBeUndefined(); + // The Claude adapter top-level require()s this; without it the installed + // hook would crash with MODULE_NOT_FOUND (the antigravity-side bug class). + await expect( + fs.access(path.join(destHooksDir, 'resolve-analyze-cmd.cjs')), + ).resolves.toBeUndefined(); + }); + + it('records errors and returns the failed REQUIRED helpers when copies fail', async () => { + const { copyHookHelpers } = await import('../../src/cli/setup.js'); + const destDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-copy-helpers-')); + const result = { configured: [] as string[], skipped: [] as string[], errors: [] as string[] }; + try { + // A non-existent source dir makes every helper copy fail. + const failedRequired = await copyHookHelpers( + path.join(destDir, 'nope'), + destDir, + 'Claude Code hooks', + result, + ); + expect(result.errors.length).toBe(4); + expect(result.errors.some((e) => e.includes('resolve-analyze-cmd.cjs'))).toBe(true); + expect(result.errors.every((e) => e.startsWith('Claude Code hooks:'))).toBe(true); + // Only the hard-required .cjs trio gates registration; win-rm is best-effort. + expect([...failedRequired].sort()).toEqual([ + 'hook-db-lock-probe.cjs', + 'hook-lock.cjs', + 'resolve-analyze-cmd.cjs', + ]); + expect(failedRequired).not.toContain('win-rm-list-json.ps1'); + } finally { + await fs.rm(destDir, { recursive: true, force: true }); + } + }); + + it('treats win-rm-list-json.ps1 as best-effort (no required failure when only it is missing)', async () => { + const { copyHookHelpers } = await import('../../src/cli/setup.js'); + const srcDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-helpers-src-')); + const destDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-helpers-dest-')); + const result = { configured: [] as string[], skipped: [] as string[], errors: [] as string[] }; + try { + // Provide the three hard-required .cjs helpers; omit win-rm-list-json.ps1. + for (const h of ['hook-lock.cjs', 'hook-db-lock-probe.cjs', 'resolve-analyze-cmd.cjs']) { + await fs.writeFile(path.join(srcDir, h), '// stub\n', 'utf-8'); + } + const failedRequired = await copyHookHelpers(srcDir, destDir, 'Claude Code hooks', result); + expect(failedRequired).toEqual([]); + // The best-effort helper still records a (non-gating) error. + expect(result.errors.some((e) => e.includes('win-rm-list-json.ps1'))).toBe(true); + } finally { + await fs.rm(srcDir, { recursive: true, force: true }); + await fs.rm(destDir, { recursive: true, force: true }); + } + }); + + it('does not register the Claude hook when a required helper fails to copy (fail closed)', async () => { + setPlatform('linux'); + const realCopyFile = fs.copyFile.bind(fs); + vi.spyOn(fs, 'copyFile').mockImplementation(((src: any, dest: any, ...rest: any[]) => { + if (String(src).endsWith('resolve-analyze-cmd.cjs')) { + return Promise.reject(new Error('simulated copy failure')); + } + return realCopyFile(src, dest, ...rest); + }) as typeof fs.copyFile); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + // Registration must have been skipped — settings.json must not reference the hook. + const settingsPath = path.join(tempHome, '.claude', 'settings.json'); + let registered = false; + try { + registered = (await fs.readFile(settingsPath, 'utf-8')).includes('gitnexus-hook.cjs'); + } catch { + registered = false; + } + expect(registered).toBe(false); }); it('falls back to npx on Windows when no .cmd/.bat wrapper is found', async () => { @@ -295,7 +372,7 @@ describe('setupClaudeCode', () => { expect(config.mcpServers.gitnexus).toEqual({ command: 'cmd', - args: ['/c', 'npx', '-y', NPX_REF, 'mcp'], + args: ['/c', 'npx', '-y', MCP_PINNED_REF, 'mcp'], }); }); @@ -311,7 +388,70 @@ describe('setupClaudeCode', () => { expect(config.mcpServers.gitnexus).toEqual({ command: 'cmd', - args: ['/c', 'npx', '-y', NPX_REF, 'mcp'], + args: ['/c', 'npx', '-y', MCP_PINNED_REF, 'mcp'], }); }); + + // The hook `command` string is shell-evaluated by the editor. On POSIX the + // installed path lives under $HOME, which can legitimately contain spaces and + // (adversarially) shell metacharacters; the command must neutralize them. + it('single-quotes the POSIX hook command so the path cannot word-split or expand', async () => { + setPlatform('linux'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const settings = JSON.parse( + await fs.readFile(path.join(tempHome, '.claude', 'settings.json'), 'utf-8'), + ); + const cmd: string = settings.hooks.PreToolUse[0].hooks[0].command; + // setup.ts forward-slash-normalizes the hook path (`.replace(/\\/g, '/')`) + // before quoting, so normalize the expected path the same way — otherwise + // path.join emits backslashes on the Windows runner and this mismatches. + const hookPath = path + .join(tempHome, '.claude', 'hooks', 'gitnexus', 'gitnexus-hook.cjs') + .replace(/\\/g, '/'); + // Single-quoted, not double-quoted, and the path is the literal inside quotes. + expect(cmd).toBe(`node '${hookPath}'`); + expect(cmd.startsWith("node '")).toBe(true); + expect(cmd).not.toMatch(/^node "/); + }); +}); + +describe('formatHookCommand (hook command escaping, #1945)', () => { + let mod: typeof import('../../src/cli/setup.js'); + + beforeEach(async () => { + mod = await import('../../src/cli/setup.js'); + }); + + it('single-quotes an ordinary POSIX path', () => { + expect(mod.formatHookCommand('/home/dev/.claude/hooks/gitnexus/gitnexus-hook.cjs', false)).toBe( + "node '/home/dev/.claude/hooks/gitnexus/gitnexus-hook.cjs'", + ); + }); + + it('neutralizes spaces in a POSIX path (no word-splitting)', () => { + expect(mod.formatHookCommand('/home/a b/.claude/gitnexus-hook.cjs', false)).toBe( + "node '/home/a b/.claude/gitnexus-hook.cjs'", + ); + }); + + it('neutralizes shell metacharacters in a POSIX path ($, backtick, ;)', () => { + // Single-quoting means none of these can expand or run as a command. + const evil = '/home/u$(id)/`whoami`/a;b/.claude/gitnexus-hook.cjs'; + expect(mod.formatHookCommand(evil, false)).toBe(`node '${evil}'`); + }); + + it("escapes a single quote in a POSIX path via the '\\'' idiom", () => { + expect(mod.formatHookCommand("/home/o'brien/.claude/gitnexus-hook.cjs", false)).toBe( + "node '/home/o'\\''brien/.claude/gitnexus-hook.cjs'", + ); + }); + + it('keeps the double-quoted form on Windows (metacharacters are illegal in filenames)', () => { + expect(mod.formatHookCommand('C:/Users/dev/.claude/gitnexus-hook.cjs', true)).toBe( + 'node "C:/Users/dev/.claude/gitnexus-hook.cjs"', + ); + }); }); diff --git a/gitnexus/test/unit/skills-steering.test.ts b/gitnexus/test/unit/skills-steering.test.ts new file mode 100644 index 0000000000..7d6baa1b6c --- /dev/null +++ b/gitnexus/test/unit/skills-steering.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import path from 'node:path'; + +// Steering policy (#1939, #1945): the committed skill files route gitnexus +// commands through the project-local runner `gitnexus analyze` drops next to the +// index (`node .gitnexus/run.cjs `). That one CLI-neutral command +// resolves the available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call +// time, so the docs make no package-manager assumption. The runner only exists +// after the first analyze, so the cli skill documents a bootstrap path (and the +// npm-11 `node.target is null` npx install-crash escape hatch). When the pnpm +// fallback is shown it must use the pre-`dlx` `--allow-build` position (honored +// since pnpm 10.2); the post-`dlx` position is rejected as a package spec on +// pnpm 10.2–10.13.x. +// +// Pure file reads resolved via path.resolve — deterministic, no host-PATH or +// glob-CWD dependence, so this needs no cross-platform-tests.ts registration. + +const GITNEXUS_ROOT = path.resolve(__dirname, '..', '..'); // gitnexus/test/unit -> gitnexus/ +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); // -> monorepo root + +function collectSkillFiles(): string[] { + const files: string[] = []; + + // Bundled ship source: flat *.md files installSkills() copies to new users. + const bundled = path.join(GITNEXUS_ROOT, 'skills'); + if (existsSync(bundled)) { + for (const f of readdirSync(bundled)) { + if (f.endsWith('.md')) files.push(path.join(bundled, f)); + } + } + + // Per-skill /SKILL.md copies across the other distribution locations. + const skillRoots = [ + path.join(REPO_ROOT, '.claude', 'skills', 'gitnexus'), + path.join(REPO_ROOT, 'gitnexus-claude-plugin', 'skills'), + path.join(REPO_ROOT, 'gitnexus-cursor-integration', 'skills'), + ]; + for (const root of skillRoots) { + if (!existsSync(root)) continue; + for (const dir of readdirSync(root)) { + const skillMd = path.join(root, dir, 'SKILL.md'); + if (existsSync(skillMd)) files.push(skillMd); + } + } + + return files; +} + +function cliSkillFiles(files: string[]): string[] { + return files.filter( + (f) => + /gitnexus-cli/.test(path.basename(path.dirname(f))) || path.basename(f) === 'gitnexus-cli.md', + ); +} + +describe('skill-file steering (#1939, #1945)', () => { + const files = collectSkillFiles(); + + it('collects skill files from all four committed locations (guard is not vacuous)', () => { + const rels = files.map((f) => path.relative(REPO_ROOT, f)); + expect(rels.some((r) => r.startsWith(`gitnexus${path.sep}skills${path.sep}`))).toBe(true); + expect( + rels.some((r) => r.startsWith(path.join('.claude', 'skills', 'gitnexus') + path.sep)), + ).toBe(true); + expect( + rels.some((r) => r.startsWith(path.join('gitnexus-claude-plugin', 'skills') + path.sep)), + ).toBe(true); + expect( + rels.some((r) => r.startsWith(path.join('gitnexus-cursor-integration', 'skills') + path.sep)), + ).toBe(true); + }); + + it('routes EVERY cli skill subcommand through the project-local runner (#1945)', () => { + // The cli skill demonstrates every subcommand. Each must invoke the + // CLI-neutral runner `gitnexus analyze` drops next to the index — not a + // hardcoded package manager — so the docs make no pnpm/npx assumption. + // Checking each subcommand (not just `analyze`) guards against a regression + // where status/clean/wiki/list silently revert to `npx gitnexus `. + const cli = cliSkillFiles(files); + expect(cli.length).toBeGreaterThan(0); // guard is not vacuous + const SUBCOMMANDS = ['analyze', 'status', 'clean', 'wiki', 'list']; + const offenders: string[] = []; + for (const f of cli) { + const text = readFileSync(f, 'utf-8'); + for (const sub of SUBCOMMANDS) { + if (!new RegExp(`node\\s+\\.gitnexus/run\\.cjs\\s+${sub}\\b`).test(text)) { + offenders.push(`${path.relative(REPO_ROOT, f)}:${sub}`); + } + } + } + expect(offenders).toEqual([]); + }); + + it('documents the not-analyzed-yet / npm-11 bootstrap fallback in the cli skill (#1939)', () => { + // The runner only exists after the first analyze, so the cli skill must + // document the bootstrap path (and the npm-11 npx install crash escape + // hatch): the issue reference plus at least one fallback mechanism. + const cli = cliSkillFiles(files); + const offenders = cli.filter((f) => { + const text = readFileSync(f, 'utf-8'); + const refsIssue = /1939/.test(text); + const hasFallback = + /install -g gitnexus/.test(text) || /--allow-build.*dlx gitnexus/.test(text); + return !(refsIssue && hasFallback); + }); + expect(offenders.map((f) => path.relative(REPO_ROOT, f))).toEqual([]); + + // Positive vacuity guard: at least one cli skill must still carry the pnpm + // pre-`dlx` fallback form, so the npm-11 pnpm path can't silently vanish + // from every skill while the OR above is satisfied by `install -g` alone. + const withPnpmFallback = cli.filter((f) => + /--allow-build.*dlx gitnexus/.test(readFileSync(f, 'utf-8')), + ); + expect(withPnpmFallback.length).toBeGreaterThan(0); + }); + + it('routes every stale-index reanalyze hint through the runner, not a raw package manager', () => { + // Skills that tell the agent to reanalyze a stale index must point at the + // runner so the package-manager choice is resolved at call time. + const offenders = files.filter((f) => { + const text = readFileSync(f, 'utf-8'); + if (!/[Ss]tale/.test(text)) return false; // only skills with a reanalyze hint + return !/node\s+\.gitnexus\/run\.cjs\s+analyze/.test(text); + }); + expect(offenders.map((f) => path.relative(REPO_ROOT, f))).toEqual([]); + }); + + it('any pnpm fallback uses the pre-`dlx` --allow-build form, never the broken post-`dlx` position', () => { + // `pnpm dlx --allow-build=…` (flags after `dlx`) is parsed as a package spec + // and rejected on pnpm 10.2–10.13.x; the flags must precede `dlx` (#1939). + const postDlxOffenders = files.filter((f) => + /pnpm dlx --allow-build/.test(readFileSync(f, 'utf-8')), + ); + expect(postDlxOffenders.map((f) => path.relative(REPO_ROOT, f))).toEqual([]); + }); +}); diff --git a/gitnexus/test/utils/hook-test-helpers.ts b/gitnexus/test/utils/hook-test-helpers.ts index 3f519bc81e..0898252556 100644 --- a/gitnexus/test/utils/hook-test-helpers.ts +++ b/gitnexus/test/utils/hook-test-helpers.ts @@ -14,7 +14,7 @@ export function runHook( encoding: 'utf-8', timeout: 10000, cwd, - env: options.env, + env: options.env ? { ...process.env, ...options.env } : process.env, stdio: ['pipe', 'pipe', 'pipe'], }); return {