Merge main into fix/union-declarations-not-indexed

Resolves the CHANGELOG conflict — main and this branch each prepended a
bullet to [Unreleased] > Fixes; both are kept. Everything else auto-merged,
including src/mcp/tools.ts, which main reworked heavily for the explore
allocation/displacement work (CG-28/31/36/38) while this branch added the
`union` kind to its container sets.

Verified on the merged tree with the native kernel built: 3070 passed,
9 skipped, 0 failed.
This commit is contained in:
Colby McHenry
2026-08-07 21:26:53 -05:00
105 changed files with 15911 additions and 190 deletions
+2 -2
View File
@@ -2246,7 +2246,7 @@ program
*/
program
.command('install')
.description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)')
.description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot)')
.option('-t, --target <ids>', 'Target agent(s): comma-separated ids, or "auto"|"all"|"none". Default: prompt')
.option('-l, --location <where>', 'Install location: "global" or "local". Default: prompt')
.option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on')
@@ -2346,7 +2346,7 @@ program
*/
program
.command('uninstall')
.description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)')
.description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot)')
.option('-t, --target <ids>', 'Target agent(s): comma-separated ids, or "all". Default: all')
.option('-l, --location <where>', 'Uninstall location: "global" or "local". Default: prompt')
.option('-y, --yes', 'Non-interactive: defaults to --location=global --target=all')
+234 -2
View File
@@ -1113,11 +1113,28 @@ export class QueryBuilder {
}
/**
* Get nodes by exact name match (uses idx_nodes_name index)
* Get nodes by exact name match (uses idx_nodes_name index).
*
* This is resolution's candidate list, and the ORDER BY is load-bearing for
* index correctness, not cosmetic (CG-33). When a reference names a symbol
* that several files define and nothing disambiguates them, resolution binds
* to the first candidate — so without an ORDER BY the winner was decided by
* rowid, i.e. by the order files happened to be WRITTEN. A full index writes
* them in scan order; an incremental sync appends each file as it changes, so
* the same tree resolved to different edges depending on how the index was
* built, and a long-lived synced index drifted away from a rebuild of itself
* (measured at 4.3% of distinct edges, mostly `calls`).
*
* `(file_path, start_line)` is a property of the CODE, so both paths now pick
* the same candidate. The sort is paid once per distinct name per resolution
* run — ReferenceResolver memoizes this in its nameCache — and the population
* is capped by AMBIGUOUS_NAME_CEILING (#999).
*/
getNodesByName(name: string): Node[] {
if (!this.stmts.getNodesByName) {
this.stmts.getNodesByName = this.db.prepare('SELECT * FROM nodes WHERE name = ?');
this.stmts.getNodesByName = this.db.prepare(
'SELECT * FROM nodes WHERE name = ? ORDER BY file_path, start_line'
);
}
const rows = this.stmts.getNodesByName.all(name) as NodeRow[];
return rows.map(rowToNode);
@@ -1944,6 +1961,101 @@ export class QueryBuilder {
return (filePath: string) => flagged.has(filePath) || isGeneratedFile(filePath);
}
/**
* Which of `filePaths` are AMBIENT DECLARATION files — they declare nothing
* but types, and nothing in the index depends on them (CG-28). A hand-written
* ambient `.d.ts` of global shims, a vendored typings file, module
* augmentation: reachable only by name, structurally attached to nothing.
*
* Structural, not extension-based, so a hand-written `types.ts` and a `.d.ts`
* are judged by the same rule and a `.d.ts` that does declare a class or a
* const is (correctly) not caught. Four conditions, all required:
*
* 1. it declares at least one symbol — an empty or unparsed file is not a
* declaration file, it is a file we know nothing about;
* 2. EVERY declared symbol is a type-level kind (interface / type alias /
* enum / namespace). The narrowness is deliberate and measured: a rule
* of "no callables" alone flags 118% of a repo, including Kotlin sealed
* classes, Rust `mod.rs` re-exports and django's locale constant tables —
* real source that must not be demoted. This rule flags 04%;
* 3. no symbol in it originates a `calls`/`instantiates` edge — the direct
* evidence that nothing here has a body;
* 4. NOTHING ELSE IN THE INDEX points at it. This is the condition that
* separates an ambient shim from a working type module, and it is why
* the flag is narrow enough to be safe: `displacement-ts`'s pipeline
* `types.ts` passes 13 identically but carries 13 inbound imports and
* 21 references, so the files that answer a query about the pipeline are
* typed BY it — it is part of that answer's structure. An ambient
* `declare global` shim has zero. Deliberately index-wide rather than
* restricted to the candidate list: the file that imports it is usually
* not itself a candidate.
*
* Bounded-lookup like {@link getGeneratedPathsAmong}: callers hold a ranked
* candidate list, so this is a partial-index probe over a handful of paths.
*/
getAmbientDeclarationPathsAmong(filePaths: Iterable<string>): Set<string> {
const unique = [...new Set(filePaths)];
const found = new Set<string>();
if (unique.length === 0) return found;
for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) {
const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(',');
// `file`/`import`/`export`/`parameter` are structural bookkeeping, not
// things the file declares, so they neither qualify nor disqualify.
const rows = this.db
.prepare(`
SELECT file_path,
SUM(CASE WHEN kind NOT IN ('file','import','export','parameter')
THEN 1 ELSE 0 END) AS declared,
SUM(CASE WHEN kind IN ('interface','type_alias','enum','enum_member','namespace')
THEN 1 ELSE 0 END) AS typeDeclared
FROM nodes
WHERE file_path IN (${placeholders})
GROUP BY file_path
`)
.all(...chunk) as Array<{ file_path: string; declared: number; typeDeclared: number }>;
let candidates = rows
.filter((r) => r.declared > 0 && r.declared === r.typeDeclared)
.map((r) => r.file_path);
if (candidates.length === 0) continue;
const disqualify = (sql: string): void => {
if (candidates.length === 0) return;
const hit = new Set(
(this.db
.prepare(sql.replace('$IN$', candidates.map(() => '?').join(',')))
.all(...candidates) as Array<{ file_path: string }>).map((r) => r.file_path),
);
candidates = candidates.filter((p) => !hit.has(p));
};
// (3) originates behaviour
disqualify(`
SELECT DISTINCT n.file_path AS file_path
FROM edges e JOIN nodes n ON n.id = e.source
WHERE e.kind IN ('calls','instantiates') AND n.file_path IN ($IN$)
`);
// (4) something outside the file depends on it
disqualify(`
SELECT DISTINCT t.file_path AS file_path
FROM edges e JOIN nodes t ON t.id = e.target JOIN nodes s ON s.id = e.source
WHERE t.file_path IN ($IN$) AND s.file_path <> t.file_path
`);
for (const path of candidates) found.add(path);
}
return found;
}
/**
* A reusable `(path) => boolean` ambient-declaration test over a bounded
* candidate list — the shape a ranking comparator wants: one query up front,
* O(1) per comparison.
*/
ambientDeclarationPredicateFor(filePaths: Iterable<string>): (filePath: string) => boolean {
const flagged = this.getAmbientDeclarationPathsAmong(filePaths);
return (filePath: string) => flagged.has(filePath);
}
/** How many indexed files carry the generated flag. Surfaced by `status`. */
countGeneratedFiles(): number {
const row = this.db
@@ -2445,6 +2557,99 @@ export class QueryBuilder {
}));
}
/**
* Resolution edges whose TARGET symbol is named one of `names` — the edges a
* sync must re-resolve after `names` gained or lost a definition (CG-33).
*
* Resolution binds a reference to a node whose name matches the reference's
* tail, and it picks among ALL same-named definitions project-wide. So adding
* or removing one definition of `pct` changes the answer for every `pct(...)`
* reference in the repo — including references in files this sync never
* touches, whose edges nothing else revisits. Those edges' current target is,
* by that same rule, a node named `pct`, which is why the target's name is a
* sufficient (and index-backed, via idx_nodes_name) way to find them without
* a schema change or a scan of edge metadata.
*
* Returns the source file/language alongside each edge so the caller can
* resurrect it as its original reference. Excludes `provenance='heuristic'`
* (synthesized dispatch edges are not resolution output and carry no refName
* stamp to resurrect from — deleting one would be a permanent loss).
*
* Names matching more than `perNameCeiling` edges are skipped entirely, same
* rationale and same default as {@link getRetryableFailedReferences}: at that
* population the name is generic (`get`, `clear`, …), one definition changing
* won't flip most of them, and rebinding an arbitrary subset is both wasted
* work and incoherent coverage.
*/
getResolutionEdgesByTargetName(
names: string[],
perNameCeiling: number = 500
): Array<Edge & { edgeId: number; sourceFilePath: string; sourceLanguage: Language }> {
if (names.length === 0) return [];
// Pass 1: per-name edge counts, chunked under the SQLite parameter limit.
const keep: string[] = [];
for (let i = 0; i < names.length; i += SQLITE_PARAM_CHUNK_SIZE) {
const chunk = names.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(',');
const counts = this.db
.prepare(
`SELECT tgt.name AS name, COUNT(*) AS count
FROM edges e
JOIN nodes tgt ON tgt.id = e.target
WHERE tgt.name IN (${placeholders})
AND (e.provenance IS NULL OR e.provenance != 'heuristic')
GROUP BY tgt.name`
)
.all(...chunk) as Array<{ name: string; count: number }>;
for (const row of counts) {
if (row.count <= perNameCeiling) keep.push(row.name);
}
}
if (keep.length === 0) return [];
// Pass 2: load the surviving edges with the source file context a
// resurrection needs.
const out: Array<Edge & { edgeId: number; sourceFilePath: string; sourceLanguage: Language }> = [];
for (let i = 0; i < keep.length; i += SQLITE_PARAM_CHUNK_SIZE) {
const chunk = keep.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(',');
const rows = this.db
.prepare(
`SELECT e.*, src.file_path AS source_file_path, src.language AS source_language
FROM edges e
JOIN nodes tgt ON tgt.id = e.target
JOIN nodes src ON src.id = e.source
WHERE tgt.name IN (${placeholders})
AND (e.provenance IS NULL OR e.provenance != 'heuristic')`
)
.all(...chunk) as Array<EdgeRow & { source_file_path: string; source_language: Language }>;
for (const row of rows) {
out.push({
...rowToEdge(row),
edgeId: row.id,
sourceFilePath: row.source_file_path,
sourceLanguage: row.source_language,
});
}
}
return out;
}
/** Delete edges by primary key — the rebind pass's half of a re-resolution. */
deleteEdgesByIds(edgeIds: number[]): number {
if (edgeIds.length === 0) return 0;
let changed = 0;
this.db.transaction(() => {
for (let i = 0; i < edgeIds.length; i += SQLITE_PARAM_CHUNK_SIZE) {
const chunk = edgeIds.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(',');
changed += this.db.prepare(`DELETE FROM edges WHERE id IN (${placeholders})`).run(...chunk).changes;
}
})();
return changed;
}
/**
* Distinct node names present in the given files — the symbol names a sync
* pass uses to look up retryable failed refs after those files changed.
@@ -2463,6 +2668,33 @@ export class QueryBuilder {
return [...names];
}
/**
* Distinct `file\0name` pairs defined by the given files — the shape sync's
* definition delta needs (CG-33).
*
* Deliberately NOT `getNodeNamesByFiles`: a bare name set is taken over the
* WHOLE changed batch, so a name that moves between two files in one commit
* (or exists in one changed file and is newly added to another) appears on
* both sides and cancels out of the symmetric difference — even though a
* definition genuinely appeared or vanished and every reference to that name
* repo-wide may now bind elsewhere. Keying by file makes each definition its
* own fact, so the move is seen as one removal plus one addition.
*/
getNodeNamePairsByFiles(filePaths: string[]): Set<string> {
const pairs = new Set<string>();
if (filePaths.length === 0) return pairs;
for (let i = 0; i < filePaths.length; i += SQLITE_PARAM_CHUNK_SIZE) {
const chunk = filePaths.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(',');
const rows = this.db
.prepare(`SELECT DISTINCT file_path, name FROM nodes WHERE file_path IN (${placeholders})`)
.all(...chunk) as Array<{ file_path: string; name: string }>;
// NUL-joined: a path or a symbol name can contain a space, never a NUL.
for (const row of rows) pairs.add(`${row.file_path}\0${row.name}`);
}
return pairs;
}
// ===========================================================================
// Statistics
// ===========================================================================
+8
View File
@@ -181,6 +181,14 @@ const GENERATED_CONTENT_PATTERNS: ReadonlyArray<RegExp> = [
// "by" is required — bare "automatically generated" appears in hand-written
// prose ("the table below is automatically generated at runtime").
/\b(?:automatically generated|auto[- ]?generated|autogenerated) by\b/i,
// The "run this command to regenerate" shape: Cloudflare Wrangler
// ("Generated by Wrangler by running `wrangler types` (hash: …)"), and the
// same phrasing used by other CLI-driven emitters. Bare "generated by" is
// deliberately NOT enough — it is ordinary prose — so the reproduction
// instruction is the discriminator: the banner must name a tool AND then
// say `by running`, i.e. TWO separate "by" clauses. That rules out
// "the report is generated by running the nightly job", which has only one.
/\bgenerated by\s+\S.{0,80}?\bby running\b/i,
// Self-declaring in-house banners that name no tool.
/\bthis (?:file|class|code|module) (?:is|was) (?:auto[- ]?)?generated\b/i,
// The reverse ordering: "DO NOT EDIT — this is a generated file".
+107
View File
@@ -116,6 +116,20 @@ export interface SyncResult {
nodesUpdated: number;
durationMs: number;
changedFilePaths?: string[];
/**
* Symbol names whose set of definitions this sync CHANGED — names the synced
* files gained or lost, as the symmetric difference of their `file\0name`
* definition pairs before and after the store phase (per file, so a name
* moving between two changed files does not cancel itself out).
* Resolution picks among all same-named definitions project-wide,
* so these are exactly the names whose already-resolved edges — in files this
* sync never touched — may now bind elsewhere and must be re-resolved for the
* index to stay convergent with a full rebuild (CG-33).
*
* A body-only edit leaves this empty, which is the common case and costs
* nothing downstream.
*/
definitionDelta?: string[];
}
/**
@@ -2491,6 +2505,64 @@ export class ExtractionOrchestrator {
}
}
/**
* Re-open, for re-resolution, every resolution edge whose answer this sync
* may have changed — the fix for index drift (CG-33).
*
* Incremental sync re-resolves only the references IN the changed files, but
* resolution's answer is a function of the WHOLE graph: a reference binds to
* one of the same-named definitions project-wide, so adding or removing a
* definition of `pct` can change which `pct` every other file's `pct(...)`
* should bind to. Those other files are never revisited, and their references
* resolved successfully once and were deleted from `unresolved_refs`, so
* nothing existed to revisit them with — the index kept an answer that was
* correct against an older graph. Measured on codegraph's own long-lived
* index: 4.3% of distinct edges differed from a clean rebuild, in BOTH
* directions, overwhelmingly `calls`. See docs/benchmarks/index-drift-cg33.md.
*
* This deletes each affected edge and re-inserts it as the reference that
* created it (the refName/refKind stamp), status='pending', for the sync's
* resolution sweep to bind against the post-sync graph — the same input a
* full rebuild resolves from, which is what makes the two converge.
*
* Deliberately conservative in three ways, because a wrong deletion is a
* permanent edge loss while a missed rebind is only residual drift:
* - an edge with no refName stamp (synthesized, or built by an engine older
* than the stamp) is left ALONE rather than reconstructed from the target's
* plain name, same rule as `resurrectRefFromDroppedEdge`;
* - edges whose source is in a file this sync already re-extracted are
* skipped — their references were re-resolved from scratch moments ago;
* - very common names are skipped by the per-name ceiling in
* `getResolutionEdgesByTargetName`.
*
* Returns the number of references resurrected.
*/
resurrectStaleResolutionEdges(definitionDelta: string[], changedFilePaths: string[]): number {
if (definitionDelta.length === 0) return 0;
const alreadyFresh = new Set(changedFilePaths);
const candidates = this.queries.getResolutionEdgesByTargetName(definitionDelta);
const edgeIds: number[] = [];
const refs: UnresolvedReference[] = [];
for (const e of candidates) {
if (alreadyFresh.has(e.sourceFilePath)) continue;
const ref = resurrectRefFromDroppedEdge(e);
if (!ref) continue; // no stamp — never delete what we cannot restore
edgeIds.push(e.edgeId);
refs.push(ref);
}
if (refs.length === 0) return 0;
// Delete first. The sweep re-inserts whichever edge resolution now picks,
// and `insertEdges` is INSERT OR IGNORE against idx_edges_identity — so a
// rebind to the same target is a clean no-op, but leaving the old row in
// place for a rebind ELSEWHERE would keep both, turning drift into
// duplication.
this.queries.deleteEdgesByIds(edgeIds);
this.queries.insertUnresolvedRefsBatch(refs);
return refs.length;
}
/**
* Sync the index with the current file state.
*
@@ -2520,6 +2592,10 @@ export class ExtractionOrchestrator {
let filesRemoved = 0;
let nodesUpdated = 0;
const changedFilePaths: string[] = [];
// `file\0name` definition pairs for the files this sync touches, sampled
// BEFORE their nodes are replaced/deleted. Compared against the post-store
// pairs below to derive `definitionDelta` (CG-33).
const pairsBefore = new Set<string>();
onProgress?.({
phase: 'scanning',
@@ -2585,6 +2661,9 @@ export class ExtractionOrchestrator {
// failed until the symbol reappears somewhere. (A deleted file whose
// CALLERS are also being deleted is fine: their nodes cascade later
// in this loop and take the resurrected rows with them.)
// Every name this file defined is about to stop existing here, which
// narrows the candidate set for that name repo-wide (CG-33).
for (const pair of this.queries.getNodeNamePairsByFiles([tracked.path])) pairsBefore.add(pair);
const incoming = this.queries.getCrossFileIncomingEdgesWithTarget(tracked.path);
if (incoming.length > 0) {
const resurrected = incoming
@@ -2651,6 +2730,14 @@ export class ExtractionOrchestrator {
}
}
// Sampled here — after the add/modify classification, before any file is
// re-extracted — because `storeExtractionResult` deletes a file's nodes
// before inserting the new ones, so this is the last point the pre-edit
// definition set is readable (CG-33).
if (filesToIndex.length > 0) {
for (const pair of this.queries.getNodeNamePairsByFiles(filesToIndex)) pairsBefore.add(pair);
}
// Load only grammars needed for changed files
if (filesToIndex.length > 0) {
const overrides = loadExtensionOverrides(this.rootDir);
@@ -2677,6 +2764,25 @@ export class ExtractionOrchestrator {
nodesUpdated += result.nodes.length;
}
// Names whose definition set this sync changed: a `file\0name` pair present
// before but not after (removed/renamed away) or after but not before
// (added). A pair on both sides is untouched as far as resolution's
// candidate set is concerned — only its node id moved, which
// reattachCrossFileEdges already follows — so an edit that only changes
// bodies yields an empty delta and no downstream rebind work (CG-33).
//
// Compared per FILE, not as one name set over the whole batch: a commit
// that adds `collect` to a new file while an unrelated changed file already
// defined `collect` must still flag the name, and a bare name set cancels
// exactly that case out. That miss left the largest residual class in the
// first measurement of this fix.
const pairsAfter = this.queries.getNodeNamePairsByFiles(filesToIndex);
const deltaNames = new Set<string>();
const nameOf = (pair: string) => pair.slice(pair.indexOf('\0') + 1);
for (const pair of pairsBefore) if (!pairsAfter.has(pair)) deltaNames.add(nameOf(pair));
for (const pair of pairsAfter) if (!pairsBefore.has(pair)) deltaNames.add(nameOf(pair));
const definitionDelta = [...deltaNames];
return {
filesChecked,
filesAdded,
@@ -2685,6 +2791,7 @@ export class ExtractionOrchestrator {
nodesUpdated,
durationMs: Date.now() - startTime,
changedFilePaths: changedFilePaths.length > 0 ? changedFilePaths : undefined,
definitionDelta: definitionDelta.length > 0 ? definitionDelta : undefined,
};
}
+39
View File
@@ -883,6 +883,32 @@ export class CodeGraph {
}
}
// Re-open resolution edges this sync may have invalidated ELSEWHERE in
// the repo (CG-33). Everything above re-resolves references in the
// changed files; this covers the opposite direction — references in
// files the sync never touched whose answer depended on a definition
// that just appeared or disappeared. Without it a synced index never
// converges to a full rebuild: measured at 4.3% of distinct edges wrong
// on codegraph's own index, in both directions, mostly `calls`. The
// resurrected refs are pending rows, so the orphan sweep immediately
// below is what resolves them — batched, yielding, multi-pass, exactly
// as a full index resolves.
//
// `definitionDelta` is empty for a body-only edit, so the overwhelmingly
// common sync pays one branch. CODEGRAPH_NO_REBIND=1 disables it.
if (result.definitionDelta && process.env.CODEGRAPH_NO_REBIND !== '1') {
const tRebind = Date.now();
const rebound = this.orchestrator.resurrectStaleResolutionEdges(
result.definitionDelta,
result.changedFilePaths ?? []
);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) {
console.error(
`[phase-timing] sync-rebind: ${Date.now() - tRebind}ms (${result.definitionDelta.length} changed names, ${rebound} edges re-opened)`
);
}
}
// Orphan sweep (#1187). A resolution pass that dies mid-run — the #850
// daemon liveness watchdog's SIGKILL (#1122), Ctrl-C, a crash — leaves
// the refs it never reached in unresolved_refs, and the git-scoped fast
@@ -1550,6 +1576,19 @@ export class CodeGraph {
return this.queries.generatedPredicateFor(filePaths);
}
/**
* A `(path) => boolean` ambient-declaration test over a BOUNDED candidate
* list: true for a file that declares nothing but types, originates no call
* edge, and that nothing in the index depends on — an ambient `.d.ts` of
* global shims, vendored typings, module augmentation (CG-28). Structural
* rather than extension-based, and deliberately narrow: see
* `QueryBuilder.getAmbientDeclarationPathsAmong` for why each condition is
* there, in particular why a `types.ts` the codebase imports is NOT flagged.
*/
ambientDeclarationFilePredicate(filePaths: Iterable<string>): (filePath: string) => boolean {
return this.queries.ambientDeclarationPredicateFor(filePaths);
}
/** How many indexed files are flagged tool-generated. Reported by `status`. */
getGeneratedFileCount(): number {
return this.queries.countGeneratedFiles();
+4 -3
View File
@@ -3,7 +3,8 @@
*
* Multi-target: writes MCP server config + instructions for the
* agents the user picks (Claude Code, Cursor, Codex CLI, opencode,
* Hermes Agent, Gemini CLI, Antigravity IDE).
* Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and GitHub
* Copilot in VS Code / the Copilot CLI / JetBrains IDEs).
* Defaults to the Claude-only behavior for backwards compatibility
* when no targets are explicitly chosen and nothing else is detected.
*
@@ -467,8 +468,8 @@ export async function runUninstaller(opts: RunUninstallerOptions): Promise<void>
const sel = await clack.select({
message: 'Remove CodeGraph from all your projects, or just this one?',
options: [
{ value: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.hermes, ~/.gemini, ~/.kiro' },
{ value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./opencode.jsonc, ./.gemini, ./.kiro' },
{ value: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.hermes, ~/.gemini, ~/.kiro, ~/.copilot, ~/.config/github-copilot' },
{ value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./.vscode, ./opencode.jsonc, ./.gemini, ./.kiro' },
],
initialValue: 'global' as const,
});
+192
View File
@@ -0,0 +1,192 @@
/**
* GitHub Copilot CLI target.
*
* - MCP server entry to `~/.copilot/mcp-config.json` under the
* `mcpServers` key (same wrapper as Claude/Cursor). Entry shape per
* the GitHub docs: `{ "type": "stdio", "command", "args", "tools" }`
* — `type` accepts `"local"` or `"stdio"`; we write `"stdio"` (the
* standard MCP name, recommended by the docs for cross-client
* compatibility). `"tools": ["*"]` mirrors the docs' example and is
* the documented default.
* - The config dir is `~/.copilot` unless the user moved it via
* `COPILOT_HOME` (documented override) — we honor it so install and
* detect follow the CLI's own resolution.
*
* Copilot CLI as of 2026-07 has no project-local MCP config — per-repo
* config (`.github/mcp.json`) is an open feature request
* (github/copilot-cli#2528). `supportsLocation('local')` returns false;
* the orchestrator skips this target for local installs with a clear
* message (same pattern as Codex).
*
* The file is machine-written by the CLI's own `/mcp add` flow, so it's
* plain JSON — no JSONC handling needed; surgical edits go through the
* shared read/mutate/write helpers (Cursor pattern), preserving sibling
* servers.
*
* No instructions file (MCP `initialize` instructions are the single
* source of truth, #529) and no permissions concept — `autoAllow` is
* silently ignored.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import {
AgentTarget,
DetectionResult,
InstallOptions,
Location,
WriteResult,
} from './types';
import {
getMcpServerConfig,
jsonDeepEqual,
readJsonFile,
writeJsonFile,
} from './shared';
function configDir(): string {
const override = process.env.COPILOT_HOME;
if (override && override.trim().length > 0) return override;
return path.join(os.homedir(), '.copilot');
}
function mcpConfigPath(): string {
return path.join(configDir(), 'mcp-config.json');
}
/**
* `~/.copilot` existing is NOT proof the CLI is installed: the VS Code
* Copilot Chat extension drops MCP socket-handoff lock files into
* `~/.copilot/ide/` on launch, so a machine with only the VS Code
* extension still has the dir (with a lone `ide` entry). Count the dir
* as a CLI footprint only when it holds anything besides `ide` — the
* CLI writes `config.json` (and later `mcp-config.json`, history state)
* on first run.
*/
function cliConfigDirPresent(): boolean {
let entries: string[];
try {
entries = fs.readdirSync(configDir());
} catch {
return false;
}
return entries.some((e) => e !== 'ide');
}
/**
* Best-effort check that the `copilot` binary is reachable on PATH.
* A plain fs scan (no shell-out) — cheap enough to run inside
* `detectAll()` for the multiselect prompt.
*/
function copilotOnPath(): boolean {
const pathVar = process.env.PATH || '';
const exts = process.platform === 'win32'
? ['.exe', '.cmd', '.bat', '.ps1']
: [''];
for (const dir of pathVar.split(path.delimiter)) {
if (!dir) continue;
for (const ext of exts) {
try {
if (fs.existsSync(path.join(dir, 'copilot' + ext))) return true;
} catch { /* ignore unreadable PATH entries */ }
}
}
return false;
}
function buildCopilotMcpConfig(): { type: string; command: string; args: string[]; tools: string[] } {
const base = getMcpServerConfig();
return { ...base, tools: ['*'] };
}
class CopilotCliTarget implements AgentTarget {
readonly id = 'copilot-cli' as const;
readonly displayName = 'GitHub Copilot CLI';
readonly docsUrl = 'https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers';
supportsLocation(loc: Location): boolean {
return loc === 'global';
}
detect(loc: Location): DetectionResult {
if (loc !== 'global') {
return { installed: false, alreadyConfigured: false };
}
const file = mcpConfigPath();
const config = readJsonFile(file);
const alreadyConfigured = !!config.mcpServers?.codegraph;
const installed = cliConfigDirPresent() || copilotOnPath();
return { installed, alreadyConfigured, configPath: file };
}
install(loc: Location, _opts: InstallOptions): WriteResult {
if (loc !== 'global') {
return {
files: [],
notes: ['Copilot CLI has no project-local config — re-run with --location=global to install.'],
};
}
return {
files: [writeMcpEntry()],
notes: ['Restart any running Copilot CLI session to pick up the MCP server.'],
};
}
uninstall(loc: Location): WriteResult {
if (loc !== 'global') return { files: [] };
const file = mcpConfigPath();
if (!fs.existsSync(file)) {
return { files: [{ path: file, action: 'not-found' }] };
}
const config = readJsonFile(file);
if (!config.mcpServers?.codegraph) {
return { files: [{ path: file, action: 'not-found' }] };
}
delete config.mcpServers.codegraph;
if (Object.keys(config.mcpServers).length === 0) {
delete config.mcpServers;
}
if (Object.keys(config).length === 0) {
// Nothing left but the `{}` we'd write back — delete the file so
// uninstall fully reverses a from-scratch install. A leftover
// empty file would keep detect() reporting the CLI as installed.
fs.unlinkSync(file);
} else {
writeJsonFile(file, config);
}
return { files: [{ path: file, action: 'removed' }] };
}
printConfig(loc: Location): string {
if (loc !== 'global') {
return '# Copilot CLI has no project-local config — use --location=global.\n';
}
const snippet = JSON.stringify({ mcpServers: { codegraph: buildCopilotMcpConfig() } }, null, 2);
return `# Add to ${mcpConfigPath()}\n\n${snippet}\n`;
}
describePaths(loc: Location): string[] {
if (loc !== 'global') return [];
return [mcpConfigPath()];
}
}
function writeMcpEntry(): WriteResult['files'][number] {
const file = mcpConfigPath();
const existing = readJsonFile(file);
const before = existing.mcpServers?.codegraph;
const after = buildCopilotMcpConfig();
if (jsonDeepEqual(before, after)) {
return { path: file, action: 'unchanged' };
}
const existed = fs.existsSync(file);
if (!existing.mcpServers) existing.mcpServers = {};
existing.mcpServers.codegraph = after;
writeJsonFile(file, existing);
return { path: file, action: existed ? 'updated' : 'created' };
}
export const copilotCliTarget: AgentTarget = new CopilotCliTarget();
+230
View File
@@ -0,0 +1,230 @@
/**
* JetBrains IDEs (GitHub Copilot plugin) target.
*
* - MCP server entry to the plugin's user-level `mcp.json`, which
* lives under the shared `github-copilot` config dir (the same dir
* the Copilot ecosystem uses for `hosts.json`):
*
* macOS/Linux: $XDG_CONFIG_HOME|~/.config/github-copilot/intellij/mcp.json
* Windows: %LOCALAPPDATA%\github-copilot\intellij\mcp.json
*
* `$XDG_CONFIG_HOME` is honored on every platform when set —
* matching the plugin family's own resolution (copilot.vim /
* copilot-language-server check it before the OS default).
* - Shape is VS Code-compatible: `{ "servers": { "<name>": { "type":
* "stdio", "command", "args" } } }` — the plugin documents mcp.json
* parity with `.vscode/mcp.json`.
* - **Global-only.** The plugin reads exactly one user-level file; a
* project-level mcp.json is an open feature request
* (microsoft/copilot-intellij-feedback#701, still open 2026-07).
* `supportsLocation('local')` returns false so the orchestrator
* skips local installs with a clear message (Codex pattern).
* - No `--path` injection: the config is user-global and the plugin
* documents no `${workspaceFolder}`-style variable expansion for
* this file, so we ship the plain entry and let the MCP server
* resolve the project from the client's roots/cwd as with other
* global installs.
* - No instructions file (MCP `initialize` instructions are the
* single source of truth, #529) and no permissions concept —
* `autoAllow` is silently ignored.
*
* The IDE opens this file in a JSON editor for hand-editing (Settings →
* Tools → GitHub Copilot → MCP → Configure), so reads + writes go
* through `jsonc-parser` — surgical edits that preserve sibling
* servers, user comments, and formatting (same approach as the
* copilot-vscode target).
*
* The plugin only re-reads mcp.json on IDE restart
* (microsoft/copilot-intellij-feedback#1139) — hence the restart note.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { parse as parseJsonc, modify, applyEdits } from 'jsonc-parser';
import {
AgentTarget,
DetectionResult,
InstallOptions,
Location,
WriteResult,
} from './types';
import {
atomicWriteFileSync,
getMcpServerConfig,
jsonDeepEqual,
} from './shared';
/**
* The `github-copilot` config root, resolved the way the Copilot
* plugin family resolves it: `$XDG_CONFIG_HOME` first on every
* platform, then `%LOCALAPPDATA%` on Windows, then `~/.config`.
*/
function copilotConfigRoot(): string {
const xdg = process.env.XDG_CONFIG_HOME;
if (xdg && xdg.trim().length > 0) {
return path.join(xdg, 'github-copilot');
}
if (process.platform === 'win32') {
const localAppData = process.env.LOCALAPPDATA && process.env.LOCALAPPDATA.trim().length > 0
? process.env.LOCALAPPDATA
: path.join(os.homedir(), 'AppData', 'Local');
return path.join(localAppData, 'github-copilot');
}
return path.join(os.homedir(), '.config', 'github-copilot');
}
function intellijDir(): string {
return path.join(copilotConfigRoot(), 'intellij');
}
function mcpJsonPath(): string {
return path.join(intellijDir(), 'mcp.json');
}
/**
* Best-effort "a JetBrains IDE exists here" heuristic for the
* multiselect default — the per-OS dir every JetBrains IDE creates on
* first launch. False positives (IDE without the Copilot plugin) are
* acceptable per the `DetectionResult` contract.
*/
function jetbrainsConfigDirExists(): boolean {
const home = os.homedir();
if (process.platform === 'darwin') {
return fs.existsSync(path.join(home, 'Library', 'Application Support', 'JetBrains'));
}
if (process.platform === 'win32') {
const appData = process.env.APPDATA && process.env.APPDATA.trim().length > 0
? process.env.APPDATA
: path.join(home, 'AppData', 'Roaming');
return fs.existsSync(path.join(appData, 'JetBrains'));
}
const xdg = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME.trim().length > 0
? process.env.XDG_CONFIG_HOME
: path.join(home, '.config');
return fs.existsSync(path.join(xdg, 'JetBrains'));
}
function readConfigText(file: string): string {
if (!fs.existsSync(file)) return '';
return fs.readFileSync(file, 'utf-8');
}
function parseConfig(text: string): Record<string, any> {
if (!text.trim()) return {};
const errors: any[] = [];
const result = parseJsonc(text, errors, { allowTrailingComma: true });
if (result == null || typeof result !== 'object' || Array.isArray(result)) {
return {};
}
return result as Record<string, any>;
}
const FORMATTING = { tabSize: 2, insertSpaces: true, eol: '\n' };
class CopilotJetbrainsTarget implements AgentTarget {
readonly id = 'copilot-jetbrains' as const;
readonly displayName = 'JetBrains IDEs (Copilot plugin)';
readonly docsUrl = 'https://docs.github.com/en/copilot/how-tos/provide-context/use-mcp/extend-copilot-chat-with-mcp';
supportsLocation(loc: Location): boolean {
return loc === 'global';
}
detect(loc: Location): DetectionResult {
if (loc !== 'global') {
return { installed: false, alreadyConfigured: false };
}
const file = mcpJsonPath();
const config = parseConfig(readConfigText(file));
const alreadyConfigured = !!config.servers?.codegraph;
// The `intellij/` subdir is created by the Copilot plugin itself;
// fall back to "some JetBrains IDE is installed" for first-time
// plugin users.
const installed = fs.existsSync(intellijDir()) || jetbrainsConfigDirExists();
return { installed, alreadyConfigured, configPath: file };
}
install(loc: Location, _opts: InstallOptions): WriteResult {
if (loc !== 'global') {
return {
files: [],
notes: ['The JetBrains Copilot plugin has no project-local MCP config — re-run with --location=global to install.'],
};
}
return {
files: [writeMcpEntry()],
notes: ['Restart your JetBrains IDE — the Copilot plugin only reads mcp.json on startup.'],
};
}
uninstall(loc: Location): WriteResult {
if (loc !== 'global') return { files: [] };
return { files: [removeMcpEntry()] };
}
printConfig(loc: Location): string {
if (loc !== 'global') {
return '# The JetBrains Copilot plugin has no project-local MCP config — use --location=global.\n';
}
const snippet = JSON.stringify({ servers: { codegraph: getMcpServerConfig() } }, null, 2);
return `# Add to ${mcpJsonPath()}\n# (Settings → Tools → GitHub Copilot → Model Context Protocol → Configure)\n\n${snippet}\n`;
}
describePaths(loc: Location): string[] {
if (loc !== 'global') return [];
return [mcpJsonPath()];
}
}
function writeMcpEntry(): WriteResult['files'][number] {
const file = mcpJsonPath();
const existed = fs.existsSync(file);
let text = readConfigText(file);
if (!text.trim()) text = '{}\n';
const config = parseConfig(text);
const before = config.servers?.codegraph;
const after = getMcpServerConfig();
if (jsonDeepEqual(before, after)) {
return { path: file, action: 'unchanged' };
}
// Surgical edit — preserves comments, formatting, and sibling
// servers ("servers" is created when missing).
const edits = modify(text, ['servers', 'codegraph'], after, {
formattingOptions: FORMATTING,
});
const updated = applyEdits(text, edits);
atomicWriteFileSync(file, updated);
return { path: file, action: existed ? 'updated' : 'created' };
}
function removeMcpEntry(): WriteResult['files'][number] {
const file = mcpJsonPath();
if (!fs.existsSync(file)) return { path: file, action: 'not-found' };
const text = readConfigText(file);
const config = parseConfig(text);
if (!config.servers?.codegraph) return { path: file, action: 'not-found' };
let edits = modify(text, ['servers', 'codegraph'], undefined, {
formattingOptions: FORMATTING,
});
let updated = applyEdits(text, edits);
// Drop an emptied `servers` wrapper; the file itself is left in
// place — the plugin owns it and siblings may remain.
const afterParsed = parseConfig(updated);
if (afterParsed.servers && typeof afterParsed.servers === 'object' &&
Object.keys(afterParsed.servers).length === 0) {
edits = modify(updated, ['servers'], undefined, { formattingOptions: FORMATTING });
updated = applyEdits(updated, edits);
}
atomicWriteFileSync(file, updated);
return { path: file, action: 'removed' };
}
export const copilotJetbrainsTarget: AgentTarget = new CopilotJetbrainsTarget();
+212
View File
@@ -0,0 +1,212 @@
/**
* VS Code (GitHub Copilot Chat) target.
*
* - MCP server entry to `.vscode/mcp.json` (local, workspace-scoped)
* or the user-level `mcp.json` in the VS Code User dir (global):
*
* macOS: ~/Library/Application Support/Code/User/mcp.json
* Windows: %APPDATA%\Code\User\mcp.json
* Linux: $XDG_CONFIG_HOME|~/.config/Code/User/mcp.json
*
* VS Code moved MCP config out of settings.json into this dedicated
* `mcp.json` (v1.102, "MCP: Open User Configuration"). Shape is
* `{ "servers": { "<name>": { "type": "stdio", "command", "args" } } }`
* — note `servers`, not the `mcpServers` wrapper Claude/Cursor use.
* - No instructions file: Copilot Chat consumes the MCP `initialize`
* instructions, the single source of truth (#529).
* - No permissions concept — `autoAllow` is silently ignored.
*
* ## Why `--path` only for local installs (NOT the Cursor pattern)
*
* Unlike Cursor, VS Code DOCUMENTS the launch cwd for stdio MCP
* servers: "Working directory for the server command. Defaults to the
* workspace folder when run in a workspace" (mcp-configuration
* reference). The codegraph server resolves its project via the MCP
* roots/list dance with a cwd fallback, so cwd alone is sufficient:
*
* - `local` install: absolute `--path` (known at install time) —
* deterministic, and free of variables.
* - `global` install: NO `--path`. Do not be tempted to pin it with
* `${workspaceFolder}`: VS Code refuses to start a user-level
* server whose entry uses that variable whenever a window has no
* folder open (loose files, welcome tab), surfacing an error toast
* "Variable workspaceFolder can not be resolved" in every such
* window — exactly the error-noise that teaches users to disable
* the server. With no `--path`, a folderless window still starts
* the server fine and it serves the "no project" guidance.
*
* ## JSONC
*
* VS Code parses its config files as JSONC (comments + trailing commas
* allowed), so reads + writes go through `jsonc-parser` — surgical
* edits that preserve sibling servers, user comments, and formatting
* across install / re-install / uninstall (same approach as opencode).
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { parse as parseJsonc, modify, applyEdits } from 'jsonc-parser';
import {
AgentTarget,
DetectionResult,
InstallOptions,
Location,
WriteResult,
} from './types';
import {
atomicWriteFileSync,
getMcpServerConfig,
jsonDeepEqual,
} from './shared';
function vscodeUserDir(): string {
const home = os.homedir();
if (process.platform === 'win32') {
const appData = process.env.APPDATA && process.env.APPDATA.trim().length > 0
? process.env.APPDATA
: path.join(home, 'AppData', 'Roaming');
return path.join(appData, 'Code', 'User');
}
if (process.platform === 'darwin') {
return path.join(home, 'Library', 'Application Support', 'Code', 'User');
}
const xdg = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME.trim().length > 0
? process.env.XDG_CONFIG_HOME
: path.join(home, '.config');
return path.join(xdg, 'Code', 'User');
}
function mcpJsonPath(loc: Location): string {
return loc === 'global'
? path.join(vscodeUserDir(), 'mcp.json')
: path.join(process.cwd(), '.vscode', 'mcp.json');
}
/**
* Build the codegraph server entry for VS Code at the given location.
* Local installs pin `--path`; global installs rely on VS Code's
* documented workspace-folder cwd — see file header for why the global
* entry must stay variable-free.
*/
function buildVscodeServerEntry(loc: Location): { type: string; command: string; args: string[] } {
const base = getMcpServerConfig();
if (loc === 'local') {
return { ...base, args: [...base.args, '--path', process.cwd()] };
}
return { ...base, args: [...base.args] };
}
function readConfigText(file: string): string {
if (!fs.existsSync(file)) return '';
return fs.readFileSync(file, 'utf-8');
}
function parseConfig(text: string): Record<string, any> {
if (!text.trim()) return {};
const errors: any[] = [];
const result = parseJsonc(text, errors, { allowTrailingComma: true });
if (result == null || typeof result !== 'object' || Array.isArray(result)) {
return {};
}
return result as Record<string, any>;
}
const FORMATTING = { tabSize: 2, insertSpaces: true, eol: '\n' };
class CopilotVscodeTarget implements AgentTarget {
readonly id = 'copilot-vscode' as const;
readonly displayName = 'VS Code (Copilot Chat)';
readonly docsUrl = 'https://code.visualstudio.com/docs/copilot/customization/mcp-servers';
supportsLocation(_loc: Location): boolean {
return true;
}
detect(loc: Location): DetectionResult {
const file = mcpJsonPath(loc);
const config = parseConfig(readConfigText(file));
const alreadyConfigured = !!config.servers?.codegraph;
// "Installed" heuristic: the VS Code User dir (created on first
// launch) or ~/.vscode (extensions dir) for global; an existing
// .vscode/ dir in the project for local.
const installed = loc === 'global'
? fs.existsSync(vscodeUserDir()) || fs.existsSync(path.join(os.homedir(), '.vscode'))
: fs.existsSync(path.join(process.cwd(), '.vscode'));
return { installed, alreadyConfigured, configPath: file };
}
install(loc: Location, _opts: InstallOptions): WriteResult {
return {
files: [writeMcpEntry(loc)],
notes: ['Restart VS Code for MCP changes to take effect.'],
};
}
uninstall(loc: Location): WriteResult {
return { files: [removeMcpEntry(loc)] };
}
printConfig(loc: Location): string {
const target = mcpJsonPath(loc);
const snippet = JSON.stringify({ servers: { codegraph: buildVscodeServerEntry(loc) } }, null, 2);
return `# Add to ${target}\n\n${snippet}\n`;
}
describePaths(loc: Location): string[] {
return [mcpJsonPath(loc)];
}
}
function writeMcpEntry(loc: Location): WriteResult['files'][number] {
const file = mcpJsonPath(loc);
const existed = fs.existsSync(file);
let text = readConfigText(file);
if (!text.trim()) text = '{}\n';
const config = parseConfig(text);
const before = config.servers?.codegraph;
const after = buildVscodeServerEntry(loc);
if (jsonDeepEqual(before, after)) {
return { path: file, action: 'unchanged' };
}
// Surgical edit — preserves comments, formatting, and sibling
// servers ("servers" is created when missing).
const edits = modify(text, ['servers', 'codegraph'], after, {
formattingOptions: FORMATTING,
});
const updated = applyEdits(text, edits);
atomicWriteFileSync(file, updated);
return { path: file, action: existed ? 'updated' : 'created' };
}
function removeMcpEntry(loc: Location): WriteResult['files'][number] {
const file = mcpJsonPath(loc);
if (!fs.existsSync(file)) return { path: file, action: 'not-found' };
const text = readConfigText(file);
const config = parseConfig(text);
if (!config.servers?.codegraph) return { path: file, action: 'not-found' };
let edits = modify(text, ['servers', 'codegraph'], undefined, {
formattingOptions: FORMATTING,
});
let updated = applyEdits(text, edits);
// Drop an emptied `servers` wrapper; the file itself is left in
// place — VS Code recreates/reads it and siblings like `inputs`
// may remain.
const afterParsed = parseConfig(updated);
if (afterParsed.servers && typeof afterParsed.servers === 'object' &&
Object.keys(afterParsed.servers).length === 0) {
edits = modify(updated, ['servers'], undefined, { formattingOptions: FORMATTING });
updated = applyEdits(updated, edits);
}
atomicWriteFileSync(file, updated);
return { path: file, action: 'removed' };
}
export const copilotVscodeTarget: AgentTarget = new CopilotVscodeTarget();
+6
View File
@@ -16,6 +16,9 @@ import { hermesTarget } from './hermes';
import { geminiTarget } from './gemini';
import { antigravityTarget } from './antigravity';
import { kiroTarget } from './kiro';
import { copilotVscodeTarget } from './copilot-vscode';
import { copilotCliTarget } from './copilot-cli';
import { copilotJetbrainsTarget } from './copilot-jetbrains';
export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
claudeTarget,
@@ -26,6 +29,9 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
geminiTarget,
antigravityTarget,
kiroTarget,
copilotVscodeTarget,
copilotCliTarget,
copilotJetbrainsTarget,
]);
export function getTarget(id: string): AgentTarget | undefined {
+1 -1
View File
@@ -19,7 +19,7 @@ export type Location = 'global' | 'local';
* lookup. New targets add a value here when they're added to the
* registry. Keep these short and lowercase.
*/
export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro';
export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'copilot-vscode' | 'copilot-cli' | 'copilot-jetbrains';
/**
* Result of `target.detect(location)`.
+66 -2
View File
@@ -66,6 +66,12 @@ export interface ExploreCandidateMeta {
spine: boolean;
lowValue: boolean;
generated: boolean;
/**
* Nothing but type declarations in this file, and nothing in the index
* depends on it (CG-28) — it cannot answer a flow question, so it ranks on
* discounted signals unless the query named one of the types it declares.
*/
ambientDeclaration: boolean;
/**
* Multiplier `rankPenalty` applied to BOTH `score` and `graphScore` (1 = no
* penalty). Generated and test/i18n files rank on discounted signals, so the
@@ -89,9 +95,31 @@ interface FileRecord extends ExploreCandidateMeta {
* it rendered anything. `0` = cliffed; `null` = never reached the allocator.
* The gap between this and `emittedChars` is the whole story of a budget bug:
* reserved-but-unspent means the file had nothing to say, spent-over-reserved
* means an oversize first cluster or the whole-file grace overshot.
* means an oversize first cluster or the whole-file grace overshot — but read
* `spendable` before calling it an overshoot, since inherited slack legitimately
* lifts a file above its reservation.
*/
allowance: number | null;
/**
* What the file could actually SPEND: its reservation plus the slack the
* files above it left on the table (bounded by MAX_SHARE). Every render bound
* reads this, not `allowance`, so it — not the reservation — is what an
* overshoot is measured against. `null` until the render loop reaches the
* file. Reporting only `allowance` makes an ordinary carry-forward look like
* a file spending over its reservation.
*/
spendable: number | null;
/**
* The DISPLACEMENT-GUARDED ceiling (CG-31): the most this file may render
* without spending a reservation still owed to a file the loop has not
* reached AND can still pay. `spendable` is what the file was promised, this
* is what is actually still there to pay it with — every render path is
* bounded by it, so `emittedChars` above it is a bug. Sits ABOVE `spendable`
* when the room is there (the bounded overshoot a big cluster member may
* take) and BELOW it when the files underneath need the bytes. `null` until
* the render loop reaches the file.
*/
funded: number | null;
render?: ExploreRenderMode;
/**
* Source chars this call did NOT re-send because an earlier call in the
@@ -139,6 +167,10 @@ interface BudgetShape {
export interface ExploreDiagnosticFile extends ExploreCandidateMeta {
path: string;
allowance: number | null;
/** Reservation + inherited slack — the bound the render paths actually use. */
spendable: number | null;
/** Render ceiling after holding back what is still owed to unreached files. */
funded: number | null;
render: ExploreRenderMode | null;
skipped: ExploreSkipReason | null;
clipped: boolean;
@@ -362,7 +394,7 @@ export class ExploreDiagnostics {
/** Record one ranked candidate's scoring inputs, in final sort order. */
noteCandidate(path: string, meta: ExploreCandidateMeta): void {
this.files.set(path, {
path, ...meta, allowance: null,
path, ...meta, allowance: null, spendable: null, funded: null,
dedupSavedChars: 0, dedupCovered: [],
emittedChars: 0, finalChars: 0, share: 0, allocatedShare: 0, clipped: false,
});
@@ -391,6 +423,24 @@ export class ExploreDiagnostics {
}
}
/**
* What the render loop will let this file spend — reservation plus inherited
* slack. Called once per file, before any of its render paths run.
*/
recordSpendable(path: string, chars: number): void {
const rec = this.files.get(path);
if (rec) rec.spendable = chars;
}
/**
* What the render loop will let this file spend once the reservations still
* owed BELOW it are held back (CG-31). Called alongside `recordSpendable`.
*/
recordFunded(path: string, chars: number): void {
const rec = this.files.get(path);
if (rec) rec.funded = chars;
}
/** A candidate rendered source into the response. */
recordRender(path: string, render: ExploreRenderMode, sourceChars: number, clipped: boolean): void {
const rec = this.files.get(path);
@@ -535,9 +585,12 @@ export class ExploreDiagnostics {
spine: r.spine,
lowValue: r.lowValue,
generated: r.generated,
ambientDeclaration: r.ambientDeclaration,
penalty: round6(r.penalty),
kinds: r.kinds,
allowance: r.allowance,
spendable: r.spendable,
funded: r.funded,
render: r.render ?? null,
skipped: r.skipped ?? null,
clipped: r.clipped,
@@ -704,6 +757,16 @@ export function renderTable(report: ExploreDiagnosticReport): string {
f.path,
);
out.push(' kinds: ' + (f.kinds || '-'));
// Only when it differs: a file that spent over `reserved` but inside
// `spendable` took inherited slack, not a budget bug.
if (f.spendable !== null && f.allowance !== null && f.spendable !== f.allowance) {
out.push(` spendable: ${num(f.spendable)} (reservation + inherited slack)`);
}
// Only when the displacement guard actually bit: the gap is the overshoot
// this file was refused so the files below it could still be paid.
if (f.funded !== null && f.spendable !== null && f.funded < Math.round(f.spendable * 1.5)) {
out.push(` funded: ${num(f.funded)} (held to this so the files below keep their reservations)`);
}
if (f.dedupSavedChars > 0) {
const spans = f.dedupCovered.slice(0, 6).map(([a, b]) => (a === b ? `${a}` : `${a}-${b}`)).join(',');
const more = f.dedupCovered.length > 6 ? `,+${f.dedupCovered.length - 6}` : '';
@@ -740,5 +803,6 @@ function flagString(f: ExploreDiagnosticFile): string {
if (f.spine) flags.push('spine');
if (f.lowValue) flags.push('low-value');
if (f.generated) flags.push('generated');
if (f.ambientDeclaration) flags.push('ambient-decl');
return flags.join(' ') || '-';
}
+5 -3
View File
@@ -22,8 +22,10 @@ export const SERVER_INSTRUCTIONS = `# Codegraph — code intelligence over an in
Codegraph is a SQLite knowledge graph of every symbol, edge, and file in
the workspace — pre-computed structure you would otherwise re-derive by
reading files (cached intelligence: thousands of parse/trace decisions you
don't pay to re-reason each run). Reads are sub-millisecond; the index lags
writes by ~1s through the file watcher. Reach for it BEFORE *and* while
don't pay to re-reason each run). It indexes 30+ languages
(TypeScript/JavaScript, Python, Go, Rust, Java, C#, C/C++, PHP, Ruby, Swift,
Kotlin, and more) — don't assume a language here isn't covered. Reads are
sub-millisecond; the index lags writes by ~1s through the file watcher. Reach for it BEFORE *and* while
writing or editing code — not just for questions: one call returns the
verbatim source PLUS who calls it and what it affects, so you edit with the
blast radius in view. More accurate context, in far fewer tokens and
@@ -87,7 +89,7 @@ calls; a grep/read exploration is dozens.
export const SERVER_INSTRUCTIONS_NO_ROOT_INDEX = `# Codegraph — available (per-project; pass projectPath)
Codegraph is a SQLite knowledge graph of a codebase's symbols, edges, and
files: one \`codegraph_explore\` call returns the verbatim, line-numbered source
files (30+ languages): one \`codegraph_explore\` call returns the verbatim, line-numbered source
of the relevant symbols PLUS the call paths between them and a blast-radius
summary — replacing a grep + Read loop with one round-trip.
+861 -162
View File
File diff suppressed because it is too large Load Diff