Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions gitnexus/src/cli/ai-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ async function upsertGitNexusSection(
content: string,
projectName: string,
stats: RepoStats,
noStats?: boolean,
): Promise<'created' | 'updated' | 'appended' | 'preserved'> {
const exists = await fileExists(filePath);

Expand Down Expand Up @@ -246,14 +247,23 @@ async function upsertGitNexusSection(
// like `({target: "symbolName", direction: "upstream"})`
// when noStats is set
// Passing projectName + stats explicitly makes the contract obvious.
// noStats controls template generation, not keep-section stat updates — the user opted into a stats line by keeping it.
// --no-stats wins in the keep path too (#1706): a lean block committed
// to git would otherwise churn the volatile counts on every analyze,
// producing no-value merge conflicts between branches. Under noStats we
// drop the parenthetical but still refresh the project name so renames
// propagate.
const newStatsInner = `${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows`;
const statsLine = `Indexed as **${projectName}** (${newStatsInner})`;
const statsLine = noStats
? `Indexed as **${projectName}**`
: `Indexed as **${projectName}** (${newStatsInner})`;

// Match either canonical phrasing at line start (`^` with `m` flag) so we
// cannot replace prose embedded mid-paragraph. Deliberately no `$`: text
// after the closing `)` on the same line (e.g. ". MCP tools.") stays intact.
const statsPattern = /^(?:Indexed as|indexed by GitNexus as) \*\*[^*]+\*\* \([^)]+\)/m;
// after the line on the same line (e.g. ". MCP tools.") stays intact.
// The parenthetical is optional so a count-free line left by a prior
// --no-stats run still matches — letting the name refresh, and letting
// counts return if --no-stats is later dropped.
const statsPattern = /^(?:Indexed as|indexed by GitNexus as) \*\*[^*]+\*\*(?: \([^)]+\))?/m;

if (statsPattern.test(existingSection)) {
const updatedSection = existingSection.replace(statsPattern, statsLine);
Expand Down Expand Up @@ -389,12 +399,24 @@ export async function generateAIContextFiles(
if (!options?.skipAgentsMd) {
// Create AGENTS.md (standard for Cursor, Windsurf, OpenCode, Cline, etc.)
const agentsPath = path.join(repoPath, 'AGENTS.md');
const agentsResult = await upsertGitNexusSection(agentsPath, content, projectName, stats);
const agentsResult = await upsertGitNexusSection(
agentsPath,
content,
projectName,
stats,
options?.noStats,
);
createdFiles.push(`AGENTS.md (${agentsResult})`);

// Create CLAUDE.md (for Claude Code)
const claudePath = path.join(repoPath, 'CLAUDE.md');
const claudeResult = await upsertGitNexusSection(claudePath, content, projectName, stats);
const claudeResult = await upsertGitNexusSection(
claudePath,
content,
projectName,
stats,
options?.noStats,
);
createdFiles.push(`CLAUDE.md (${claudeResult})`);
} else {
createdFiles.push('AGENTS.md (skipped via --skip-agents-md)');
Expand Down
105 changes: 90 additions & 15 deletions gitnexus/test/unit/ai-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,21 +626,28 @@ Indexed as **Idem** (1 symbols, 2 relationships, 3 execution flows). Custom.
}
});

it('noStats + keep marker: stats line update is NOT corrupted by Always-Do tuple text (#1508 review F3)', async () => {
// Regression guard: with the old fallback regex `\(([^)]+)\)`, when
// noStats=true suppressed the canonical stats line from generated
// content, the fallback matched the FIRST parenthesized text in the
// template, which was `({target: "symbolName", direction: "upstream"})`
// from the Always Do bullet — silently writing that as the stats line.
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 claudePath = path.join(dir, 'CLAUDE.md');
const seed = `<!-- gitnexus:start -->
<!-- gitnexus:keep -->
Indexed as **NoStatsTest** (1 symbols, 1 relationships, 1 execution flows). Custom.
<!-- gitnexus:end -->
`;
await fs.writeFile(claudePath, seed, 'utf-8');
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(
Expand All @@ -649,16 +656,84 @@ Indexed as **NoStatsTest** (1 symbols, 1 relationships, 1 execution flows). Cust
'NoStatsTest',
stats,
undefined,
{ noStats: true },
{
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');
// Stats line MUST NOT have been corrupted with the Always-Do tuple text
expect(result).not.toMatch(/\(\{target:/);
expect(result).not.toMatch(/direction:\s*"upstream"/);
// Stats line should reflect a sensible numeric update (passed stats)
expect(result).toContain('42 symbols');
// Custom prose still preserved
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 });
Expand Down
Loading