fix(installer): upgrade keeps everything around CodeGraph's section in instructions files (#2765)

Root cause: the write that refreshes CodeGraph's marker-fenced section in
an agent's instructions file (CLAUDE.md, AGENTS.md, GEMINI.md) located the
section with two independent searches: the first start marker anywhere in
the file and the first end marker anywhere in the file. Everything between
them was treated as CodeGraph's and replaced. A stray start marker above
the real section (what is left when part of an older section is deleted by
hand, or the marker quoted in the user's own notes) therefore stretched the
"section" over the user's text, and when that marker was the first line
the file came back as just the block, which is the report. The
`codegraph upgrade` refresh (`codegraph install --refresh`) and plain
`codegraph install` both go through this write, for the Claude Code, Codex,
Gemini and opencode targets. The same pairing made a lone end marker look
like "no section", so every run appended another block, and left a second
block stale. Uninstall (and the npm preuninstall hook) and the Cursor
rules-file cleanup used the same pairing to delete the same span.

Fix: one parser finds the well-formed sections. Each end marker closes the
nearest start marker before it, and a marker counts only when it is alone
on its line, as every block codegraph has written puts it. A stray start
or end marker is left as plain text. The refresh rewrites only the bytes
between the first section's markers, using the line breaks that section
already has, so a CRLF file stays CRLF and a current CRLF block is
unchanged. Further sections are duplicates and are removed, keeping the
text around them. With no section yet the block is appended after the
existing bytes, which are no longer trimmed. Uninstall and the Cursor
cleanup strip only well-formed sections.

Validation:
- 18 new tests (17 in installer-targets.test.ts: exact bytes for a stray
  start marker on line 1, a stray start marker with no section after it, a
  marker quoted in prose, a lone end marker, two sections, CRLF, no final
  newline, appending, a BOM, uninstall; a refresh plus uninstall per
  target for Claude Code, Codex, Gemini and opencode; the Cursor rules
  file. 1 in upgrade.test.ts: an npm upgrade whose refresh runs the built
  CLI against a private home). 16 fail on main's sources (the other 2 are
  guards that hold on both), all pass with the fix.
- The real CLI on this Windows machine against private temp homes:
  `codegraph install --refresh`, 14 file shapes x 4 targets x 2 runs. Main
  had 28 bad results of 112 (the user's text lost in 3 shapes, a block
  added on every run after a lone end marker, a stale second block kept),
  the fix 0. With the fix, the bytes outside the section are identical in
  every single-section shape, and the input stays a byte prefix in every
  append.
- `codegraph install --yes` and `codegraph uninstall --yes --keep-cli` on
  the reported shape: main kept none of the notes (install left only the
  802-byte block, uninstall left 19 bytes); the fix kept them all.

