* fix(ui): a screen's steps read as clusters, and a link too far to follow is said in words
The mobile app's /capture came back as a web: 100 boxes in a 1,227x5,588
ribbon, 113 lines drawn at rest crossing each other 652 times, each one
running over about five other boxes' names. Measured, not guessed — three
separate causes, very unequal.
The region grouping had nothing to divide there (98 of 100 boxes take their
region from one memoized component), so the picture fell back to a single
719px column. But the region dimension was not the lever. The lever was that
`packRegions` packed every step of one distance onto shared rows and wrapped
those rows at a fixed 720px, so a box and the thing it fires landed seven
lines apart: 70 of the 113 lines joined boxes ONE step apart. That is what
the crossings were made of.
So a region is now packed as CLUSTERS — a step, then the steps it sets in
motion on the line under it, stepped in — while the starting points that fire
nothing still share a line, because a screen's handlers are siblings and
giving each its own line turned a flat region into a column. A region's line
width is earned rather than fixed (sqrt(total * pitch), clamped 720..2600),
so a big screen comes out about as wide as it is tall.
Clustering makes most links local but not all: a step reached from two places
is drawn under whichever reached it first, so the other way in still crosses
the picture. Those are now said in WORDS at both ends — `-> resumeInference`
under the box that leads there, `<- CaptureView` under the box it arrives at,
capped at three with `+N more` — rather than drawn. This is not a hiding: the
link is stated, which says more than a line vanishing off the edge of the
screen does, and selecting the box draws every one of its real lines exactly
as before. It is the one at-rest cut that does not produce the "box that leads
somewhere and draws nothing" every earlier cut produced.
Also fixed while here, and predicted by the earlier region work: the in-region
row relaxation had no cycle guard, so a region holding one loop pushed 65 of
its boxes to rows 294-301 while the rest sat at 0-2. `forwardLinks` sets
cycle-closing links aside first, as the order reading's `withoutBackEdges`
already did.
/capture: 1,227x5,588 -> 2,279x4,356, at-rest crossings 652 -> 1,
lines-over-boxes 553 -> 26, with half the links still drawn as real lines and
every quiet box still one the screen itself fires directly. The order reading
is untouched (it keeps every line).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD
* fix(ui): a stub names its box without the mark the box wears for its kind
`← ⇠ onCaptureProgress +2` reads as two arrows arguing: the stub already
leads with a direction, and the box's own kind mark was competing with it.
Verified in the live canvas. The box keeps its mark, where nothing competes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD
* fix(ui): the screen's line into each of its parts stops sweeping the picture
Audited all 51 screens of the mobile app on this branch. 96 lines — the
screen's own stand-in line into each region — were 17% of everything drawn
and caused 79% of every crossing left. A screen with ten regions tiles them
into bands, so the line into a region two bands down travelled the height of
the whole picture.
Two causes, both fixed. The entry the line lands on was the walk's first
member of the region; clustering moves a step that fires something BELOW the
ones that fire nothing, so that box could sit lines down inside the region
and the line had to reach past everything above it. It now lands on the box
nearest the region's top-left that the screen actually leads to. And the
stand-in line is no longer exempt from the stub rule — when the region is
still too far to follow, the link is said in words like any other. The rest
of the anchor's fan stays quiet as before: it is already stood in for.
Across the 51 screens: crossings 47 -> 10, no screen above 10 (worst was 19,
now 2); lines-over-boxes 236 -> 182; boxes with neither a line nor a word
150 -> 135.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD
* fix(ui): a screen's parts fill the canvas instead of squaring off into rows
Audited the app's 28 regioned screens: the median canvas was 55% region and
45% nothing, and /home was 44% — 4,860px tall to hold about 2,160px of
picture. The cause is that regions were tiled a row at a time with each row
as tall as its tallest member, so one short region beside a tall one left the
rest of that row blank, and a reader scrolls through the blank.
Each region now goes as high as it can and then as far left as it can, over a
skyline of what is already placed. Reading order is untouched: regions are
still walked in the screen's own source order, so an earlier one is never
pushed below a later one — a short one just tucks under another short one
rather than waiting for the tall one beside it. Layering now comes from the
finished geometry rather than a band counter, since once regions drop
independently what a reader sees as one row IS one row.
/home 4,860px -> 3,584px, aspect 0.59 -> 0.94. Tallest screen in the app
4,860 -> 4,356. Crossings 10 -> 13 across all 51 screens, still none above 10.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD
* fix(ui): the width a picture wraps at is tried, not estimated
A region's line width came from sqrt(total * pitch) — the width at which
total/width lines come out square. That estimate is wrong for how these
pictures are drawn: a cluster spends lines on its own structure (a hub gets a
line to itself, and what it fires starts another), so it undercounts a
region's lines badly and wrapped /capture's 98 boxes into a 4,356px column.
Laying a picture out is cheap and exact, so the widths are tried instead:
layoutAt runs the whole pack at each of eight widths and the best finished
canvas wins (~2ms for the model, all eight included). It has to be scored on
the CANVAS, not per region — squaring each region off individually leaves
fewer of them side by side, which took /home from 3,584px to 5,624px while
every region looked better on its own.
Also measured and rejected while here: dropping a region's CLUSTERS side by
side the way the regions drop onto the canvas. Total height 42,084 -> 39,756px
(-6%), but lines-over-boxes 120 -> 134 and crossings 5 -> 8, because two
clusters side by side put each one's lines through the other. Height is cheap
to scroll; a crossed line is what made this picture unreadable. The reasoning
is recorded in the code so it is not re-tried blindly. Regions differ — they
sit far enough apart that few lines run between them.
Across the app's 51 screens: tallest picture 4,356 -> 3,796px, total height
45,744 -> 42,084px, lines-over-boxes 184 -> 120, crossings 13 -> 5, and no
screen is a tall ribbon any more.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD
* stuff
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reuse unsafeIndexRootReason before scanning indexed subprojects so stray manifests at home or broader roots cannot inject unrelated context. Preserve workspace adoption for #964.
Validation: four new regressions fail before the guard and pass after it; 48 relevant tests and npm run build pass. Confirmed the real os.homedir() leak before and after the fix with fixture cleanup.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
* fix(db): graceful FTS5 fallback when Node.js build lacks FTS5 support (#1532)
Official Node.js binaries do not compile FTS5 by default, causing codegraph
init to fail with 'no such module: fts5'. Added runtime FTS5 detection:
- Split schema execution to try FTS5 separately, skip on failure with warning
- Added fts5Available flag to DatabaseConnection and QueryBuilder
- Bulk-load and search paths skip FTS5 operations when unavailable
- Search falls back to LIKE + fuzzy matching when FTS5 is missing
(cherry picked from commit ed708b7f60540367d8a810b0388aaa05ce0a0933)
* fix(db): preserve core schema during FTS5 fallback (#1532)
Keep required tables and indexes after the FTS triggers outside the
optional schema block in the upstream #1625 fix. Without this boundary,
simulated-missing-FTS5 indexing still fails on name_segment_vocab.
Add seven regressions using real SQLite with FTS5 creation intercepted,
covering initialization/open, LIKE and fuzzy search, non-FTS schema parity,
bulk no-ops, and real FTS5 search and bulk-load recovery. Credit
@aniruddhaadak80 under Unreleased fixes.
Validation on Linux x64 with Node v22.19.0:
- npm run build passed, including viewer and grammar asset checks.
- 34 tests passed across fts5-fallback, node-sqlite-backend,
sqlite-backend, and db-perf.
- Rebuilt CodeGraph initialization, indexing, reopening, search, and
cross-file callers passed with simulated missing FTS5 and real FTS5.
Fixes#1532.
Supersedes #1625.
---------
Co-authored-by: Aniruddha Adak <aniruddhaadak80@users.noreply.github.com>
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.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>
Adapt upstream PR #1485 by @valkyriweb (cc791cfc51b100097571421b4ab7c53b96ceb202)
onto current main. Keep the upstream alias-binding module and six-test suite
verbatim, preserve target-kind gating and default-export bindings, and add
one credited Unreleased changelog entry.
Linux verification (x86_64, Node 22.19.0): TypeScript build and asset copy
pass; the fresh ./impl.js repro changes callers/impact of realImpl from
missing consumerFn to including it. All 6 upstream tests and 231 related
resolver regression tests pass.
Fixes#1482
The Forge PR will supersede upstream PR #1485.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Land upstream PR #1687 by danusha2345 (fix commit 6e9bbb26), using the
PR tip implementation with only a Rust doc-comment placement cleanup.
Read parameter lists and return types positionally in the wasm extractor
and native kernel in lockstep, preserving verbatim signature text.
Verified on Linux x64 with Node 22.19.0: reproduced three undefined
signatures in both backends before the fix, then confirmed all three
expected signatures and exact wasm/kernel parity after rebuilding
TypeScript and the linux-x64 kernel. All 31 focused tests pass: 15 Kotlin
extraction, 6 Kotlin parity, and 10 kernel scaffold checks, with
CODEGRAPH_KERNEL_EXPECT=1 for the native suites.
Add the upstream #1495 changelog bullet while preserving all other
Unreleased entries. Keep EXTRACTION_VERSION unchanged for this bug fix.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
A project CodeGraph has no grammar for was indistinguishable from an empty one: unsupported extensions are filtered out at discovery, so filesDiscovered was 0, the reconciliation in index.ts found no shortfall and recorded index_state as complete, and the CLI printed the same 'No files found to index' it prints for an empty repo.
That silence is what makes it costly over MCP: an empty result reads identically to 'no match', and the agent has been told to trust the graph rather than grep.
The scan already visits every file, so the tally of what it declined to index costs no extra I/O and no second pass. index_state itself is left alone: changing its values would change the status --json contract, which is a call for the maintainer to make.
(cherry picked from commit 2c20892789897f502f84152f78b777b11d65fdaa)
Co-authored-by: Max Hsu <maxmilian@gmail.com>
Co-authored-by: netbrah <netbrah@users.noreply.github.com>
Apply upstream commit 1572d90d7174f0c65019e768c8f65728fae668c5 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>
`codegraph affected` kept six regexes of its own — `.test.`, `.spec.`,
`/tests/`… — so a Go `foo_test.go`, a Python `test_foo.py` or a JVM
`FooTest.kt` beside the changed file was never reported, and "no tests
affected" read as "no coverage". Use isTestPath from search/query-utils,
the same predicate search and the MCP tools already rank by.
Cherry-picked from danusha2345's upstream PR #1688
(commit 995b6f1f262564cafa6eb0b026b84fa4122f2cb0).
Preserve main's CLI imports and Unreleased entries, and credit the
contributor in the changelog. The default affected depth remains 5.
Fixes#1507.
Supersedes #1688.
Verified on Linux with Node 22.19.0: npm run build; the Go fixture
changes from no affected tests to math_test.go; matching and nonmatching
custom filters still override. Vitest: 3 files, 36 tests passed, including
the upstream Go/Python/Kotlin suite and affected path/dependency coverage.
Co-authored-by: danusha2345 <ewidusoc498@gmail.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
c0ccbacd3f52007b65ce5b9599fa7a086501ac39 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 upstream PR #1514 for issue #1513 by cherry-picking
ctype_lab's a94c9dc94eee348bf63c7e2dd567c0b43577678a.
Keep allowBodilessStruct as a Rust-only opt-in, with matching behavior in
the wasm/TypeScript walker and native kernel. Create the node before
checking for a body and walk members only when present. Resolve against
main's shared struct/union walker while retaining its stack guard, kinds,
fields, and existing impl-receiver fixes.
Verified FAIL to PASS on Linux x64 with Node 22.19.0 after rebuilding via
tsc, copy-assets, and build:kernel. The identical fixture on main 7b339373
had two structs and two implements edges; fresh wasm (CODEGRAPH_KERNEL=0)
and native indexes now have three of each. UnitStruct and its Greet
implements edge are recovered; tuple and brace structs remain intact.
The native run loaded the rebuilt kernel and completed without fallback.
Focused extraction.test.ts and kernel-rustlang-parity.test.ts runs:
645 tests passed with the kernel disabled, and 645 with it enabled;
all three parity tests ran in each configuration, with no skips.
(cherry picked from commit a94c9dc94eee348bf63c7e2dd567c0b43577678a)
Co-authored-by: ctype_lab <cksgud1226@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
An explicit path names the project to rebuild; it is not a hint to go
looking for one. resolveProjectPath's upward walk is right for a query run
from a subdirectory, but for a full re-index it silently rebuilt the
nearest initialized parent's graph when <path> had no index of its own.
Refuse with the parent's path and the way to index <path> itself; a bare
`codegraph index` still resolves from cwd as before.
Fixes#1524
Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
* fix(resolution): gate extends/implements to real supertypes
An inheritance reference bound to whatever local symbol shared its name.
The name-matcher scores node kind as a bonus, never a filter, and awards
no bonus at all for inheritance refs, so `use std::error::Error;` +
`impl Error for MapperError {}` resolved to the local `MapperError::Error`
VARIANT — an implementation relationship absent from the source.
Two changes, both needed. Filtering by kind alone was measured and it
only RELOCATES the false edge: with enum members excluded, the same 7
refs moved onto an unrelated local `type Error` alias, which is a legal
supertype kind and therefore harder for a consumer to reject.
1. Eligibility before ranking. `matchByExactName` restricts its candidate
pool to kinds that can BE a supertype, so a legitimate trait outranks
a same-named variant instead of merely losing its edge. `resolveOne`
is wrapped by a gate that applies the same set to every other strategy
at one seam — filtering inside the name-matcher would have missed the
framework, import, chain and CFML paths.
2. Locality. A name imported from outside the repository has no in-repo
referent at all, so no candidate is correct. Only oracles that cannot
be wrong are consulted: Rust `use` paths rooted at a stdlib crate, and
`isExternalImport` for ES modules. Generalizing the Rust side to "the
module path doesn't resolve to a file" was tried and reverted — a
crate re-exporting a sibling's modules (`pub use pupil_core::ports;`)
has no directory to walk, and that version deleted 13 real trait
implementations.
Measured on a Rust/Tauri project (2,682 nodes): the 11 false inheritance
edges are gone, all 59 real trait relationships are preserved, and node
count is unchanged. On this repository as a control, the only edge
removed is a class recorded as extending a function. Synthesized-edge
counts are identical in both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(resolution): an import never resolves to a member of a type
`import * as path from 'node:path'` is unresolvable — the module is
external — so the name-matcher fell back to finding any node called
`path`, and a common word like path/url/join/get matches a class property
or interface method somewhere in almost any repo. Nothing in any
supported language lets an import bind to a member that only exists
inside a type; you import the type.
Same shape as the inheritance gate that precedes it: eligibility applied
to the candidate pool before ranking, plus the resolveOne gate as the
backstop for every other strategy.
On this repository as a control: 19 imports pointing at methods and 4 at
properties are gone (all of them coincidences — `Walker::join`,
`Telemetry::events`), 3 refs now find the module constant they actually
name, node count unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(resolution): classify SFC script imports as ES module specifiers
`isExternalImport` had a TS/JS branch listing typescript/tsx/javascript/jsx/
arkts, so for Svelte, Vue and Astro it fell through every branch and returned
false — "not external" — for `import { Foo } from 'some-npm-pkg'`.
An SFC imports inside its `<script>` block (Astro: the `---` frontmatter) with
ordinary ES module syntax; `extractImportMappings` already routes all three
through the same `extractJSImports`. So the classifier disagreed with the
extractor about what those imports are.
Effect on the preceding commit: its locality check asks `isExternalImport`, so
it silently did nothing for SFCs. A class in a `.svelte`/`.vue`/`.astro` file
implementing a type imported from an npm package still bound to whatever local
class shared that name — verified against this branch before the fix, all three
languages.
The language set is now one constant used by both the classifier and the
locality check, so they cannot drift apart again. Relative and aliased
specifiers are unaffected: the branch returns "not external" for `./…`,
workspace members, tsconfig alias prefixes, `@/`, `~/` and `src/` exactly as it
does for `.ts`.
No edge changes on this repository as a control.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: ctype_lab <cksgud1226@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Land upstream PR #1604 by @maxmilian from commit
27c149524d968485164fb43bdec994fdb9323c75 on current main cece0720.
Fixes#1549.
Use the upstream lsFilesStaged helper at both collection and embedded-repo
discovery call sites. Retry without --recurse-submodules when staged
recursive listing fails, retaining the mode bits needed for gitlinks.
Keep the upstream regression test byte-for-byte and preserve main's
existing extraction/watcher changes, including #1728. Resolve the
changelog conflict with a concise Unreleased entry, and keep the existing
collectGitFiles documentation attached to its function.
Verified on Linux x86_64 with Node 22.19.0 and Git 2.47.3:
- The unchanged upstream test fails against main: expected [ 'a.ts' ]
to include 'dir_b/b.ts'; it passes with the fix.
- The same persistent fixture under a PATH shim rejecting -s with
--recurse-submodules (exit 128) changes scanDirectory from [a.ts]
to [a.ts, dir_b/b.ts], and gitlink watcher roots from [] to [lib/].
- Real-Git controls return both files and the watcher root in both arms.
- 69 related scope/config tests and 23 extraction scanning tests pass.
- npx tsc --noEmit passes.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Co-authored-by: Max Hsu <maxmilian@gmail.com>
Co-authored-by: newshowardz777 <newshowardz777@users.noreply.github.com>
Land the six-file fix from upstream PR #1691 by danusha2345
(pr-1691 at 6d0e80d52ae953b22d615bd20c4a4c7758814e60), preserving
wasm/native extraction parity and exclusive field-type resolution.
Preserve coexistence with the #1566 Map/collection fix merged in #1790,
including nested holder.values.get coverage and the unchanged #1566
Unreleased changelog bullet. EXTRACTION_VERSION remains unchanged.
Align the existing chained-receiver regression with the fix: a declared
service field calls its method, while an anonymous field type does not
bind to unrelated same-named project functions.
Verified on Linux with Node 22.19.0:
- Rebuilt the native kernel and TypeScript/browser distribution.
- Both backends change Outbox::send -> Outbox::send into
Outbox::send -> Mailer::send, keep Relay::forward -> Mailer::send,
and store no self-edges in the issue repro.
- Wasm: 224 tests passed; native kernel: 255 tests passed, no skips.
- All 10 #1566 resolution cases pass on each backend, plus all four
nested-receiver extraction parity cases.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
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 63992facabbbcef2797167dcdd90695d3802b533)
Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
* fix(resolution): resolve Python module members through an aliased from-import (#1626)
resolvePythonModuleMember rebuilt the submodule's dotted path by joining the
import source with the LOCAL name. Under 'from pkg import mod as alias' that
produces 'pkg.alias' — a module that does not exist — so the file lookup found
nothing and the call fell through to unresolved_refs with status='failed'.
codegraph_callers then reported the target as having fewer callers than it
does, which is the same wrong 'is this dead code?' answer #578 produced for
the unaliased form.
Join with the exported name instead. For an unaliased import the two names are
identical, so nothing changes there; '*' (the namespace form) keeps using the
local name, which is what it already bound to.
Scope note: the issue also reports 'import top as alias' failing. That form is
a namespace import and binds at source, so it resolves on current main — a
probe against the reverted resolver confirms it already produces its call edge.
The regression test pins both halves so the working one cannot silently break.
Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit f7a8940e679b5d4093dd2d00412306a6b4800723)
* fix(resolution): restore aliased Python module import edges (#1626)
Use the exported module name in the file-import resolver, matching the member resolver from upstream PR #1635. Keep both aliased call assertions and verify the file-to-file imports edge in the #1626 regression test. Update the Unreleased note to cover file dependencies.
Validation on Node 22.19.0: npm run build; supplied cg1626 repro; vitest run __tests__/resolution.test.ts -t 1626. Pass evidence saved in /workspace/cg1626-PASS.json and /workspace/cg1626-VERIFY.json.
---------
Co-authored-by: Max Hsu <maxmilian@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
* fix(installer): honor CODEX_HOME for the Codex global install (#1627)
Codex resolves its user layer from `CODEX_HOME` and only falls back to
`~/.codex`. The target hardcoded the fallback, so a user on a custom profile
got a correct install into a directory Codex never reads — the MCP entry, the
AGENTS.md block, and detect() all pointed at the wrong profile, and the
failure is silent.
Resolve the global config dir from `CODEX_HOME` when set and non-blank,
mirroring what the copilot-cli target already does for `COPILOT_HOME`. Only
the user layer moves; the project layer (#1531) stays anchored to the project.
The test harness now also clears `CODEX_HOME` in setHome() alongside
HERMES_HOME/COPILOT_HOME — without that, the existing codex tests fail on a
developer machine that has the variable exported.
Note this is only half of #1627: the CLAUDE_CONFIG_DIR half is already
covered by the open PR #1029, which this deliberately does not touch.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(installer): honor CLAUDE_CONFIG_DIR for global Claude installs
Build on #1633 by @maxmilian and port the CLAUDE_CONFIG_DIR approach
from #1029 by @borfast onto the current installer. Keep the CODEX_HOME
cherry-pick cbb08231 intact.
Resolve non-blank Claude profile paths with path.resolve. Put the global
MCP JSON inside a custom profile while preserving ~/.claude.json for the
default profile. Settings, instructions, detection, and uninstall follow
the selected profile; local installs keep their existing paths.
Clear and restore CLAUDE_CONFIG_DIR in the setHome test harness. Cover
absolute and relative profiles, idempotency, unset/empty/blank fallback,
default-profile preservation, detection/uninstall, and local installs.
Combine the Unreleased note for both environment variables.
Thanks @seanchann for reporting the issue.
Validation on Linux with Node 22.19.0:
- npx tsc -p tsconfig.json
- npx vitest run __tests__/installer-targets.test.ts: 245 passed, 3 skipped
- Reproduced both failures against main e720f6ca; the rebuilt CLI writes
all files into CLAUDE_CONFIG_DIR and CODEX_HOME with no ~/.claude,
~/.claude.json, or ~/.codex created.
Fixes#1627
---------
Co-authored-by: Max Hsu <maxmilian@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Land upstream PR #1634 by @maxmilian, commit
8d398a92e30c9df27196bc90833efd674384569d.
Path-only detection classifies .h files as c, so preloading previously
covered c and cpp but missed objc selected by content-aware detection.
Preload objc alongside cpp whenever c is present. Full indexing and
changed-file reindexing now share preloadLanguagesForFiles().
Retain all four upstream unit tests and place the existing #1628 changelog
entry under Unreleased / Fixes / Symbols, tests and the viewer.
Verified fail -> pass on Linux with the reporter's repro.h as the only
source file, without a .m or .mm grammar seed. Before: Objective-C parser
initialization failure, 0 nodes, index state failed. After: Indexed 1 files,
2 nodes, 1 edge, index state complete; node CGRepro finds the class and
errors.log is absent.
Validation: npx tsc && npm run copy-assets passed; rebuilt CLI is executable.
npx vitest run __tests__/preload-languages.test.ts: 4 tests passed.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Co-authored-by: Max Hsu <maxmilian@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>
Apply upstream PR #1623 by danusha2345 (fix commit aa777063),
which also addresses #1616, to the current main base. Preserve the
upstream WASM and Rust implementations and regression coverage.
Index assigned locals, table members, static string keys and nested
callback tables as callable symbols, with calls owned by those symbols.
Keep dynamic keys unguessed. Add #1650 to the Unreleased changelog and
retain the existing re-index guidance without an extraction-version bump.
Verified on Linux x64 with Node 22.19.0:
- native kernel build, tsc, asset copy, executable CLI
- issue repro: 3 nodes / 2 edges -> 4 nodes / 4 edges
- EPR.PowerController::SyncHydroPower is indexed; its caller is client.lua
- syncHydroPower depth-2 impact reaches client.lua
- extraction/resolution/Lua parity: 844 tests passed (kernel expected)
- forced-WASM Lua/Luau extraction/resolution: 20 tests passed
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Use same-file module variable initializers to filter builtin collection calls before class-name heuristics, and require a Python type node for the class escape. Preserve imported project module calls.
Add callers/callees regressions for dict, list, set, tuple, and frozenset, with a real instance control and same-name bindings across files.
Validation on Linux with Node 22.19.0: the new suite had 14 failures and one passing control on main at 8733c288; all 216 tests in the new suite, call-receiver-no-fabrication, and resolution now pass. TypeScript and copy-assets pass. Re-indexed /tmp/cg-1652-repro: callers get is empty and read_setting no longer calls a cache.py method.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
`const handleClear = () => {…}` inside a component — every React handler
that skips useCallback — was never a symbol: the body walker only named
nested function declarations and hook-bound arrows, so the handler was
absent from callers/impact ("Symbol not found", indistinguishable from
"no callers") and its calls attributed to the component. Bind the arrow
or function expression to its declarator the way module scope already
does, in both the wasm walker and the kernel.
A navigation such a handler makes is now the handler's own edge and a hop
in the Screens `via` chain — the shape a useCallback handler already has —
so the react-router and expo-router expectations follow that convention.
Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
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>
`exports.getItems = async (req, res) => {…}` and `module.exports.x =
function () {…}` — the Express controller style — produced no symbol: the
arrow's parent is an assignment, not a declarator, so it stayed anonymous,
its calls attributed to the file, and `node`/`callers` answered "Symbol
not found" for a route-wired handler. Resolve the name from the export
property, mark it exported, in both the wasm walker and the kernel.
Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
`def f(o: "Alpha")` is the same annotation as `def f(o: Alpha)` — a
forward reference, and what every file under `from __future__ import
annotations` writes — but the receiver-type pattern stopped at the quote,
read no type, and `o.render()` produced no edge. Admit the quoted form.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Claude Code persists hook stdout over 10,000 characters to a file and shows
the model a 2 KB preview. The prompt-hook MAX of 16,000 always hit that path
once explore filled the budget. Cap at 9,000 (exported + unit-tested) so the
payload lands inline, with headroom for the wrapper and projectPath nudges.
Lands the approach from #1695 with a testable helper.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
OpenCode 2 exposes MCP tools through Code Mode by default; `codemode: false`
only survives on `mcp.servers.<name>` with `disabled`. The installer wrote the
v1 `mcp.codegraph` + `enabled` shape, so the opt-out was dropped on normalize.
Write `mcp.servers.codegraph` with `disabled: false` and `codemode: false`,
migrate a leftover v1 entry on re-install, uninstall either shape, and keep
printConfig / README / AGENTS.md in sync.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
`chrome.storage.local.get(key)` and `document.body.querySelector(s)` end in
a platform API, but the extractor emitted the bare method name for them. That
name then exact-matched whatever project symbol shared it: in a Chrome
extension every `chrome.storage.local.get/set` inside a storage wrapper bound
to the wrapper's own `get`/`set`, giving self-edges that are not in the
source (#1707).
A member chain whose root identifier is a host object the project never
declares now emits nothing — a silent miss instead of a wrong edge, the same
trade the literal-receiver gate makes (#1230). `window` is deliberately not a
host root: `window.MyNs.doThing()` reaches a project symbol. A chain rooted at
a project value keeps the bare name, so `store.getState().act()`, `ref.value
.m()` and `this.<field>.m()` are untouched.
The Rust kernel mirrors the same gate. Verified on Linux: fail→pass on both
kernel and wasm arms for `__tests__/ts-chained-receiver.test.ts` (2 fail / 1
pass on main → 3/3 with the fix).
Lands / rebases https://github.com/colbymchenry/codegraph/pull/1710 onto
current main.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Co-authored-by: Aaron Queen <bompus@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>
`serialize(this.raw)` inside `Record.serialize`, with a module-scope
`function serialize` in the same file, resolved onto the method itself:
both were exact-name candidates, both same-file, and findBestMatch's
line-proximity term always prefers the enclosing method (#1714). In JS/TS a
call written without a receiver cannot reach a method at all — methods
need `this.`, an object, or a bound reference.
The extractor emits `this.m()` and `super.m()` under the bare method name,
so the receiver is read back from the call site's own line: when the text
there begins with the name itself and nothing but whitespace, an operator
or an opener precedes it, the call is bare, and `method` nodes leave the
candidate set before ranking. matchFuzzy declines a lone `method` survivor
for the same ref. A name the file binds itself also has no cross-file
candidate for a bare call. `this.serialize()` and `other.serialize()` are
unchanged.
Rebased #1735 onto current main (resolved conflicts with sealed-module /
cross-file visibility guards from #1719/#1730/#1731).
Fixes#1714
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Pure-virtual declarations (`virtual int read(int key) = 0;`) parse as
field_declaration, not function_definition, so they minted no method node —
calls through an abstract base and cpp-override synthesis had nothing to
attach to. Mirror Java interface methods: mint the node (TS + kernel), mark
isAbstract, and cover with extraction/e2e/parity fixtures.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
tree-sitter-c has no rule for `.field = value` as a call argument. A
statement-level `MACRO(a, b, .x = …, .y = { … },);` recovers by extending
the enclosing function_definition to EOF — later functions vanish or nest
as outer::inner (#1729). blankCDesignatedMacroArgs empties such argument
lists to spaces (newlines kept) at the head of preParseCSource, before the
kernel route point, so both wasm and kernel C arms see the same bytes.
Tests cover the issue fixture (trailing-comma designated args) and a
120-field scale guard. Refs #1729.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
buildDefaultIgnore now reads .git/info/exclude and core.excludesFile; buildScopeIgnore also seeds directories git ls-files reports as ignored-untracked so nested .gitignore effects prune the live watcher. Defect B (full-project sync per event) was already fixed via scoped pendingFiles sync.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
sync() armed the WAL valve but never called backpressure(), so daemon
catch-up could grow the WAL without bound while query-pool readers pinned
frames. Wire the writer pause into sync store + batched resolution, and
abort with WalValveAbortError after parked backfills fail past the
documented hard/file caps instead of disabling parking for 60s.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Could not reproduce the reported nested-gitignore node_modules blowup on
Linux against main (or with the real Boba-Base ignore files): both the git
ls-files path and scanDirectoryWalk already exclude via DEFAULT_IGNORE and
per-directory .gitignore. Add CODEGRAPH_DEBUG logging when the git listing
falls back, plus regression tests for the reporter's layout on both scan
paths so a future regression fails loudly.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
isBuiltInOrExternal treated ledger.append as list.append unless the receiver
matched a known class, so real module exports never reached resolveViaImport.
Allow project-module receivers (verified via resolveImportPath) through while
keeping stdlib/PyPI silent. Completes #1681 after #1748 fixed the FP half.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
A member call whose receiver is itself a call — `d.setdefault(k, []).append(v)`,
`make().run()` — used to drop the receiver at extraction time, degrade to the
bare method name, and exact-match any top-level project symbol of that name
(Python and JavaScript/TypeScript). Keep the inner callee encoded as
`<inner>().<method>` in the TS extractor and native kernel; the name-matcher
refuses to guess for that shape (store-accessor exception only). Based on
#1692, rebased onto main after #1746. Fixes#1683.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
* 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>
* fix(resolution): a definition its language makes file-local is not a cross-file target
Name matching accepted any same-named definition as the target of a call
from another file, however the language scopes it. isVisibleAcrossFiles
now declines, for a candidate in another file:
C / C++ a function whose definition line carries `static` (read from
source — the extractor records no storage class, and the kernel
arm would need the same field)
Kotlin, Java, C#, Swift, Scala, Dart, PHP
visibility === 'private'
Go a lowercase identifier from another directory (by the name's
case: the extractor's isExported is unset for every Go method)
Rust a non-`pub` item unless the reference is in the item's module
subtree (a child sees its ancestors' private items via super::);
a method in an `impl Trait for Type` block has the trait's
visibility and is exempt
The test runs in ReferenceResolver on the target the whole name-matching
pipeline settled on, so a rejection ends the reference unresolved. Declining
inside matchByExactName instead let the ref fall through to matchFuzzy,
which committed to a same-language namesake the ranking had passed over —
eight edges on one tree, all onto a local `const fail = …` arrow the graph
does not hold. matchFuzzy checks its own survivor too; nothing runs after it.
Five corpora, all against b9ca4b7, wasm arm, edge rows keyed with
resolvedBy:
betaflight fork (2,109 C files) LOST 4,451 GAINED 0 (#1730)
Android/Go/JS app (114 kt, 42 go) LOST 142 GAINED 0 (#1731)
emmc-reader-gui (71 rs) LOST 195 GAINED 0
skylab_hub (35 rs) LOST 92 GAINED 0
vitejs/vite (JS/TS only) LOST 0 GAINED 0
Samples read back: `Vec::new()` onto a private `fn new` in another crate,
`ui.add(…)` (egui) onto a private `add`, `latch.await()` onto a test file's
`private fun await`, `leaflet.js` onto an unexported Go `func add`,
`usbd_get_descriptor` onto a `static get_device_descriptor` in a USB class
file it never links.
Fixes#1730. Fixes#1731.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(resolution): a static in a header is part of every unit that includes it
The C rule declined any `static` function defined in another file. A
`static` in a SOURCE file is local to that translation unit and the rule is
right there; a `static` in a header — `static inline`, the whole of
MAVLink's generated `mavlink_msg_*.h` — is textually included into every
unit that names it, and the call is real. On the betaflight tree 4,306 of
the 4,451 rows the first cut removed were exactly that: `testsuite.h` and
`mavlink_msg_*.h` calling `protocol.h`'s `_mav_put_char_array`,
`mav_array_assign_char` and each other's `_pack` / `_decode` helpers.
The rule now applies only to a candidate whose file is a translation unit
(`.c .cc .cpp .cxx .c++ .m .mm`). Same tree: LOST 145, GAINED 0, every one
onto a `static` in another `.c` — STM32 USB class sources onto GD32's
`usbd_enum.c`, and the USB descriptor table shape from #1730. Header
targets in the removed set: 0.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs(changelog): cite #1731 and narrow the C file-local note
Rebased #1732 onto latest main. Clarify that only a static in another
source file is declined (header static inline stays), name the Kotlin/Go/Rust
shapes from #1731, and note the post-pipeline placement that avoids fuzzy
fallback.
---------
Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.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>
Tree-sitter kinds generator_function_declaration / generator_function were
missing from both the wasm and native kernel function-type lists, so
function* / async function* (and const g = function* () {}) produced no
nodes. Add the kinds on both paths and cover TS+JS declaration/expression
forms in extraction tests.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Codex reads AGENTS.md by default, not CLAUDE.md. Move project guidance to
AGENTS.md (trim/nest long validation notes under docs/AGENTS.md), leave
CLAUDE.md as a thin @AGENTS.md wrapper for Claude Code, and document the
Codex project_doc_max_bytes raise needed for the ~35KiB root file.
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
Adds a new grouping system for the Map with a new grouping depth control, exposes per-module dependents (files and modules) to drive a weight bar, and renders it on each module. Introduces a MapKey to explain visuals, collapses lone root-file buckets for clearer labeling, and supports a nullable depth value to let the provider pick grouping. The Symbol tab now has its own address (#/s) when nothing is selected, and routing/top-bar logic is updated accordingly. Also updates export SVG rendering to include weight-based bars, and extends tests and docs to cover the new visuals and behavior.
The section had grown to 67 entries with no summary, and the release notes
publish it verbatim — so the biggest thing in it was unfindable.
Three changes, none of which rewrites an entry:
**A Highlights block**, eight bullets, in plain language: the viewer, Screens,
Steps, the server and cross-tier coverage, the in-order reading, the new
screens (dead code, entry points, type hierarchy, trails), the six languages
that gained conditions and arguments, and the note that this release wants a
re-index.
**Fifteen entries moved from Fixes to New Features.** They were whole new
capabilities filed as fixes — `codegraph ui` itself was the thirty-ninth
bullet under Fixes, below a list of one-line corrections.
**The remaining thirty-seven fixes grouped** under four sub-headings, as the
house rules ask past fifteen, so a reader can find their area. A stray empty
`### Fixes` heading is folded into the one above it.
Every entry is moved verbatim; none was reworded, added or dropped. Verified
bullet-for-bullet, and both release scripts still promote and render the
section.
Claude-Session: https://claude.ai/code/session_012M9UE2Txyh7w8wyothDPTe
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two long-standing complaints from svelte-check, both in the one `fitOptions`
block, and neither harmless-looking for the right reason:
The per-side padding was widened to `string`, and the canvas types a side as
`` `${number}px` `` — so `{ left: '440px' }` silently failed to check against
the very option it is written for. It keeps its literal types now. The values
were always correct at runtime, which is why the fit looked right and the
error looked ignorable.
And `fitOptions` read `model` from above its declaration. `$derived` is lazy
so it ran, but it was a forward reference all the same; it now sits below the
model, where it reads.
`ui/` is at 0 errors, 0 warnings across 416 files.
Claude-Session: https://claude.ai/code/session_012M9UE2Txyh7w8wyothDPTe
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A lost `:` in an scp target to the Windows VM wrote three git bundles into
the working tree instead of onto the guest, and they were committed — 7.9 MB
of repository history carried in a feature branch. They have been scrubbed
from this branch; this keeps the next slip from being committed at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012M9UE2Txyh7w8wyothDPTe
Adds full support for decisions at forks in both the code graph and the UI. Key changes introduce a decision model for forks (innermost guard decisions), propagate decision data through the server and wire layer, and render decisions in the UI as distinct points with labeled arms. New components (ForkPoint and DecisionCaption) visualize the decision and its arms, while utilities (armWords, forkLabel) generate arm captions. The order reading (canvas) now shows decisions as points, and arms are drawn as separate edges (yes/no/case), with labels and captions displayed under the deciding box. Tests, typings, and docs updated to reflect the new decision visualization and behavior, including selection reach and resting-label semantics. This lays the groundwork for clearer visualization of conditional navigation and guarded branches on the order canvas.
Adds region-based layout support for screens: steps now carry region information, and the server packs regions into dedicated bands with per-region captions. UI changes introduce RegionCaption and region-aware step rendering; StepsModel and related views (StepsView) consume region data, while the region-aware layout keeps anchor and region boundaries intact. Tests and docs updated to reflect region-driven organization and visualization of screen regions. This enables visualizing a screen’s picture as region-based columns rather than a single distance-driven row.
Adds multi-arm navigation support: when a destination is produced by a conditional, every arm is now drawn as its own edge. Introduces helpers (hrefArms, destinationsForHref) and updates framework resolvers and edge creation to emit multiple navigates edges (via alsoTargets) instead of a single one. Also introduces per-app rooted route tables to avoid cross-app crossings, and updates various resolvers (React Router, TanStack Router, Vue Router, SvelteKit, Vue, and SvelteKit’s linker) and the UI to reflect multiple possible destinations. Tests and docs updated to reflect the new behavior, ensuring the Screens tab shows all possible navigation paths from conditional destinations. This makes navigation visualization more accurate for forked destinations.
The first cut drew the code's order as a nested document — a column of boxes,
forks as rows of arm columns. Wrong picture: hard to read, and it threw away
the thing that made the tree legible. The ask was the canvas back, with the
timing fixed: the 200 comes after the token is signed, so it should branch out
of it.
So the order reading is now the SAME canvas, the same boxes, the same pills,
hover and panel — only the graph changes. `ui/src/lib/program-model.ts` walks
the server's block tree carrying a set of tails (the steps a next step would
follow) and emits one edge per "and then": proshop's login draws the anchor,
`User.findOne`, then the fork — `jwt.sign` under one arm with the `200` a row
below it, the `401` under the other. A row down is one more thing that has
already happened; an arm that answers, returns or throws has nothing leaving
it; a helper, a loop, `later` and `together` ride on the line into what they
hold. Rows are settled by relaxation, because a step reached twice can make
the graph cyclic.
A line means "and then" here and "leads to" in the tree, so the key says which.
The fork conditions are drawn at rest rather than only for a selected box —
`placeLabels` takes an `atRest` flag — because on this picture they are the
content, and two ways to one step merge as one condition (`WHEN userExists OR
NOT user`), not as two rendered labels stuck together.
`StepsRail.svelte` and `RailBlock.svelte` are gone; `StepBox.svelte` stays as
the box both readings draw.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
A fork with nothing on one side is `if (!user) return` — a reader takes it as a
guard, not as a decision with two sides. Drawn as a branch it costs a column
and a step right, and a handler with four guards (every server handler) read as
four nested branches with three-quarters of the width holding the words
"returns here". It is now one line — the condition and where the code leaves —
with everything below it running because it did not, and the rail stays on its
own hairline. next-saas-starter's `signIn` goes from not fitting the screen to
fitting it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
Spec §3.13.1 describes the rail as built: what it is made of (records the same
pass makes as the links), what makes the fold possible (a guard naming the
decision it belongs to), the items, and the words. `CLAUDE.md` names
`api/program.ts` and what the guard reader now returns. `CHANGELOG.md` gets the
user-facing feature and the three fixes under it.
Both plans now say what happened: the 2026-08-29 plan carries a BUILT header
with where the build differs from it (a guard's `branch`, reading a function
once per rail, blocks as one kind carrying facts, loops needing a reading of
their own), the answers to its open questions, and a §8 recording the six
endpoints read against their source — plus the two gaps left open on purpose,
a mongoose `product.save()` the effects table does not know and the nested
`const handleX = async () => …` that is still not a node.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
Validating the reading against four real servers turned up three defects, all
in the walk and all visible in both readings:
- **A hop's span is only a hop's span when the call is the one we asked for.**
An inline Express handler's edges carry the ROUTE's line — where the only
call is `router.post('/users/login', async (req, res) => {` — so the read
span covered the whole registration and every call in the handler counted as
written inside it, and so as running first. express-realworld's login drew
its 200 before the `login()` that produces it. A read that does not find the
call it was asked for is now a bare position: no span, no `inside`.
- **A name-match the call as written disproves.** `crypto.createHash('sha256')
.update(…)` in a Nest service kept only `update` in the index and matched it
to the caller's own `AuthService.update` — and the login endpoint then read
as though it updated the user, four extra replies and a session delete
included. In this family a method of your own class is written `this.x(…)`,
so a receiver that is not `this` proves the guess wrong; the call leaves the
index instead. The endpoint goes from 15 steps to 6, all of them real.
- **A value with no calls of its own is lent the file's.** The gate counted any
edge, and `const signIn = validatedAction(schema, async (data) => { … })`
holds one plain `references` edge to its schema — so the whole server action
went unlent and its picture had one call out of nine. Only edges the walk
follows as behaviour count now, and next-saas-starter's `signIn` reads whole:
the lookup, two early returns, `Promise.all` of session and activity log, and
the redirect to /dashboard or the checkout session.
Also: an `elif` whose body raises does not mean the arm it is written in always
raises — FastAPI's `if not user: raise … elif not user.is_active: raise …` was
ending its own arm.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
A body drawn once, with nothing to say it repeats, is a quiet lie about the
order — so the reading now reads the loops a site is written inside, the same
way it reads its conditions: one climb up the same ancestors, per language,
`for` / `foreach` / `for … in` / `while` / `do` / `repeat`, with the header as
written (`item of items`, `queue.length > 0`) and where the loop starts.
Loops and forks nest in either direction, and neither reading knows about the
other, so the block builder merges them by where each construct BEGINS: on one
ancestor chain the outer one always starts first, which rebuilds the nesting
from the positions alone. A `for` inside an `if` and an `if` inside a `for` come
out the way the code has them.
With it, the per-framework readings are pinned: an Express handler with its
helper drawn inside the reply it builds, a FastAPI `raise HTTPException` ending
the arm it is in, a Spring early `return` as the other arm of its `if` (with
the comparison flipped, not wrapped), an ASP.NET handler's two outcomes, and a
Nest controller read on through the service it delegates to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
The reading the walk records now has a picture. `#/steps?…&view=order` draws
the anchor, then its body: a box per step in the order the code writes them, a
fork where the code forks with its arms side by side under the condition, a
helper drawn where it is called, and an arm that answers, returns or throws
ending there — so proshop's login reads *look the user up · if the password
matches, sign a token inside the reply and answer 200 · otherwise 401*, which
is what the code says and what a row of four boxes could not.
- `program-model.ts` decides the words: the fork carries the decision once and
its arms say only which side they are (WHEN / WHEN NOT), except a `switch`,
whose arms each have a case to say, and a `try`, which says `on error` once.
- `StepBox.svelte` is the box both readings draw — the canvas wraps it in
handles, the rail lets it size to its words. Same look, same click, same
double-click-to-start-here.
- `StepsKey.svelte` is the key, floating over the canvas as before and last in
the document on the rail, which scrolls and cannot have things sitting on it.
- The reading travels in the URL (`view=order` / `view=tree`) and the summary
offers both; without one, the answer's own default decides — the code's order
for a handler or an endpoint, the tree for a screen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
The picture answers "what does this set in motion", a row per distance from
the anchor. On proshop's login that puts `User.findOne`, `jwt.sign`, `200` and
`401` side by side — all one step out — when the code says: look the user up,
then IF the password matches sign a token and answer 200, ELSE answer 401. The
signing is not beside the 200, it happens INSIDE the reply it is part of.
So the walk now records what happens in each function where the code writes it
— the step reached (or the helper folded into), the call's position and span,
and the branch guards, structured — and `api/program.ts` folds those records
into the anchor's body: items in source order, a fork wherever two sites are
arms of one decision, a helper drawn in place at its call, an arm that answers
the request or leaves ending there. A call written inside another call's
arguments comes first, so the token is signed before the reply that carries it.
It is a derivation, not a second walk: the records are made by the pass that
makes the links, so the two readings can never hold different steps. A fork
exists only where a guard was READ — a language without rules, or a file that
changed since the index, reads as a plain sequence rather than an invented
structure. The reading opens each function once, however many times it is
called (`again`), and is capped like everything else here.
The payload carries it as `program`, with `defaultView`: the code's order for a
handler, an endpoint or any function; the tree for a screen, where handlers
fire on events and have no order between them.
Measured on a 87-step / 160-link screen: +3% wall clock, +95 KB.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
A joined `when` string cannot tell an `if` from its `else`: two sites read as
opposite conditions, and nothing says they are the two arms of ONE decision.
The reading a rail needs is the structure, so each guard now carries it:
- `branch` — where the branching construct starts (`line:column`). Both arms of
an `if`, every case of a `switch`, an early exit and the code it guards share
it; two `try`/`catch` blocks in one function no longer collapse into one.
- `armExit` — how the arm the site is in leaves, when it always does (`return`,
`throw`, or `exit` for a `panic` / `exit()` the rules count but no keyword
names), read from the arm's last statement.
- `exit` — for an early exit, how the arm that was NOT taken leaves.
`SiteReader.guards()` returns the array; `when` is now `guardLabel` over it, so
a caller that wants both pays for one read. Nothing else changes: `guardLabel`
ignores the new fields and every existing label is byte-identical.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
- branch-guards callSiteInTree: a call's span and the call it is written inside the arguments of (`within`), stopping at a function or block boundary
- steps.ts: each step records the hop that first reached it (position, span, enclosing call — the fold's first hop out of the root, inherited down the fold); a row is ordered by that position, a hop inside another site's arguments before that site, and `WireStep.order` carries it; links carry `within`
- map-model: an `order` option — the row's initial order, sweeps over parents only, tie-broken by it; the Map and Screens tabs pass none and are unchanged
- viewer: rows laid out by `order`; `inside res.json(…)` in the panel rows and the tooltip
- tests: servers fixture (a token signed inside the reply's arguments: `within`, and the row `create · queue · mail · jwt.sign · 201`), model row order; spec §3.13, CHANGELOG
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
A reply's identity is its status, not its call: a handler answering 200 or 401 draws two boxes (id per function, response, status), so each line from the handler carries its own condition on the picture — the Screens view's idiom — and the anchor's Leads-to list reads as the contract; replies whose status the code does not spell out share one box labelled by the call. Panel note, spec §3.13, CHANGELOG, plan; servers test asserts the ASP.NET and Spring outcomes per box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
- effects.ts implicitResponseStatus: a body-sending reply with no status in its chain (res.json / send / render, reply.send, c.json, NextResponse.json, JSONResponse / jsonify / render_template, Rails render, Laravel response()->json) is a 200; a variable status, end, sendStatus and redirects stay as they were
- branch-guards callSiteInTree: a status set by the statement just before the reply (`res.status(202); res.json(user)`) is that reply's — looked back within the block, only a statement that IS the status call counts
- steps.ts: explicit chain/args → set-before → implicit 200
- express.ts: an inline handler's reply calls (`res.status(404).json(…)`, `res.json(user)`) are references at their own line and column instead of framework noise, so the route's own reply box exists
- tests: servers fixture (inline route's 200 beside the service's 404; a 202 set before), ui-effects
- CHANGELOG
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
The flow canvas zooms on any double-click that reaches its pane, including one bubbling up from a step or screen box. A box's double-click is a navigation, not a zoom: it is stopped at the box in the capture phase (the delegated handler runs at the root, after the pane), so the picture keeps its fit while the pane's own double-click still zooms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
- StepsView / StepNode: a double-click on any step with a symbol re-anchors the Steps picture on it (the panel's Start here) — an endpoint or another screen drawn as a boundary opens as its own chapter in one gesture; detected in the view's click path (two clicks on one box within 400 ms) since the flow canvas does not reliably pass dblclick on, with ondblclick kept
- ScreensView / ScreenNode: a double-click on a screen (or an origin) opens its Steps picture
- a boundary's panel says it is not entered instead of "nothing leaves this step"; tooltips and the boundary notes mention the gesture
- spec §3.13, CHANGELOG
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
- python.ts postExtract: APIRouter(prefix=) and literal include_router(prefix=) composed down the include tree (module import, alias, local); a computed prefix leaves that mount alone; full-stack-fastapi-template 23 routes named by path
- csharp.ts: handler-first MapPost(Handler[, "path"]) under the endpoint-group class, the app's $"/api/{groupName}" head read in postExtract, RoutePrefix honoured; detection covers Endpoints/ files; CleanArchitecture 10 routes
- tier-synthesizer: a type argument between a client call and its parentheses (useSWR<T>(…), ky.get<T>(…)); recorded callee without it
- screens.ts: a file-scope navigation attributed to the value spanning it; a value nothing calls attributed to the functions mentioning it in importing files (request-time source read, bounded); steps.ts lends navigates edges to a value root
- tests: servers fixture (FastAPI prefixed routers, ASP.NET endpoint group end to end), frameworks.test.ts (group form, RoutePrefix), cross-tier (generic useSWR)
- docs: CHANGELOG, plan, playbook rows
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
- frameworks/nextjs.ts (split out of react.ts): App Router app/**/page.tsx and Pages Router pages → routes named by path ((group) stripped, [slug] → :slug, [...all] → :all*), bound to the default export; app/**/route.ts exports → METHOD /api/… endpoints referencing their functions; pages/api → ANY; resolve() claims router.push/replace/prefetch, redirect/permanentRedirect and NextResponse.redirect(new URL(…)) into navigates edges via the Expo href readers, against a Next-only route table gated on the app's root
- next-router-synthesizer.ts: <Link href> and internal <a href> → dashed navigates edges from the component (next-link, registeredAt)
- expo-router.ts: href readers exported; matcher accepts :param / :all* segments
- steps.ts: a Next page's own work fires from page load; a Next page makes the project a web app; {status: 201} read off the call site (branch-guards CallSiteText.status) for response rows
- frameworks/package-deps.ts: nested package.json files probed on disk (getAllFiles lists only sources); Express/React/Expo/Nest detectors use it; routing manifest names constant handlers
- tests: nextjs.test.ts (file→route rules, extract, verbs, end to end with Screens and Steps); frameworks.test.ts Next cases moved to the Next resolver
- docs: CHANGELOG, spec §3.12 frameworks paragraph, CLAUDE.md, synthesis doc, plan P4 built, playbook rows for Next / MERN / Nest channels
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
- 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
- api/route-roots.ts: the symbol a route runs (references-edge handler, exported page component, or the route itself for an inline handler), shared by steps and screens; the bare Steps tab lists an API's endpoints by router file
- api/effects.ts: database / response / queue / email / payments / cache / auth / process / network / storage / device / telemetry, matched on the call as written per language family, with model + read/write and the literal status on a response site
- graph/branch-guards.ts: callSitesForFile (the whole member chain), memberTypesInTree, decoratorsForFile, request/decorator triggers with the middleware/guard chain; guard + argument rules for Python, Java, Kotlin, C#, Go and C
- steps.ts: classify on the chain before trusting a name match, retarget this.x.y() by declared type, skip test doubles after the effect pre-check, project kind on the wire
- viewer: kindWord/kindWords per project kind, endpoint chooser, response boxes labelled by status codes
- python.ts: FastAPI detected from a monorepo sub-directory; is-test-file: samples/examples package paths are not tests
- tests: ui-steps-api-servers, ui-effects, branch-guards-languages; spec §3.13 Servers paragraph, CHANGELOG, plan doc
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
- Introduces trigger metadata for steps and edges to capture what fires a site (JS prop, on* option, or callback) to improve cross-boundary flow analysis.
- Extends parsing/analysis to detect triggers in JSX attributes, on* bindings, and late-bound callbacks; adds utilities (calleeText, lastSegment) to extract trigger sources.
- Ships new trigger structures (WireStepTrigger, trigger on WireStepSite/WireStep) and propagates trigger through built steps; updates step labeling to reflect trigger information.
- Adds triggerWords helper and uses it to render human-readable trigger descriptions in Steps UI, including edge labels and per-site visuals.
- Updates UI (ScreensView, StepsView) to display FIRES FROM information, with styling tweaks to highlight triggers and related elements; enhances tooltips and inline text wrapping for readability.
- Extends tests to cover trigger detection and rendering across various binding patterns (prop, option, callback) and inline RN listeners.
- Updates design/docs and changelog to reflect Expo Router integration, per-site trigger metadata, and the new Steps surface.
- Adds Expo Router integration with a new Screens view and a Steps API to surface screens and their transitions.
- Extends codegraph extraction/resolution to handle namespace objects, React hook bindings for handlers, and Swift RN bridge evidence; introduces per-site guard arguments and trigger metadata, enabling richer flow analysis across JS ↔ native boundaries.
- Introduces UI and data-model changes to represent conditions as words (WHEN/AND/OR/NOT), display per-site call arguments, and show what fires a site (triggers). Adds new utilities (ui/conditions.ts) and updates ScreensView and StepsView to render scenarios with multiple sites and “ways” counts.
- Implements site readers for WHEN/ARGS/TRIGGER, and wiring to expose steps via API endpoints (including /api/steps); enhances tests to cover namespace resolution, useCallback-driven handlers, and inline RN event listeners.
- Updates styling and templates to reflect the new wording, scenario rows, and per-site details, including NOT instead of leading negation strings and multi-way links.
- Documents and reflects changes in changelog and design docs to describe Expo Router integration and the Steps surface.
Introduce Expo Router integration with a new Screens view and API to surface screens and transitions, plus a new Steps API and UI to depict typed steps from anchors or symbols. Extend codegraph’s extraction and resolution to handle namespace objects (export default NAME, two-statement forms, and default bindings) and React hook bindings for handlers, improving accuracy of flows across JS ↔ native boundaries. Add Swift/React Native bridge receiver evidence (RCT_EXTERN_MODULE, RCT_EXTERN_METHOD) and related resolution logic, with tests covering namespace-object resolution, useCallback-driven handlers, and inline RN event listeners. Update UI to include a Steps tab and associated components (StepsView, StepNode, ScreenEdge) and wire navigation to expose steps-based exploration via /api/steps and UI routes. Documentation and changelog reflect the new Expo Router integration and steps surface capabilities.
Introduce Expo Router integration by adding a new Screens view and API to surface screens and their transitions. Implement a map-based layout with directional ports, extended layering and port pitch to accommodate edge labels, and a pill-based labeling system for transition conditions. Include tests for the new map/screens models, updates to the UI components, and design/docs changes reflecting the Screens design. Merge CodeGraph UI viewer changes to render and interact with Expo Router-based screen graphs. This enables CodeGraph UI to surface screens and navigations from Expo Router apps.
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.
Three epics, 20 tasks, landing as one subsystem.
CG-39 (phase 1, the reader): loopback-only read-only server behind
`codegraph ui`, a read-only JSON API over the index, the Symbol view
(callers | gutter-ported source | line-anchored callee rail), the search
palette and trail, and the File view.
CG-48 (phase 2, the map and the flow): the module-granularity Map, the
Flow strip over one shared path finder, the "where the graph stops" end
cap, the whole-file source view with intra-file call arcs, live refresh
over SSE, the entry-points panel, and SVG/PNG export.
CG-56 (phase 3, depth and a library): syntax classification taken off the
engine's own tree-sitter parse (retiring Shiki and its 56 bundled
grammars), the type hierarchy, dead code and islands, saved trails, and
ui/ packaged as @colbymchenry/codegraph-ui.
Two derivations were lifted out of ToolHandler into src/graph/ so the
viewer and codegraph_explore can never draw different answers from the
same graph: named-symbol-flow.ts and dynamic-boundary-report.ts.
Saved trails are the viewer's only write. The loopback boundary gained a
write shape (POST/DELETE under /api/ carrying x-codegraph-ui, no CORS
headers ever) rather than being widened; `--read-only` turns it off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Save trail on the trail bar writes the walk to .codegraph/ui/trails/ as one
JSON file, listed on the empty screen and on Entry points above the derived
suggestions, reopened at the symbol you left with the whole path restored.
A hop is stored by qualified name, kind and file — never by node id, which
contains a start line and so changes the first time anybody edits above the
symbol. Every hop is re-resolved against the current index on the way out and
each row says what became of it: still here, moved to another file, now
ambiguous, or gone. A hole is never stitched over: the row opens the longest
run of CONSECUTIVE resolved hops and says which ones those are, because the
trail is a path and a skipped hop would draw a call that does not exist.
This is the first write the viewer makes, and the boundary moved with it:
POST/DELETE answer under /api/ only, must carry X-CodeGraph-UI and
application/json (neither of which a cross-origin form can produce without a
preflight this server answers none of), and --read-only refuses both while
still listing what is there. The blanket "read-only" claim is retired from the
banner, the README, the CLI help and the docs site in favour of the narrower
true one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Dead code screen and a mark on the Map, both drawn from one derivation in
src/graph/dead-code.ts so a second surface can never disagree with the first.
The SQL half is four lines — no incoming edge but `contains`. It returns ~2 500
candidates on this repository and the shipped list is 20; everything in between
is the feature. A candidate is dropped the moment there is any reason to believe
something outside the graph reaches it: exported symbols and header
declarations, test and generated files, abstract and interface members, anything
carrying a `decorates` edge, overrides of an ancestor's member, names the
language calls by itself, vendored directories, files nothing in the index
reaches (those are islands, and the Map says so instead), names the resolver
failed to resolve somewhere, and names shared with a symbol that IS referenced —
the mis-resolution that leaves a used method with a self-edge and its twin with
nothing. The last rule is the only one that is not a graph query: before a claim
is made, the declaring file and every file that reaches it are read and the
identifier counted, which is what catches the references the extractor never
recorded (`this.handleMessage.bind(this)`, a call inside an object literal, a
shorthand property).
Every subtraction is counted and printed under the list with the scale it came
from, and the caveat line above it never collapses: the claim is "no static
reference in the index", not "unused".
On the Map a module nothing depends on keeps its stroke and says so in its count
line, and tool-generated files and modules recede to ink-4 there, in the map's
file list, in search results and on the file screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
`ui/src` now builds two ways from one tree: the static app `codegraph ui`
serves, and — via `svelte-package` — a Svelte library the Pro app imports.
A forked component would be a second answer to the same question about the
same graph, so there is no fork.
Everything a screen knows arrives through a `GraphAdapter`: eleven methods
answering the wire shapes verbatim, with `createHttpAdapter()` (the loopback
JSON API) as the default and a host's in-process engine reads as the point.
`lib/api.ts` became a one-line-per-call facade over it, which is why no call
site in the views changed. The payload types moved to `lib/wire.ts` — no
imports, no runtime — so a host can depend on the vocabulary alone.
Two more seams and one guard:
- `lib/navigation.ts` holds the href builders behind a `NavigationDriver`, so
a host addresses its own URL space. The app's half — the hash parser and the
live route, which attach window listeners at module scope — stays in
`router.svelte.ts` and is pruned out of the package: rendering a Symbol view
must not install a hash router in somebody else's application.
- `lib/theme.css` carries the design tokens and maps Svelte Flow's `--xy-*`
variables onto them, so a host never sees library defaults. Dark now also
answers to a bare `[data-theme]`, which is how `<CodegraphUi theme>` themes
a container rather than the document.
- `scripts/check-ui-package.mjs` prunes the app's shell, resolves the
extensionless specifiers svelte-package leaves behind, and asserts that
nothing but `lib/adapter.js` reaches the network.
The search box, its keyboard and its panel are one component now
(`SearchPalette`), because splitting them is what breaks a palette.
`__tests__/ui-package.test.ts` mounts the three screens from the package entry
against a mock adapter in jsdom; it runs as a second vitest project so the
`browser` resolve condition it needs cannot reach the engine's suites.
Versioned with the engine. Prepared, not published: `private: true` is the
guard and `pack-npm.sh` only packs a tarball under CODEGRAPH_PACK_UI=1.
The viewer ran a second highlighter over source the engine had already parsed
with a real grammar: Shiki, plus 56 pruned TextMate grammars shipped in
dist/textmate/. The classification now comes off that tree instead, so a file is
read by exactly the grammar that decided what its symbols are.
The swap is complete rather than flagged: @shikijs/core, @shikijs/engine-javascript
and @shikijs/langs are off the dependency list, scripts/prune-grammars.mjs and
`npm run build:textmate` are deleted, and check-ui-build.mjs asserts the
tree-sitter grammars in dist/extraction/wasm instead of dist/textmate.
The wire contract is unchanged — `[classId, text]` pairs with the class names
alongside — so the viewer's decoder and code blocks did not have to be rewritten.
Two classes are added to the six: `type` (a named type reference, painted at
plain ink) and `def` (the name a definition declares, weight 600), the latter
taken from the extractors' own definition tables so it cannot drift from what
indexing calls a definition.
Three differences are not cosmetic:
* Interpolations (`${…}`, `#{…}`, `$"{…}"`, f-strings) are classified as code,
not as string. The call-site overlay refuses to claim a token classed string,
so calls written inside interpolated strings now link.
* Built-in type words are emitted whole and classed `type` in every language.
The grammars disagree about whether `string` is a type_identifier or an
anonymous token inside a predefined_type, and TextMate scoped them
inconsistently too.
* 3 000 lines of TypeScript cost 24-41 ms instead of ~700 ms.
Given up deliberately: Liquid, Razor, YAML, Twig, XML and .properties render
plain. .svelte/.vue/.astro are classified through their <script> blocks, the same
delegation the SFC extractors do. Pulling html/css/vue out of tree-sitter-wasms
would cover them, but those ABI-13 builds are the known cause of shared-WASM-heap
corruption for every other language in the same process.
Measured parity, per-language before/after screenshots and the reproduction
recipe: docs/design/cg57-highlighting-parity.md.
"Copy image" and "Download SVG" on the Flow strip's header and in the Map's
side panel. The image is the distribution loop: a flow pasted into a review, a
map pasted into a README, read by somebody with no viewer open.
The exporter serialises the LAYOUT OBJECT rather than scraping the DOM — no
html-to-image, no foreignObject, no new dependency. buildFlowLayout and
buildMapLayout already compute every rectangle, port and curve before a
component renders, so the image and the screen come from one piece of
arithmetic and cannot drift apart, and the whole exporter is a pure function a
test runs with no browser. Output is presentation-only SVG (rect, line, path,
polygon, text, tspan, clipPath) — no script, no external reference, no data:
URL — which is what GitHub's sanitiser accepts in a README.
Light theme is forced whatever the viewer is set to: a dark strip on GitHub's
white comment background reads as a mistake, not a preference. 24px of paper
around the drawing, a caption naming the path or the root at the bottom left,
a CodeGraph mark at the bottom right.
Fonts travel as family stacks, not bytes (spec). An SVG loaded as an image may
not fetch a webfont, so a raster falls back to the platform's own monospace —
every fallback in the stack advances at ~0.6em like IBM Plex Mono, so the code
grid survives and only the letterforms change. Text is truncated
arithmetically with an ellipsis and clipped as well, so a wider fallback
cannot spill a source line out of a card.
`scale` multiplies only the root width/height while the viewBox stays in CSS
pixels, so the raster draws an image whose intrinsic size is already 2x
instead of upscaling a 1x bitmap. The clipboard write uses the ClipboardItem
promise form (Safari discards the gesture across an await) and falls back to
downloading the PNG, saying which happened rather than claiming a copy it did
not make.
Measured on this repo: execute -> rowToFileRecord (8 hops) exports 3690x253
CSS px, 491 kB PNG at 2x / 38 kB SVG; the 16-module map reproduces the canvas
exactly — 16 boxes, 52 links, 9 layer rules, both band labels, and with
src/index.ts selected 15 links and 4 dimmed boxes.
`#/entry` answers "where does anything start" at full length, and turns any row
that names a symbol into a flow.
Server. `/api/entrypoints` gains `frameworks` (from `getDetectedFrameworks`), a
`tests` list, a `routes` limit of its own, and a cache keyed on the index build
— nothing here is read from disk, so unlike `/api/source` a cached answer cannot
be stale about drift. `routes.items` is now a `WireList` like every other list on
the payload.
Routes carry where the URL is REGISTERED as well as where it is served:
`getRoutingManifest` selects the route node's id, file and line, and
`buildRoutes` splits the verb off the name against a fixed list (never "the
first word", which would take the head off a file-routed `/blog/[slug]`). All
four payroll-go routes register in one router file and three are served from
another — group by the handler file and one router becomes two groups plus an
orphan.
`isTestFile` is split into `isTestPath` (test filename and directory
conventions) + the non-production catch-all, byte-identical at every existing
call site. The Tests list uses the narrow half: an example, a benchmark or a
fixture is off-target for ranking but is not a test, and a heading that says
"Tests" must not quietly count them. Tests rank by REACH — distinct other files
touched — because Go, Rust and Java put test work inside functions where a
module-level-calls ranking sees nothing. Two read-only engine queries make that
affordable: `getFileReachCounts` (the mirror of `getFileDependentCounts`, driven
from `nodes` by path so the cost follows the files asked about rather than the
edge table) and `getFileNodes`.
Viewer. `ui/src/lib/entry-model.ts` folds the four lists into file groups —
pure, and `panel.rows` stays exactly the sections it draws. `EntryView` +
`EntrySection` render them with the caller rail's `.filegroup` / `.row` shapes
rather than a second visual language for the same idea. A row that names a
callable symbol carries a `Flow ›` chip; the other end is typed or picked with
`→ here` on another row. File and test rows carry none: `/api/flow` searches by
name, and a file has none the path finder can look up.
A project with fewer than three resolvable routes gets no Routes heading at all,
not an empty one. Typing into the search box now also returns matching entry
points under their own heading below the symbol matches, so a URL comes back
with its handler attached; rows already in the results are dropped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
`GET /api/events` is a server-sent-event stream the viewer holds open for the
life of the page. Two signals, two things the browser could not know:
changed source files touched on disk, before any sync — the drift banner
index the graph moved, naming what the sync re-indexed — the live refresh
The server WATCHES and never syncs: the project tree through the engine's own
FileWatcher with a notify-only syncFn, the index through one non-recursive
fs.watch on the data directory settled at 400 ms. Both start with the first
subscriber and stop with the last, so a viewer nobody has open costs no watch
descriptors. Nothing polls, on either side.
Drift is now parity with codegraph_node (#1474) rather than an absence.
`/api/source?ondrift=current` serves a drifted file's CURRENT bytes flagged
`showing: 'current'`, and the three screens that can say so switch off
everything anchored to the old line numbering — gutter ports, call-site links,
call arcs, the callee rail's anchoring — while keeping the source. The banner is
paper-2 with a hairline rule, never amber: amber belongs to the untested badge.
Also fixes a stale read this exposed. A long-lived reader holds an LRU of nodes
by id that only its own writes invalidate, so `/api/node/<id>` kept answering
with a symbol another process's sync had deleted while `/api/search` beside it
said it was gone. GraphSession now drops the read caches when the database (or
its WAL) has been written, and the Symbol view follows a symbol whose id changed
because an edit above it moved its start line, carrying the trail across.
Measured on a live viewer: banner 360 ms after a save, toast 440 ms after
`codegraph sync` returns, 0 requests in 4 idle seconds, and the client gives up
reconnecting after ~90 s with "Not live" rather than hammering a dead port.
The File view gains a Source tab: the file itself, top to bottom, with the
Symbol view's line grid, gutter ports and call-site links, a line-anchored
callee rail, and — in the left margin — an arc for every call that stays inside
the file, drawn from the calling line to the callee's definition line.
The arcs are the point. Source order is already a layout, chosen by whoever
wrote the file, so a file's internal call structure can be drawn with no
algorithm placing anything. Crabviz's idea, in the one place it is legible.
Everything is arithmetic, not measurement. The Symbol view queries the laid-out
DOM to place a callee row beside its line; a 6 820-line file cannot afford that.
Here a line is exactly 20px at `10 + (n - 1) x 20`, so ~90 line elements exist at
a time and the arcs, ports, rail rows and connectors are all functions of a line
number. `src/mcp/tools.ts` scrolls at a 16.6ms median frame.
- `GET /api/filecode/<path>` — outline, one call group per (caller, callee) PAIR
with its call-site lines, unresolved references, and the file's length. The
source is NOT in it: it pages through `/api/source` 800 lines at a time with a
discarded 150-line lead-in, so a page starting inside a block comment does not
render prose as code, and so the ports and arcs are complete from the first
frame while the text fills in behind them.
- `intraFileCalls` is counted over the groups actually returned, so the header
and the picture under it cannot disagree once a cap bites.
- Above 40 arcs the diagram narrows to the symbol under the pointer (or the one
the scroll position is inside) and the header states the total. Accent is for
the pointer only, never for the filter.
- Sticky outline rail at >= 1400px, following the reader down the file.
- `QueryBuilder.getUnresolvedReferencesInFile` — one indexed lookup instead of
one per symbol; `buildOutlineEntries` lifted out of `/api/file` so both
readings of a file draw the same rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>