mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +00:00
* fix(cli): steer npm 11 users away from npx install crash (#1939) Prefer global gitnexus or pnpm dlx in hooks and generated AI context, warn when npm 11.x would use the broken npx path, and document workarounds for the arborist node.target null failure mode. Co-authored-by: Cursor <cursoragent@cursor.com> * test(hooks): stage resolve-analyze-cmd.cjs for antigravity adapter; harden load checks The antigravity adapter gained a top-level require('./resolve-analyze-cmd.cjs') but stageAdapter() did not copy it, so the spawned adapter crashed with MODULE_NOT_FOUND. Three load-sensitive tests failed; four silent-path tests false-passed on empty stdout. Stage the helper alongside the other sibling helpers, and assert status===0 and no MODULE_NOT_FOUND on the four silent-path tests so a non-loading hook can never pass green again. Force a deterministic invocation mode in the stale-index test so the emitted analyze command no longer varies by CI-runner PATH. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): standardize invocation hints on gitnexus@latest; single-source CJS helper NPX_REF becomes a literal `gitnexus@latest` in resolve-invocation.ts, dropping the package.json require and the module-load throw (a malformed/absent version can no longer crash any CLI command at import). The safety this PR delivers is the install method steered to (global / pnpm dlx), not a pinned gitnexus version, and the in-repo CJS mirror already degraded to `latest` once copied outside the package. Make the two resolve-analyze-cmd.cjs copies byte-identical and add a parity test that fails on drift. The separate, version-pinned NPX_REF that setup.ts writes into the MCP server registration is intentional and left unchanged. Co-authored-by: Cursor <cursoragent@cursor.com> * perf(cli): move npm-11 npx warning off module load; memoize invocation mode warnIfNpm11NpxRisk() ran at index.ts module load, so every CLI invocation (including the `gitnexus mcp` stdio hot path) paid which/where + npm --version spawns — against the lazy-startup/MCP-stdout discipline (#207, #1383). Move the call into analyzeCommand, after the ensureHeap() re-exec guard, so it fires once in the working process and only for `analyze`. Memoize the PATH-probe-derived invocation mode (the GITNEXUS_INVOCATION override stays uncached) so repeated callers don't re-probe, and add a test-only reset so the cache + once-only warning flag don't leak across the unit suite. Covers the mode!=='npx', npm<11, and npm-absent suppression branches. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): detect .exe/extensionless global gitnexus shims on Windows The winGitnexusWrapper branch only matched .cmd/.bat, so a global gitnexus installed by Volta or scoop (a .exe or an extensionless shim) was missed and the hint fell back to pnpm/npx. Accept .exe and treat any non-empty `where` hit as on-PATH (the emitted hint is `gitnexus analyze` regardless of which shim resolves it). Mirror the change into both resolve-analyze-cmd.cjs copies so the TS source and the byte-identical hook mirrors stay in sync. Add Windows-mocked test cases (.exe-only, extensionless, .cmd preference, CRLF stripping) and register resolve-invocation.test.ts in cross-platform-tests.ts so the windows-latest runner exercises the branch. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): emit fixed pnpm dlx analyze command in generated AGENTS.md/CLAUDE.md ai-context baked a machine-resolved command (formatAnalyzeCommand) into git-tracked AGENTS.md/CLAUDE.md, so the stale-index hint varied per machine and churned across branches (the #1706 class). Emit the fixed string `pnpm dlx gitnexus@latest analyze` instead: committed AI-context is the most authoritative instruction an agent reads, so it must name an install-free, crash-free method — never `npx`, the npm-11 path #1939 steers away from. formatAnalyzeCommand stays exported and unit-tested in resolve-invocation.ts (it still mirrors the two .cjs hook copies); ai-context just no longer calls it. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(cli): unify hook-helper copy into one non-silent routine installClaudeCodeHooks copied its four hook helpers in separate try/catch blocks that silently swallowed failures, while installAntigravityHooks recorded an error per failed copy. Extract one copyHookHelpers(srcDir, destDir, label, result) with a single canonical helper list (including resolve-analyze-cmd.cjs) and the antigravity loop's error-reporting policy, and use it from both paths so a missing helper surfaces as a setup error instead of a silent runtime crash. Assert both the Claude and Antigravity install paths co-locate resolve-analyze-cmd.cjs next to the adapter, and that a failed copy records an error rather than passing silently. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(cli): reattach installClaudeCodeHooks JSDoc after helper extraction The extracted HOOK_HELPERS/copyHookHelpers block landed between the installClaudeCodeHooks JSDoc and its function, leaving the doc reading as if it described the helper list. Move the block above the doc so it documents the function again. No behavior change. Co-authored-by: Cursor <cursoragent@cursor.com> * test(cli): enforce TS<->CJS invocation parity and guard CLI startup posture Tier-2 review found two in-scope gaps in the #1945 follow-up: - The "mirrors resolve-invocation.ts / test enforces parity" comments overclaimed: the parity test only compared the two .cjs copies to each other, so the TS source and the CJS hook copies could silently drift (NPX_REF, the per-mode command, and the Windows shim regex were hand-edited in all three this PR). Add TS<->CJS value parity (NPX_REF + formatAnalyzeCommand for every forced mode) and a source-level shim-regex parity check, and make the mirror comments accurately describe what is enforced. - No test locked the R3/R4 startup posture, so re-adding warnIfNpm11NpxRisk() (or any resolve-invocation import) at index.ts module scope -- the #207/#1383 lazy-startup regression -- would pass CI. Add a guard asserting index.ts has no module-load invocation probe and the warning is wired into analyzeCommand. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(cli): collapse npx-invocation resolver to one source of truth PR #1945 carried the gitnexus/pnpm/npx selection in three hand-synced places — the canonical hook helper, its byte-identical plugin copy, and a full TypeScript re-implementation in resolve-invocation.ts — kept in lockstep by per-mode-command and regex-extracted-by-regex parity tests. The TS formatAnalyzeCommand had no production caller (ai-context emits a fixed string), and the module memoized + exposed a test-only reset for a "repeated callers" case that has exactly one caller. Make hooks/claude/resolve-analyze-cmd.cjs the single source: extract the Windows-shim line-picking into a pure, exported pickPathMatch() and add an injectable probe to resolveInvocationMode() so the shipped logic is testable without spawning or global mocks. resolve-invocation.ts (118 -> 59 lines) now consumes that cjs via createRequire for resolveInvocationMode/NPX_REF and adds only the CLI-only npm-version probe and warning; the relative path resolves identically from src/cli/ (tsx, vitest) and dist/cli/ (shipped, hooks/ is a published sibling of dist/). Tests exercise the real shipped artifact, the NPX_REF/mode-command parity scaffolding is dropped (one implementation can't drift), and parity narrows to the two cjs copies staying byte-identical. No behavior change: hook stale-index hints and the analyze warning are byte-identical; the pre-existing setup.ts resolveGitnexusBin is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): bound stale-index hook PATH probe under the hook budget (U1) The PostToolUse stale-index hint calls formatAnalyzeCommand(), which probes which/where; named PROBE_TIMEOUT_MS=2000 keeps git rev-parse (~3s) + up to two probes well under Claude Code's 10s hook timeout while preserving the machine-correct hint. Byte-identical in the plugin copy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): steer generated cross-repo group commands off npx (#1939) (U2) The Cross-Repo Groups block in generated AGENTS.md/CLAUDE.md still emitted bare 'npx gitnexus group ...', funneling npm-11 users into the arborist crash; switch to fixed 'pnpm dlx gitnexus@latest group ...'. Export generateGitNexusContent and add a group-branch test asserting no 'npx gitnexus' literal survives. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: align steering guidance on pnpm dlx gitnexus@latest (U3) README troubleshooting uses gitnexus@latest; the repo's own committed CLAUDE.md/AGENTS.md stale-index hint now matches the generated output (pnpm dlx gitnexus@latest analyze) so the repo dogfoods the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(hooks): assert exact @latest analyze command and pin invocation mode (U4) Drop dead PKG_VERSION/NPX_REF version-pinned constants; the cjs always emits gitnexus@latest, so assert exact toContain(...) instead of the /@\\S+/ wildcard; pin GITNEXUS_INVOCATION in the --embeddings tests for host-independent determinism. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(cli): cover resolver warn/edge branches; document probe seam (U5) Add coverage for the gitnexus-mode warn suppression, getNpmMajorVersion edge inputs (empty/pre-release/non-numeric), and the Windows non-wrapper pickPathMatch branch; widen the InvocationResolver interface to document the optional probe param. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): lower hook PATH-probe timeout to 1000ms (U1) In a linked worktree the stale-index hook runs git rev-parse --git-common-dir (~2s) + rev-parse HEAD (~3s) before up to two PATH probes; PROBE_TIMEOUT_MS=1000 holds the worst case near ~7s under Claude Code's 10s hook budget (was 2000, ~1s headroom). Byte-identical in the plugin copy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): fail closed in gitnexus setup on missing required hook helper/adapter (U2) copyHookHelpers now returns the failed REQUIRED helpers (the .cjs trio; win-rm-list-json.ps1 stays best-effort since it fails open). Both install paths skip hook registration with an actionable error when a required helper failed; the Claude path also gains the adapter-existence guard the Antigravity path already had. Prevents registering a hook that crashes MODULE_NOT_FOUND on every tool event. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(skills): steer committed skill files off npx to pnpm dlx gitnexus@latest (U3) All 26 committed skill-file copies (gitnexus/skills, .claude, plugin, cursor) used 'npx gitnexus analyze', contradicting the generated freshness line and funneling npm-11 users into the arborist crash. Replace with 'pnpm dlx gitnexus@latest analyze'; add a regression guard (skills-steering.test.ts) that globs all four locations and fails if any reintroduces it. The cli skill's non-analyze npx subcommands (status/clean/list/wiki) are left as-is (out of the analyze-funnel scope). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): guard resolver import shape; assert group-impact steering (U4) Add a load-time guard on the createRequire(resolve-analyze-cmd.cjs) cast so a drifted/renamed cjs export fails loudly at module load instead of as a late TypeError in warnIfNpm11NpxRisk. Add the missing 'group impact' assertion to the ai-context Cross-Repo Groups test, and a resolver-contract test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): auto-select invocation path with pnpm --allow-build (#1939) Probe npm/pnpm versions and PATH to pick a working analyze command without user configuration: global gitnexus first, pnpm dlx with --allow-build on npm 11+ (Ladybug native scripts), npx on npm 10 and earlier. Update docs, skills, and tests to match the canonical install-free command. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): place pnpm --allow-build before dlx, repair version-injection seam (#1939) The auto-selected install command emitted `pnpm dlx --allow-build=… analyze`, but pnpm < 10.14 keeps `dlx` in its argv escape list, so flags placed *after* `dlx` are parsed as package specs and rejected (ERR_PNPM_SPEC_NOT_SUPPORTED) on pnpm 10.2–10.13.x — strictly worse than the bare command. Move the flags before `dlx` (the position pnpm has honored since 10.2.0) in both byte-identical hook copies, the committed AGENTS.md / CLAUDE.md, and every skill tree. Also repairs the CI-red resolveInvocationMode seam: injecting `{ npmMajor: null }` to simulate an absent npm fell through `??` to the host's real `npm --version` (npm 10.x on the CI runners → routed 'npx' instead of 'pnpm'). Use an `'npmMajor' in deps` sentinel so an injected null is honored, drop the dead parseMajorVersion guard, and gate the flags on pnpm >= 10.2 via a single minor-aware probeVersion spawn (skipped for committed docs). Align the TS getNpmMajorVersion timeout to the 1s hook budget and strengthen the skills-steering guard with a pre-dlx positive assertion plus a post-dlx regression check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: add npm-11 pnpm caveat to README Quick Starts (#1939) The root, package, and cursor-integration README Quick Starts still steered first-contact users to bare `npx gitnexus analyze` — the exact npm 11.x arborist install crash issue #1939 names as a funnel. Add a one-line pnpm `--allow-build … dlx` caveat (keeping the simple npx default for npm<=10 / pnpm / yarn users); the package README points to its existing npm-11 workaround section. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(skills): route every gitnexus-cli command off npx to pnpm dlx (#1939) The gitnexus-cli skill demonstrated analyze via `pnpm --allow-build … dlx` but still showed status/clean/wiki/list via bare `npx gitnexus` — the same package, the same npm-11 crash-prone install path — and its header claimed "all commands work via npx". Convert every subcommand to the pnpm form across all three skill copies and reconcile the header. Broaden the skills-steering guard to forbid any `npx gitnexus` command in the cli-skill copies. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(hook): probe pnpm once on the stale-index path (#1939) The stale-index hook resolved pnpm twice — `which pnpm` for mode selection then `pnpm --version` for the allow-build gate — two spawns for one tool in a ~9s/10s budget. Capture the version once in formatAnalyzeCommand and thread it through the existing deps seam (a successful `pnpm --version` proves presence), sharing a memoized PATH probe with resolveInvocationMode. Add explicit pnpm 10.0-suppress / 10.2-emit boundary tests and relabel the unknown-minor case. Both byte-identical cjs copies updated together. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(setup): single-quote POSIX hook command + assert cliPath patch applied (#1939) The hook `command` written into editor settings is shell-evaluated; the double-quoted `node "<path>"` form left `$`, backtick, and other metacharacters live in an adversarial $HOME. Single-quote the path on POSIX (Windows keeps the double-quoted form — those chars are illegal in Windows filenames). Also assert the cliPath source-literal replace() actually matched, recording an actionable error on drift instead of silently shipping a hook with an unresolved relative path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(setup): normalize expected hook path for the Windows runner (#1939) The new POSIX-escaping test built its expected hook path with path.join, which emits backslashes on the Windows runner, while setup.ts forward-slash- normalizes the path before quoting — so `expect(cmd).toBe(node '<path>')` mismatched on tests/windows-latest. Normalize the expected path the same way. Production code was already correct; only the test's expected value was platform-fragile. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): steer docs/skills via a project-local runner, not a pnpm default (#1939) The prior approach hardcoded `pnpm --allow-build=… dlx gitnexus@latest <cmd>` into every committed skill + the generated AGENTS.md/CLAUDE.md, which assumes pnpm is installed. Replace it with a CLI-neutral project-local runner: - `gitnexus analyze` drops `.gitnexus/run.cjs` (a copy of the canonical `resolve-analyze-cmd.cjs`, which gains `buildRunnerArgv` + a `require.main` exec tail) next to the index. Docs/skills reference `node .gitnexus/run.cjs <cmd>`, which auto-selects the runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time — no package-manager assumption. README first-run + an inline bootstrap note stay universal `npx gitnexus analyze`. - The exec tail uses `shell` on Windows so `.cmd`/`.ps1`/`.exe` shims resolve (execFileSync can't otherwise; Node blocks `.cmd` without a shell, CVE-2024-27980), and prints a diagnostic instead of a silent exit 1. Tests: runner exec-tail (real spawn, exit-code propagation + ENOENT diagnostic), copy-failure graceful degradation, and per-subcommand routing + pnpm-fallback vacuity guards. The generated CLAUDE.md block stays under the #856 token budget. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): resolve Windows .cmd version probes so pnpm steering fires (#1939) probeVersion (and the TS getNpmMajorVersion mirror) spawned npm/pnpm --version via execFileSync with no shell, so on Windows the .cmd shims ENOENT'd, the probe reported a present tool as absent, and the stale-index hook recommended the npx crash path #1939 exists to avoid. Add shell: process.platform === 'win32' to the version probes (the exec tail already does this). Parse the first version-shaped line so a Corepack/notice banner on stdout no longer defeats the parse. Carry pnpm presence separately from version so a present-but-unparseable pnpm still selects pnpm. Drop the dead probe ?? resolveOnPath coalesce. Cover resolve-analyze-cmd.cjs (+ plugin twin) with the shell-injection and windowsHide source-regression guards. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): widen pnpm allow-build for the --embeddings=N equals form (#1945) buildRunnerArgv detected embeddings via gitnexusArgs.includes('--embeddings'), which missed the equals form (--embeddings=5000) that Commander also accepts, dropping --allow-build=onnxruntime-node on pnpm 10.2+. Match both forms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(cli): cover the runner exec-tail Windows shell branch on CI (#1945) runner-exec-tail.test.ts was POSIX-only and unregistered in cross-platform-tests.ts, so the run.cjs Windows shell:true exec branch ran on no platform despite the file comment claiming windows-latest covered it. Add a .cmd-shim it.skipIf(onPosix) case and register the file in SPAWN_CLI so the windows-latest job runs it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: fix broken troubleshooting anchor in gitnexus README (#1945) The npm-11 quick-start note linked to #npx-gitnexus-crashes-with-nodetarget-is-null-npm-11, which matches no heading; the actual troubleshooting heading slugifies to #cannot-destructure-property-package-of-nodetarget-as-it-is-null. Repoint the link. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(hooks): guard resolve-analyze-cmd.cjs in antigravity e2e sanity check (#1945) The antigravity adapter top-level require()s resolve-analyze-cmd.cjs, but the beforeAll helper-presence loop did not check for it — a failed copy would surface as noisy MODULE_NOT_FOUND in downstream tests instead of the intended actionable 'Helper not installed' error. Add it to the loop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(skills): tie a missing-runner Cannot-find-module error to recovery (#1945) Generated CLAUDE.md/AGENTS.md make `node .gitnexus/run.cjs` the primary command, but the runner is gitignored, so a fresh clone or git clean leaves an agent facing a raw MODULE_NOT_FOUND. The CLAUDE.md block is token-budget-capped (#856), so the recovery guidance lives in the cli skill (its documented home): the bootstrap note now names the `Cannot find module` error and points at `npx gitnexus analyze` to (re)generate the runner. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(cli): disambiguate the MCP-pinned ref from the @latest hint (#1945) setup.ts and resolve-analyze-cmd.cjs both exported a constant named NPX_REF with different values (version-pinned for the persisted MCP entry vs. gitnexus@latest for hints). Rename setup.ts's module-private constant to MCP_PINNED_REF (value and behavior unchanged — the MCP pin stays pinned), leaving the cjs hint ref and its re-export alone. Also route the createRequire cast through 'unknown' so it reads as an explicit narrowing to the subset this module uses rather than a claim about the cjs's full export shape. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
879 lines
38 KiB
TypeScript
879 lines
38 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { generateAIContextFiles, generateGitNexusContent } from '../../src/cli/ai-context.js';
|
|
|
|
describe('generateAIContextFiles', () => {
|
|
let tmpDir: string;
|
|
let storagePath: string;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-test-'));
|
|
storagePath = path.join(tmpDir, '.gitnexus');
|
|
await fs.mkdir(storagePath, { recursive: true });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
try {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
});
|
|
|
|
it('generates context files', async () => {
|
|
const stats = {
|
|
nodes: 100,
|
|
edges: 200,
|
|
processes: 10,
|
|
};
|
|
|
|
const result = await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
|
|
expect(result.files).toBeDefined();
|
|
expect(result.files.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('creates or updates CLAUDE.md with GitNexus section', async () => {
|
|
const stats = { nodes: 50, edges: 100, processes: 5 };
|
|
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
|
|
|
|
const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
|
|
const content = await fs.readFile(claudeMdPath, 'utf-8');
|
|
expect(content).toContain('gitnexus:start');
|
|
expect(content).toContain('gitnexus:end');
|
|
expect(content).toContain('TestProject');
|
|
});
|
|
|
|
it('omits volatile counts when noStats option is set (#1477)', async () => {
|
|
// Distinct subdir per case so we can assert on a clean slate.
|
|
const subDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-no-stats-test-'));
|
|
const subStorage = path.join(subDir, '.gitnexus');
|
|
await fs.mkdir(subStorage, { recursive: true });
|
|
try {
|
|
// Stats values picked to be unmistakable if they leak through.
|
|
const stats = { nodes: 12345, edges: 67890, processes: 99 };
|
|
await generateAIContextFiles(subDir, subStorage, 'NoStatsProject', stats, undefined, {
|
|
noStats: true,
|
|
});
|
|
|
|
for (const f of ['CLAUDE.md', 'AGENTS.md']) {
|
|
const content = await fs.readFile(path.join(subDir, f), 'utf-8');
|
|
expect(content).toContain('NoStatsProject');
|
|
// The "(N symbols, N relationships, N execution flows)"
|
|
// phrase MUST NOT appear when noStats=true.
|
|
expect(content).not.toMatch(
|
|
/\(\d+\s+symbols,\s+\d+\s+relationships,\s+\d+\s+execution flows\)/,
|
|
);
|
|
// And the distinctive numbers must not leak via any other path.
|
|
expect(content).not.toContain('12345');
|
|
expect(content).not.toContain('67890');
|
|
}
|
|
} finally {
|
|
await fs.rm(subDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('preserves volatile counts when noStats is not set (default)', async () => {
|
|
const subDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-with-stats-test-'));
|
|
const subStorage = path.join(subDir, '.gitnexus');
|
|
await fs.mkdir(subStorage, { recursive: true });
|
|
try {
|
|
const stats = { nodes: 12345, edges: 67890, processes: 99 };
|
|
await generateAIContextFiles(subDir, subStorage, 'WithStatsProject', stats);
|
|
for (const f of ['CLAUDE.md', 'AGENTS.md']) {
|
|
const content = await fs.readFile(path.join(subDir, f), 'utf-8');
|
|
expect(content).toContain('WithStatsProject');
|
|
expect(content).toMatch(
|
|
/\(12345\s+symbols,\s+67890\s+relationships,\s+99\s+execution flows\)/,
|
|
);
|
|
}
|
|
} finally {
|
|
await fs.rm(subDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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,
|
|
// the Always Do / Never Do imperative lists, the Resources URI table
|
|
// (projectName-interpolated), and the skills routing table that tells
|
|
// the agent which skill file to read for each task.
|
|
const stats = { nodes: 50, edges: 100, processes: 5 };
|
|
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
|
|
|
|
const content = await fs.readFile(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
|
|
|
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');
|
|
expect(content).toContain('gitnexus://repo/TestProject/context');
|
|
expect(content).toContain('gitnexus-impact-analysis/SKILL.md');
|
|
expect(content).toContain('gitnexus-refactoring/SKILL.md');
|
|
expect(content).toContain('gitnexus-debugging/SKILL.md');
|
|
expect(content).toContain('gitnexus-cli/SKILL.md');
|
|
});
|
|
|
|
it('does not duplicate content that already lives in skill files (#856)', async () => {
|
|
// The six sections listed in issue #856 are redundant with the skill
|
|
// files shipped alongside the CLAUDE.md block (both are loaded into
|
|
// every Claude Code session). Their absence is the whole point of the
|
|
// trim — assert each header is gone so a future regression that pads
|
|
// the block back out fails here.
|
|
const stats = { nodes: 50, edges: 100, processes: 5 };
|
|
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
|
|
|
|
const content = await fs.readFile(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
|
|
|
expect(content).not.toContain('## Tools Quick Reference');
|
|
expect(content).not.toContain('## Impact Risk Levels');
|
|
expect(content).not.toContain('## Self-Check Before Finishing');
|
|
expect(content).not.toContain('## When Debugging');
|
|
expect(content).not.toContain('## When Refactoring');
|
|
expect(content).not.toContain('## Keeping the Index Fresh');
|
|
});
|
|
|
|
it('keeps the CLAUDE.md GitNexus block under the token-cost budget (#856)', async () => {
|
|
// The pre-trim block was ~5465 chars. After #856 it's ~2580 — about a
|
|
// 52% reduction. 2700 is a soft ceiling that still leaves headroom for
|
|
// legitimate future additions but will fail loudly if the trim is
|
|
// reverted or someone pads the block back out toward the original size.
|
|
const stats = { nodes: 50, edges: 100, processes: 5 };
|
|
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
|
|
|
|
const content = await fs.readFile(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
|
const block = content.slice(
|
|
content.indexOf('<!-- gitnexus:start -->'),
|
|
content.indexOf('<!-- gitnexus:end -->'),
|
|
);
|
|
expect(block.length).toBeLessThan(2700);
|
|
});
|
|
|
|
it('handles empty stats', async () => {
|
|
const stats = {};
|
|
const result = await generateAIContextFiles(tmpDir, storagePath, 'EmptyProject', stats);
|
|
expect(result.files).toBeDefined();
|
|
});
|
|
|
|
it('updates existing CLAUDE.md without duplicating', async () => {
|
|
const stats = { nodes: 10 };
|
|
|
|
// Run twice
|
|
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
|
|
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
|
|
|
|
const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
|
|
const content = await fs.readFile(claudeMdPath, 'utf-8');
|
|
|
|
// Should only have one gitnexus section
|
|
const starts = (content.match(/gitnexus:start/g) || []).length;
|
|
expect(starts).toBe(1);
|
|
});
|
|
|
|
it('preserves custom section when gitnexus:keep is present', async () => {
|
|
const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
|
|
|
|
// Write a custom lean section with keep marker
|
|
const customContent = `# My Project
|
|
|
|
Some project docs here.
|
|
|
|
<!-- gitnexus:start -->
|
|
<!-- gitnexus:keep -->
|
|
# GitNexus — Code Knowledge Graph
|
|
|
|
Indexed as **TestProject** (50 symbols, 100 relationships, 5 execution flows). MCP tools.
|
|
|
|
| Tool | Use for |
|
|
|------|---------|
|
|
| query | Find flows |
|
|
|
|
Resources: gitnexus://repo/TestProject/context
|
|
<!-- gitnexus:end -->
|
|
`;
|
|
await fs.writeFile(claudeMdPath, customContent, 'utf-8');
|
|
|
|
// Run analyze with new stats — should only update the stats line
|
|
const stats = { nodes: 999, edges: 1234, processes: 42 };
|
|
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
|
|
|
|
const result = await fs.readFile(claudeMdPath, 'utf-8');
|
|
|
|
// Stats should be updated
|
|
expect(result).toContain('999 symbols');
|
|
expect(result).toContain('1234 relationships');
|
|
expect(result).toContain('42 execution flows');
|
|
expect(result).toContain('. MCP tools.');
|
|
|
|
// Custom layout should be preserved (not replaced with verbose template)
|
|
expect(result).toContain('<!-- gitnexus:keep -->');
|
|
expect(result).toContain('Code Knowledge Graph');
|
|
expect(result).toContain('| query | Find flows |');
|
|
|
|
// Verbose template sections should NOT be present
|
|
expect(result).not.toContain('## Always Do');
|
|
expect(result).not.toContain('## Never Do');
|
|
expect(result).not.toContain('## When Debugging');
|
|
|
|
// Non-GitNexus content should be preserved
|
|
expect(result).toContain('# My Project');
|
|
expect(result).toContain('Some project docs here.');
|
|
});
|
|
|
|
it('replaces section when no keep marker is present', async () => {
|
|
const agentsPath = path.join(tmpDir, 'AGENTS.md');
|
|
|
|
// Write a section WITHOUT keep marker
|
|
const content = `<!-- gitnexus:start -->
|
|
# GitNexus — Code Intelligence
|
|
|
|
Old content here.
|
|
<!-- gitnexus:end -->
|
|
`;
|
|
await fs.writeFile(agentsPath, content, 'utf-8');
|
|
|
|
const stats = { nodes: 100, edges: 200, processes: 10 };
|
|
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
|
|
|
|
const result = await fs.readFile(agentsPath, 'utf-8');
|
|
|
|
// Should have the full verbose template
|
|
expect(result).toContain('## Always Do');
|
|
expect(result).not.toContain('Old content here');
|
|
});
|
|
|
|
it('installs skills files', async () => {
|
|
const stats = { nodes: 10 };
|
|
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
|
|
|
|
// Should have installed skill files
|
|
const skillsDir = path.join(tmpDir, '.claude', 'skills', 'gitnexus');
|
|
try {
|
|
const entries = await fs.readdir(skillsDir, { recursive: true });
|
|
expect(entries.length).toBeGreaterThan(0);
|
|
} catch {
|
|
// Skills dir may not be created if skills source doesn't exist in test context
|
|
}
|
|
});
|
|
|
|
it('does not create .claude/skills/gitnexus/ when skipSkills is true (#742)', async () => {
|
|
// Regression guard for #742. The --skip-skills flag must prevent
|
|
// installSkills() from writing the 6 standard skill dirs into the
|
|
// analyzed repo. Per-test tmpdir so we start from a known-clean
|
|
// slate — the shared tmpDir from beforeAll may already contain
|
|
// .claude/skills/gitnexus/ from an earlier test.
|
|
const skipDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-skip-skills-'));
|
|
const skipStorage = path.join(skipDir, '.gitnexus');
|
|
await fs.mkdir(skipStorage, { recursive: true });
|
|
try {
|
|
const stats = { nodes: 50, edges: 100, processes: 5 };
|
|
const result = await generateAIContextFiles(
|
|
skipDir,
|
|
skipStorage,
|
|
'TestProject',
|
|
stats,
|
|
undefined,
|
|
{ skipSkills: true },
|
|
);
|
|
|
|
expect(result.files).toContain('.claude/skills/gitnexus/ (skipped via --skip-skills)');
|
|
await expect(
|
|
fs.access(path.join(skipDir, '.claude', 'skills', 'gitnexus')),
|
|
).rejects.toThrow();
|
|
} finally {
|
|
await fs.rm(skipDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('writes nothing when both skipAgentsMd and skipSkills are true (--index-only, #742)', async () => {
|
|
// Regression guard for #742. analyzeCommand() resolves --index-only
|
|
// into BOTH skipAgentsMd=true and skipSkills=true. This test pins
|
|
// the resolved-flag combination so a future regression that drops
|
|
// either guard fails here. Per-test tmpdir for the same reason as
|
|
// the skipSkills test above.
|
|
const idxDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-index-only-'));
|
|
const idxStorage = path.join(idxDir, '.gitnexus');
|
|
await fs.mkdir(idxStorage, { recursive: true });
|
|
try {
|
|
const stats = { nodes: 50, edges: 100, processes: 5 };
|
|
const result = await generateAIContextFiles(
|
|
idxDir,
|
|
idxStorage,
|
|
'TestProject',
|
|
stats,
|
|
undefined,
|
|
{ skipAgentsMd: true, skipSkills: true },
|
|
);
|
|
|
|
expect(result.files).toContain('AGENTS.md (skipped via --skip-agents-md)');
|
|
expect(result.files).toContain('CLAUDE.md (skipped via --skip-agents-md)');
|
|
expect(result.files).toContain('.claude/skills/gitnexus/ (skipped via --skip-skills)');
|
|
|
|
await expect(fs.access(path.join(idxDir, 'AGENTS.md'))).rejects.toThrow();
|
|
await expect(fs.access(path.join(idxDir, 'CLAUDE.md'))).rejects.toThrow();
|
|
await expect(fs.access(path.join(idxDir, '.claude', 'skills', 'gitnexus'))).rejects.toThrow();
|
|
} finally {
|
|
await fs.rm(idxDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('omits standard skill references from AGENTS.md/CLAUDE.md when skipSkills is true (#742)', async () => {
|
|
// The skills routing table in AGENTS.md/CLAUDE.md points agents at
|
|
// .claude/skills/gitnexus/*/SKILL.md files installed by installSkills().
|
|
// When --skip-skills suppresses that install but AGENTS.md/CLAUDE.md
|
|
// are still written, the routing table must NOT name files that don't
|
|
// exist — otherwise every agent load incurs 6 failed reads and the
|
|
// routing instructions are worthless. Per-test tmpdir so the assertions
|
|
// are not contaminated by a CLAUDE.md from an earlier test.
|
|
const noStdDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-no-std-skills-'));
|
|
const noStdStorage = path.join(noStdDir, '.gitnexus');
|
|
await fs.mkdir(noStdStorage, { recursive: true });
|
|
try {
|
|
const stats = { nodes: 50, edges: 100, processes: 5 };
|
|
await generateAIContextFiles(noStdDir, noStdStorage, 'TestProject', stats, undefined, {
|
|
skipSkills: true,
|
|
});
|
|
|
|
const content = await fs.readFile(path.join(noStdDir, 'CLAUDE.md'), 'utf-8');
|
|
expect(content).not.toContain('gitnexus-exploring/SKILL.md');
|
|
expect(content).not.toContain('gitnexus-impact-analysis/SKILL.md');
|
|
expect(content).not.toContain('gitnexus-debugging/SKILL.md');
|
|
expect(content).not.toContain('gitnexus-refactoring/SKILL.md');
|
|
expect(content).not.toContain('gitnexus-guide/SKILL.md');
|
|
expect(content).not.toContain('gitnexus-cli/SKILL.md');
|
|
// The load-bearing imperative sections must still ship — only the
|
|
// routing rows are conditional.
|
|
expect(content).toContain('## Always Do');
|
|
expect(content).toContain('## Never Do');
|
|
expect(content).toContain('gitnexus://repo/TestProject/context');
|
|
} finally {
|
|
await fs.rm(noStdDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('preserves manual AGENTS.md and CLAUDE.md edits when skipAgentsMd is enabled', async () => {
|
|
const stats = { nodes: 42, edges: 84, processes: 3 };
|
|
const agentsPath = path.join(tmpDir, 'AGENTS.md');
|
|
const claudePath = path.join(tmpDir, 'CLAUDE.md');
|
|
const agentsContent = '# AGENTS\n\nCustom manual instructions only\n';
|
|
const claudeContent = '# CLAUDE\n\nCustom manual instructions only\n';
|
|
|
|
await fs.writeFile(agentsPath, agentsContent, 'utf-8');
|
|
await fs.writeFile(claudePath, claudeContent, 'utf-8');
|
|
|
|
const result = await generateAIContextFiles(
|
|
tmpDir,
|
|
storagePath,
|
|
'TestProject',
|
|
stats,
|
|
undefined,
|
|
{ skipAgentsMd: true },
|
|
);
|
|
|
|
expect(result.files).toContain('AGENTS.md (skipped via --skip-agents-md)');
|
|
expect(result.files).toContain('CLAUDE.md (skipped via --skip-agents-md)');
|
|
|
|
const agentsAfter = await fs.readFile(agentsPath, 'utf-8');
|
|
const claudeAfter = await fs.readFile(claudePath, 'utf-8');
|
|
expect(agentsAfter).toBe(agentsContent);
|
|
expect(claudeAfter).toBe(claudeContent);
|
|
});
|
|
|
|
it('preserves inline marker references in prose and does not corrupt markdown (#1041)', async () => {
|
|
// Regression guard for #1041. The shipped CLAUDE.md ships with a
|
|
// prose paragraph referencing the marker pair inline — wrapped in a
|
|
// backtick-quoted fragment mid-sentence. `indexOf` (the pre-fix
|
|
// matcher) would match both of those inline markers and replace the
|
|
// content between them with the full injected block, destroying the
|
|
// sentence and leaving the backtick unclosed.
|
|
//
|
|
// Per-test tmpdir so we start from a known clean slate — the shared
|
|
// `tmpDir` from beforeAll may already contain CLAUDE.md from earlier
|
|
// tests in this describe block.
|
|
const bugDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-1041-'));
|
|
const bugStorage = path.join(bugDir, '.gitnexus');
|
|
await fs.mkdir(bugStorage, { recursive: true });
|
|
|
|
const inlineProseLine =
|
|
'See the `<!-- gitnexus:start --> … <!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** for the canonical MCP tools, impact analysis rules, and index instructions.';
|
|
const originalContent = `# Claude Code Rules\n\nLast reviewed: 2026-04-21\n\n## GitNexus rules\n\n${inlineProseLine}\n`;
|
|
|
|
const claudeMd = path.join(bugDir, 'CLAUDE.md');
|
|
await fs.writeFile(claudeMd, originalContent, 'utf-8');
|
|
|
|
try {
|
|
const stats = { nodes: 50, edges: 100, processes: 5 };
|
|
|
|
// First run — no section-position markers exist yet, so the
|
|
// injector must append a fresh section at end. The inline prose
|
|
// must be preserved verbatim; if it disappears or gets altered,
|
|
// the bug has recurred.
|
|
await generateAIContextFiles(bugDir, bugStorage, 'TestProject', stats);
|
|
let contentAfter = await fs.readFile(claudeMd, 'utf-8');
|
|
|
|
expect(contentAfter, 'inline prose line must survive the first run verbatim').toContain(
|
|
inlineProseLine,
|
|
);
|
|
// Exactly 2 start markers total: 1 inline (in prose) + 1
|
|
// section-position (appended by the injector). The pre-fix
|
|
// behaviour would have only 1 — the inline pair having been
|
|
// consumed as if they were section delimiters.
|
|
expect((contentAfter.match(/<!-- gitnexus:start -->/g) || []).length).toBe(2);
|
|
expect((contentAfter.match(/<!-- gitnexus:end -->/g) || []).length).toBe(2);
|
|
|
|
// Second run — the section from run 1 is now at section position,
|
|
// so the injector must UPDATE in place (not re-append). Inline
|
|
// prose stays preserved; marker counts unchanged.
|
|
await generateAIContextFiles(bugDir, bugStorage, 'TestProject', stats);
|
|
contentAfter = await fs.readFile(claudeMd, 'utf-8');
|
|
|
|
expect(contentAfter, 'inline prose line must survive the second run verbatim').toContain(
|
|
inlineProseLine,
|
|
);
|
|
expect((contentAfter.match(/<!-- gitnexus:start -->/g) || []).length).toBe(2);
|
|
expect((contentAfter.match(/<!-- gitnexus:end -->/g) || []).length).toBe(2);
|
|
} finally {
|
|
await fs.rm(bugDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('matches section markers on files with CRLF line endings (#1041 cross-platform)', async () => {
|
|
// Locks in the CRLF leg of the section-position matcher. Git on
|
|
// Windows may store files with `\r\n` line endings depending on
|
|
// `core.autocrlf`; when a section line ends `<!-- gitnexus:start
|
|
// -->\r\n`, the byte at `endPos` is `\r` (not `\n`). A `\n`-only
|
|
// line-end check would reject the real section, fall through to
|
|
// "append", and duplicate the block every run.
|
|
const crlfDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-crlf-'));
|
|
const crlfStorage = path.join(crlfDir, '.gitnexus');
|
|
await fs.mkdir(crlfStorage, { recursive: true });
|
|
|
|
// Inline reference carries BOTH markers in a backtick-quoted
|
|
// fragment — matches the shape of the shipped CLAUDE.md line
|
|
// that triggered #1041 so the regression guard is meaningful.
|
|
const inlineProseLine =
|
|
'See the `<!-- gitnexus:start --> … <!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** for more.';
|
|
const seeded = [
|
|
'# Claude Code Rules',
|
|
'',
|
|
'## GitNexus rules',
|
|
'',
|
|
inlineProseLine,
|
|
'',
|
|
'<!-- gitnexus:start -->',
|
|
'# GitNexus — Code Intelligence (stale stub)',
|
|
'<!-- gitnexus:end -->',
|
|
'',
|
|
].join('\r\n');
|
|
|
|
const claudeMd = path.join(crlfDir, 'CLAUDE.md');
|
|
await fs.writeFile(claudeMd, seeded, 'utf-8');
|
|
|
|
try {
|
|
const stats = { nodes: 50, edges: 100, processes: 5 };
|
|
await generateAIContextFiles(crlfDir, crlfStorage, 'TestProject', stats);
|
|
const content = await fs.readFile(claudeMd, 'utf-8');
|
|
|
|
// Inline prose survives verbatim — no corruption of CRLF bytes.
|
|
expect(content).toContain(inlineProseLine);
|
|
// Exactly 2 start markers total (1 inline + 1 section-position).
|
|
// If CRLF handling broke, the inline marker would be (incorrectly)
|
|
// matched as a section start, OR the real section would be
|
|
// appended duplicated — either way we'd see !== 2.
|
|
expect((content.match(/<!-- gitnexus:start -->/g) || []).length).toBe(2);
|
|
expect((content.match(/<!-- gitnexus:end -->/g) || []).length).toBe(2);
|
|
// Stale stub content must be gone — proves the section was
|
|
// REPLACED (not appended as a duplicate), which requires the
|
|
// CRLF-ending markers to have been matched.
|
|
expect(content).not.toContain('# GitNexus — Code Intelligence (stale stub)');
|
|
} finally {
|
|
await fs.rm(crlfDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Keep-marker edge cases (added to address PR #1508 review findings)
|
|
// ──────────────────────────────────────────────────────────────────
|
|
|
|
it('keep marker OUTSIDE the GitNexus section has no effect (#1508 review F5)', async () => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-scope-'));
|
|
try {
|
|
const claudePath = path.join(dir, 'CLAUDE.md');
|
|
// Keep marker appears in user prose BEFORE the GitNexus section.
|
|
// The keep-path must NOT be triggered — full template replacement
|
|
// is the correct behavior here, because the marker is not inside
|
|
// the generated block.
|
|
const fileWithOutOfBandMarker = `# My Project
|
|
|
|
A note about <!-- gitnexus:keep --> markers: they only apply inside the
|
|
GitNexus block below, not in prose like this.
|
|
|
|
<!-- gitnexus:start -->
|
|
Old verbose stub here.
|
|
<!-- gitnexus:end -->
|
|
`;
|
|
await fs.writeFile(claudePath, fileWithOutOfBandMarker, 'utf-8');
|
|
|
|
const stats = { nodes: 50, edges: 100, processes: 5 };
|
|
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'TestProject', stats);
|
|
|
|
const result = await fs.readFile(claudePath, 'utf-8');
|
|
// Section MUST have been fully replaced — keep marker outside section ignored
|
|
expect(result).toContain('## Always Do');
|
|
expect(result).not.toContain('Old verbose stub here.');
|
|
// User's prose with the marker reference is preserved untouched
|
|
expect(result).toContain('A note about <!-- gitnexus:keep --> markers');
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('AGENTS.md keep path preserves custom layout (#1508 review F5)', async () => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-agents-'));
|
|
try {
|
|
const agentsPath = path.join(dir, 'AGENTS.md');
|
|
const customAgents = `# AGENTS instructions
|
|
|
|
Project-specific agent guidance.
|
|
|
|
<!-- gitnexus:start -->
|
|
<!-- gitnexus:keep -->
|
|
# GitNexus context for AGENTS
|
|
|
|
Indexed as **AgentsTest** (10 symbols, 20 relationships, 1 execution flows).
|
|
|
|
Use 'query' for finding flows, 'context' for symbol details.
|
|
<!-- gitnexus:end -->
|
|
`;
|
|
await fs.writeFile(agentsPath, customAgents, 'utf-8');
|
|
|
|
const stats = { nodes: 777, edges: 888, processes: 9 };
|
|
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'AgentsTest', stats);
|
|
|
|
const result = await fs.readFile(agentsPath, 'utf-8');
|
|
// Stats updated
|
|
expect(result).toContain('777 symbols');
|
|
expect(result).toContain('888 relationships');
|
|
expect(result).toContain('9 execution flows');
|
|
// Custom layout preserved
|
|
expect(result).toContain('# GitNexus context for AGENTS');
|
|
expect(result).toContain("Use 'query' for finding flows");
|
|
// Verbose template NOT injected
|
|
expect(result).not.toContain('## Always Do');
|
|
// Non-GitNexus content preserved
|
|
expect(result).toContain('# AGENTS instructions');
|
|
expect(result).toContain('Project-specific agent guidance.');
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('idempotent: second run with keep marker produces byte-identical output (#1508 review F5)', async () => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-idem-'));
|
|
try {
|
|
const claudePath = path.join(dir, 'CLAUDE.md');
|
|
const seed = `# Project
|
|
|
|
<!-- gitnexus:start -->
|
|
<!-- gitnexus:keep -->
|
|
Indexed as **Idem** (1 symbols, 2 relationships, 3 execution flows). Custom.
|
|
<!-- gitnexus:end -->
|
|
`;
|
|
await fs.writeFile(claudePath, seed, 'utf-8');
|
|
|
|
const stats = { nodes: 99, edges: 100, processes: 7 };
|
|
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'Idem', stats);
|
|
const afterFirst = await fs.readFile(claudePath, 'utf-8');
|
|
|
|
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'Idem', stats);
|
|
const afterSecond = await fs.readFile(claudePath, 'utf-8');
|
|
|
|
expect(afterSecond).toBe(afterFirst);
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('CRLF file with keep marker: stats line updates without corrupting content (#1508 review F5)', async () => {
|
|
// upsertGitNexusSection writes with .trim() + '\n', so the saved file uses LF
|
|
// line endings throughout — CRLF in the seed input is not preserved.
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-crlf-'));
|
|
try {
|
|
const claudePath = path.join(dir, 'CLAUDE.md');
|
|
const crlfContent =
|
|
'# Project\r\n' +
|
|
'\r\n' +
|
|
'<!-- gitnexus:start -->\r\n' +
|
|
'<!-- gitnexus:keep -->\r\n' +
|
|
'Indexed as **CRLFTest** (5 symbols, 6 relationships, 7 execution flows). Custom CRLF.\r\n' +
|
|
'<!-- gitnexus:end -->\r\n';
|
|
await fs.writeFile(claudePath, crlfContent, 'utf-8');
|
|
|
|
const stats = { nodes: 50, edges: 60, processes: 7 };
|
|
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'CRLFTest', stats);
|
|
|
|
const result = await fs.readFile(claudePath, 'utf-8');
|
|
// Stats updated correctly
|
|
expect(result).toContain('50 symbols');
|
|
expect(result).toContain('60 relationships');
|
|
// Custom prose preserved
|
|
expect(result).toContain('Custom CRLF');
|
|
// No verbose template injected
|
|
expect(result).not.toContain('## Always Do');
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('noStats + keep marker: stats line drops the volatile counts (#1706)', async () => {
|
|
// #1706: --no-stats must win in the keep-marker path too. A lean block
|
|
// committed to git would otherwise churn the parenthetical counts on
|
|
// every analyze, producing no-value merge conflicts between branches.
|
|
// The parenthetical is stripped; the project name still refreshes.
|
|
//
|
|
// Also a regression guard (#1508 review F3): the rewritten stats line
|
|
// MUST NOT pick up the `({target: "symbolName", direction: "upstream"})`
|
|
// tuple from the Always Do bullet.
|
|
//
|
|
// Asserted for BOTH AGENTS.md and CLAUDE.md: generateAIContextFiles
|
|
// updates them through separate upsertGitNexusSection call sites, so the
|
|
// parity check guards against a future asymmetry between the two.
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-nostats-'));
|
|
try {
|
|
const seed = `<!-- gitnexus:start -->
|
|
<!-- gitnexus:keep -->
|
|
Indexed as **NoStatsTest** (1 symbols, 1 relationships, 1 execution flows). Custom.
|
|
<!-- gitnexus:end -->
|
|
`;
|
|
await fs.writeFile(path.join(dir, 'CLAUDE.md'), seed, 'utf-8');
|
|
await fs.writeFile(path.join(dir, 'AGENTS.md'), seed, 'utf-8');
|
|
|
|
const stats = { nodes: 42, edges: 84, processes: 3 };
|
|
await generateAIContextFiles(
|
|
dir,
|
|
path.join(dir, '.gitnexus'),
|
|
'NoStatsTest',
|
|
stats,
|
|
undefined,
|
|
{
|
|
noStats: true,
|
|
},
|
|
);
|
|
|
|
for (const f of ['CLAUDE.md', 'AGENTS.md']) {
|
|
const result = await fs.readFile(path.join(dir, f), 'utf-8');
|
|
// Stats line MUST NOT have been corrupted with the Always-Do tuple text
|
|
expect(result, f).not.toMatch(/\(\{target:/);
|
|
expect(result, f).not.toMatch(/direction:\s*"upstream"/);
|
|
// The volatile counts MUST be gone — no parenthetical, no leaked numbers.
|
|
expect(result, f).not.toContain('42 symbols');
|
|
expect(result, f).not.toMatch(/\(\d+\s+symbols,/);
|
|
// The count-free stats line is still present and the name refreshed.
|
|
expect(result, f).toContain('Indexed as **NoStatsTest**');
|
|
// Custom prose still preserved
|
|
expect(result, f).toContain('Custom.');
|
|
}
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('noStats + keep marker: project name still refreshes when counts are stripped (#1706)', async () => {
|
|
// Stripping the parenthetical must not freeze the whole line: a repo
|
|
// rename should still propagate into the keep-section stats line, even
|
|
// when the existing line has no parenthetical to match against.
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-nostats-rename-'));
|
|
try {
|
|
const claudePath = path.join(dir, 'CLAUDE.md');
|
|
// Seed already in the count-free shape a prior --no-stats run produces.
|
|
const seed = `<!-- gitnexus:start -->
|
|
<!-- gitnexus:keep -->
|
|
Indexed as **OldName**. Custom.
|
|
<!-- gitnexus:end -->
|
|
`;
|
|
await fs.writeFile(claudePath, seed, 'utf-8');
|
|
|
|
const stats = { nodes: 7, edges: 8, processes: 9 };
|
|
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'NewName', stats, undefined, {
|
|
noStats: true,
|
|
});
|
|
|
|
const result = await fs.readFile(claudePath, 'utf-8');
|
|
expect(result).toContain('Indexed as **NewName**');
|
|
expect(result).not.toContain('OldName');
|
|
expect(result).not.toMatch(/\(\d+\s+symbols,/);
|
|
expect(result).toContain('Custom.');
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('noStats + keep marker: counts return when --no-stats is dropped after a count-free run (#1706)', async () => {
|
|
// --no-stats must not be sticky: once a prior run has left the
|
|
// keep-section line count-free, a later run WITHOUT --no-stats must
|
|
// restore the parenthetical. The optional parenthetical in statsPattern
|
|
// is what keeps the count-free line re-matchable.
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-counts-return-'));
|
|
try {
|
|
const claudePath = path.join(dir, 'CLAUDE.md');
|
|
// Seed already in the count-free shape a prior --no-stats run produces.
|
|
const seed = `<!-- gitnexus:start -->
|
|
<!-- gitnexus:keep -->
|
|
Indexed as **FreezeTest**. Custom.
|
|
<!-- gitnexus:end -->
|
|
`;
|
|
await fs.writeFile(claudePath, seed, 'utf-8');
|
|
|
|
const stats = { nodes: 11, edges: 22, processes: 3 };
|
|
// No noStats option — the counts must come back.
|
|
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'FreezeTest', stats);
|
|
|
|
const result = await fs.readFile(claudePath, 'utf-8');
|
|
expect(result).toContain(
|
|
'Indexed as **FreezeTest** (11 symbols, 22 relationships, 3 execution flows)',
|
|
);
|
|
// Suffix prose after the stats line is preserved.
|
|
expect(result).toContain('Custom.');
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("returns 'preserved' (not 'updated') when keep marker is present but no stats line matches (#1508 review F1)", async () => {
|
|
// Regression guard for the misleading-return-value bug: previously the
|
|
// function returned 'updated' without writing when the keep-section had
|
|
// no recognizable stats line, causing CLI output to claim files were
|
|
// updated when they were not.
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-noline-'));
|
|
try {
|
|
const claudePath = path.join(dir, 'CLAUDE.md');
|
|
// Custom keep-section with NO "Indexed as ..." or "indexed by GitNexus as ..." line
|
|
const seed = `# Project
|
|
|
|
<!-- gitnexus:start -->
|
|
<!-- gitnexus:keep -->
|
|
# GitNexus block (custom, no stats line)
|
|
|
|
This block intentionally omits the standard stats line.
|
|
<!-- gitnexus:end -->
|
|
`;
|
|
await fs.writeFile(claudePath, seed, 'utf-8');
|
|
|
|
const stats = { nodes: 100, edges: 200, processes: 10 };
|
|
const result = await generateAIContextFiles(
|
|
dir,
|
|
path.join(dir, '.gitnexus'),
|
|
'NoLineTest',
|
|
stats,
|
|
);
|
|
|
|
// The result manifest should reflect 'preserved', not 'updated'
|
|
expect(result.files).toContain('CLAUDE.md (preserved)');
|
|
// File on disk is unchanged
|
|
const onDisk = await fs.readFile(claudePath, 'utf-8');
|
|
expect(onDisk).toBe(seed);
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('project name with markdown-sensitive punctuation lands intact in stats line (#1508 review F5)', async () => {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-punct-'));
|
|
try {
|
|
const claudePath = path.join(dir, 'CLAUDE.md');
|
|
const seed = `<!-- gitnexus:start -->
|
|
<!-- gitnexus:keep -->
|
|
Indexed as **placeholder** (1 symbols, 1 relationships, 1 execution flows). Custom.
|
|
<!-- gitnexus:end -->
|
|
`;
|
|
await fs.writeFile(claudePath, seed, 'utf-8');
|
|
|
|
// Name with hyphens, dot, and slash — exactly what dp-web4/some-repo
|
|
// style names look like
|
|
const trickyName = 'dp-web4/some-repo.v2';
|
|
const stats = { nodes: 5, edges: 10, processes: 1 };
|
|
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), trickyName, stats);
|
|
|
|
const result = await fs.readFile(claudePath, 'utf-8');
|
|
// The full name appears in the bold of the stats line, intact
|
|
expect(result).toContain(`Indexed as **${trickyName}** (5 symbols`);
|
|
} finally {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|