diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 42dcb7aa7b..1eecc00b47 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -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); @@ -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); @@ -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)'); diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index 68dee21dda..b0f17ae61c 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -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 = ` Indexed as **NoStatsTest** (1 symbols, 1 relationships, 1 execution flows). Custom. `; - 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( @@ -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 = ` + +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'); - // 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 = ` + +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 });