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:
@@ -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
@@ -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 1–18% 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 0–4%;
|
||||
* 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 1–3 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
|
||||
// ===========================================================================
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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(' ') || '-';
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user