Fixes #2506

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-22 00:33:16 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent efff745783
commit d6e8c91e0a
5 changed files with 429 additions and 41 deletions
+1
View File
@@ -35,6 +35,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- VB.NET designer code such as `New System.Drawing.Point(…)` or `Me.Size = New System.Drawing.Size(…)` is no longer linked to a project type or member that only shares the name, and a class nested inside another is matched by its bare name only from inside that class or one that inherits it.
- Upgrading CodeGraph while an agent session is open no longer leaves the old version's background server in charge of your project: the first session started from the new install stops it and starts a current one in its place, even while sessions opened before the upgrade are still running. That old server could no longer load the language parsers the upgrade removed, so it saved every file it re-indexed with no symbols, while new sessions could only read the index beside it without keeping it up to date. A background server from a newer install is never stopped, and sessions opened before the upgrade keep the old version until you restart them. Thanks @lipchey for the report. (#2335)
- A file is no longer saved with no symbols when its language parser can't be loaded, which is what happened to every file a background server re-indexed after an upgrade removed its install: the file keeps what it had and is indexed again once the parser loads, and files an earlier version emptied this way are re-indexed by the next sync. A background server also exits on its own once its install is upgraded or removed, so the next session starts one from the current install. Thanks @lipchey for the report. (#2335)
- `codegraph upgrade` no longer wipes your agent instructions file, such as a global `AGENTS.md`, `CLAUDE.md` or `GEMINI.md`, when it refreshes CodeGraph's section in it: a stray `<!-- CODEGRAPH_START -->` line above that section, like one left behind when part of an older section was deleted by hand, made the refresh replace everything in between, often the whole file. CodeGraph now rewrites only its own section, in `codegraph install` and `codegraph uninstall` too, and keeps everything around it exactly as it was, Windows line endings included. Thanks @pzaj2 for the report. (#2506)
- `codegraph status` no longer says the index is up to date while indexed files are missing their symbols: it now names files the parser couldn't read and files stored without their symbols (which `codegraph sync` repairs), `status --json` counts both, and `codegraph files --json` lists each file's recorded errors. Thanks @lipchey for the report. (#2336)
- Indexing large Python projects is much faster again and needs less memory: since 1.6.2, resolving Python references re-read source files over and over, so a project the size of CPython took several times as long to index. The graph it builds is unchanged. Thanks @bompus for the report. (#2332)
- In VB.NET, reading or setting a `Shared` field or property through its class or module name, like `AppSession.SessionId`, `AppSession.CurrentUser = "demo"` or `AppSession.Items(0)`, now counts as a use of that member and of the class, and so do reading an `Enum` value like `Mode.Fast` and calling a `Shared` function without parentheses. Before, only calls written with parentheses were linked, so `codegraph callers` on such a field, property or class came back empty and impact missed most of the code that depends on it. A local, parameter or field that only shares a class's name is not mistaken for the class. Thanks @serkanince for the report and @ChrisPrapas. (#2305)
+201
View File
@@ -24,6 +24,12 @@ import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targ
import { uninstallTargets, refreshTargets } from '../src/installer';
import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml';
import { cleanupLegacyHooks, writePromptHookEntry, removePromptHookEntry } from '../src/installer/targets/claude';
import { replaceOrAppendMarkedSection, removeMarkedSection } from '../src/installer/targets/shared';
import {
CODEGRAPH_INSTRUCTIONS_BLOCK,
CODEGRAPH_SECTION_END,
CODEGRAPH_SECTION_START,
} from '../src/installer/instructions-template';
function mkTmpDir(label: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), `cg-targets-${label}-`));
@@ -1798,6 +1804,190 @@ describe('Installer — refreshTargets sweep (codegraph install --refresh)', ()
});
});
// ---------------------------------------------------------------------------
// The instructions block never touches anything outside its markers (#2506)
//
// `codegraph upgrade` runs `install --refresh`, which rewrites the block in
// every configured agent's instructions file. The write paired the FIRST
// start marker with the FIRST end marker anywhere in the file, so one stray
// start marker above the real block — an older block whose end marker was
// deleted by hand, or the marker quoted in the user's own notes — made the
// refresh replace everything between the two. With the stray marker on the
// first line, the reported global AGENTS.md came back as just the block.
// ---------------------------------------------------------------------------
describe('Installer — the instructions block keeps everything outside its markers (#2506)', () => {
const START = CODEGRAPH_SECTION_START;
const END = CODEGRAPH_SECTION_END;
const BLOCK = CODEGRAPH_INSTRUCTIONS_BLOCK;
const BOM = String.fromCharCode(0xfeff);
const crlf = (s: string) => s.replace(/\n/g, '\r\n');
// A maintained guidance file like the reporter's, with text below the block too.
const NOTES = [
'# Global agent guidance',
'',
'## Toolset inventory',
'- ripgrep, fd, jq',
'',
'## Commit policy',
'- conventional commits',
].join('\n');
const MORE = ['## Runtime notes', '', '- prefer the sandbox'].join('\n');
// What is left of an older block whose end marker was deleted by hand.
const STRAY = `${START}\n## CodeGraph\n\n`;
let dir: string;
let file: string;
beforeEach(() => {
dir = mkTmpDir('marked');
file = path.join(dir, 'AGENTS.md');
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
function upsert(content: string): { action: string; after: string } {
fs.writeFileSync(file, content);
const action = replaceOrAppendMarkedSection(file, BLOCK, START, END);
return { action, after: fs.readFileSync(file, 'utf-8') };
}
function upsertAgain(): { action: string; after: string } {
const action = replaceOrAppendMarkedSection(file, BLOCK, START, END);
return { action, after: fs.readFileSync(file, 'utf-8') };
}
function remove(content: string): { action: string; after: string | null } {
fs.writeFileSync(file, content);
const action = removeMarkedSection(file, START, END);
return { action, after: fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null };
}
it('a stray start marker on the first line no longer reduces the file to the block (the report)', () => {
const { action, after } = upsert(`${STRAY}${NOTES}\n\n${LEGACY_BLOCK}\n`);
expect(action).toBe('updated');
expect(after).toBe(`${STRAY}${NOTES}\n\n${BLOCK}\n`);
expect(upsertAgain()).toEqual({ action: 'unchanged', after });
});
it('a stray start marker with no block after it: the block is appended, and the next run keeps the text below the stray marker', () => {
const content = `${NOTES}\n\n${STRAY}old text, end marker deleted\n\n${MORE}\n`;
const first = upsert(content);
expect(first.action).toBe('appended');
expect(first.after).toBe(`${content}\n${BLOCK}\n`);
expect(upsertAgain()).toEqual({ action: 'unchanged', after: first.after });
});
it('the start marker quoted in a sentence of the user\'s is their text, not the edge of a block', () => {
const prose = `Keep your own notes above the ${START} line.\n\n`;
const { after } = upsert(`${prose}${NOTES}\n\n${LEGACY_BLOCK}\n`);
expect(after).toBe(`${prose}${NOTES}\n\n${BLOCK}\n`);
});
it('a lone end marker no longer makes every run append another block', () => {
const content = `${NOTES}\n\nold text, start marker deleted\n${END}\n\n${MORE}\n`;
const first = upsert(content);
expect(first.after).toBe(`${content}\n${BLOCK}\n`);
const second = upsertAgain();
expect(second).toEqual({ action: 'unchanged', after: first.after });
});
it('two blocks: the first is refreshed, the duplicate removed, and the text between them kept', () => {
const { action, after } = upsert(`${NOTES}\n\n${LEGACY_BLOCK}\n\n${MORE}\n\n${LEGACY_BLOCK}\n`);
expect(action).toBe('updated');
expect(after).toBe(`${NOTES}\n\n${BLOCK}\n\n${MORE}\n`);
expect(upsertAgain()).toEqual({ action: 'unchanged', after });
});
it('a CRLF file stays CRLF: the text around the block is untouched and the block takes the file\'s line breaks', () => {
const { action, after } = upsert(crlf(`${NOTES}\n\n${LEGACY_BLOCK}\n\n${MORE}\n`));
expect(action).toBe('updated');
expect(after).toBe(crlf(`${NOTES}\n\n${BLOCK}\n\n${MORE}\n`));
expect(upsertAgain()).toEqual({ action: 'unchanged', after });
});
it('a CRLF file that already holds the current block is left alone', () => {
const content = crlf(`${NOTES}\n\n${BLOCK}\n`);
expect(upsert(content)).toEqual({ action: 'unchanged', after: content });
});
it('a file without a trailing newline keeps that shape', () => {
expect(upsert(`${NOTES}\n\n${LEGACY_BLOCK}`).after).toBe(`${NOTES}\n\n${BLOCK}`);
});
it('appending keeps every existing byte: nothing is trimmed, only line breaks are added', () => {
expect(upsert(NOTES).after).toBe(`${NOTES}\n\n${BLOCK}\n`);
expect(upsert(`${NOTES}\n`).after).toBe(`${NOTES}\n\n${BLOCK}\n`);
expect(upsert(`${NOTES}\n\n\n`).after).toBe(`${NOTES}\n\n\n${BLOCK}\n`);
expect(upsert(crlf(`${NOTES}\n`)).after).toBe(crlf(`${NOTES}\n\n${BLOCK}\n`));
});
it('a byte-order mark survives', () => {
expect(upsert(`${BOM}${NOTES}\n\n${LEGACY_BLOCK}\n`).after).toBe(`${BOM}${NOTES}\n\n${BLOCK}\n`);
expect(upsert(`${BOM}${LEGACY_BLOCK}\n\n${NOTES}\n`).after).toBe(`${BOM}${BLOCK}\n\n${NOTES}\n`);
});
it('uninstall removes only the block, never the text after a stray start marker', () => {
const { action, after } = remove(`${STRAY}${NOTES}\n\n${LEGACY_BLOCK}\n\n${MORE}\n`);
expect(action).toBe('removed');
expect(after).toBe(`${STRAY}${NOTES}\n\n${MORE}\n`);
});
it('uninstall removes every block, keeps CRLF, and leaves a file holding only stray markers alone', () => {
expect(remove(crlf(`${NOTES}\n\n${LEGACY_BLOCK}\n\n${MORE}\n\n${LEGACY_BLOCK}\n`)).after).toBe(crlf(`${NOTES}\n\n${MORE}\n`));
const strays = `${NOTES}\n${START}\n${MORE}\n${END}x\n`;
expect(remove(strays)).toEqual({ action: 'not-found', after: strays });
// A file that held nothing but the block is deleted, as before.
expect(remove(`${LEGACY_BLOCK}\n`)).toEqual({ action: 'removed', after: null });
});
});
describe('Installer — install --refresh keeps each agent\'s instructions file (#2506)', () => {
let tmpHome: string;
let tmpCwd: string;
let origCwd: string;
let homeRestore: { restore: () => void };
beforeEach(() => {
tmpHome = mkTmpDir('rf2506-home');
tmpCwd = mkTmpDir('rf2506-cwd');
origCwd = process.cwd();
process.chdir(tmpCwd);
homeRestore = setHome(tmpHome);
});
afterEach(() => {
homeRestore.restore();
process.chdir(origCwd);
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpCwd, { recursive: true, force: true });
});
// Every target that writes an instructions file, at its global path.
const INSTRUCTIONS: Array<[string, (home: string) => string]> = [
['claude', (home) => path.join(home, '.claude', 'CLAUDE.md')],
['codex', (home) => path.join(home, '.codex', 'AGENTS.md')],
['gemini', (home) => path.join(home, '.gemini', 'GEMINI.md')],
['opencode', (home) => path.join(home, '.config', 'opencode', 'AGENTS.md')],
];
const USER = `${CODEGRAPH_SECTION_START}\n## CodeGraph\n\n# My notes\n\nAlways run the tests first.\n\n`;
for (const [id, instructionsFile] of INSTRUCTIONS) {
it(`${id}: the refresh rewrites only the block, and uninstall removes only the block`, () => {
const target = getTarget(id)!;
target.install('global', { autoAllow: false });
const file = instructionsFile(tmpHome);
fs.writeFileSync(file, `${USER}${LEGACY_BLOCK}\n`);
const report = refreshTargets(ALL_TARGETS, 'global').find((r) => r.id === id)!;
expect(report.status).toBe('refreshed');
expect(report.changedPaths).toContain(file);
expect(fs.readFileSync(file, 'utf-8')).toBe(`${USER}${CODEGRAPH_INSTRUCTIONS_BLOCK}\n`);
expect(refreshTargets(ALL_TARGETS, 'global').find((r) => r.id === id)!.status).toBe('unchanged');
target.uninstall('global');
expect(fs.readFileSync(file, 'utf-8')).toBe(USER.trimEnd() + '\n');
});
}
});
describe('Installer — detection never writes (#1870)', () => {
let tmpHome: string;
let tmpCwd: string;
@@ -1946,6 +2136,17 @@ describe('Installer — Cursor rules file cleanup on uninstall', () => {
expect(after).not.toContain('codegraph_search');
expect(after).not.toContain('CODEGRAPH_START');
});
it('a stray start marker above the block never takes the user\'s rules with it (#2506)', () => {
fs.mkdirSync(path.dirname(rulesFile()), { recursive: true });
const userRules = `${CODEGRAPH_SECTION_START}\n## My own rule\nkeep me\n`;
fs.writeFileSync(rulesFile(), `${MDC_FRONTMATTER}${userRules}\n${LEGACY_BLOCK}\n`);
cursor.uninstall('local');
expect(fs.existsSync(rulesFile())).toBe(true);
expect(fs.readFileSync(rulesFile(), 'utf-8')).toBe(`${MDC_FRONTMATTER}${userRules}`);
});
});
function listAllFiles(dir: string): string[] {
+71
View File
@@ -2,6 +2,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { spawnSync } from 'node:child_process';
import { CODEGRAPH_INSTRUCTIONS_BLOCK } from '../src/installer/instructions-template';
import {
detectInstallMethod,
deriveInstallDir,
@@ -824,6 +826,75 @@ describe('post-upgrade refresh of installed agent surfaces', () => {
});
});
// ---------------------------------------------------------------------------
// The refresh an upgrade runs keeps the user's instructions files (#2506)
// ---------------------------------------------------------------------------
describe('post-upgrade refresh keeps the user\'s instructions files (#2506)', () => {
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
let home: string;
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-upgrade-2506-'));
});
afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
});
it('an npm upgrade refreshes the block in the global opencode AGENTS.md and keeps every line around it', async () => {
// opencode configured globally, as a previous `codegraph install` left it.
const dir = path.join(home, '.config', 'opencode');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'opencode.jsonc'), JSON.stringify({
$schema: 'https://opencode.ai/config.json',
mcp: { servers: { codegraph: { type: 'local', command: ['codegraph', 'serve', '--mcp'], disabled: false, codemode: false } } },
}, null, 2) + '\n');
// The user's maintained guidance, opening with what is left of an older
// block whose end marker was deleted, and an old block at the end.
const agentsMd = path.join(dir, 'AGENTS.md');
const notes = '<!-- CODEGRAPH_START -->\n## CodeGraph\n\n# Toolset\n\n- ripgrep, fd\n\n## Commit policy\n\n- conventional commits\n\n';
const oldBlock = '<!-- CODEGRAPH_START -->\n## CodeGraph\n\nPrefer `codegraph_search` over grep.\n<!-- CODEGRAPH_END -->\n';
fs.writeFileSync(agentsMd, notes + oldBlock);
const work = path.join(home, 'work');
fs.mkdirSync(work);
const env: NodeJS.ProcessEnv = {
...process.env,
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: path.join(home, '.config'),
APPDATA: path.join(home, 'AppData', 'Roaming'),
LOCALAPPDATA: path.join(home, 'AppData', 'Local'),
CODEGRAPH_NO_DAEMON: '1',
CODEGRAPH_TELEMETRY: '0',
CODEGRAPH_NO_UPDATE_CHECK: '1',
DO_NOT_TRACK: '1',
};
for (const key of ['CODEX_HOME', 'COPILOT_HOME', 'HERMES_HOME', 'CLAUDE_CONFIG_DIR', 'CODEGRAPH_NO_INSTALL_REFRESH']) delete env[key];
const { deps, calls } = makeDeps({
method: { kind: 'npm', scope: 'global' },
currentVersion: '0.9.8',
hasCommand: (cmd) => cmd === 'codegraph',
});
// `npm install -g` succeeds without running; the refresh it is followed
// by runs the real CLI against the private home.
deps.run = (cmd, args, runEnv) => {
calls.runs.push({ cmd, args, env: runEnv });
if (cmd !== 'codegraph') return 0;
const r = spawnSync(process.execPath, [BIN, ...args], { cwd: work, env, encoding: 'utf-8', timeout: 60_000 });
return r.status ?? -1;
};
expect(await runUpgrade({}, deps)).toBe(0);
const refresh = calls.runs[calls.runs.length - 1];
expect(refresh?.cmd).toBe('codegraph');
expect(refresh?.args).toEqual(['install', '--refresh']);
expect(fs.readFileSync(agentsMd, 'utf-8')).toBe(notes + CODEGRAPH_INSTRUCTIONS_BLOCK + '\n');
}, 90_000);
});
// ---------------------------------------------------------------------------
// Post-upgrade version probe — does the PATH-resolved `codegraph` serve the
// version we just installed, in THIS terminal?
+9 -9
View File
@@ -46,6 +46,7 @@ import {
getMcpServerConfig,
jsonDeepEqual,
readJsonFile,
stripMarkedSections,
writeJsonFile,
} from './shared';
import {
@@ -217,18 +218,17 @@ function removeRulesEntry(): WriteResult['files'][number] {
}
const ourFrontmatter = MDC_FRONTMATTER.trim();
const startIdx = content.indexOf(CODEGRAPH_SECTION_START);
const endIdx = content.indexOf(CODEGRAPH_SECTION_END);
// Our marked block is present — strip it, then decide what's left.
if (startIdx !== -1 && endIdx > startIdx) {
const before = content.substring(0, startIdx).trimEnd();
const after = content.substring(endIdx + CODEGRAPH_SECTION_END.length).trimStart();
const remainder = (before + (before && after ? '\n\n' : '') + after).trim();
if (remainder === '' || remainder === ourFrontmatter) {
// Our marked block is present — strip it, then decide what's left. Only
// well-formed blocks are stripped, so a stray marker never takes the
// user's rules with it (#2506).
const remainder = stripMarkedSections(content, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END);
if (remainder !== null) {
const rest = remainder.trim();
if (rest === '' || rest === ourFrontmatter) {
try { fs.unlinkSync(file); } catch { /* ignore */ }
} else {
atomicWriteFileSync(file, remainder + '\n');
atomicWriteFileSync(file, remainder);
}
return { path: file, action: 'removed' };
}
+147 -32
View File
@@ -154,17 +154,111 @@ export function jsonDeepEqual(a: unknown, b: unknown): boolean {
return ak.every((k) => jsonDeepEqual(ao[k], bo[k]));
}
/** One marker-delimited block: `start` is the start marker's offset, `end` is just past the end marker. */
interface MarkedSection {
start: number;
end: number;
}
/**
* True when the marker at `at` is alone on its line: only spaces or tabs
* around it (plus a file-leading BOM, or the `\r` of a CRLF). Every block
* codegraph has ever written puts its markers on lines of their own, so a
* marker the user quotes inside a sentence is their text, not a block edge.
*/
function markerOnOwnLine(content: string, at: number, length: number): boolean {
for (let i = at - 1; i >= 0 && content[i] !== '\n'; i--) {
if (content[i] !== ' ' && content[i] !== '\t' && content[i] !== '\uFEFF') return false;
}
for (let i = at + length; i < content.length && content[i] !== '\n'; i++) {
if (content[i] !== ' ' && content[i] !== '\t' && content[i] !== '\r') return false;
}
return true;
}
/**
* Every well-formed marker block in `content`, in file order. Each end
* marker closes the nearest start marker before it, so a block never
* contains another start marker. A start marker left without its end (half
* of a block deleted by hand) and an end marker without its start are plain
* text — neither can stretch a block over the user's own content.
*
* Issue #2506: the first start marker was paired with the first end marker
* anywhere in the file, so one stray start marker above the real block made
* an upgrade's refresh replace everything between them — the whole file,
* when the stray marker was its first line.
*/
function findMarkedSections(content: string, startMarker: string, endMarker: string): MarkedSection[] {
const marks: Array<{ at: number; isStart: boolean }> = [];
for (const [marker, isStart] of [[startMarker, true], [endMarker, false]] as const) {
for (let at = content.indexOf(marker); at !== -1; at = content.indexOf(marker, at + marker.length)) {
if (markerOnOwnLine(content, at, marker.length)) marks.push({ at, isStart });
}
}
marks.sort((a, b) => a.at - b.at);
const sections: MarkedSection[] = [];
let open = -1;
for (const mark of marks) {
if (mark.isStart) {
open = mark.at;
} else if (open !== -1) {
sections.push({ start: open, end: mark.at + endMarker.length });
open = -1;
}
}
return sections;
}
/** The line break `text` uses (its first one); `\n` when it has none. */
function lineBreakOf(text: string): '\n' | '\r\n' {
const nl = text.indexOf('\n');
return nl > 0 && text[nl - 1] === '\r' ? '\r\n' : '\n';
}
function withLineBreaks(text: string, eol: string): string {
return text.replace(/\r?\n/g, eol);
}
/** True when `text` holds nothing but whitespace (and a BOM). */
function isBlank(text: string): boolean {
return text.replace(/^\uFEFF/, '').trim() === '';
}
/**
* `content` without the given blocks. Only the whitespace touching a removed
* block changes: the text on either side is rejoined with one blank line, or
* with nothing at the start or end of the file.
*/
function dropMarkedSections(content: string, sections: readonly MarkedSection[], eol: string): string {
let out = content.slice(0, sections[0]!.start);
for (let i = 0; i < sections.length; i++) {
const nextStart = i + 1 < sections.length ? sections[i + 1]!.start : content.length;
const left = out.replace(/[ \t\r\n]+$/, '');
const right = content.slice(sections[i]!.end, nextStart).replace(/^[ \t\r\n]+/, '');
if (right === '') out = isBlank(left) ? left : left + eol;
else if (isBlank(left)) out = left + right;
else out = left + eol + eol + right;
}
return out;
}
/**
* Replace or append a marker-delimited section in a markdown-ish file.
*
* Used by Claude / Codex for the `<!-- CODEGRAPH_START --> ... <!--
* CODEGRAPH_END -->` block. Preserves all content outside the
* markers verbatim.
* Used for the `<!-- CODEGRAPH_START --> ... <!-- CODEGRAPH_END -->` block
* every target writes into its instructions file. Only the bytes between the
* first block's markers change: everything outside them — the user's text,
* their line endings, a missing final newline, a BOM, a stray marker — is
* kept byte-for-byte. The block is written with the line breaks it already
* had, so a CRLF file stays CRLF. Further blocks (duplicates) are removed,
* keeping the text around them. With no block yet, the section is appended
* after the existing bytes, which are left exactly as they were.
*
* Returns `created` when the file didn't exist; `updated` when
* markers were found and content swapped; `appended` when markers
* weren't found and section was added at end. `unchanged` when the
* existing block already matches `body`.
* Returns `created` when the file didn't exist; `updated` when a block was
* found and rewritten; `appended` when none was found and the section was
* added at the end; `unchanged` when the file already holds exactly one
* block matching `body`.
*/
export function replaceOrAppendMarkedSection(
filePath: string,
@@ -178,34 +272,49 @@ export function replaceOrAppendMarkedSection(
}
const content = fs.readFileSync(filePath, 'utf-8');
const startIdx = content.indexOf(startMarker);
const endIdx = content.indexOf(endMarker);
const sections = findMarkedSections(content, startMarker, endMarker);
if (startIdx !== -1 && endIdx > startIdx) {
const existingBlock = content.substring(startIdx, endIdx + endMarker.length);
if (existingBlock === body) {
if (sections.length > 0) {
const [first, ...duplicates] = sections;
const block = withLineBreaks(body, lineBreakOf(content.slice(first!.start, first!.end)));
let next = content.slice(0, first!.start) + block + content.slice(first!.end);
if (duplicates.length > 0) {
const shift = block.length - (first!.end - first!.start);
next = dropMarkedSections(
next,
duplicates.map((s) => ({ start: s.start + shift, end: s.end + shift })),
lineBreakOf(content),
);
}
if (next === content) {
return 'unchanged';
}
const before = content.substring(0, startIdx);
const after = content.substring(endIdx + endMarker.length);
atomicWriteFileSync(filePath, before + body + after);
atomicWriteFileSync(filePath, next);
return 'updated';
}
// No markers — append. Preserve existing content with a separating
// blank line.
const trimmed = content.trimEnd();
const sep = trimmed.length > 0 ? '\n\n' : '';
atomicWriteFileSync(filePath, trimmed + sep + body + '\n');
// No block yet — append one, separated by a blank line. Nothing already in
// the file is trimmed or rewritten; only line breaks are added, in the
// file's own style.
const eol = lineBreakOf(content);
const block = withLineBreaks(body, eol);
if (isBlank(content)) {
atomicWriteFileSync(filePath, (content.startsWith('\uFEFF') ? '\uFEFF' : '') + block + eol);
return 'appended';
}
const sep = /\n[ \t]*\r?\n[ \t\r]*$/.test(content) ? '' : /\n[ \t\r]*$/.test(content) ? eol : eol + eol;
atomicWriteFileSync(filePath, content + sep + block + eol);
return 'appended';
}
/**
* Upsert the CodeGraph instructions block into an agent instructions
* file (CLAUDE.md / AGENTS.md / GEMINI.md). The one write shared by
* every target: self-heals a stale pre-#529 long block (markers match →
* replaced by the current short one), appends after existing user
* content otherwise, and reports `unchanged` on byte-equal re-runs so
* every target — `install`, and the `install --refresh` that
* `codegraph upgrade` runs: self-heals a stale pre-#529 long block
* (markers match → replaced by the current short one), appends after
* existing user content otherwise, never changes a byte outside the
* block (#2506), and reports `unchanged` on byte-equal re-runs so
* install stays idempotent. See `instructions-template.ts` for why this
* block exists (#704: subagents + non-MCP harnesses never see the MCP
* initialize instructions).
@@ -220,6 +329,17 @@ export function upsertInstructionsEntry(file: string): { path: string; action: '
return { path: file, action: action === 'appended' ? 'updated' : action };
}
/**
* `content` with every well-formed marker block removed, or `null` when it
* has none. A stray start or end marker is left alone (see
* `findMarkedSections`), and only the whitespace touching a removed block
* changes. Shared by `removeMarkedSection` and the Cursor rules-file cleanup.
*/
export function stripMarkedSections(content: string, startMarker: string, endMarker: string): string | null {
const sections = findMarkedSections(content, startMarker, endMarker);
return sections.length === 0 ? null : dropMarkedSections(content, sections, lineBreakOf(content));
}
/**
* Inverse of `replaceOrAppendMarkedSection`. Strips the marker
* block from `filePath` if present. If the file becomes empty after
@@ -243,18 +363,13 @@ export function removeMarkedSection(
return 'kept';
}
const startIdx = content.indexOf(startMarker);
const endIdx = content.indexOf(endMarker);
if (startIdx === -1 || endIdx <= startIdx) return 'not-found';
const rest = stripMarkedSections(content, startMarker, endMarker);
if (rest === null) return 'not-found';
const before = content.substring(0, startIdx).trimEnd();
const after = content.substring(endIdx + endMarker.length).trimStart();
const joined = before + (before && after ? '\n\n' : '') + after;
if (joined.trim() === '') {
if (isBlank(rest)) {
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
} else {
atomicWriteFileSync(filePath, joined.trim() + '\n');
atomicWriteFileSync(filePath, rest);
}
return 'removed';
}