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'; import { _captureLogger } from '../../src/core/logger.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 --index-only`'); expect(content).not.toContain('run `gitnexus analyze`'); // no machine-resolved leak // Bootstrap path (for a not-yet-analyzed checkout) + npm-11 escape hatch. // Every install-free runner is named, so a machine without npm (bun-only) // still finds a bootstrap command it can actually run. expect(content).toContain('`npx`, `bunx`, or `pnpm dlx`'); expect(content).toContain('bunx gitnexus@latest 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('pairs mandatory MCP checks with project-local CLI fallbacks (#2671)', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-transport-fallback-')); const storage = path.join(dir, '.gitnexus'); await fs.mkdir(storage, { recursive: true }); try { await generateAIContextFiles( dir, storage, 'TransportFallback', { nodes: 50, edges: 100, processes: 5 }, undefined, { defaultBranch: 'develop' }, ); for (const file of ['AGENTS.md', 'CLAUDE.md']) { const content = await fs.readFile(path.join(dir, file), 'utf-8'); expect(content).toContain('impact({target: "symbolName", direction: "upstream"})'); expect(content).toContain( 'node .gitnexus/run.cjs impact "symbolName" --direction upstream --repo .', ); expect(content).toContain('detect_changes({scope: "all"})'); expect(content).toContain('node .gitnexus/run.cjs detect-changes --scope all --repo .'); expect(content).toContain('detect_changes({scope: "compare", base_ref: "develop"})'); expect(content).toContain('--scope compare --base-ref "develop" --repo .'); expect(content).toContain('Never substitute grep for graph analysis'); expect(content).not.toContain('gitnexus impact --target'); } } finally { await fs.rm(dir, { recursive: true, force: true }); } }); it('uses the configured runner for transport fallbacks, including PDG impact (#2671)', () => { const content = generateGitNexusContent( 'TransportFallback', { nodes: 50, edges: 100, processes: 5 }, { runnerPath: '.custom/gitnexus-runner.cjs', hasPdg: true, }, ); expect(content).toContain( 'node .custom/gitnexus-runner.cjs impact "symbolName" --direction upstream --repo .', ); expect(content).toContain( 'node .custom/gitnexus-runner.cjs detect-changes --scope all --repo .', ); expect(content).toContain( 'node .custom/gitnexus-runner.cjs impact "symbolName" --direction upstream --mode pdg --line --repo .', ); expect(content).toContain('--mode pdg'); expect(content).toContain('--line '); expect(content).not.toContain('node .gitnexus/run.cjs impact'); }); 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'); expect(withPdg).toContain('line: '); expect(withPdg).toContain('affectedStatements'); expect(withPdg).toContain('byDepth'); // 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('gates Spring Actuator runtime evidence guidance on index metadata', () => { const stats = { nodes: 50, edges: 100, processes: 5 }; const enabled = generateGitNexusContent('SpringProject', stats, { hasSpringActuator: true, }); const disabled = generateGitNexusContent('PlainProject', stats); expect(enabled).toContain('Spring Actuator runtime evidence is enabled'); expect(enabled).toContain('runtimeConfirmed === true'); expect(enabled).toContain('Snapshot values are never persisted'); expect(disabled).not.toContain('Spring Actuator runtime evidence is enabled'); }); it('documents the MCP and Graph-RAG File-risk scale difference', () => { const content = generateGitNexusContent('RiskScaleProject', { nodes: 50, edges: 100, processes: 5, }); expect(content).toContain('MCP File omits axes'); expect(content).toContain('Graph-RAG expands File'); }); it('emits MD060-compatible compact tables in generated docs (#2709)', () => { const content = generateGitNexusContent('MarkdownProject', { nodes: 50, edges: 100, processes: 5, }); expect(content).toContain('| Resource | Use for |\n| --- | --- |\n'); expect(content).toContain('| Task | Read this skill file |\n| --- | --- |\n'); expect(content).not.toContain('|----------|---------|'); expect(content).not.toContain('|------|---------------------|'); }); 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 // (`bunx gitnexus@latest 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('bunx gitnexus@latest 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 --index-only`'); expect(content).toContain( 'On query/context/impact/cypher object results, read staleness.status and branch/lastCommit', ); 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, then 2900 → 2950 for the bunx bootstrap note, // then 0.55 → 0.65 for the #2899 `risk: UNKNOWN` Always-Do bullet + Never-Do // clause (previously hand-added inside the committed docs instead of this // template, so a real `gitnexus analyze` silently deleted them on every // regeneration — moving them into the template is the fix, and they are // unconditional text load-bearing enough to warrant the budget), then // 0.65 → 0.70 for the #3291 always-on `staleness` blockquote line (a second // `>` line, not a new Always-Do bullet — the cheaper durable slot; the // 0.65 band had ~3 chars of headroom so any useful line required a bump) — // each time with the same argument, that the added line is load-bearing and // the block is still meaningfully smaller than the original. That is a // ratchet with no ratchet: an absolute cap can only ever fail on the PR // that adds the character, and the fix is always to nudge the number. // Assert the invariant the justifications actually appeal to — the RATIO // to the pre-trim size — so a legitimate clause fits without ceremony // while a genuine re-pad fails. (The structural guard is the sibling test // asserting the six #856 section headers stay deleted; this one bounds bulk.) const PRE_TRIM_BLOCK_CHARS = 5465; 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(''), content.indexOf(''), ); expect(block.length).toBeLessThan(PRE_TRIM_BLOCK_CHARS * 0.7); }); 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 — Code Knowledge Graph Indexed as **OldName** (50 symbols, 100 relationships, 5 execution flows). MCP tools. | Tool | Use for | |------|---------| | query | Find flows | Resources: gitnexus://repo/TestProject/context `; await fs.writeFile(claudeMdPath, customContent, 'utf-8'); // Run analyze with new stats — should only update the stats line. The seed // carries a stale project NAME because a counts-only delta is now preserved // rather than written (#2907); the rename is what makes this a real update. 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(''); 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 — Code Intelligence Old content here. `; 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 standard skills as direct children of .claude/skills (#2433)', async () => { const stats = { nodes: 10 }; await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats); const standardSkills = [ 'gitnexus-exploring', 'gitnexus-debugging', 'gitnexus-impact-analysis', 'gitnexus-refactoring', 'gitnexus-guide', 'gitnexus-cli', ]; for (const skill of standardSkills) { await expect( fs.access(path.join(tmpDir, '.claude', 'skills', skill, 'SKILL.md')), ).resolves.toBeUndefined(); await expect( fs.access(path.join(tmpDir, '.claude', 'skills', 'gitnexus', skill, 'SKILL.md')), ).rejects.toThrow(); } const claudeContent = generateGitNexusContent('TestProject', stats); expect(claudeContent).toContain('.claude/skills/gitnexus-exploring/SKILL.md'); expect(claudeContent).not.toContain('.claude/skills/gitnexus/gitnexus-exploring/SKILL.md'); }); it('migrates known nested standard skills without deleting user-owned siblings (#2433)', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-skill-migrate-')); const storage = path.join(dir, '.gitnexus'); const legacyKnown = path.join(dir, '.claude', 'skills', 'gitnexus', 'gitnexus-exploring'); const legacyUnknown = path.join(dir, '.claude', 'skills', 'gitnexus', 'custom-team-skill'); const unrelated = path.join(dir, '.claude', 'skills', 'auth'); await fs.mkdir(legacyKnown, { recursive: true }); await fs.mkdir(legacyUnknown, { recursive: true }); await fs.mkdir(unrelated, { recursive: true }); await fs.writeFile(path.join(legacyKnown, 'SKILL.md'), 'legacy', 'utf-8'); await fs.writeFile(path.join(legacyUnknown, 'SKILL.md'), 'custom nested', 'utf-8'); await fs.writeFile(path.join(unrelated, 'SKILL.md'), 'custom direct', 'utf-8'); try { await generateAIContextFiles(dir, storage, 'TestProject', { nodes: 10 }); await expect( fs.access(path.join(dir, '.claude', 'skills', 'gitnexus-exploring', 'SKILL.md')), ).resolves.toBeUndefined(); // Divergent nested SKILL.md is preserved (#3080); only byte-identical // leftovers are still removed. await expect(fs.readFile(path.join(legacyKnown, 'SKILL.md'), 'utf-8')).resolves.toBe( 'legacy', ); await expect(fs.readFile(path.join(legacyUnknown, 'SKILL.md'), 'utf-8')).resolves.toBe( 'custom nested', ); await expect(fs.readFile(path.join(unrelated, 'SKILL.md'), 'utf-8')).resolves.toBe( 'custom direct', ); } finally { await fs.rm(dir, { recursive: true, force: true }); } }); it('does not create standard skill directories 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 // direct .claude/skills/gitnexus-* directories 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-exploring')), ).rejects.toThrow(); } finally { await fs.rm(skipDir, { recursive: true, force: true }); } }); it('skipSkills does not remove nested leftover standard skills (#3080 / AE4)', async () => { const skipDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-skip-nested-')); const skipStorage = path.join(skipDir, '.gitnexus'); const nested = path.join(skipDir, '.claude', 'skills', 'gitnexus', 'gitnexus-cli'); const bundled = await fs.readFile( path.join(__dirname, '../../skills/gitnexus-cli.md'), 'utf-8', ); await fs.mkdir(nested, { recursive: true }); await fs.mkdir(skipStorage, { recursive: true }); await fs.writeFile(path.join(nested, 'SKILL.md'), bundled, 'utf-8'); try { await generateAIContextFiles(skipDir, skipStorage, 'TestProject', { nodes: 1 }, undefined, { skipAgentsMd: true, skipSkills: true, }); await expect(fs.readFile(path.join(nested, 'SKILL.md'), 'utf-8')).resolves.toBe(bundled); await expect( fs.access(path.join(skipDir, '.claude', 'skills', 'gitnexus-cli')), ).rejects.toThrow(); } finally { await fs.rm(skipDir, { recursive: true, force: true }); } }); it('preserves customized flat SKILL.md under skipAgentsMd (#3080 / AE1)', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-flat-')); const storage = path.join(dir, '.gitnexus'); const cliSkill = path.join(dir, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md'); await fs.mkdir(path.dirname(cliSkill), { recursive: true }); await fs.mkdir(storage, { recursive: true }); await fs.writeFile(cliSkill, 'CUSTOM-COMMITTED-SKILL-3080-cli\n', 'utf-8'); const cap = _captureLogger(); try { const result = await generateAIContextFiles( dir, storage, 'TestProject', { nodes: 1 }, undefined, { skipAgentsMd: true }, ); expect(result.files).toContain('AGENTS.md (skipped via --skip-agents-md)'); expect(result.files.some((f) => f.includes('skipped via --skip-skills'))).toBe(false); await expect(fs.readFile(cliSkill, 'utf-8')).resolves.toBe( 'CUSTOM-COMMITTED-SKILL-3080-cli\n', ); const msgs = cap.records().map((r) => r.msg ?? ''); expect(msgs.some((m) => m.includes(cliSkill) && m.includes('--skip-skills'))).toBe(true); } finally { cap.restore(); await fs.rm(dir, { recursive: true, force: true }); } }); it('creates a missing standard skill from the bundle (#3080 / AE2)', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-missing-')); const storage = path.join(dir, '.gitnexus'); await fs.mkdir(storage, { recursive: true }); try { await generateAIContextFiles(dir, storage, 'TestProject', { nodes: 1 }, undefined, { skipAgentsMd: true, }); const created = await fs.readFile( path.join(dir, '.claude', 'skills', 'gitnexus-debugging', 'SKILL.md'), 'utf-8', ); const bundled = await fs.readFile( path.join(__dirname, '../../skills/gitnexus-debugging.md'), 'utf-8', ); expect(created).toBe(bundled); } finally { await fs.rm(dir, { recursive: true, force: true }); } }); it('rewrites a SKILL.md that already matches the current bundle (R2)', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-ident-')); const storage = path.join(dir, '.gitnexus'); const dest = path.join(dir, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md'); const bundled = await fs.readFile( path.join(__dirname, '../../skills/gitnexus-cli.md'), 'utf-8', ); await fs.mkdir(path.dirname(dest), { recursive: true }); await fs.mkdir(storage, { recursive: true }); await fs.writeFile(dest, bundled, 'utf-8'); const writeSpy = vi.spyOn(fs, 'writeFile'); try { await generateAIContextFiles(dir, storage, 'TestProject', { nodes: 1 }, undefined, { skipAgentsMd: true, }); await expect(fs.readFile(dest, 'utf-8')).resolves.toBe(bundled); expect(writeSpy).toHaveBeenCalledWith(dest, bundled, 'utf-8'); } finally { writeSpy.mockRestore(); await fs.rm(dir, { recursive: true, force: true }); } }); it('removes nested leftover when SKILL.md matches the bundle', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-nested-ident-')); const storage = path.join(dir, '.gitnexus'); const nested = path.join(dir, '.claude', 'skills', 'gitnexus', 'gitnexus-cli'); const bundled = await fs.readFile( path.join(__dirname, '../../skills/gitnexus-cli.md'), 'utf-8', ); await fs.mkdir(nested, { recursive: true }); await fs.mkdir(storage, { recursive: true }); await fs.writeFile(path.join(nested, 'SKILL.md'), bundled, 'utf-8'); try { await generateAIContextFiles(dir, storage, 'TestProject', { nodes: 1 }, undefined, { skipAgentsMd: true, }); await expect(fs.access(nested)).rejects.toThrow(); } finally { await fs.rm(dir, { recursive: true, force: true }); } }); it('preserves nested leftover siblings even when SKILL.md matches the bundle', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-nested-sibling-')); const storage = path.join(dir, '.gitnexus'); const nested = path.join(dir, '.claude', 'skills', 'gitnexus', 'gitnexus-cli'); const bundled = await fs.readFile( path.join(__dirname, '../../skills/gitnexus-cli.md'), 'utf-8', ); await fs.mkdir(nested, { recursive: true }); await fs.mkdir(storage, { recursive: true }); await fs.writeFile(path.join(nested, 'SKILL.md'), bundled, 'utf-8'); await fs.writeFile(path.join(nested, 'notes.md'), 'operator notes\n', 'utf-8'); const cap = _captureLogger(); try { const result = await generateAIContextFiles( dir, storage, 'TestProject', { nodes: 1 }, undefined, { skipAgentsMd: true }, ); await expect(fs.readFile(path.join(nested, 'notes.md'), 'utf-8')).resolves.toBe( 'operator notes\n', ); expect(result.files).toContain( '.claude/skills/gitnexus// (legacy directories preserved: 1)', ); expect(cap.records().some((record) => record.msg?.includes('operator-owned files'))).toBe( true, ); } finally { cap.restore(); await fs.rm(dir, { recursive: true, force: true }); } }); it('preserves a divergent .agents mirror while writing a missing .claude copy', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-3080-agents-')); const storage = path.join(dir, '.gitnexus'); const agentsSkill = path.join(dir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'); await fs.mkdir(path.dirname(agentsSkill), { recursive: true }); await fs.mkdir(storage, { recursive: true }); await fs.writeFile(agentsSkill, 'CUSTOM-AGENTS-MIRROR\n', 'utf-8'); try { const result = await generateAIContextFiles( dir, storage, 'TestProject', { nodes: 1 }, undefined, { skipAgentsMd: true, }, ); await expect(fs.readFile(agentsSkill, 'utf-8')).resolves.toBe('CUSTOM-AGENTS-MIRROR\n'); const claudeCopy = await fs.readFile( path.join(dir, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md'), 'utf-8', ); expect(claudeCopy).not.toBe('CUSTOM-AGENTS-MIRROR\n'); expect(claudeCopy.length).toBeGreaterThan(0); expect(result.files).toContain( '.agents/skills/gitnexus-*/ (5 written, 1 preserved for .agents)', ); } finally { await fs.rm(dir, { recursive: true, force: true }); } }); it('mirrors standard skills to .agents/skills/ when .agents/ exists', async () => { // Some agents prefer repo-local .agents/skills over the global // ~/.agents/skills install. When the repo contains an .agents/ directory, // installSkills() must mirror the same SKILL.md files there so those agents // serve up-to-date repo-specific skills instead of stale global copies. const agentsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-agents-')); const agentsStorage = path.join(agentsDir, '.gitnexus'); await fs.mkdir(agentsStorage, { recursive: true }); // Opt-in: create the repo-local .agents/ directory. await fs.mkdir(path.join(agentsDir, '.agents'), { recursive: true }); try { const stats = { nodes: 50, edges: 100, processes: 5 }; const result = await generateAIContextFiles(agentsDir, agentsStorage, 'TestProject', stats); // Canonical .claude copy is always written (flat layout, #2434). expect(result.files).toContain('.claude/skills/gitnexus-*/ (6 skills)'); // Mirror is reported. expect(result.files).toContain('.agents/skills/gitnexus-*/ (6 skills mirrored for .agents)'); const claudeSkill = await fs.readFile( path.join(agentsDir, '.claude', 'skills', 'gitnexus-cli', 'SKILL.md'), 'utf-8', ); const agentsSkill = await fs.readFile( path.join(agentsDir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'), 'utf-8', ); expect(agentsSkill).toBe(claudeSkill); expect(agentsSkill.length).toBeGreaterThan(0); } finally { await fs.rm(agentsDir, { recursive: true, force: true }); } }); it('does not mirror skills to .agents/ when the directory is absent', async () => { // Without an .agents/ opt-in, only the canonical .claude/skills/ copy is // written — no .agents/ tree should be created. const noAgentsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-no-agents-')); const noAgentsStorage = path.join(noAgentsDir, '.gitnexus'); await fs.mkdir(noAgentsStorage, { recursive: true }); try { const stats = { nodes: 50, edges: 100, processes: 5 }; const result = await generateAIContextFiles( noAgentsDir, noAgentsStorage, 'TestProject', stats, ); expect(result.files).toContain('.claude/skills/gitnexus-*/ (6 skills)'); expect(result.files.some((f) => f.startsWith('.agents/skills/'))).toBe(false); await expect(fs.access(path.join(noAgentsDir, '.agents'))).rejects.toThrow(); } finally { await fs.rm(noAgentsDir, { recursive: true, force: true }); } }); it('keeps canonical skills intact when .agents/skills is a file (mirror mkdir fails)', async () => { // MEDIUM 1 (standard-skill half): when .agents/skills exists as a regular // file, the per-skill mirror mkdir fails. The failure must be warned per // skill and canonical .claude/skills/ must still hold all 6 skills. const { _captureLogger } = await import('../../src/core/logger.js'); const cap = _captureLogger(); const agentsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-agents-file-')); const agentsStorage = path.join(agentsDir, '.gitnexus'); await fs.mkdir(agentsStorage, { recursive: true }); await fs.mkdir(path.join(agentsDir, '.agents'), { recursive: true }); // .agents/skills is a file — per-skill mirror mkdir will EEXIST. await fs.writeFile(path.join(agentsDir, '.agents', 'skills'), 'not a directory'); try { const stats = { nodes: 50, edges: 100, processes: 5 }; const result = await generateAIContextFiles(agentsDir, agentsStorage, 'TestProject', stats); // Canonical 6 skills are all present. expect(result.files).toContain('.claude/skills/gitnexus-*/ (6 skills)'); for (const name of [ 'gitnexus-exploring', 'gitnexus-debugging', 'gitnexus-impact-analysis', 'gitnexus-refactoring', 'gitnexus-guide', 'gitnexus-cli', ]) { await expect( fs.readFile(path.join(agentsDir, '.claude', 'skills', name, 'SKILL.md'), 'utf-8'), ).resolves.toHaveProperty('length'); } // Mirror failures were warned, not thrown. const warned = cap.records().some((r) => r.level === 40); // pino warn level expect(warned).toBe(true); } finally { cap.restore(); await fs.rm(agentsDir, { recursive: true, force: true }); } }); it('does not mirror and does not create .agents/ when .agents is a file (gate is false)', async () => { // The gate checks isDirectory(); a file at .agents must NOT trigger // mirroring and must not throw. const fileDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-agents-filegate-')); const fileStorage = path.join(fileDir, '.gitnexus'); await fs.mkdir(fileStorage, { recursive: true }); await fs.writeFile(path.join(fileDir, '.agents'), 'not a directory'); try { const stats = { nodes: 50, edges: 100, processes: 5 }; const result = await generateAIContextFiles(fileDir, fileStorage, 'TestProject', stats); expect(result.files).toContain('.claude/skills/gitnexus-*/ (6 skills)'); expect(result.files.some((f) => f.startsWith('.agents/skills/'))).toBe(false); } finally { await fs.rm(fileDir, { recursive: true, force: true }); } }); it('is idempotent across repeated runs (no duplicates, stable mirror content)', async () => { const agentsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-idem-')); const agentsStorage = path.join(agentsDir, '.gitnexus'); await fs.mkdir(agentsStorage, { recursive: true }); await fs.mkdir(path.join(agentsDir, '.agents'), { recursive: true }); try { const stats = { nodes: 50, edges: 100, processes: 5 }; await generateAIContextFiles(agentsDir, agentsStorage, 'TestProject', stats); const first = await fs.readFile( path.join(agentsDir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'), 'utf-8', ); // Second run — must not duplicate or corrupt. await generateAIContextFiles(agentsDir, agentsStorage, 'TestProject', stats); const second = await fs.readFile( path.join(agentsDir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'), 'utf-8', ); expect(second).toBe(first); // Mirror tree has exactly one dir per standard skill (no duplicates). const entries = await fs.readdir(path.join(agentsDir, '.agents', 'skills'), { withFileTypes: true, }); const skillDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); expect(skillDirs).toContain('gitnexus-cli'); expect(skillDirs.filter((n) => n === 'gitnexus-cli')).toHaveLength(1); } finally { await fs.rm(agentsDir, { recursive: true, force: true }); } }); it('does not mirror standard skills when --skip-skills is set', async () => { const skipDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-skip-mirror-')); const skipStorage = path.join(skipDir, '.gitnexus'); await fs.mkdir(skipStorage, { recursive: true }); await fs.mkdir(path.join(skipDir, '.agents'), { 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)'); expect(result.files.some((f) => f.startsWith('.agents/skills/'))).toBe(false); await expect( fs.access(path.join(skipDir, '.agents', 'skills', 'gitnexus-cli')), ).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-exploring')), ).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('keeps direct community skill paths when standard skills are skipped (#2433)', () => { const content = generateGitNexusContent( 'TestProject', { nodes: 50, edges: 100, processes: 5 }, { skipSkills: true, generatedSkills: [ { name: 'gitnexus-area-auth', label: 'Auth', symbolCount: 40, fileCount: 5, }, ], }, ); expect(content).toContain('.claude/skills/gitnexus-area-auth/SKILL.md'); expect(content).not.toContain('.claude/skills/gitnexus-exploring/SKILL.md'); expect(content).not.toContain('.claude/skills/generated/'); }); 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 ` … ` 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(//g) || []).length).toBe(2); expect((contentAfter.match(//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(//g) || []).length).toBe(2); expect((contentAfter.match(//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 `\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 ` … ` block in **[AGENTS.md](AGENTS.md)** for more.'; const seeded = [ '# Claude Code Rules', '', '## GitNexus rules', '', inlineProseLine, '', '', '# GitNexus — Code Intelligence (stale stub)', '', '', ].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(//g) || []).length).toBe(2); expect((content.match(//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 markers: they only apply inside the GitNexus block below, not in prose like this. Old verbose stub here. `; 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 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 context for AGENTS Indexed as **AgentsOldName** (10 symbols, 20 relationships, 1 execution flows). Use 'query' for finding flows, 'context' for symbol details. `; 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 Indexed as **Idem** (1 symbols, 2 relationships, 3 execution flows). Custom. `; 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' + '\r\n' + '\r\n' + 'Indexed as **CRLFOldName** (5 symbols, 6 relationships, 7 execution flows). Custom CRLF.\r\n' + '\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 = ` Indexed as **NoStatsTest** (1 symbols, 1 relationships, 1 execution flows). Custom. `; 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 = ` Indexed as **OldName**. Custom. `; 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 = ` Indexed as **FreezeTest**. Custom. `; 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 block (custom, no stats line) This block intentionally omits the standard stats line. `; 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 = ` Indexed as **placeholder** (1 symbols, 1 relationships, 1 execution flows). Custom. `; 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({scope: "all"})'); // #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"'); expect(content).toContain('--base-ref "we\\"ird" --repo .'); }); 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 — Code Intelligence - run \`detect_changes({scope: "compare", base_ref: "main"})\`. | Task | Read this skill file | |------|---------------------| | Work in the Auth area (40 symbols) | \`.claude/skills/gitnexus-area-auth/SKILL.md\` | `; 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/gitnexus-area-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 = ` Indexed as **P**. Custom. `; 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 }); } }); }); // AGENTS.md and CLAUDE.md are the agent guides teams commit, so a rewrite whose // only delta is the volatile counts dirties a tracked file on every reindex // (#2907). These assert the write is skipped for a count-only delta and still // happens for every material one. describe('count-only reindex does not churn the committed block (#2907)', () => { let dir: string; let storage: string; beforeAll(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-2907-')); storage = path.join(dir, '.gitnexus'); await fs.mkdir(storage, { recursive: true }); }); afterAll(async () => { await fs.rm(dir, { recursive: true, force: true }); }); const read = (file: string): Promise => fs.readFile(path.join(dir, file), 'utf-8'); it('creates the file with a trailing newline so the next analyze has nothing to append', async () => { await generateAIContextFiles(dir, storage, 'P', { nodes: 10, edges: 20, processes: 3 }); expect(await read('CLAUDE.md')).toMatch(/\n$/); expect(await read('AGENTS.md')).toMatch(/\n$/); }); it('leaves both files byte-identical when only the counts moved', async () => { const before = { claude: await read('CLAUDE.md'), agents: await read('AGENTS.md') }; const result = await generateAIContextFiles(dir, storage, 'P', { nodes: 999999, edges: 888888, processes: 777, }); expect(await read('CLAUDE.md')).toBe(before.claude); expect(await read('AGENTS.md')).toBe(before.agents); expect(result.files).toContain('CLAUDE.md (preserved)'); expect(result.files).toContain('AGENTS.md (preserved)'); // The counts the block was created with are the ones still on disk. expect(before.claude).toContain('(10 symbols, 20 relationships, 3 execution flows)'); }); it('still rewrites when something other than the counts changed', async () => { const result = await generateAIContextFiles(dir, storage, 'RenamedProject', { nodes: 10, edges: 20, processes: 3, }); expect(result.files).toContain('CLAUDE.md (updated)'); expect(await read('CLAUDE.md')).toContain('**RenamedProject**'); }); it('still applies --no-stats to an already-injected block', async () => { const result = await generateAIContextFiles( dir, storage, 'RenamedProject', { nodes: 10, edges: 20, processes: 3 }, undefined, { noStats: true }, ); expect(result.files).toContain('CLAUDE.md (updated)'); const content = await read('CLAUDE.md'); expect(content).toContain('indexed by GitNexus as **RenamedProject**.'); expect(content).not.toContain('(10 symbols, 20 relationships, 3 execution flows)'); }); }); describe('--no-stats drops the per-cluster symbol counts too (#2907)', () => { const stats = { nodes: 10, edges: 20, processes: 3 }; const skills = [{ label: 'ingestion', name: 'p-ingestion', symbolCount: 120 }]; it('omits the count under --no-stats and keeps it otherwise', () => { const lean = generateGitNexusContent('P', stats, { generatedSkills: skills, noStats: true }); const full = generateGitNexusContent('P', stats, { generatedSkills: skills }); expect(lean).toContain( '| Work in the ingestion area | `.claude/skills/p-ingestion/SKILL.md` |', ); expect(lean).not.toContain('(120 symbols)'); expect(full).toContain( '| Work in the ingestion area (120 symbols) | `.claude/skills/p-ingestion/SKILL.md` |', ); }); }); describe('keep-marker blocks follow the same count-only rule (#2907)', () => { const seed = (name: string, counts: string): string => `# Guide Indexed as **${name}**${counts}. Lean block. `; it('preserves on a count-only delta and updates on a rename', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-2907-keep-')); const storage = path.join(dir, '.gitnexus'); await fs.mkdir(storage, { recursive: true }); try { const original = seed('P', ' (10 symbols, 20 relationships, 3 execution flows)'); await fs.writeFile(path.join(dir, 'CLAUDE.md'), original, 'utf-8'); await fs.writeFile(path.join(dir, 'AGENTS.md'), original, 'utf-8'); const preserved = await generateAIContextFiles(dir, storage, 'P', { nodes: 55, edges: 66, processes: 7, }); expect(preserved.files).toContain('CLAUDE.md (preserved)'); expect(await fs.readFile(path.join(dir, 'CLAUDE.md'), 'utf-8')).toBe(original); const renamed = await generateAIContextFiles(dir, storage, 'Q', { nodes: 55, edges: 66, processes: 7, }); expect(renamed.files).toContain('CLAUDE.md (updated)'); expect(await fs.readFile(path.join(dir, 'CLAUDE.md'), 'utf-8')).toContain( 'Indexed as **Q** (55 symbols, 66 relationships, 7 execution flows). Lean block.', ); } finally { await fs.rm(dir, { recursive: true, force: true }); } }); });