From 0867f57931341d965a4fb95689b87a2f35ec433b Mon Sep 17 00:00:00 2001 From: Dennis Palatov Date: Fri, 20 Mar 2026 23:20:51 -0700 Subject: [PATCH 1/4] feat: gitnexus:keep marker preserves custom context sections When is present inside the gitnexus block, analyze only updates the stats line instead of replacing the entire section with the verbose template. Lets users maintain lean custom context without it being overwritten on every reindex. Co-Authored-By: Claude Opus 4.6 (1M context) --- gitnexus/src/cli/ai-context.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 8e18fed7d5..d93b32e80b 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -223,7 +223,24 @@ async function upsertGitNexusSection( ); if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { - // Replace existing section + const existingSection = existingContent.substring(startIdx, endIdx + GITNEXUS_END_MARKER.length); + + // If the existing section contains , only update the stats line + // This lets users customize the section without it being overwritten on every analyze + if (existingSection.includes('')) { + // Update just the "Indexed as **Name** (N nodes, M edges, P flows)" line + const statsPattern = /Indexed as \*\*[^*]+\*\* \([^)]+\)/; + const statsLine = `Indexed as **${(content.match(/\*\*([^*]+)\*\*/)?.[1]) || 'unknown'}** (${(content.match(/\(([^)]+)\)/)?.[1]) || '0 nodes'})`; + if (statsPattern.test(existingSection)) { + const updatedSection = existingSection.replace(statsPattern, statsLine); + const before = existingContent.substring(0, startIdx); + const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length); + await fs.writeFile(filePath, (before + updatedSection + after).trim() + '\n', 'utf-8'); + return 'updated'; + } + } + + // No keep marker — replace existing section with full verbose content const before = existingContent.substring(0, startIdx); const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length); const newContent = before + content + after; From d61338294b90b8c42a5bf02d84f1c9cc4052328d Mon Sep 17 00:00:00 2001 From: dp-web4 Date: Tue, 31 Mar 2026 07:00:49 -0700 Subject: [PATCH 2/4] feat: improve gitnexus:keep marker to reliably preserve custom sections The `` marker inside a GitNexus block tells `analyze` to only update the stats line (node/edge/flow counts) while preserving the user's custom layout. This lets teams trim the verbose default template to a lean format without having it overwritten on every reindex. Changes: - Broaden stats-line regex to match both "Indexed as" and "indexed by GitNexus as" formats - Improve stats extraction from generated content (prefer structured match over greedy parentheses) - If keep marker is present but no stats line found, preserve the section as-is instead of falling through to full replace - Add tests for keep preservation and no-keep replacement Co-Authored-By: Claude Opus 4.6 (1M context) --- gitnexus/src/cli/ai-context.ts | 30 ++++++++--- gitnexus/test/unit/ai-context.test.ts | 73 ++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index d93b32e80b..d4d6129ea3 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -223,14 +223,28 @@ async function upsertGitNexusSection( ); if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { - const existingSection = existingContent.substring(startIdx, endIdx + GITNEXUS_END_MARKER.length); - - // If the existing section contains , only update the stats line - // This lets users customize the section without it being overwritten on every analyze + const existingSection = existingContent.substring( + startIdx, + endIdx + GITNEXUS_END_MARKER.length, + ); + + // If the existing section contains , preserve the user's + // custom layout and only update the stats line (node/edge/flow counts). + // This lets teams trim the verbose default template to a lean format without + // having it overwritten on every `gitnexus analyze`. if (existingSection.includes('')) { - // Update just the "Indexed as **Name** (N nodes, M edges, P flows)" line - const statsPattern = /Indexed as \*\*[^*]+\*\* \([^)]+\)/; - const statsLine = `Indexed as **${(content.match(/\*\*([^*]+)\*\*/)?.[1]) || 'unknown'}** (${(content.match(/\(([^)]+)\)/)?.[1]) || '0 nodes'})`; + // Match both formats: + // "Indexed as **name** (N symbols, M relationships, P execution flows)" + // "This project is indexed by GitNexus as **name** (N symbols, ...)" + const statsPattern = /(?:Indexed as|indexed by GitNexus as) \*\*[^*]+\*\* \([^)]+\)/; + + // Extract fresh stats from the newly generated content + const newName = (content.match(/\*\*([^*]+)\*\*/) || [])[1] || 'unknown'; + const newStats = + (content.match(/\((\d[\d,]* symbols[^)]+)\)/) || content.match(/\(([^)]+)\)/) || [])[1] || + '0 nodes'; + const statsLine = `Indexed as **${newName}** (${newStats})`; + if (statsPattern.test(existingSection)) { const updatedSection = existingSection.replace(statsPattern, statsLine); const before = existingContent.substring(0, startIdx); @@ -238,6 +252,8 @@ async function upsertGitNexusSection( await fs.writeFile(filePath, (before + updatedSection + after).trim() + '\n', 'utf-8'); return 'updated'; } + // Keep marker present but no stats line found — preserve section as-is + return 'updated'; } // No keep marker — replace existing section with full verbose content diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index 71d7ddbdc8..015d73b1fb 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -123,9 +123,80 @@ describe('generateAIContextFiles', () => { 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 **TestProject** (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 + 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'); + + // 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 skills files', async () => { const stats = { nodes: 10 }; - const result = await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats); + await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats); // Should have installed skill files const skillsDir = path.join(tmpDir, '.claude', 'skills', 'gitnexus'); From 545ca50e56da54cf98a67d6b043648b3093aaccf Mon Sep 17 00:00:00 2001 From: Dennis Palatov Date: Wed, 13 May 2026 15:42:20 -0700 Subject: [PATCH 3/4] fix: address PR #1508 review findings (F1-F5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the keep-marker stats-update path and close the test-coverage gaps surfaced by the production-readiness review. ## Findings 2 + 3 (high) — fragile extraction → silent corruption Stop re-extracting `newName` (first `**bold**`) and `newStats` (first `(...)`, with fallback) from generated content. Both are structurally fragile: - F2: newName silently picks the wrong value if the template ever emits bold text before the project-name line (no current bug; an unstated contract with no enforcement) - F3: newStats fallback `\(([^)]+)\)` matches `({target: "symbolName", direction: "upstream"})` from the Always-Do bullet when `noStats: true` suppresses the canonical stats line, silently corrupting the stats output Fix: pass `projectName: string` and `stats: RepoStats` directly into `upsertGitNexusSection`. Build the stats line from those values. Both callers in `generateAIContextFiles` already have them in scope. ## Finding 1 (high) — misleading return value When a keep marker is present but no stats line matches the pattern, the function previously returned `'updated'` without writing, producing `CLAUDE.md (updated)` in CLI output for a file that was not touched. Add a distinct `'preserved'` return variant; CLI now reports `CLAUDE.md (preserved)` honestly. ## Finding 4 (medium) — unanchored stats regex `/(?:Indexed as|...) \*\*[^*]+\*\* \([^)]+\)/` could match prose embedded mid-paragraph in user content (e.g. "you'll see it Indexed as **Foo** (note: ...)"). Anchor with `^...$` plus the `m` flag so only standalone stats lines match. ## Finding 5 — test coverage gaps Seven new tests, each cross-referenced to the review finding: - keep marker OUTSIDE the GitNexus section has no effect - AGENTS.md keep path preserves custom layout (parity with CLAUDE.md) - idempotent: second run produces byte-identical output - CRLF file with keep marker: stats line updates correctly - noStats + keep marker: not corrupted by Always-Do tuple text (F3 regression guard) - returns 'preserved' (not 'updated') when no stats line matches (F1 regression guard) - project name with markdown punctuation (hyphens/slash/dot) lands intact All 23 ai-context tests pass; typecheck, prettier, eslint clean. --- gitnexus/src/cli/ai-context.ts | 45 +++-- gitnexus/test/unit/ai-context.test.ts | 234 ++++++++++++++++++++++++++ 2 files changed, 263 insertions(+), 16 deletions(-) diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index d4d6129ea3..487a9c23fa 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -199,7 +199,9 @@ async function fileExists(filePath: string): Promise { async function upsertGitNexusSection( filePath: string, content: string, -): Promise<'created' | 'updated' | 'appended'> { + projectName: string, + stats: RepoStats, +): Promise<'created' | 'updated' | 'appended' | 'preserved'> { const exists = await fileExists(filePath); if (!exists) { @@ -232,18 +234,27 @@ async function upsertGitNexusSection( // custom layout and only update the stats line (node/edge/flow counts). // This lets teams trim the verbose default template to a lean format without // having it overwritten on every `gitnexus analyze`. + // + // Note: the keep-marker check operates on `existingSection` (the substring + // between valid section markers identified by findSectionMarkerIndex), so + // a keep marker in user prose OUTSIDE the GitNexus block has no effect. if (existingSection.includes('')) { - // Match both formats: - // "Indexed as **name** (N symbols, M relationships, P execution flows)" - // "This project is indexed by GitNexus as **name** (N symbols, ...)" - const statsPattern = /(?:Indexed as|indexed by GitNexus as) \*\*[^*]+\*\* \([^)]+\)/; - - // Extract fresh stats from the newly generated content - const newName = (content.match(/\*\*([^*]+)\*\*/) || [])[1] || 'unknown'; - const newStats = - (content.match(/\((\d[\d,]* symbols[^)]+)\)/) || content.match(/\(([^)]+)\)/) || [])[1] || - '0 nodes'; - const statsLine = `Indexed as **${newName}** (${newStats})`; + // Build the new stats line from the caller-provided values directly. + // We do NOT re-extract from `content` because: + // (a) first-bold extraction is fragile if the template evolves + // (b) the parenthesized-text fallback can match unrelated tuples + // like `({target: "symbolName", direction: "upstream"})` + // when noStats is set + // Passing projectName + stats explicitly makes the contract obvious. + const newStatsInner = `${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows`; + const statsLine = `Indexed as **${projectName}** (${newStatsInner})`; + + // Match either canonical phrasing, anchored to line boundaries (`^`/`$` + // with `m` flag) so we cannot replace prose embedded mid-paragraph like + // "you'll see it Indexed as **Foo** (note: ...)". The trailing period + // / sentence text the generator emits is preserved by sitting outside + // the matched pattern. + const statsPattern = /^(?:Indexed as|indexed by GitNexus as) \*\*[^*]+\*\* \([^)]+\)/m; if (statsPattern.test(existingSection)) { const updatedSection = existingSection.replace(statsPattern, statsLine); @@ -252,8 +263,10 @@ async function upsertGitNexusSection( await fs.writeFile(filePath, (before + updatedSection + after).trim() + '\n', 'utf-8'); return 'updated'; } - // Keep marker present but no stats line found — preserve section as-is - return 'updated'; + // Keep marker present but no stats line matched. Section is preserved + // unchanged on disk; return a distinct status so callers/CLI output + // don't mis-report this as 'updated' (which would imply a write). + return 'preserved'; } // No keep marker — replace existing section with full verbose content @@ -377,12 +390,12 @@ 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); + const agentsResult = await upsertGitNexusSection(agentsPath, content, projectName, stats); createdFiles.push(`AGENTS.md (${agentsResult})`); // Create CLAUDE.md (for Claude Code) const claudePath = path.join(repoPath, 'CLAUDE.md'); - const claudeResult = await upsertGitNexusSection(claudePath, content); + const claudeResult = await upsertGitNexusSection(claudePath, content, projectName, stats); 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 015d73b1fb..bf614b9bd3 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -442,4 +442,238 @@ Old content here. 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 **AgentsTest** (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 () => { + 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 **CRLFTest** (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 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. + 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'); + + const stats = { nodes: 42, edges: 84, processes: 3 }; + await generateAIContextFiles( + dir, + path.join(dir, '.gitnexus'), + 'NoStatsTest', + 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('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 }); + } + }); }); From c44d036c27b5f9e936467df8094e3a3c7d8984dc Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 14 May 2026 07:11:29 +0100 Subject: [PATCH 4/4] docs(ai-context): address PR #1508 review findings on keep-marker path - Clarify that noStats affects generated template only, not keep-section stats updates - Fix stats-line regex comment to match behavior (no end anchor; trailing suffix kept) - Assert '. MCP tools.' survives stats replacement in preserve-custom-section test - Document LF normalization when rewriting CRLF seed in keep-marker CRLF test Co-authored-by: Cursor --- gitnexus/src/cli/ai-context.ts | 9 ++++----- gitnexus/test/unit/ai-context.test.ts | 3 +++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 487a9c23fa..42dcb7aa7b 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -246,14 +246,13 @@ 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. const newStatsInner = `${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows`; const statsLine = `Indexed as **${projectName}** (${newStatsInner})`; - // Match either canonical phrasing, anchored to line boundaries (`^`/`$` - // with `m` flag) so we cannot replace prose embedded mid-paragraph like - // "you'll see it Indexed as **Foo** (note: ...)". The trailing period - // / sentence text the generator emits is preserved by sitting outside - // the matched pattern. + // 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; if (statsPattern.test(existingSection)) { diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index bf614b9bd3..13e927637c 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -156,6 +156,7 @@ Resources: gitnexus://repo/TestProject/context 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(''); @@ -547,6 +548,8 @@ Indexed as **Idem** (1 symbols, 2 relationships, 3 execution flows). Custom. }); 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');