GitNexus/gitnexus/test/unit/ai-context.test.ts
Gergő Magyar 7c3d4e6862
feat(pdg): control dependence — post-dominators + CDG (Ferrante) [M5 #2085] (#2188)
* feat(pdg): add CDG + POST_DOMINATE edge types (M5 #2085)

* feat(pdg): post-dominator tree on reverse CFG (M5 #2085)

* feat(pdg): Ferrante control-dependence over the post-dom tree (M5 #2085)

* feat(pdg): emitFileCdg + optional POST_DOMINATE debug edges (M5 #2085)

* feat(pdg): wire CDG emission in-phase + pdgModeMismatch CDG-cap stamp (M5 #2085)

* test(pdg): CDG snapshot + end-to-end pipeline answerability (M5 #2085)

* fix(review): apply autofix feedback (M5 #2085)

* fix(pdg): label CDG edges by controller arm sense, not edge kind (#2188 F1/F2/F4)

Tri-review (with Codex as the independent engine) found the CDG 'T'/'F' label
was wrong for the commonest control flow: the M1 TS visitor wires a condition's
fall-through FALSE arm as `seq`/`loop-back`, but `branchSense` mapped both to
'T', so guard clauses, if-no-else, and loop `break` got 'T' instead of 'F' (F1,
P1). The structural CDG edges were correct; only the label — the AC3 "under what
condition does X run?" answer — was wrong.

- F1: replace edge-kind `branchSense` with controller-arm-sense `labelFor`. An
  ambiguous fall-through edge (seq/loop-back) takes the COMPLEMENT of its source
  block's explicit cond-true/cond-false sibling arm. This correctly handles
  do/while (loop-back = TRUE arm) and inner-if-in-loop (loop-back = FALSE arm) —
  the ambiguity a kind→label table cannot resolve. Adds real-parser regression
  tests (the hand-built tests used a fictional cond-false edge and missed it).
- F2: correct the false "sound over-approximation that never drops a real
  dependence" claim in post-dominators.ts — exit-unreachable regions both drop
  and invent control dependences (latent for the current TS visitor, which keeps
  EXIT reverse-reachable). Reframe the exit-less-loop test to characterize, not
  bless, the degenerate behavior.
- F4: make the AC2 property-test reference compute post-dominance INDEPENDENTLY
  (node-removal reachability, no shared code with post-dominators.ts), so a
  post-dom direction bug can no longer pass both the impl and the reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): root-prettier format + run-analyze pdg stamp gains maxCdgEdgesPerFunction (#2085)

Two deterministic CI failures from the M5 CDG work:
- quality/format: basicblock-roundtrip.test.ts failed CI's root `prettier --check .`
  (the pre-commit hook uses the gitnexus-local prettier config, which differs);
  reformatted with the root config.
- tests/ubuntu/coverage: run-analyze.test.ts pinned the resolved RepoMeta.pdg
  shape (DEFAULTS) and the all-zero cap override without the new
  maxCdgEdgesPerFunction key (default 5000); added it so resolvePdgConfig
  toEqual and pdgModeMismatch(DEFAULTS) pass. (The stale-test sweep missed this
  file in PR #2188 — same trap M2 hit.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(mcp): add pdg_query tool definition (controls/flows modes) [M6 #2086]

* feat(mcp): pdg_query backend — controls (CDG) + flows (REACHING_DEF) + e2e test [M6 #2086]

* feat(mcp): document PDG edges + pdg_query (schema, cypher, skill, --pdg-gated ai-context) [M6 #2086]

* fix(mcp): correct pdg_query symbol-anchor lower bound + harden inputs [PR #2188 review]

Tri-review (Codex + adversarial + correctness lanes) of the M6 pdg_query
surface found the symbol-anchor window over-includes a neighbor function's
block. The upper bound was widened to the 1-based BasicBlock basis (symEnd+1)
but the lower bound was left 0-based, so a block on the line directly above the
target function leaked into the result. Shift both bounds +1 ([symStart+1,
symEnd+1]) so the window is the function's true block span.

Also from the same review:
- pdg_query no longer throws on a no-arguments MCP call: the dispatch passes
  raw `params`, so default it to {} → a clean mode-validation error instead of
  a TypeError. (`explain` shares this latent pattern — pre-existing follow-up.)
- tools.ts: the controls-mode description no longer hard-codes the 'F' branch
  sense for guards — `if (!ok) return;` rides the predicate's 'T' arm; the
  guard:true flag is label-agnostic (regex on the dependent block text).

Tests: a hand-seeded adjacency regression (verified failing without the
lower-bound +1) + a no-arguments validation test. Skill doc updated to document
the two-sided [symStart+1, symEnd+1] window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): drop always-true anchor conditional in pdg_query [CodeQL #2188]

CodeQL alert 756 flagged `...(anchor ? { anchor } : {})` in _pdgQueryImpl as a
useless conditional: `anchor` is unconditionally assigned in both the file-path
and symbol branches before the return (the not-found/ambiguous/no-layer paths
return earlier), so it is always truthy. Drop `| undefined` from the declaration
(TypeScript definite-assignment holds across both branches) and emit `anchor`
directly.

No runtime change — the `anchor` field was already present on every result.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cli): add hasPdg to the noStats bridge expectation [#2188]

The M6 work threaded `hasPdg: options.pdg === true` into the AIContextOptions
passed to generateAIContextFiles on the --skills regeneration path, but this
test's strict .toEqual expectation predated it (4 keys vs 3 → CI failure). Add
`hasPdg: false` (the value on this non---pdg path). The assertion stays strict;
the #1477 noStats bridging it guards is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): collapse generateGitNexusContent params to an options bag [#2188]

The function had grown to 9 positional params; reaching `hasPdg` meant passing
six `undefined`s (the M6 review's maintainability flag). Collapse params 3-9
(generatedSkills, groupNames, noStats, skipSkills, runnerPath, defaultBranch,
hasPdg) into a `GitNexusContentOptions` object with the defaults moved to
destructuring. The body is unchanged (same local names); the single production
caller and the test calls become self-documenting named fields.

Pure refactor — generated AGENTS.md/CLAUDE.md content is byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cfg): skip CDG for exit-unreachable CFGs (unsound post-dominance) [#2188]

M5 review P2: computePostDominators roots only at cfg.exitIndex and nothing
enforced that EXIT is reachable from every block. For an entry-reachable region
that cannot reach EXIT (a non-terminating loop, or a multi-terminal CFG a future
visitor might emit) the EXIT-rooted reverse walk degenerates — it both drops
real control dependences and invents spurious ones.

Add a pure precondition predicate `isExitReachableFromAllBlocks` (co-located with
the algorithm it guards) and gate it in emitFileCdg: a CFG that violates it is
skipped for CDG (counted as skippedUnsoundFunctions + one onWarn), while its CFG
and REACHING_DEF projections — which do not depend on post-dominance — are kept.
A CDG-specific gate, not a widening of isEmitSafeCfg, so the blast radius is
exactly the unsound CDG. The current TS visitor always satisfies the
precondition (every loop gets a structural header→loopExit edge), so CDG output
for real fixtures is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cfg): bound computeControlDependence materialization (heap parity) [#2188]

M5 review P2: unlike computeReachingDefs (maxFacts) and the emit-side edge cap,
computeControlDependence materialized the full deduped seen/out before
emitFileCdg's per-function cap could trim it — O(edges × post-dom depth) heap
for a deeply nested function.

Add a `maxEdges` ceiling (default 0 = unbounded) returning {edges, truncated},
mirroring computeReachingDefs's {facts, truncated}. The ceiling is checked
before pushing a new unique edge, so `truncated` means a genuine overflow (not
merely "reached cap"). emitFileCdg passes a FIXED materialization ceiling (8× the
default edge cap) — deliberately NOT derived from the runtime edge cap, because
CDG's materialization IS the deduped-edge quantity the cap reports on (deriving
it would pre-truncate that set and lose the exact dropped count). A ceiling hit
is surfaced via onWarn + the truncated flag — never silent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(mcp): share resolveBlockAnchor; fix explain's anchor off-by-one [#2188]

M6 review P2 (duplication) + the flagged pre-existing _explainImpl correctness
follow-up. _pdgQueryImpl and _explainImpl each carried a near-identical
symbol↔block anchor resolver that had DRIFTED: pdg_query used the corrected
[symStart+1, symEnd+1] window (BasicBlock startLine is 1-based, the symbol span
0-based) while _explainImpl still used [symStart, symEnd] — dropping a taint
source on the function's final line AND leaking a neighbor's block on the line
directly above.

Extract one `resolveBlockAnchor` helper, used by both, that applies the correct
window and a single (bare) clause convention (callers compose their own WHERE).
This removes ~50 duplicated lines and fixes explain's anchor in one place.

A hand-seeded characterization test (taint-explain Block 4) pins both bounds —
verified to FAIL on the pre-fix window (it returned the line-10 neighbor instead
of the line-15 final-line source). Existing taint-explain + pdg-query suites are
unchanged (their fixtures have interior sources/sinks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): pdg_query reports "status unknown" when the layer can't be confirmed [#2188]

M6 review P3 (Codex): when meta is UNREADABLE and the bounded global existence
probe returns zero rows of the edge type, _pdgQueryImpl asserted "no PDG layer"
— but a genuinely edge-free layer (all-linear functions) is indistinguishable
from a missing one via that probe. Soften only that fallback path to an
inconclusive "PDG layer status unknown — was this repo indexed with --pdg?"
note. The meta-stamped path (stamp present, cap absent ⇒ layer truly missing)
keeps the definitive "no PDG layer" wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(mcp): cover pdg_query ambiguous / pagination / Windows-path gaps [#2188]

M6 review test-gap follow-ups, all hand-seeded with controlled data:
- ambiguous symbol name → status:'ambiguous' + ranked candidates shape
  (uid/name/filePath/score), never a silent guess;
- total/truncated page boundary in both directions (limit below the match count
  sets truncated with the full total; limit above it omits truncated);
- a Windows-style filePath containing ':' resolves and fnLineOf decodes the
  function-line segment correctly (split-from-right past the drive letter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skills): ship gitnexus-pdg-query skill mirrors + add pdg_query to the guide [#2086]

M6 bundled pdg_query into this PR, but the skill shipped only in the canonical
gitnexus/skills/ root. Mirror it (byte-identical) to the two hand-maintained
roots the sibling taint skill uses — .claude/skills/gitnexus/ and the plugin —
so Claude Code + plugin users get it too.

Also extend the gitnexus-guide tool reference (all 3 copies, now byte-identical):
add a `pdg_query` row + a "Control & data dependence" section mirroring the
taint/`explain` section, and reconcile the pre-existing drift where only the
.claude copy carried the `check` tool row (a real registered tool) — all three
now list it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(architecture): refresh CFG/PDG section for the full M1–M6 stack [#2086]

The PR body had deferred the "ARCHITECTURE docs refresh" to #2086; now that M6
ships here, do it:
- MCP tools table gains `explain` and `pdg_query` (were absent).
- "Optional CFG/PDG emission" was M1-only; rewrite to cover the whole opt-in
  stack — M1 CFG, M2 REACHING_DEF, M3/M4 taint, M5 CDG (Ferrante over CHK
  post-dominators, with the exit-unreachable skip), M6 read surface (pdg_query +
  explain, anchored + LIMIT-bounded, shared resolveBlockAnchor) — and note the
  no-Function→BasicBlock-edge join.
- LadybugDB schema notes the `--pdg` additions: the `BasicBlock` node table and
  the CFG/REACHING_DEF/CDG/TAINTED/SANITIZES/TAINT_PATH relation types, kept out
  of the default VALID_RELATION_TYPES / web schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:49:03 +01:00

1037 lines
46 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,
refreshBaseRefLine,
markdownSafeBranch,
} 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 },
{ groupNames: ['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('gates the pdg_query line on hasPdg (#2086 M6 — no existing taint gate to mirror)', () => {
const stats = { nodes: 50, edges: 100, processes: 5 };
// hasPdg=true → the pdg_query line is present.
const withPdg = generateGitNexusContent('PdgProject', stats, { hasPdg: true });
expect(withPdg).toContain('pdg_query');
expect(withPdg).toContain('under what condition does X run');
// hasPdg omitted (default false) → no pdg_query line; a non-pdg index must
// not advertise a tool that only returns a "no PDG layer" note.
const withoutPdg = generateGitNexusContent('PlainProject', stats);
expect(withoutPdg).not.toContain('pdg_query');
// the unconditional explain line stays regardless of the pdg flag.
expect(withoutPdg).toContain('explain(');
});
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. The ceiling is a soft cap 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.
//
// Raised 2700 → 2900 for #243: the regression-compare example (one
// load-bearing per-repo `base_ref` line on the detect_changes bullet) is a
// legitimate addition, not a revert of the trim — the block stays roughly
// half 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(2900);
});
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 });
}
});
// ──────────────────────────────────────────────────────────────────
// Configurable default branch in the regression example (#243)
// ──────────────────────────────────────────────────────────────────
it('generated regression-compare example uses the configured default branch (#243)', () => {
const stats = { nodes: 50, edges: 100, processes: 5 };
const develop = generateGitNexusContent('P', stats, { defaultBranch: 'develop' });
expect(develop).toContain('base_ref: "develop"');
expect(develop).not.toContain('base_ref: "main"');
});
it('defaults the regression-compare example to "main" when no branch is configured (#243)', () => {
const content = generateGitNexusContent('P', { nodes: 50, edges: 100, processes: 5 });
expect(content).toContain('base_ref: "main"');
});
it('references MCP tools by their registered (unprefixed) names (#2059)', () => {
const content = generateGitNexusContent('P', { nodes: 50, edges: 100, processes: 5 });
// The server registers tools without a `gitnexus_` prefix (see mcp/tools.ts);
// generated instructions must use the exact callable names or agents call a
// tool that does not exist.
expect(content).not.toMatch(/gitnexus_(impact|query|context|detect_changes|rename|cypher)/);
expect(content).toContain('impact({target: "symbolName", direction: "upstream"})');
expect(content).toContain('detect_changes()');
// #2175: the generated guidance must advertise the renamed param, never the
// legacy "query" key (Claude Code drops a tool arg named exactly "query").
expect(content).toContain('query({search_query: "concept"})');
expect(content).not.toContain('query({query:');
expect(content).toContain('context({name: "symbolName"})');
});
it('JSON-escapes a markdown/quote-bearing branch so it cannot break the code span (#243)', () => {
// A branch name with a double-quote must be JSON-escaped, not concatenated
// raw, so it stays inside the inline code span.
const content = generateGitNexusContent('P', { nodes: 1 }, { defaultBranch: 'we"ird' });
expect(content).toContain('base_ref: "we\\"ird"');
});
it('a backtick branch cannot break the generated Markdown code span (#1996 P1)', () => {
// The branch is embedded inside a backtick inline-code span; a stray
// backtick would close it early. markdownSafeBranch strips it at the sink.
const content = generateGitNexusContent('P', { nodes: 1 }, { defaultBranch: 'main`evil' });
const line = content.split('\n').find((l) => l.includes('base_ref'))!;
// Even backtick count ⇒ every span is balanced (the regression line opens
// and closes exactly one).
expect((line.match(/`/g) || []).length % 2).toBe(0);
expect(line).not.toContain('main`evil');
expect(markdownSafeBranch('a`b`c')).toBe('abc');
});
it('refreshBaseRefLine updates base_ref in place, preserving the rest of the block (#1996 P2)', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-baseref-'));
try {
// Seed a realistic block: a configured base_ref "main" plus a community
// skill row that a prior --skills run would have written.
const seed = `# Project
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
- run \`detect_changes({scope: "compare", base_ref: "main"})\`.
| Task | Read this skill file |
|------|---------------------|
| Work in the Auth area (40 symbols) | \`.claude/skills/generated/auth/SKILL.md\` |
<!-- gitnexus:end -->
`;
for (const f of ['AGENTS.md', 'CLAUDE.md']) {
await fs.writeFile(path.join(dir, f), seed, 'utf-8');
}
const res = await refreshBaseRefLine(dir, 'develop');
expect(res.files.sort()).toEqual(['AGENTS.md', 'CLAUDE.md']);
for (const f of ['AGENTS.md', 'CLAUDE.md']) {
const after = await fs.readFile(path.join(dir, f), 'utf-8');
expect(after).toContain('base_ref: "develop"');
expect(after).not.toContain('base_ref: "main"');
// The community-skill row (and everything else) is preserved.
expect(after).toContain('.claude/skills/generated/auth/SKILL.md');
}
// Idempotent: a second run with the same branch writes nothing.
const again = await refreshBaseRefLine(dir, 'develop');
expect(again.files).toEqual([]);
// skipAgentsMd short-circuits entirely.
const skipped = await refreshBaseRefLine(dir, 'master', { skipAgentsMd: true });
expect(skipped.files).toEqual([]);
expect(await fs.readFile(path.join(dir, 'AGENTS.md'), 'utf-8')).toContain(
'base_ref: "develop"',
);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
it('refreshBaseRefLine is a no-op when there is no base_ref line or no file (#1996 P2)', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-baseref-noop-'));
try {
// No AGENTS.md/CLAUDE.md at all → no files updated, no throw.
expect((await refreshBaseRefLine(dir, 'develop')).files).toEqual([]);
// A keep-style block with no base_ref line is left untouched.
const seed = `<!-- gitnexus:start -->
<!-- gitnexus:keep -->
Indexed as **P**. Custom.
<!-- gitnexus:end -->
`;
await fs.writeFile(path.join(dir, 'CLAUDE.md'), seed, 'utf-8');
expect((await refreshBaseRefLine(dir, 'develop')).files).toEqual([]);
expect(await fs.readFile(path.join(dir, 'CLAUDE.md'), 'utf-8')).toBe(seed);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
it('threads defaultBranch through generateAIContextFiles into AGENTS.md and CLAUDE.md (#243)', async () => {
const subDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-default-branch-'));
const subStorage = path.join(subDir, '.gitnexus');
await fs.mkdir(subStorage, { recursive: true });
try {
const stats = { nodes: 50, edges: 100, processes: 5 };
await generateAIContextFiles(subDir, subStorage, 'P', stats, undefined, {
defaultBranch: 'release/1.0',
});
for (const f of ['CLAUDE.md', 'AGENTS.md']) {
const content = await fs.readFile(path.join(subDir, f), 'utf-8');
expect(content).toContain('base_ref: "release/1.0"');
expect(content).not.toContain('base_ref: "main"');
}
} finally {
await fs.rm(subDir, { recursive: true, force: true });
}
});
});