Compare commits

...
Author SHA1 Message Date
Gergo MagyarandCursor c44d036c27 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 <cursoragent@cursor.com>
2026-05-14 07:11:29 +01:00
Gergő Magyar 8a584dc536 Merge branch 'main' into feat/gitnexus-keep-marker-v3 2026-05-14 06:51:33 +01:00
Dennis Palatov 545ca50e56 fix: address PR #1508 review findings (F1-F5)
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.
2026-05-13 15:42:20 -07:00
dp-web4andClaude Opus 4.6 d61338294b feat: improve gitnexus:keep marker to reliably preserve custom sections
The `<!-- gitnexus:keep -->` 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) <noreply@anthropic.com>
2026-05-13 15:33:25 -07:00
Dennis PalatovandClaude Opus 4.6 0867f57931 feat: gitnexus:keep marker preserves custom context sections
When <!-- gitnexus:keep --> 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) <noreply@anthropic.com>
2026-05-13 15:33:25 -07:00
2 changed files with 358 additions and 5 deletions
+49 -4
View File
@@ -199,7 +199,9 @@ async function fileExists(filePath: string): Promise<boolean> {
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) {
@@ -223,7 +225,50 @@ 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 <!-- gitnexus:keep -->, 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`.
//
// 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('<!-- gitnexus:keep -->')) {
// 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.
// 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 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)) {
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';
}
// 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
const before = existingContent.substring(0, startIdx);
const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length);
const newContent = before + content + after;
@@ -344,12 +389,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)');
+309 -1
View File
@@ -123,9 +123,81 @@ 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:start -->
<!-- gitnexus:keep -->
# GitNexus — Code Knowledge Graph
Indexed as **TestProject** (50 symbols, 100 relationships, 5 execution flows). MCP tools.
| Tool | Use for |
|------|---------|
| query | Find flows |
Resources: gitnexus://repo/TestProject/context
<!-- gitnexus:end -->
`;
await fs.writeFile(claudeMdPath, customContent, 'utf-8');
// Run analyze with new stats — should only update the stats line
const stats = { nodes: 999, edges: 1234, processes: 42 };
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
const result = await fs.readFile(claudeMdPath, 'utf-8');
// Stats should be updated
expect(result).toContain('999 symbols');
expect(result).toContain('1234 relationships');
expect(result).toContain('42 execution flows');
expect(result).toContain('. MCP tools.');
// Custom layout should be preserved (not replaced with verbose template)
expect(result).toContain('<!-- gitnexus:keep -->');
expect(result).toContain('Code Knowledge Graph');
expect(result).toContain('| query | Find flows |');
// Verbose template sections should NOT be present
expect(result).not.toContain('## Always Do');
expect(result).not.toContain('## Never Do');
expect(result).not.toContain('## When Debugging');
// Non-GitNexus content should be preserved
expect(result).toContain('# My Project');
expect(result).toContain('Some project docs here.');
});
it('replaces section when no keep marker is present', async () => {
const agentsPath = path.join(tmpDir, 'AGENTS.md');
// Write a section WITHOUT keep marker
const content = `<!-- gitnexus:start -->
# GitNexus — Code Intelligence
Old content here.
<!-- gitnexus:end -->
`;
await fs.writeFile(agentsPath, content, 'utf-8');
const stats = { nodes: 100, edges: 200, processes: 10 };
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
const result = await fs.readFile(agentsPath, 'utf-8');
// Should have the full verbose template
expect(result).toContain('## Always Do');
expect(result).not.toContain('Old content here');
});
it('installs skills files', async () => {
const stats = { nodes: 10 };
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');
@@ -371,4 +443,240 @@ describe('generateAIContextFiles', () => {
await fs.rm(crlfDir, { recursive: true, force: true });
}
});
// ──────────────────────────────────────────────────────────────────
// Keep-marker edge cases (added to address PR #1508 review findings)
// ──────────────────────────────────────────────────────────────────
it('keep marker OUTSIDE the GitNexus section has no effect (#1508 review F5)', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-scope-'));
try {
const claudePath = path.join(dir, 'CLAUDE.md');
// Keep marker appears in user prose BEFORE the GitNexus section.
// The keep-path must NOT be triggered — full template replacement
// is the correct behavior here, because the marker is not inside
// the generated block.
const fileWithOutOfBandMarker = `# My Project
A note about <!-- gitnexus:keep --> markers: they only apply inside the
GitNexus block below, not in prose like this.
<!-- gitnexus:start -->
Old verbose stub here.
<!-- gitnexus:end -->
`;
await fs.writeFile(claudePath, fileWithOutOfBandMarker, 'utf-8');
const stats = { nodes: 50, edges: 100, processes: 5 };
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'TestProject', stats);
const result = await fs.readFile(claudePath, 'utf-8');
// Section MUST have been fully replaced — keep marker outside section ignored
expect(result).toContain('## Always Do');
expect(result).not.toContain('Old verbose stub here.');
// User's prose with the marker reference is preserved untouched
expect(result).toContain('A note about <!-- gitnexus:keep --> markers');
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
it('AGENTS.md keep path preserves custom layout (#1508 review F5)', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-agents-'));
try {
const agentsPath = path.join(dir, 'AGENTS.md');
const customAgents = `# AGENTS instructions
Project-specific agent guidance.
<!-- gitnexus:start -->
<!-- gitnexus:keep -->
# GitNexus context for AGENTS
Indexed as **AgentsTest** (10 symbols, 20 relationships, 1 execution flows).
Use 'query' for finding flows, 'context' for symbol details.
<!-- gitnexus:end -->
`;
await fs.writeFile(agentsPath, customAgents, 'utf-8');
const stats = { nodes: 777, edges: 888, processes: 9 };
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'AgentsTest', stats);
const result = await fs.readFile(agentsPath, 'utf-8');
// Stats updated
expect(result).toContain('777 symbols');
expect(result).toContain('888 relationships');
expect(result).toContain('9 execution flows');
// Custom layout preserved
expect(result).toContain('# GitNexus context for AGENTS');
expect(result).toContain("Use 'query' for finding flows");
// Verbose template NOT injected
expect(result).not.toContain('## Always Do');
// Non-GitNexus content preserved
expect(result).toContain('# AGENTS instructions');
expect(result).toContain('Project-specific agent guidance.');
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
it('idempotent: second run with keep marker produces byte-identical output (#1508 review F5)', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-idem-'));
try {
const claudePath = path.join(dir, 'CLAUDE.md');
const seed = `# Project
<!-- gitnexus:start -->
<!-- gitnexus:keep -->
Indexed as **Idem** (1 symbols, 2 relationships, 3 execution flows). Custom.
<!-- gitnexus:end -->
`;
await fs.writeFile(claudePath, seed, 'utf-8');
const stats = { nodes: 99, edges: 100, processes: 7 };
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'Idem', stats);
const afterFirst = await fs.readFile(claudePath, 'utf-8');
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'Idem', stats);
const afterSecond = await fs.readFile(claudePath, 'utf-8');
expect(afterSecond).toBe(afterFirst);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
it('CRLF file with keep marker: stats line updates without corrupting content (#1508 review F5)', async () => {
// upsertGitNexusSection writes with .trim() + '\n', so the saved file uses LF
// line endings throughout — CRLF in the seed input is not preserved.
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-crlf-'));
try {
const claudePath = path.join(dir, 'CLAUDE.md');
const crlfContent =
'# Project\r\n' +
'\r\n' +
'<!-- gitnexus:start -->\r\n' +
'<!-- gitnexus:keep -->\r\n' +
'Indexed as **CRLFTest** (5 symbols, 6 relationships, 7 execution flows). Custom CRLF.\r\n' +
'<!-- gitnexus:end -->\r\n';
await fs.writeFile(claudePath, crlfContent, 'utf-8');
const stats = { nodes: 50, edges: 60, processes: 7 };
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'CRLFTest', stats);
const result = await fs.readFile(claudePath, 'utf-8');
// Stats updated correctly
expect(result).toContain('50 symbols');
expect(result).toContain('60 relationships');
// Custom prose preserved
expect(result).toContain('Custom CRLF');
// No verbose template injected
expect(result).not.toContain('## Always Do');
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
it('noStats + keep marker: stats line 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 = `<!-- gitnexus:start -->
<!-- gitnexus:keep -->
Indexed as **NoStatsTest** (1 symbols, 1 relationships, 1 execution flows). Custom.
<!-- gitnexus:end -->
`;
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:start -->
<!-- gitnexus:keep -->
# GitNexus block (custom, no stats line)
This block intentionally omits the standard stats line.
<!-- gitnexus:end -->
`;
await fs.writeFile(claudePath, seed, 'utf-8');
const stats = { nodes: 100, edges: 200, processes: 10 };
const result = await generateAIContextFiles(
dir,
path.join(dir, '.gitnexus'),
'NoLineTest',
stats,
);
// The result manifest should reflect 'preserved', not 'updated'
expect(result.files).toContain('CLAUDE.md (preserved)');
// File on disk is unchanged
const onDisk = await fs.readFile(claudePath, 'utf-8');
expect(onDisk).toBe(seed);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
it('project name with markdown-sensitive punctuation lands intact in stats line (#1508 review F5)', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-punct-'));
try {
const claudePath = path.join(dir, 'CLAUDE.md');
const seed = `<!-- gitnexus:start -->
<!-- gitnexus:keep -->
Indexed as **placeholder** (1 symbols, 1 relationships, 1 execution flows). Custom.
<!-- gitnexus:end -->
`;
await fs.writeFile(claudePath, seed, 'utf-8');
// Name with hyphens, dot, and slash — exactly what dp-web4/some-repo
// style names look like
const trickyName = 'dp-web4/some-repo.v2';
const stats = { nodes: 5, edges: 10, processes: 1 };
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), trickyName, stats);
const result = await fs.readFile(claudePath, 'utf-8');
// The full name appears in the bold of the stats line, intact
expect(result).toContain(`Indexed as **${trickyName}** (5 symbols`);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
});