The #1465 change read the default project root on every tool call, before
checking whether a query worker would serve it. Resolve it only for a pooled
call; in-process dispatch no longer depends on it (restores the oversized-output
truncation test).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Empty explore results returned no explanation or retry guidance.
Check FTS and live name segments with two bounded SQL queries to distinguish matched and unmatched words and suggest indexed names.
Preserve success-shaped responses, unresolved-path notes, ranking, and non-empty output; cap added diagnostics below 1.5K characters.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): enable query workers in direct mode (#1465)
Direct and proxy-fallback engines never enabled the query pool, serializing concurrent reads despite CODEGRAPH_QUERY_POOL_SIZE.
Enable bounded workers in both paths and support sessions without a default index, including indexes discovered later.
Preserve main-thread catch-up, staleness notices, and session accounting, with regression coverage for concurrency, parity, configuration, and teardown.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): enable query workers in direct mode (#1465)
Direct and proxy-fallback engines never enabled the query pool, serializing concurrent reads despite CODEGRAPH_QUERY_POOL_SIZE.
Enable bounded workers in both paths and support sessions without a default index, including indexes discovered later.
Preserve main-thread catch-up, staleness notices, and session accounting, with regression coverage for concurrency, parity, configuration, and teardown.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): enable query workers in direct mode (#1465)
Direct and proxy-fallback engines never enabled the query pool, serializing concurrent reads despite CODEGRAPH_QUERY_POOL_SIZE.
Enable bounded workers in both paths and support sessions without a default index, including indexes discovered later.
Preserve main-thread catch-up, staleness notices, and session accounting, with regression coverage for concurrency, parity, configuration, and teardown.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): share one connection across two spellings of one root (#1057)
A symlinked checkout, or a case-variant of a root on a case-insensitive
mount (macOS, NTFS, WSL DrvFs), resolved to two path strings and opened two
connections to the same .codegraph/codegraph.db.
The connection cache stays keyed by path, so a root recreated at the same
path still heals in place (#925). On a miss, an open root that is the same
index NOW (both data directories stat to one dev:ino, read as bigints) is
reused and the new spelling filed as its alias. Comparing live identities
means a deleted root never matches, even if a new directory reuses its
inode. Windows has no usable inode, so it compares the case-folded
realpath.native instead. statInode moves to directory.ts so the DB layer
and the cache share one reading, and closeAll closes an aliased instance
once.
* fix(mcp): keep root aliases out of the connection cache (#1057)
A second spelling of an open root was filed in projectCache under its own key,
so one CodeGraph sat under two keys. Anything that drops one key and closes its
connection (a bounded cache's eviction, as proposed in #1929) then left the
other key holding a closed handle: every query on that spelling, and through
the alias scan on the original spelling too, failed with "database is not
open" until restart.
Aliases now live in their own map (spelling -> projectCache key). projectCache
is back to one key per instance, and an alias whose entry is gone is dropped
and re-resolved instead of served.
* fix(mcp): deduplicate equivalent project paths (#1057)
Equivalent project paths could open duplicate cached connections.
Compare current filesystem identities using bigint inodes and native Windows paths.
Revalidate aliases and pin cached connection owners to symlink targets.
Preserve recreation recovery and single-owner cleanup.
Co-authored-by: Eric Minish <eric.minish@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Eric Minish <eric.minish@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(mcp): match pending paths as whole paths in the stale-file banner (#1968)
withStalenessNotice counted a pending file as referenced whenever its path
was a substring of the response, so a pending src/app.ts was named in the
banner for a response that only showed src/app.tsx, and a pending root
app.tsx for one that showed src/app.tsx. A match now has to stand as a
whole path.
* fix(mcp): match complete paths in stale banners (#1968)
Substring matching incorrectly classified pending files as referenced inside longer paths.
Build on PR #1971 with response delimiters that reject Unicode and punctuation continuations while preserving exact paths and line references.
Add real-index regression coverage for truthful warnings and the elsewhere footer.
Co-authored-by: Eric Minish <eric.minish@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Eric Minish <eric.minish@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(explore): flag dynamic imports built from a template or concatenation (#1967)
The JS dynamic-import form skipped any argument that started with a quote,
so import(`./locales/${lang}.js`) and import('./x/' + y) were treated as
literal imports and the boundary note never appeared. The call is now
dropped only when its argument is exactly one complete string literal.
* fix(explore): detect runtime import templates and concatenations (#1967)
The detector skipped every import argument beginning with a quote or backtick.
Build on PR #1970 to suppress only complete static literals and recognize interpolation with escape-aware scanning.
Add scanner regressions and real-file/SQLite explore coverage.
Co-authored-by: Eric Minish <eric.minish@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Eric Minish <eric.minish@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A query span like `scripts/deploy` was neither pinned nor reported.
`isClearlyPathShaped` demands a dot-extension on the last segment, so a
dotless slashed span that resolved to no indexed file fell through every
arm: it was not consumed, never entered `unresolvedPathSpans`, and its
fragments (`scripts`, `deploy`) went on to feed FTS. The agent got a pile
of unrelated source with no hint that the file it named was never read.
Both caveat sinks — the empty-subgraph note and the summary line — were
already in place; they were simply never handed the span.
Shape alone cannot decide this: `and/or`, `input/output` and
`client/server` have exactly the same shape, and loosening the shape test
to "has a slash" strips that prose out of every query and mints a false
"No indexed file uniquely matches `and/or`" caveat (the existing
`leaves slash-bearing non-paths alone` test pins this). The file's
existence on disk is the discriminator instead — and since query-paths.ts
is deliberately pure (no DB, no fs, safe in the query-pool workers), the
check is injected by the caller as an `existsOnDisk` predicate. The call
site in tools.ts owns the project root and runs it through
`validatePathWithinRoot`, so a `../` span cannot probe outside the
project, and only a regular file counts — a directory span keeps flowing
to the normal matching pipeline.
Retrieval effect (AGENTS.md "do not regress"), measured as a deterministic
A/B on the built dist over an indexed repro, baseline = upstream/main:
- query that is only the dotless path — before: 998 chars of unrelated
source, no caveat; after: `No relevant code found ... (no indexed file
uniquely matches `scripts/deploy`)`.
- same path inside a real symbol query — output byte-identical except the
added caveat sentence (+51 chars); the same 4 symbols across 2 files.
- query with no path span — byte-identical between arms.
Claude-Session: https://claude.ai/code/session_01XDR1wm73oH8J8cRWnKyv9m
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`codegraph node "src/app.ts:42-80"` answered `No indexed file matches`
for a file that IS indexed, worded byte-for-byte like a genuine miss, so
there was no way to tell "wrong path" from "right path, wrong spelling".
The file-view normalizer treated the `:42-80` as part of the filename.
explore has stripped exactly these shapes all along
(src/search/query-paths.ts) — file-view now does too, and the range it
strips becomes the read window.
- The literal spelling is resolved FIRST; only when that finds nothing is
the suffix stripped, so a file genuinely named `foo:12` still wins.
- Supports `:<a>`, `:<a>-<b>`, `#L<n>`, `#L<a>-L<b>` (and `#L<a>-<b>`).
- A stripped range maps to `offset=a, limit=b-a+1`; a bare `:<a>` is a
start-here pointer (Read given only an offset). An explicit
offset/limit from the caller always wins over the suffix.
- `#L<n>` was not mentioned in the issue but was broken the same way.
Tests: 8 new cases across __tests__/node-file-view.test.ts and
__tests__/cli-node-command.test.ts, including a vacuity guard (the same
path without the suffix resolves) and a genuine-miss case. Red-armed:
with the fix reverted, 6 of them fail with the exact
`No indexed file matches` string.
Claude-Session: https://claude.ai/code/session_01XDR1wm73oH8J8cRWnKyv9m
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adapt @uvmplus's PR #1481 (3ecf7479) to the shared symbol lookup and
named-symbol flow resolver on current main. Missing names return not found
with suggestions, and exact matches with no callers stay empty.
Preserve #1512 definition grouping and --file narrowing, #173 qualified
misses, and codegraph_node's intentional fuzzy file lookup. Port the
upstream regression suite and cover the moved shared lookup paths.
Validation on Linux with Node 22: project build, 74 requested tests,
47 related flow tests, and 16 same-fixture CLI/MCP checks pass. Baseline
captured 12 failing tests and 13 failing fixture checks.
Fixes#1473.
Supersedes #1481.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Apply upstream commit 1572d90d71 from
PR #1570 by @rongbc.
Clarify that suggested explore call counts are advisory and extra calls
remain available. Sync the MCP initialization guidance and eval probe,
retain the upstream regression tests, close the footer's bold formatting,
and credit the fix under Unreleased in the changelog.
Validation: npm run build; focused Vitest (3 files, 53 tests); source and
dist wording greps; git diff --check.
Fixes#1504
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Squash danusha2345's PR #1511 at d282f9e8 onto main 8c9c4761,
preserving its nine non-merge commits and main's existing Unreleased notes.
Calls in Kotlin, Java, TS/JS, Scala, Rust and Python declaration initializers
now retain the owner established by the upstream regression expectations.
Include the upstream CFML, dynamic-dispatch summary and viewer follow-ups.
Linux fail-to-pass validation (Node 22.19.0, rebuilt dist and native kernel):
- Before: TS load belonged to file:app.ts; Python/Kotlin/Scala/Rust calls
vanished; Java lost the field-lambda, anonymous override and eager calls.
- After: all six languages PASS; 12 native/WASM LF/CRLF parity checks PASS.
- Focused initializer regressions: 10 passed with CODEGRAPH_KERNEL=0 and
10 passed with the kernel enabled; Kotlin's grammar fallback is recorded.
- Related regression suites: 879 passed, 1 skipped across 15 test files.
- Evidence: /workspace/cg-1510-repro/before and /workspace/cg-1510-repro/after
(combined test output: after/vitest.log).
Fixes#1510
Supersedes #1511
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
* fix(cli): port upstream symbol lookup consistency (#1656, #1512)
Port ferrine/fix/symbol-lookup-consistency at
c0ccbacd3f onto current main.
Qualified CLI queries use the shared matcher and ambiguous names disclose
their targets. Keep total/limit/truncated and the human truncation notice
from #1674, and share the matcher with main's named-symbol-flow module.
Refs #1512. Upstream PR: #1656.
Co-authored-by: ferres <justferres@yandex.ru>
* fix(cli): group traversal results by definition (#1512)
Extend upstream PR #1656, ported in 7038fb4f, so callers/callees/impact
show separate sections for each definition and accept --file using the
same groupDefinitions helper as MCP. Preserve same-file overload groups,
path/suffix matching, and the explicit fallback when no file matches.
JSON definitions carry their roots, own neighbors/affected nodes, and
edges. Retain the legacy top-level lists as an explicitly labeled union
and preserve #1674 total/limit/truncated; each callers/callees definition
also reports its own limit and truncation metadata.
Validation: npm run build; 141 tests across 14 targeted suites, including
32 CLI regression tests. The Linux /workspace/cg1512-repro failure now
passes for all three commands, with and without --file.
Fixes#1512.
Upstream PR: #1656 (ferrine/fix/symbol-lookup-consistency @ c0ccbacd).
---------
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Co-authored-by: ferres <justferres@yandex.ru>
Land the #1624 approach by @danusha2345: re-serve source by default so
subagents and compacted contexts never inherit stale already-sent pointers.
Keep cross-call dedup available through explicit truthy
CODEGRAPH_EXPLORE_DEDUP values for durable contexts.
Preserve current Unreleased entries, credit the contribution, and align
the MCP server guidance with the safe default.
Validation on Linux / Node 22.23.2:
- Reproduced default-on failure before the fix; default-off now passes.
- Focused explore-cross-call-dedup suite: 26 passed.
- TypeScript: npx tsc -p tsconfig.json --noEmit passed.
Fixes#1620.
(cherry picked from commit 63992facab)
Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Land upstream #1686 (maxmilian + bompus kernel/CG-28 follow-ups)
onto current main. tree-sitter-typescript interface members
(method_signature / property_signature) were never listed in the
TS extractor, so platform .d.ts APIs had no declaration nodes for
call edges. Mirrors on the Rust kernel path; keeps CG-28 damping
for pure-interface declaration files; filters damped files from
the explore RWR seed set.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Land #1647 onto current main: callers/callees/query (CLI + MCP) now say
when --limit hid matches, with totals in JSON and a widening hint. Also
covers #1639.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
When codegraph_explore trimmed a rendered file, holes printed as bare
`... (gap) ...` and the header's `+N more` hid the dropped names — while the
footer asked the model to re-explore with exact names it was never given.
Name elided defs in gap markers as `name (file:line)` (using the full file
index, not just the relevance gather), bias the per-file header toward
symbols the trim cut, and point the trimmed-footer at those names.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Claude Code defers every MCP tool behind ToolSearch by default, so a fresh session sees only the tool name until the model searches for it. The explore tool now carries `_meta: { "anthropic/alwaysLoad": true }`, which exempts it on existing installs, and the Claude Code installer target writes `alwaysLoad: true` on the server entry (re-running install adds the key to an older entry). Copilot CLI tool search holds MCP tools back the same way once ~30 tools are connected, so its entry carries `deferTools: "never"`.
* fix(resolution): a binding in a module that exports nothing is not a cross-file candidate
On vitejs/vite, 157 cross-file `imports` refs — every `import { defineConfig }
from 'vite'` in the playground and the create-vite templates — resolved onto
`playground/ssr-html/test-stacktrace.js::vite`, which is `const vite = await
createServer(...)` at module scope in a file with zero exports.
Neither existing guard can see it. `isLexicallyReachable` returns early for any
candidate that is not a `function`, and the bare-import guard correctly declines
because `vite` IS a workspace member, so the specifier really is project-local.
What is wrong is only which node the name lands on.
A JS/TS file that contains an `import` statement and no export of any form
offers nothing to any other file, so none of its bindings is a candidate for a
cross-file name match. Applied in both name-based strategies: declining in
matchByExactName alone just hands the same target to matchFuzzy, which resolves
a unique candidate on its own.
Narrow on three axes, each a class this would otherwise get wrong in the
opposite direction: a classic script is exempt (a top-level binding really is a
reachable global), CommonJS is exempt (`module.exports` and `exports.x` count as
exports), and every non-JS/TS language is exempt. The export test reads source
rather than the node's `isExported` flag, because that flag is set only from an
`export_statement` ancestor and so reads false for `const x = ...; export { x }`.
* fix(resolution): count bracket CommonJS exports and `declare global` as exports
A file writing `exports["x"] = …` exports x, and a file with a `declare
global` block contributes every name in it to every other file whether or not
it exports anything of its own — the extractor emits nodes for the ambient
`var` and `interface` members, so sealing such a file would hide names that
really are reachable everywhere. Neither shape occurs on the vite corpus, so
this changes no measured count; both are now covered by the test.
* test(resolution): bind the #1719 fixture without a bare import
The consumer bound every name from 'some-external-pkg'. A bare specifier
names a package that is not in the graph, so no project node is the right
target for such a reference and #1715 declines it -- which made four of the
five positive assertions depend on a resolution that should not happen, and
they failed the moment this branch was stacked on #1715. Free references
reach the same exact-match path without asserting that.
`strayVar` was not testable at all: a bare identifier read emits no edge, so
that assertion only ever passed through the bare-import binding. The
`declare global` coverage moves to an interface reached through a type
annotation, paired with an identical file whose interface is not in a
`declare global` -- so the assertion turns on that clause rather than
passing whichever way the guard goes.
* docs(changelog): record the sealed-module guard under Unreleased
* fix(resolution): the sealed test rejects fuzzy's survivor, never filters its set
matchFuzzy declines an ambiguous name outright, so filtering sealed
candidates out of its set can leave a lone survivor and manufacture a 0.5
edge from an ambiguity that would have been declined. Testing the single
survivor instead closes that path; matchByExactName keeps the filter,
because it ranks a crowd rather than declining one.
No instance on vitejs/vite either way (row-identical, LOST 0 / GAINED 0
per #1720 review). It also declines one shape the filter form resolved: a
sealed same-language survivor no longer yields to a cross-language
candidate at 0.3.
* fix(resolution): reject invalid fallback targets without retargeting
---------
Co-authored-by: Aaron Queen <bompus@users.noreply.github.com>
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Concurrent CODEGRAPH_NO_DAEMON / in-process fallback serve --mcp instances
each started a FileWatcher and contended on codegraph.lock until auto-sync
degraded. Add an exclusive .codegraph/writer.pid lock held by the daemon or
the single direct writer; a second writer exits with actionable guidance.
Daemon mode still multiplexes N proxies onto one writer.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
- resolution/tier-synthesizer.ts: http-client (literal fetch/axios/ky/got/$fetch paths, axios.create baseURL instances, template holes as :params, base-URL holes by a two-segment tail; unique match only), queue-job (BullMQ/Bull add ↔ @Process/@Processor, WorkerHost process, new Worker, queue.process), event-bus (EventEmitter2 emit ↔ @OnEvent with globs; socket emit ↔ @SubscribeMessage / socket.on both ways with tier); channel, tier, callee, registeredAt on every edge; generic transport events never pair; test and generated files never sources; registered before the emitter pass
- steps.ts: crossing() reads tier/channel before languages; an endpoint reached across a tier is a bridge box and a boundary like a screen (through=1 enters it); a channel's call is not also an effect; sites read as written; a Next 'use server' action is a crossing by its directive (when.ts directive); a function-valued constant handler (asyncHandler(...)) is a route root and borrows the file-scope calls and refs within its lines
- express.ts: app.use('/prefix', router) mounts composed onto route names in postExtract (nested, by import or require); chained router.route('/x').get(h).put(h2) extracted, across lines
- frameworks/package-deps.ts: dependencies read from workspace package.json files too (Express, React, Expo Router, NestJS detect)
- routing manifest names constant handlers; e2e/ is a test directory; explore's Flow section labels the new channels
- tests: ui-steps-cross-tier (monorepo fixture: Next client + Express/Nest API), servers test updated for the queue landing
- docs: CHANGELOG, spec §3.13 cross-tier paragraph, CLAUDE.md, callback-edge-synthesis.md, plan P3 built
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
Introduce Expo Router integration: a new framework resolver, route-based screen nodes, and navigates edges, plus a /api/screens endpoint and a Screens UI view. Adds branch-guard-driven labeling of edges, resolution logic, and tests to cover extraction, resolution, and end-to-end flow. This enables CodeGraph UI to surface screens and transitions from Expo Router apps.
A vertical tree above the members outline for classes, interfaces, structs,
traits, protocols, enums, unions and type aliases: ancestors above (the whole
chain, not just the direct parent), the focus in accent, subtypes below indented
per level. `extends` draws solid, `implements` dashed; a synthesized edge — Go's
implicit interface satisfaction — draws dashed wider and carries the site it was
wired at, so a relation the resolver inferred never reads like one the source
wrote down. For an interface the fan below IS the set of runtime targets a call
can land on, and a type with eight or more implementers leads with that in a
sentence. Members that redeclare an ancestor's are marked in the outline.
The walk lives in `src/graph/type-hierarchy.ts`, following CG-50/CG-51: shared
computation in `src/graph/`, presentation in the caller. Its `countImplementers`
is now also what `ToolHandler.buildPolymorphicBoundaries` counts with, so "N
types implement X" is the same N whether an agent reads it or a person does.
`/api/node` carries the block as `hierarchy` rather than a second endpoint —
it is part of the Symbol view's first paint, and gated to types, so a function
costs one kind test.
Layout is arithmetic (24px rows, 22px indent, orthogonal connectors computed
from the two): no ResizeObserver, same payload → same picture. The header's
`extends X` / `implemented by …` chips are suppressed while the tree is on
screen — two renderings of one relation in one column is how a reader ends up
trusting neither.
`TypeHierarchy` is exported from `@colbymchenry/codegraph-ui` and takes its data
as a prop, so a host holding a `WireSymbolPayload` renders it without a second
read.
A flow that does not reach what it was asked about now ends in a cap instead
of in silence: the dispatch form that ended it, the line, the static key when
the source spells one out, the candidate runtime targets as clickable rows,
and the name-only matches under 0.6 the search refused to follow. A flow that
does reach its destination never shows one.
The verdict is lifted out of `ToolHandler` into
`src/graph/dynamic-boundary-report.ts` and both callers render it —
`codegraph_explore`'s prose and `/api/flow`'s `WireFlowBoundary` — the same
move `named-symbol-flow.ts` made for the path finder, and for the same reason:
a reader holding the strip and the MCP answer must not be told two different
things. The explore prose is unchanged, byte for byte.
When nothing connects at all and a dispatch site explains why, the strip is
that site: one card opened at the line where the static path ends, plus the
cap. When nothing explains it, no stopping point is invented.
Ask "how does execute reach getFile" in the search box and the viewer draws the
call path between them, left to right, opening every card at the exact line that
makes the next call. Dynamic-dispatch hops are dashed and name the site they
were wired at; "Read as flow" turns a trail walked by hand into the same strip.
The path finder is NOT new. `codegraph_explore` already leads its answers with
the longest call chain among the symbols an agent named, and a viewer that drew
a different path would get the two quoted against each other in a review. So the
search moved out of `ToolHandler` into `src/graph/named-symbol-flow.ts` and both
callers ride it — same tokens, same overload rules, same synthesized edges. What
stayed behind in `tools.ts` is the prose.
A pinned from/to question is the same search with two options changed, because
both ends being named is the evidence explore's one-unnamed-bridge cap stands in
for: it bridges freely, keeps twelve candidates per endpoint instead of six
(the CLI's own `main` sorts seventh of ten), and searches from both ends at once
— identical paths to the one-way walk on twelve measured pairs, 3-6x faster.
`/api/flow` is deliberately the one endpoint with no cache: its cards carry
source read from disk, and a drift verdict changes without the index changing.
Verified on this repo (`execute` to `rowToFileRecord`, 8 hops; `main` to
`resolveOne`, 7) and on a fresh excalidraw index, where `mutateElement` to
`renderStaticScene` crosses callback, react-render and jsx-child hops and lists
exactly the hops `codegraph_explore` prints.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the `codegraph ui [path]` command (alias `web`) and `src/ui-server/`, a
`node:http` server with no framework and no new dependency.
The command reads an index that already exists — it never creates one, so a
missing index prints the same friendly guidance the MCP tools give instead of
a stack trace, and a sensitive system directory is refused up front.
Security is the substance here, not the routing. The server binds 127.0.0.1
only, answers GET and HEAD only, and sends no CORS headers ever. The realistic
attack on a process that serves your source code from a local port is DNS
rebinding, so every request must carry a loopback `Host` (on our port) and, if
it carries an `Origin` at all, a loopback one — anything else is 403 before
the filesystem is touched. Every path resolves through the engine's existing
`validatePathWithinRoot` chokepoint, which already handles `../` traversal and
in-tree symlinks pointing out of the root (#527); `..` segments are refused
outright so a traversal attempt gets a 404 rather than the SPA shell.
`PathRefusalError` moves from `mcp/tools.ts` into the dependency-free
`errors.ts` (re-exported from its old home, so class identity and every
`instanceof` check are unchanged) — that is what lets a non-MCP read sink
enforce the same refusal without importing the MCP tool graph.
Assets come from `dist/viewer/` resolved relative to `__dirname`, the way
`db/index.ts` finds `schema.sql`. Hashed assets are cached immutably,
`index.html` never. Port 4747, or the next free one — an explicit `--port`
stays explicit rather than silently moving. `--no-open` skips the browser, and
`CODEGRAPH_BROWSER` picks one (or `none` to suppress it), which is also what
makes "did it open a browser" testable end to end.
`resolveProjectFile` and the `/api/` handler seam are the boundary CG-42's
JSON API plugs into; `/api/*` 404s as JSON so a typo'd endpoint never returns
the app shell.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes#1610. Also fixes#1358 (the `<<binary>>` arity miscount in behaviour dispatch, reported separately and hit by the same code path).
## Problem
Arity is part of an Erlang function's identity — `f/1` and `f/2` are unrelated top-level definitions — but the extractor merged consecutive same-name `fun_decl`s regardless of arity. Reproduced on main exactly as reported:
- adjacent `f(X) -> …. f(X, Y) -> ….` → **one** node spanning both, with the first definition's signature;
- interleaved `f/1, g/0, f/2` → two nodes with **identical** `qualified_name`;
- `cowboy_req`'s `header(Name, Req) -> header(Name, Req, undefined).` → a **self-loop** `header → header`, with the `-spec` for `/3` swallowed by the merged span;
- `-export([f/1])` marked every arity exported.
## Fix
- **One node per (name, arity).** Clauses of the same name+arity still merge (that part of the old behavior was correct); a different arity starts a new node. `qualifiedName` carries the canonical spelling — `mod::f/1` — while the node **name stays bare** so search and bare-name matching are unchanged.
- **`-export` and `-spec` are per-arity.** `-export([f/1])` exports exactly `f/1`; a spec sitting between two arities attaches to the arity its signature names.
- **Refs carry the call-site arity** wherever it's statically known: local `f/1`, remote `mod::f/2`, `fun f/1` / `fun mod:f/1` values, `gen_server` dispatch (`handle_call/3`, `handle_cast/2`), and spawn/apply MFA lists (`spawn_link(?MODULE, work, [A, B])` → `work/2`).
- **The matcher resolves only to the named arity** — same file first (a local call targets its own module) — and when no definition of that arity exists it resolves to **nothing** rather than a sibling arity: silent beats wrong. An arity-less dynamic-MFA ref resolves only when the module defines exactly one arity of that name.
- **Behaviour dispatch** selects the implementer node of the site's arity, and the arity counter now skips `<<1,2,3>>` binary-literal commas per its own docstring (#1358) — `Mod:decode(<<1,2,3>>, Opts)` counts 2, not 4.
- **`codegraph_explore` / `codegraph_node`** accept the written `mod:fn/3` spelling against the new arity-qualified names (the issue's measured `cowboy_stream_h:request_process/3` shape).
## Validation
Minimal fixtures (all three reported shapes) now index as `gap::f/1` + `gap::f/2`, distinct `inter::f/1`/`inter::f/2`, and a real `deleg::header/2 → deleg::header/3` edge with no self-loop.
Cowboy (fresh `--depth 1` clone, this build vs unmodified main build):
| | main | this PR |
|---|---|---|
| nodes | 3,668 | 3,748 (+80 — the arity splits; no explosion) |
| erlang function nodes | 2,850 | 2,930 |
| behaviour dispatch edges | 38 | **44** |
| `cowboy_req::header` | one node, span 420–425, /3's spec lost | `header/2` (420–421, its own spec) + `header/3` (424–425, its spec) |
| delegation | self-loop | `header/2 → header/3` |
`calls` edges drop 6,059 → 5,656: a sample of every removed pair shows the false-positive class the issue predicted — out-of-repo/BIF calls (`length/1`, `error/1`, `quicer:*`) that previously name-matched onto unrelated same-named in-repo functions now stay unresolved.
Tests: new arity coverage in extraction + a new arity-resolution integration suite + a #1358 binary-literal behaviour test; updated existing Erlang expectations to the arity-carrying spellings. Full suite: **3,018 passed, 0 failed**.
No migration: an existing Erlang index picks the new shape up on its next re-index (`codegraph sync` / re-`init`).
Erlang is wasm-only (not in the native kernel), so there is no kernel-parity surface.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01LxZj6W6Y1SHXwvpT3uwJpK
Fixes#1606 and #1607 together — the MCP server's root resolution never got the sub-project down-scan `planFrontload` gained in #964, and the resulting no-default state was completely silent.
## What changed
**Adoption (#1606).** A new `resolveServerRoot()` in `src/directory.ts` is the single resolution every server entry point now uses: up-walk first (`findNearestCodeGraphRoot`, the common case, unchanged), and when that misses, the existing bounded down-scan (`findIndexedSubprojectRoots` — depth 4, max 64, heavy dirs skipped). **Exactly one** indexed sub-project is unambiguous and is adopted as the default project — `open` → `startWatching` → `catchUpSync` → query pool, the full normal path. Zero or several candidates → no default, never a guess. Wired into:
- `MCPEngine.doInitialize()` and `retryInitializeSync()` — the retry path also picks up a child indexed *after* the server started (its down-scan is throttled to once per 5s so the persistent no-default state doesn't pay a directory walk per tool call; the up-walk still runs every time).
- `resolveDaemonRoot()` — the adopted root gets the shared daemon (one watcher, one writer, socket keyed on the child) instead of a direct-mode server per host, exactly as the issue suggested.
- `MCPSession.handleInitialize()` — the instructions variant is picked with the same resolution, so a workspace whose single child becomes the default gets the full single-project playbook. Race-free by construction: handshake and engine compute it independently, no ordering assumed.
**Workspace-root gate (the open question in #1606).** Decided deliberately: the down-scan runs only when the base has a workspace manifest (`looksLikeProjectRoot`, unchanged list) **or a `.git` entry** — the exact container shape that motivated the report — and never when the base is `$HOME` or the filesystem root. The gate lives in the new helper only; `planFrontload` and the prompt-hook are untouched, so #1454's surface is not widened.
**Diagnostics (#1607).** The no-root branch is no longer silent:
```
[CodeGraph MCP] No .codegraph/ at or above <searchFrom>: no default project, live sync disabled.
[CodeGraph MCP] Indexed sub-projects found: service-a, service-b. Pass `projectPath` per call, or launch with --path.
```
(second line only when the scan found candidates), plus one line naming the adopted child when adoption happens. The same fact is protocol-reachable: the "No CodeGraph project is loaded" tool response now lists the discovered sub-projects with `projectPath` guidance. The list is engine-maintained (initial resolve + throttled retry) — tool calls never scan — and the response stays SUCCESS-shaped (`NotIndexedError` → `textResult`, never `isError`).
## Tested
- New `__tests__/mcp-subproject-adoption.test.ts` (real spawned server over stdio, same harness as `mcp-roots.test.ts`): single child → tool call answers from it, full instructions, adoption stderr; two children → no default, both listed in the tool response and stderr, per-project instructions; no manifest/no `.git` → gate holds, no scan, plain one-line message.
- `mcp-subproject-adoption` + `mcp-roots` + `mcp-initialize` + `daemon-bind-failure`: **13/13 pass**.
- End-to-end repro harness against the built `dist/` on an unmodified-main build first (confirmed: empty stderr, no adoption, NO_ROOT instructions even with one adoptable child), then on this branch (all three shapes behave as above; catch-up sync runs on the adopted child).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01LxZj6W6Y1SHXwvpT3uwJpK
Previously, `codegraph_explore` queries explicitly naming files by path (e.g., `src/routes/m/projects/[id]/runs/[runId]/+page.svelte`) were shredded. Bracketed path segments exploded into "named symbol" seeds, and FTS on fragments like `page` or `runs` admitted every sibling file, starving the user's intended target.
This change introduces:
- **Query path pinning:** File paths named in a query are now resolved against the index, "pinned," and stripped from the query. Pinned files are guaranteed inclusion, top ranking, and fair allocation. Unresolvable path-like spans are reported.
- **Segment vocabulary supplement:** Natural language query terms (e.g., "auto-scroll to bottom") can now reach camelCase identifiers (e.g., `pinFeedIfNearBottom`, `feedAtBottom`) by matching against their constituent segments.
- **Variable seeding:** `variable` and `constant` node kinds are now included in identifier seeding, improving recall for `$state`-style variables common in frameworks like Svelte.
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.
Recut of #678 against the current instructions — the original predated the
explore-first rewrite and conflicted in both files it touched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`codegraph_explore` never returned `queueMessage` (L1087) or
`flushQueuedMessages` (L1102) from a 1,414-line file, on a symbol bag or a
prose question, even with that file at rank #1 holding 67% of the envelope —
the agent got a same-stem `QueuedMessage` interface at L70 and had to Read the
file for the functions it had named. Pre-existing at every build including
pre-epic (controlled bisect, index held fixed).
Two independent causes:
1. `buildFlowFromNamedSymbols` returns the Flow prose AND the set of node ids
the agent named — and the latter is the whole guarantee, since it injects a
named def into its file's cluster ranges at importance 9. Its bail-outs
returned EMPTY, zeroing the identity whenever there was nothing to PRINT.
Two sibling closures that never call each other produce no chain, no synth
hop and no boundary, so both defs lost importance 9 and the file rendered
from its head. `identityOnly()` now separates the two, gated on
shape-precise tokens so a prose word that exact-matches a callable cannot
promote itself.
2. The ceiling trim filled in SOURCE order, so an over-ceiling render always
dropped the END of a large file first. The shrink HAD kept both symbols
(1022-1121); the trim cut back to 839. `windowToCeiling` now takes the
spine call site plus every importance>=9 member as focus lines, tries the
full ceiling first, and splits the held-back reserve evenly with
carry-forward — greedy-in-source-order reproduced the bug one level down.
The shrink's loose size estimate is left alone deliberately, and the comment
now says why: making it exact was built and measured WORSE (it stops at the
last member that fits whole and the released bytes carry forward to
lower-ranked files, costing payroll-go's `s.store.Upsert`). `bound()` clamps to
the ceiling anyway, so the slack costs no bytes; it just must not pick the
survivors, which is what the trim now handles.
The measurement gap this closes: every existing probe is aggregate — envelope
share, per-file spend, source totals, file counts — and all are green on a
response that returns 25K from the right file and omits the named function.
`probe-named-symbol.mjs` checks the definition LINE against the response's
rendered lines, per symbol.
Suite envelope byte-identical to main on all six repos; probe-allocation 4/4,
no starvation flags; 180 files / 2,997 tests green. Fixture: 7/7 fail on main,
7/7 pass here, deterministic over 4 runs per arm.
A file's ranked clusters were all-or-nothing past the first one: the top-ranked
cluster was taken (shrunk to fit when it had to be) and every cluster below it
was rendered whole, then either fit the remainder or was dropped entirely. On a
file whose top-ranked cluster is TRIVIAL that discards the answer — django's
`db/models/sql/query.py` kept a 22-line glue cluster and dropped the 624-line
`Query` body, spending 1,923 of a 7,947 reservation; okhttp's
`RealInterceptorChain.kt` did the same behind its import header.
The response stayed full, which is why this was invisible: the unspent
reservation carried forward exactly as designed and a file scoring a fifth as
much took the bytes.
Two sites, the same rule — hold the remainder while it is still worth a section
(CG-26's between-FILES lesson, applied between CLUSTERS):
- selection now shrinks a later cluster into what is left of the file's budget,
by the same whole-member rule the first cluster already used;
- the ceiling trim re-renders the weakest cluster into the room that remains
before dropping it. On excalidraw's `typeChecks.ts` the section-cost estimate
missed by 13 chars and a 1,512-char cluster — the file's highest-SCORING one —
was thrown away to pay for it.
Cluster RANKING is untouched: measured, both real cases lost on `maxImportance`,
not on the density tiebreak the issue suspected, and density-first is what keeps
Alamofire's `Session.swift` from burying its methods under the property list.
Suite (6 repos, clean-rebuilt indexes): all 8 starvation flags cleared,
+1,012 source chars net. django's `sql/query.py` 1,923 -> 10,082 of 7,947,
okhttp's `RealInterceptorChain.kt` 1,474 -> 6,038 of 6,058, gin's
`routergroup.go` 3,273 -> 5,632. okhttp trades its rank-6 file (score 21) for
+7,196 chars in the two files that answer the question.
Ships two fixtures pulling in opposite directions (`starved-cluster-ts` and
`dense-header-ts`), a `spendShareAtLeast` gate in probe-allocation, and
probe-file-spend.mjs — a standing per-file reservation-vs-delivered sweep.
A file that declares nothing but types and that nothing in the index depends
on — a hand-written ambient `.d.ts` of global shims, vendored typings, module
augmentation — cannot answer a flow question: no bodies, no call edges, no
behaviour, nothing typed by it. But the identifiers it declares are exactly the
generic ones a prose question uses (`Body`, `Message`, `ImageMetadata`,
`ReadableStream`), so on term overlap it out-scored the implementation. Measured
on the new fixture: rank #1 and 51% of delivered source, with the flow's own
entry file pushed out of the response entirely.
Measured first, per the issue: the Wrangler `worker-configuration.d.ts` that
opened this is already handled by CG-25's banner detection, worth 15-46 points
of envelope share across four flow queries. CG-25 credited; only the un-bannered
case needed anything.
`rankPenalty` now multiplies score and graph mass by 0.5 for such files, taken
as the STRONGER of it and the generated penalty rather than multiplied — one
property two signals see must not be charged twice. Detection is structural, not
by extension, and four conditions deep. Two of them were forced by measurement:
requiring every symbol to be type-level takes the corpus flag rate from 1-18%
(which swept in Kotlin sealed classes, Rust mod.rs re-exports and django's
locale tables) down to 0-4%; requiring that nothing depends on the file
separates an ambient shim from a working types module, and without it the rule
demoted displacement-ts's pipeline `types.ts` and broke the CG-31 gate.
A query that NAMES a declared type is exempt, so a question about a type still
reaches its declaration at full weight. Precise tokens only, so "…the file
body…" cannot exempt a `Body` interface it never meant to name; this needs its
own set because `namedSeedIds` is callable-only and a type never becomes one.
Regression evidence in docs/benchmarks/explore-declaration-only-cg28.md:
6-repo envelope sweep byte-identical against a clean baseline build, zero
ambient files reach the candidate set on VS Code across five queries, corpus
flag rate 0-0.74%, both allocation fixtures PASS, full suite 2,978 green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The invariant this closes: every admitted file receives at least its
reservation before any file draws on carry-forward slack. CG-30 bounded an
oversize cluster member and CG-31 gave the cluster path a displacement guard;
three holes were left, and each one starved a file that had been admitted,
reserved and — in the worst case — rendered.
1. The whole-file arms had no displacement guard. BUY's fit test read
`renderCeiling - totalChars` (everyone's room) while its source-space
sibling refused the same trade, and GRACE was not fit-tested at all.
okhttp's CallServerInterceptor.kt shipped 8,499 chars on a 5,964 funded
ceiling and the rank-6 file below it delivered nothing. Both arms now test
the render they actually produce against `fundedHeadroom`, and a whole
render that does not fit falls through to clustering instead of skipping
the file.
2. Every section was charged a flat 200 chars while a real header runs
300-500. The loop believed it had room it did not have — okhttp allocated
26,601 against a 24,400 ceiling — so the final truncation threw a
fully-rendered section away. Sections are charged their real cost now, the
owed-below arithmetic uses a per-file overhead estimated from the file's own
symbols, and a marginal overrun trims the weakest cluster (or windows the
last one into the room that is left) rather than skipping the file over a
rounding difference.
3. `owedPayableBelow` held all-or-nothing. When the last admitted file's FULL
reservation no longer fit, nothing was held for it: on the precise-query
fixture the rank-5 file took 4,134 chars against a 2,948 reservation while
rank 6 — admitted, reserved 2,539 — was left 4 chars and skipped. It now
holds the remainder while that remainder is still worth a section
(MIN_CHARS).
And the epilogue is budgeted instead of discarded. The flat 600-char margin was
neither the epilogue's size (1,064 gin, 1,788 django, 2,231 excalidraw) nor a
bound on it, so four of six suite repos shipped with no pointer list and no
reminders at all. The loop now reserves the epilogue's FLOOR — the one line
that says an uncovered area exists, plus a pointer for every file whose bytes
were deliberately withheld (CG-12) — and the rest is fitted to the room that
actually remains, in priority order, entry by entry. Sized from the real
strings; no constant was swept against the suite.
Deterministic, same clean-rebuilt indexes, baseline = CG-31 tip:
repo base source new source files ceiling
django 20,791 20,878 6 -> 6 was discarding its epilogue
tokio 21,521 21,607 5 -> 5 was discarding its epilogue
okhttp 19,034 18,870 5 -> 6 +1 file delivered
excalidraw 20,204 19,652 8 -> 8 keeps its pointer list
gin 10,776 10,776 4 -> 4 byte-identical
alamofire 11,662 11,662 2 -> 2 byte-identical
No repo truncates any more and none loses a file. okhttp and excalidraw trade
164 and 552 source chars on their LAST-ranked file for the pointer list naming
what the response could not cover — bytes the CG-31 tip only had because it
over-filled a ceiling it mis-measured and then discarded the epilogue whole.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The '⚠ changed on disk after the last index sync' banner is an honesty claim
about source we DID render — line refs elsewhere in the response may be
shifted — not a note about the response. Drawing the epilogue boundary after
it means the size cut can never be what silences it. Suite numbers unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two corrections found by measuring the first cut of the guard against the
6-repo suite. The first version held back the FULL sum of the reservations
below a file. On django that took 2,319 chars off a file the agent receives
and handed them to a section the hard ceiling then threw away — the guard's
own failure mode, one layer down. tokio lost 1,298 the same way.
1. `owedPayableBelow` — hold back only the prefix of what is owed below that
the response can still PAY, in rank order. A promise the ceiling cannot
reach is not a claim on this file's bytes.
2. The final truncation now spends the EPILOGUE before it spends a rendered
file section. It used to cut at the last section header, dropping that
section AND the trailing notes; dropping the notes alone is almost always
enough. A section is source the agent otherwise has to Read; the epilogue
is a pointer list and two reminders, and the note that replaces it carries
the "explore these names" instruction forward.
Also count `flow.text` in `totalChars`. It is prepended to `lines` to make the
final output, so the render loop always spent against a ceiling it was ~2K
under on symbol-bag queries.
Deterministic, same clean-rebuilt indexes, both builds (baseline = CG-30 tip):
repo base source new source files
django 20,033 20,791 5 trunc -> 6
excalidraw 18,776 20,204 7 trunc -> 8
okhttp 15,628 19,034 4 trunc -> 5
tokio 20,340 21,521 4 trunc -> 5
gin 10,776 10,776 4 -> 4 (byte-identical)
alamofire 11,662 11,662 2 -> 2 (byte-identical)
No repo delivers less; four stop truncating. `funded` in the diagnostic now
reports the render CEILING the guard allows, which is what every render path
is actually bounded by.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Carry-forward slack let a file spend what the files ABOVE it left on the
table. Nothing held back what was promised BELOW it. The whole-file BUY arm
has always refused that trade (`owedBelow`); the cluster path read `headroom`
— what is left before the hard ceiling — instead of what is still owed, so
`fileBudget` and `SPINE_CEILING` could pay a 1.5x overshoot out of another
file's reservation.
`fundedHeadroom` is the same inequality in the units the cluster path spends
in: source PLUS the per-section overhead each unreached file will charge.
Floored at the file's own reservation — a kept promise is not a displacement —
and it is <= `headroom` by construction, so it is the only bound the three
render sites need. The skeleton path's `bodyCap` takes it too.
Measured on `__tests__/fixtures/displacement-ts` (a 4-stage pipeline padded
past 500 files, where the 24K envelope genuinely saturates the 24.4K render
ceiling):
before ingest.ts emitted 9,301 on a 6,289 spendable, then lost the whole
section to the final ceiling — 0 delivered. types.ts and sink.ts
skipped `budget-whole-file`. 3 of 6 admitted files delivered.
after ingest.ts bounded to the 4,913 actually free. 6 of 6 delivered,
envelope 14,908 -> 22,066.
The self-query allocation fixture flips back to PASS with it, on a clean full
rebuild of this repo's index (CG-33). Its `afterCG30` verdict blamed an
over-RESERVED incidental file; the reservation was identical in both arms —
the file was over-SPENDING. Recorded honestly in `afterCG31`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d652c14 was committed while an ab-new-vs-baseline run had the engine checked out
at the BASELINE ref — that harness swaps src/ files mid-run and restores them on
exit — so it captured main's tools.ts and explore-diagnostics.ts and silently
undid 765c06a. Restored from 765c06a, with d652c14's doc tweak re-applied.
The A/B runs started after that commit are void with it (their "changed:" line
lists only explore-diagnostics.ts, i.e. both arms ran the same retrieval code)
and are re-run rather than reported.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The self-query allocation probe fixture's delivered-share gates now fail. The
cause is not the new bound: allocation is unchanged between arms (parse-run.mjs
32.3% vs 33.9% on main) and tools.ts delivers the same 8,282 chars in both. What
changed is that the incidental file now DELIVERS — on main its whole section was
cut by the hard-ceiling truncation, so the fixture passed on truncation luck.
Every file on this repo obeys the new bound (max 1.40x of spendable).
Recorded as `afterCG30` with that reasoning rather than tuning the bound to
restore the pass. The over-reservation it exposes is epic CG-24's subject.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
shrinkCluster keeps an oversize cluster's highest-importance member WHOLE on
purpose — an empty file section sends the agent to Read, the outcome explore
exists to prevent. What it lacked was a bound, and "never empty" quietly meant
"never bounded": on the reporting repo one file emitted 22,376 chars against a
9,181-char reservation (2.44x), past both the per-file budget and the spine
ceiling. That overshoot is what collapses `headroom` for every file below it.
The same rule has a second face. When the top member is bigger than the whole
response ceiling, the file does not overshoot — it is dropped entirely at the
renderCeiling check, so the agent gets nothing for a file it named.
renderCluster now takes a ceiling (1.5x what the file may spend — the same
multiple SPINE_CEILING already draws, and never below the cap, so a cluster
that fits is untouched). Past it the member is WINDOWED on whole lines rather
than emitted whole or dropped: leading window plus, on a flow cluster, a window
on the spine's call site. A partial window shorter than 12 lines is dropped
instead — a sliver in the session record forces the next call's dedup to shred
the block around it or re-send it — unless nothing else was emitted, where the
never-empty floor wins.
Measured on the new fixture, pre-fix vs post-fix:
monthly.ts 12,391 chars on a 3,334 budget (3.7x) → 4,941 (1.48x)
quarterly.ts dropped, no headroom left → 4,004 delivered
Also: the diagnostic now reports `spendable` (reservation + inherited slack)
alongside `reserved`. Every render bound reads the former, so reporting only
the latter makes an ordinary carry-forward read as a file spending over budget
— and it made the overshoot this issue is about unmeasurable. A windowed file
is now flagged `clipped` too, instead of presenting a window as the whole file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A later explore call re-served whatever it re-ranked, so on the #1500 report
the 4th call spent its envelope on the spine the 1st call had already
delivered. CG-17 recorded what was served; this acts on it.
What a withheld span becomes is the whole design: a POINTER, never a silence.
An insufficient-feeling response is what sends an agent to Read, and one or
two of those early in a session teach it to abandon codegraph — so the
replacement names the file, the symbols and the line spans, and says both
that the source came from THIS conversation and that the file has not changed
since.
- Content fingerprint, not the drift flag, gates it. They answer different
questions: two calls inside one drift window served the same current bytes,
while a file edited AND re-synced between calls is never "stale" and yet
the agent's copy is now wrong. An edited file re-emits in full.
- Only a covered run of >= 8 lines is replaced, and a remainder under 160
chars folds into the pointer. Below those the pointer costs more than the
source and the block reads as shredded — a fence holding `228\t` is a
broken-looking response, which is the expensive failure.
- The reclaimed bytes go to files the agent has NOT seen, two ways: a smaller
`sourceSpent` hands slack down CG-21's carry-forward pool, and a fully
back-referenced file gives up its maxFiles slot the way a cliffed one does.
Within a file, the cluster shrink now reads the DEDUPED length, so it never
drops new symbols to make room for source it isn't sending.
- If dedup suppresses everything and nothing new takes its place, the top
suppressed file is spliced back in whole. An all-pointer response is the
shape that reads as "codegraph found nothing"; one re-served file is the
cheaper mistake.
Kill switch: CODEGRAPH_EXPLORE_DEDUP=0.
Explore answers every call as if it were the first: no record of the files
and line ranges it already sent, so a 4th call re-serves the 1st call's
spine and the tier call budget can only be asked for, never enforced.
Track it per MCP session, per resolved project root — files, coalesced line
ranges, bytes, and the call's index in the session. Nothing reads it yet:
the response is byte-identical, which the suite pins against an untracked
call of the same query.
The daemon shares ONE ToolHandler and a pool of worker threads across every
connected client, so the state can live neither on the handler nor in a
worker. It lives on MCPSession and is handed to execute() per call; the
session's view rides DOWN on the args and the call's emission rides BACK on
the ToolResult, both as plain properties so they survive the structured
clone to and from a worker. execute() records the emission on the main
thread and deletes it unconditionally — including for callers that track
nothing, like the CLI — so it can never reach the wire. A view a client
spells itself is discarded rather than trusted.
Ranges are reported by the render loop itself (buildSection now returns the
spans it slices alongside the text), and only files that survive the final
hard-ceiling truncation are recorded. Where a bound forces a choice the
record keeps FEWER ranges than were emitted: under-reporting re-serves
something the agent has, over-reporting withholds source it never saw and
costs a Read.
Every bound caps detail only — callCount keeps counting past eviction, so
CG-19's decay can't reset itself every 8 calls.
Found reviewing the CG-21 fix rather than by a failing test, and it is the
same defect inverted. A whole render that overruns `renderCeiling` is
skipped ENTIRELY (the branch refuses to slice a file mid-method), so a buy
that is approved by the funding pool but refused by the ceiling trades a
clustered section for NO section.
Only reachable on the 24K tiers. The funding line is
`reservedTotal + 0.15 * envelope` — ~27.2K when a medium repo saturates —
while `renderCeiling` is `min(1.5 * envelope, 25000) - 600` = 24.4K, so
funding can approve ~2.8K the ceiling then refuses. At 13K the line is
~14.4K against an 18.9K ceiling and the two cannot cross, which is why the
small-tier fixtures cannot see it.
Failing the test in `buysWhole` drops through to the cluster path, which is
bounded by `headroom` and always renders something. The GRACE arm is left
alone deliberately: a file within a sliver of its reservation that still
does not fit is genuinely at the end of a full response, and that predates
this epic.
Verified inert on the three A/B repos, so the agent A/B measured the same
behaviour: excalidraw byte-identical on 3 queries, client-go byte-identical
on 3 queries, express reproducer unchanged at 15,984. Full suite green
(2,867 passed; one unrelated fs.watch timing flake that passes 30/30 in
isolation).
A file whose proportional reservation lands below its own size stopped
rendering whole, and the fallback cluster render could leave most of that
reservation unspent — the bytes were neither delivered nor redistributed.
Found by CG-15's agent A/B on the express control: `lib/utils.js`, the
top-ranked file, was reserved 3,870 chars and spent 583. The whole-file
grace bound (reservation + a sliver) sat just under the file's 5,293
bytes, so the whole render was declined and three matched symbols became a
stub. The source envelope fell 13,849 -> 9,241 against an UNCHANGED
budget, and the agent Read the file back four times in 1 run of 3.
Two levers, per the task's candidate fixes:
- WHOLE_FILE_BUY_FRACTION: a reservation that already covers 60% of a
file buys the whole file. Funded from ONE shared overshoot pool sized
at 15% of the envelope, spent in rank order. Per-file funding is the
version that fails, and it fails the same way the bug does — the merit
test is a ratio, so several files qualify at once and N independent
overshoots push the last section past the render ceiling. Measured on
the payroll fixture: three files bought whole and `payslip_builder.go`
was dropped entirely. A dropped section is strictly worse than a
clustered one.
- Reservation carry-forward: what a file cannot spend goes to the next
file down, bounded by MAX_SHARE. Tracked as two running totals rather
than a `spent` variable threaded through the render loop's dozen exit
paths, so no path can forget to account, and symmetric — a buy that
overshoots suppresses slack until a later under-spend covers it.
Express reproducer: `lib/utils.js` 583 -> 6,268 whole, envelope 9,241 ->
14,505 on the same 13,000 budget. The `memory-budget.ts` exception CG-14
documented is RESOLVED rather than re-justified: it ships whole again at
5,672 (27.3%) while `src/mcp/tools.ts` rises to 52.6% — so the answer
file wins the envelope AND no previously-unclipped file is clipped, which
is CG-12's own acceptance criterion finally holding.
Two hermetic fixtures added, one per lever, because nothing in the suite
had this shape — which is how it shipped. Both mutation-tested: removing
the buy arm reddens 3, removing the carry-forward reddens 2, and removing
the funding guard reddens 4 (including payroll's dropped
`payslip_builder.go`). Their `fixture shape` blocks are load-bearing: the
gates pass vacuously if a target ever drifts inside the grace bound, so
the window is asserted directly.
Full suite green (2,868 passed); both #1500 regression fixtures pass.
Coverage for the CG-12 allocator, built around "would this go red if the
lever were removed" rather than line coverage — every way this regresses
is silent, ending in an agent falling back to Read.
Unit (`explore-proportional-allocation.test.ts`, 18 -> 38): calibration
pins, envelope safety across every tier and 30 candidate shapes, the
cliff boundary, spine weighting/trim survival, the diffuse control, and
the degenerate inputs — identical scores, a lone file, a runaway top
scorer, zero results, maxFiles 0, a non-finite score.
End-to-end (`explore-allocation-e2e.test.ts`, new): CG-6's second
regression fixture as a deterministic synthetic mirror — a large relevant
file, a small helper that used to win by shipping whole, and an
incidental `explore`/`BUDGET` collision — asserting per-file budget
share, not file presence. Plus degenerate result sets and a survey-style
diffuse control through the real render loop. The live self-query arm
stays in probe-allocation.mjs, where drift is a number to re-baseline
rather than a red suite.
Reverting the render loop to the pre-CG-12 rules reproduces #1500 on the
mirror exactly and takes 5 e2e + 2 payroll gates red:
file score pre-CG-12 CG-12
src/mcp/allocator.ts 77.5 4,843 (39.7%) 9,335 (80.1%)
src/util/budget-math.ts 36.0 6,079 (49.8%) 1,037 ( 8.9%)
Two defects the invariants surfaced, both fixed in tools.ts:
- rounded shares could sum past `pool`, so "reservations fit the
envelope" was approximate rather than exact; both terms now floor
- a non-finite score made every share Infinity/Infinity, handing the
render loop a NaN allowance; `weightOf` now fails safe to 0
Also adds a hard-ceiling gate to the payroll fixture — at 19.3K against
a 19.5K ceiling it is the only fixture that stresses the ~25K inline cap
— and exports EXPLORE_ALLOCATION so invariant tests read the constants
while one test pins the literals.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>