* 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 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>
* 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>
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>
`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>
isLexicallyReachable trusted the graph's nesting for every language. C and
C++ have no nested named functions, so a function shown inside another is an
extraction artifact: tree-sitter-c cannot parse a macro call whose arguments
are designated initializers — betaflight's
RESET_CONFIG(pidProfile_t, pidProfile, .pid = { … }, …);
— and its error recovery runs the enclosing function_definition (source lines
168–309) to line 1667, nesting the 45 functions after it. That tree has 310
such functions in 73 files. Before this commit exact-match already rejected
them as unreachable and the fuzzy fallback picked them up at 0.5; with the
survivor-side check alone, fuzzy rejected them too and 117 real calls into
pid.c disappeared (base → 4c8f165 on the 2,109-file betaflight fork: LOST
117, GAINED 0, all fuzzy, all pid.c).
With the gate the same tree is LOST 117 fuzzy / GAINED 117 exact-match — the
identical edges, now resolved by the strategy that should have had them, at
0.9. vite (no C) is unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A function nested inside another function is only callable from inside its
container (#1230). matchByExactName already declined such candidates; the
fuzzy fallback did not, so a builtin method call (`res.text()`) whose only
same-named project symbol was some file's closure resolved onto that
closure at 0.5 (#1708).
and on vitejs/vite@8492422 that traded 12 correct removals for 59 wrong
additions: the repo has a dozen `resolve` definitions, most nested, so the
filter left exactly one reachable `resolve` method and the strategy
committed every `import { resolve } from 'node:path'` call in the
playground configs to it. Filtering a crowd down to one survivor is not
evidence the survivor was ever the target.
So the check sits on the ONE candidate matchFuzzy would commit to: a
unique candidate the call cannot reach is declined; a crowd stays a crowd.
Same tree, measured against this branch's own base b9ca4b7: 12 edges lost
(all fuzzy, all onto nested functions — the same 12 #1709 removes), 0
gained, fuzzy 13 -> 1, every other resolvedBy row at zero.
The two-file fixture is #1709's, credited in the previous commit; four
direct tests pin the shape: a lone unreachable closure declines, the same
closure resolves from inside its container, closure + method is ambiguous
and declines (the candidate-set filter fails exactly this one), a lone
reachable method resolves as before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.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>
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>
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.
- 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
- 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
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: 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.
Fixes#1610. Also fixes#1358 (the `<<binary>>` arity miscount in behaviour dispatch, reported separately and hit by the same code path).
## Problem
Arity is part of an Erlang function's identity — `f/1` and `f/2` are unrelated top-level definitions — but the extractor merged consecutive same-name `fun_decl`s regardless of arity. Reproduced on main exactly as reported:
- adjacent `f(X) -> …. f(X, Y) -> ….` → **one** node spanning both, with the first definition's signature;
- interleaved `f/1, g/0, f/2` → two nodes with **identical** `qualified_name`;
- `cowboy_req`'s `header(Name, Req) -> header(Name, Req, undefined).` → a **self-loop** `header → header`, with the `-spec` for `/3` swallowed by the merged span;
- `-export([f/1])` marked every arity exported.
## Fix
- **One node per (name, arity).** Clauses of the same name+arity still merge (that part of the old behavior was correct); a different arity starts a new node. `qualifiedName` carries the canonical spelling — `mod::f/1` — while the node **name stays bare** so search and bare-name matching are unchanged.
- **`-export` and `-spec` are per-arity.** `-export([f/1])` exports exactly `f/1`; a spec sitting between two arities attaches to the arity its signature names.
- **Refs carry the call-site arity** wherever it's statically known: local `f/1`, remote `mod::f/2`, `fun f/1` / `fun mod:f/1` values, `gen_server` dispatch (`handle_call/3`, `handle_cast/2`), and spawn/apply MFA lists (`spawn_link(?MODULE, work, [A, B])` → `work/2`).
- **The matcher resolves only to the named arity** — same file first (a local call targets its own module) — and when no definition of that arity exists it resolves to **nothing** rather than a sibling arity: silent beats wrong. An arity-less dynamic-MFA ref resolves only when the module defines exactly one arity of that name.
- **Behaviour dispatch** selects the implementer node of the site's arity, and the arity counter now skips `<<1,2,3>>` binary-literal commas per its own docstring (#1358) — `Mod:decode(<<1,2,3>>, Opts)` counts 2, not 4.
- **`codegraph_explore` / `codegraph_node`** accept the written `mod:fn/3` spelling against the new arity-qualified names (the issue's measured `cowboy_stream_h:request_process/3` shape).
## Validation
Minimal fixtures (all three reported shapes) now index as `gap::f/1` + `gap::f/2`, distinct `inter::f/1`/`inter::f/2`, and a real `deleg::header/2 → deleg::header/3` edge with no self-loop.
Cowboy (fresh `--depth 1` clone, this build vs unmodified main build):
| | main | this PR |
|---|---|---|
| nodes | 3,668 | 3,748 (+80 — the arity splits; no explosion) |
| erlang function nodes | 2,850 | 2,930 |
| behaviour dispatch edges | 38 | **44** |
| `cowboy_req::header` | one node, span 420–425, /3's spec lost | `header/2` (420–421, its own spec) + `header/3` (424–425, its spec) |
| delegation | self-loop | `header/2 → header/3` |
`calls` edges drop 6,059 → 5,656: a sample of every removed pair shows the false-positive class the issue predicted — out-of-repo/BIF calls (`length/1`, `error/1`, `quicer:*`) that previously name-matched onto unrelated same-named in-repo functions now stay unresolved.
Tests: new arity coverage in extraction + a new arity-resolution integration suite + a #1358 binary-literal behaviour test; updated existing Erlang expectations to the arity-carrying spellings. Full suite: **3,018 passed, 0 failed**.
No migration: an existing Erlang index picks the new shape up on its next re-index (`codegraph sync` / re-`init`).
Erlang is wasm-only (not in the native kernel), so there is no kernel-parity surface.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01LxZj6W6Y1SHXwvpT3uwJpK
Fixes#1573. Thanks @IAliceBobI — the report had the root cause exactly right, and the fix sits one layer up from the suggested spot (resolution rather than the container-kind set), for the reason below.
## What was wrong
Methods of an exported object-literal constant — `export const api = { call() {…}, get: () => {…} }` used as a module's API surface — never received a call edge from `api.call()`, same-file or through an import. The members are extracted as plain functions with **bare** qualified names (`call`, not `api::call`) sitting inside the constant's source extent, so:
- the `Container::member` lookup the class-shaped kinds use (#825) bails on kind `constant`, and even with `constant` added to that set there is no `api::call` to find;
- the declared-type inference for imported singleton instances (#1292) finds no type in a literal and falls back to the constant edge;
- the same-file strategies only consider classes and `method` kinds, so the call resolved to nothing at all.
Net effect: `callers` / impact reported zero for methods called from everywhere, with no boundary warning because nothing about `obj.method()` looks dynamic.
## What this does
Adds one helper that resolves a member **by containment** — a node named `member` whose source range lies inside the value's range, in the value's own file — and uses it from both halves:
- **Import path**: when the imported value is a constant/variable, the literal member is tried right after the `Container::member` lookup and before the #1292 instance inference, so the cross-file edge lands on the method instead of the constant.
- **Same-file path**: a same-file constant/variable receiver (TS/JS family only) is checked before the class-name strategies.
Precision rules, all tested: calls accept callable kinds only; a declaration nested inside another member's body is not a member; nothing outside the value's range can donate a match — a same-named top-level function, or a method returned by a factory the value merely holds — so those cases keep today's behavior rather than guessing. Class statics (`C.s()`) and non-literal values are untouched.
Extraction and qualified names are deliberately left alone: changing how literal members are named would have to be mirrored in the native kernel byte-for-byte, and the resolver-side lookup is contained and language-gated.
## Tests
- The issue's repro end-to-end: `sameFileCallers` and `crossFileCaller` are both callers of `m`; a decoy `m` in a third file gets none; the `C.s()` static control resolves exactly as before; `crossFileCaller` no longer has a `calls` edge to the constant.
- Arrow-property and method members both resolve; a `function call()` nested inside `get`'s body is never taken for `api.call()`.
- A value holding a factory's result (`const obj = makeObj()`) with a same-named top-level `m` in the file: no false attribution, existing behavior kept.
- The two positive tests fail on `main`; the control passes both ways, as a guard should.
- Full suite: 189 files, 3181 passed / 9 skipped.
With the built CLI on the issue's `a.ts`/`b.ts`: `codegraph callers m` → 2 callers (`sameFileCallers`, `crossFileCaller`); `callers s` unchanged; edges `sameFileCallers -> m` (0.85) and `crossFileCaller -> m` (import, 0.9), none to `obj`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01LxZj6W6Y1SHXwvpT3uwJpK
Fixes#1585. **Stacked on #1596** (the base branch is `fix/1588-rust-impl-type-qualification`; this PR's own diff is the second commit). Merge #1596 first, then retarget/merge this one.
## What was wrong
```rust
impl Outer {
pub fn run(&mut self) {
self.inner.run(); // inner: Inner
}
}
```
produced `Outer::run -> Outer::run` — recursion the source doesn't contain. The extractor collapsed every `self.<field>.<method>()` receiver to the bare method name (`run`), so the resolver only ever saw `run` and exact-matched the nearest same-named method — the calling method itself, or a method of an unrelated type. Nothing marked the edge as a guess, and no row stayed in `unresolved_refs`, so a consumer had no way to tell.
The same happened when the field's type isn't a project type at all (`its: std::vec::IntoIter<_>` → `self.its.next()`, `matcher: Regex` → `self.matcher.is_match()`): the bare `next` / `is_match` attached to whatever local method shared the name. ripgrep had 279 self-edges on `main`; the issue lists three sites, all of this shape.
(The issue's C++ control — "`Outer::run -> Inner::run` resolves correctly" — doesn't actually hold on `main`: `inner.h` is classified as C by the `.h` heuristic, so `Inner::run` never exists and the C++ repro self-edges too. That's #1592, fixed separately.)
## What this does
Rust struct fields are not graph nodes, so the field's type can only come from the struct's declaration text. This follows the Go 2-hop precedent exactly (`matchGoFieldChainCall`, #1276), including its exclusivity rule:
1. **Extraction (TS walker + native kernel, identical, parity-tested):** a call whose receiver is `self.<field>` keeps the owner-field shape — `self.inner.run()` is emitted as `self.inner.run`. Deeper chains (`self.a.b.m()`), call receivers (`self.f().m()`), parenthesized receivers and bare `self` keep the bare name, exactly as before.
2. **Resolution (`matchRustSelfFieldCall`):** owner type = the calling method's qualified-name prefix (`Outer::run` → `Outer`); the field's declared type is read from the owner struct's **own declaration lines** (comment-stripped, line by line — same discipline as the Go helper); the method is resolved **and validated** on that type by `resolveMethodOnType` (confidence 0.85, `instance-method`).
3. **Exclusive:** when the field is declared with an external type, a generic parameter (`T`), a container that doesn't auto-deref (`Option`/`Vec`/`Mutex`/…), or can't be found, the ref **stays unresolved** — it never falls through to the bare-name strategies. That is the safe behaviour the issue asks for, and it is what #1276 already chose for Go.
`rustFieldTypeName` looks through exactly the layers Rust's method-call auto-deref looks through: references (`&`, `&'a mut`) and the owning smart pointers `Box`/`Rc`/`Arc`. `Box<dyn Source>` yields the trait, whose method node the interface-impl synthesizer then fans out to every implementation. `Option<Inner>` is left alone — `self.inner.take()` is Option's method and must not become `Inner::take`.
Why it stacks on #1596: the owner is taken from the method's qualified name, which for a generic/lifetime impl was the trait's name before that fix.
## Measured on ripgrep (110 `.rs` files, #1596 build vs this branch)
| | #1596 | this PR |
|---|---|---|
| nodes | 4029 | 4029 |
| `calls` self-edges | 279 | **146** (none of the `self.<field>` shape remain — 116 bare-receiver, 30 other dotted) |
| `self.<field>.m()` calls resolved through a validated field type | — | **292** (`DecompressionMatcher::command -> GlobSet::matches`, `Parser::find_long -> FlagMap::find`, `Haystack::path -> DirEntry::path`, …) |
| `self.<field>.m()` calls left unresolved | — | **417** — every sampled one is a std/container method: `self.commands.push`, `self.child.wait`, `self.pre.is_some`, `self.colors.clone`, `self.path_terminator.unwrap_or` |
| `calls` edges total | 9150 | 8878 (the 272 removed are the former bare-name guesses for those 417) |
The issue's three sites: `walk.rs:824` now resolves to `IgnoreBuilder::add_custom_ignore_filename` (was a self-edge); `walk.rs:1195` (`self.its.next`, `IntoIter`) and `globset/lib.rs:983` (`self.matcher.is_match`, `Regex`) are parked as unresolved instead of guessed.
The issue's repro gives `Outer::run -> Inner::run` (`instance-method`, confidence 0.85) on both the kernel path and `CODEGRAPH_KERNEL=0`.
## Tests
- `__tests__/extraction.test.ts`: only the single-hop `self.<field>.<method>()` call keeps the prefix; deeper / call / parenthesized / bare-`self` receivers and a local receiver are unchanged.
- `__tests__/resolution.test.ts` (end-to-end, Cargo layout): the issue's repro → `Outer::run -> Inner::run`, no self-edge; an external field type (`std::vec::IntoIter`) with a local `next` decoy → no edge at all; `Box<Inner>` and `&'a mut Inner` resolve, `Option<Inner>` does not (even though `Inner` declares the method); a generic `T` field → no edge; genuine `self.run()` recursion keeps its self-edge; the #1588 repro's `UsesFile::go` / `UsesBuf::go` resolve to `FileSource::read` / `BufSource::read`, and a `Box<dyn Source>` field lands on `Source::read` with the synthesizer fanning out to both impls.
- `__tests__/fixtures/kernel-parity/torture.rs` grows the receiver shapes; all 15 kernel parity suites pass against the rebuilt kernel (147 tests).
- Full `npm test` on this branch: 189 files, 3187 passed, 9 skipped, 0 failed.
Re-index after upgrading.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01LxZj6W6Y1SHXwvpT3uwJpK
`loadProjectAliases()` read only the root `tsconfig.json` / `jsconfig.json`
own `compilerOptions`, so an Nx-style monorepo — every alias declared in a
`tsconfig.base.json` — got `null` back and every cross-package import fell
through to name-based matching. Silently: no unresolved-import warning, and
the results still look precise.
Two things were missing, and either one alone leaves a common Nx layout
broken:
Fold the `extends` chain into the effective options before building the
alias map. Relative and `node_modules` package specifiers both resolve,
the nearest config wins (tsc replaces `paths` rather than merging), and a
config already on the current chain is not re-entered, so `a extends b
extends a` terminates instead of recursing forever.
`paths` are anchored at `baseUrl` when one is declared — itself relative
to the config that declared it — and otherwise at the directory of the
config that declared the `paths`, which is what tsc does and what keeps
an inherited `src/*` from being read as root-relative.
Read `tsconfig.base.json` as a last candidate. A root `tsconfig.json` is
still authoritative when it exists and reaches the base through `extends`;
the fallback covers the layouts where that never happens — a solution-style
root config (`references`, no `extends`, no `paths`, which is what nx's own
repository ships) or no root `tsconfig.json` at all. A candidate that
contributes no aliases no longer shadows a later one that does.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Colby McHenry <me@colbymchenry.com>
The extraction half of #556 — indexing `.xsjs` / `.xsjslib` as JavaScript — already
landed on main via #654. This PR is now scoped to the remaining resolution gap:
the JS import-resolution list did not include the SAP HANA extensions, so an
extensionless `import { x } from './helpers'` in a `.xsjs` file resolved to
nothing and the cross-file call edge was dropped.
Add `.xsjs` / `.xsjslib` to the `javascript` entry in EXTENSION_RESOLUTION so
those imports resolve to their target file and `codegraph_callers` /
`codegraph_impact` see the edge. One resolution test covers the .xsjs -> .xsjslib
import; the now-redundant extraction/detection tests were dropped (covered by #654).
The arg-list group `(?:[^,()]+,\s*)*` was ambiguous: the trailing `\s*`
and the next iteration's `[^,()]+` could both claim the same run of
spaces, so a `.METHOD(...)` call with many comma-separated args that
never reaches `use:` forced an exponential search. Measured on
`app.get(arg0: value0, ...)`: 40ms at 20 args, 647ms at 24, 41.7s at 30,
and no result after 120s at 60.
Anchoring each repetition at a comma (`(?:[^,()]+,)*\s*`) makes the split
unique — `,` is outside the char class, so there is nothing to
re-partition. Same input is now 0.09ms at 1000 args.
Match behaviour is unchanged: all four capture groups are identical on 18
hand-written Vapor route shapes (no args, single/multi path segments,
`X.parameter`, multi-line calls, Environment.get non-matches) and on
200k fuzzed inputs.
Fixes#1544
Making unions first-class nodes leaves the third loss in #1515 open:
interfaceOverrideEdges enumerates its concrete side as ['class','struct'],
so a union implementor is skipped even though it now has a real node and a
real `implements` edge. "Who implements this trait" then answers wrongly
rather than incompletely — the struct beside it bridges and the union does
not.
Add 'union' to that tuple, plus a regression test that pins the Rust
trait -> union-impl hop (the struct implementor is the control proving the
synthesizer ran). Verified the test fails on the union assertion alone
before this change.
No EXTRACTION_VERSION bump: main is already at 25 against v1.5.0's 24, so
existing indexes are flagged stale for the next release regardless, and
over-bumping is what turns the re-index hint into noise.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Python's class-as-value idioms (return SomeClass, x = SomeClass, registry
dicts, classes passed as arguments) produced no references edges, so
callers/impact on a Django/DRF serializer missed the views that consume it.
Three gates dropped them:
- return_statement was never dispatched by PYTHON_SPEC (kernel mirrored)
- the extraction gate (definedHere) collected function/method names only
- resolution accepted function/method targets only (matchFunctionRef +
the function_ref import fast path)
Capture return_statement for Python (single expression; tuple returns not
descended), admit same-file CLASS names to the gate, and accept class
targets for Python bare identifiers — scoped to Python so the TS/JS KIND
FILTER contract is untouched. The docopt false-positive mechanism behind
the function-only rule (lowercase locals vs same-named methods) doesn't
transfer: methods stay excluded for bare ids, and the same-file/import
gate + unique-or-drop rules still apply.
Probed on django-rest-framework (~250 files): 559 new references→class
edges, 10/10 sampled genuine (serializer_class = AuthTokenSerializer, the
ModelSerializer field-mapping registry, aliases, ctor args, isinstance).
EXTRACTION_VERSION 24 → 25.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The swiftc =2/nm:mc-* attribution located the wall: the
getSupertypes conformance walk ran 971,200 times (565s of combined
worker time, 581µs each) — every resolveMethodOnType miss re-queried
implements/extends edges for every same-named type node, recursing
depth-4 through Swift's protocol landscape with no memoization, and
post-inference resolveMethodOnType averaged 1,912µs per call.
Fix 1 — generation-tagged getSupertypes memo. Supertype edges GROW
during the resolution loop (batch k persists its edges BEFORE batch
k+1 fans out — the #1320 ordering), so a plain cache would freeze an
early batch's emptier answer. Within a batch the edge state is fixed
by that same ordering, so memo entries carry a generation that
advances at every batch entry point (resolveBatchYielding /
resolveListForAdmission — covering the sequential loop, pool workers,
sync admission, and the conformance pass); a stale-gen entry
recomputes. Behavior-identical to no memo at every point in time;
walk invocation counts match the unmemoized run exactly (971,200 /
24,336 / 76,415).
Fix 2 — per-(language, method-name) owner index in getMethodMatches:
candidates bucket once by their qualifiedName's last two segments
(exactly the span the match predicate tests), so a (type, method)
query is a map lookup instead of an O(candidates) scan per
methodMatchCache miss. ObjC selectors and multi-segment typeNames
keep the legacy linear path. Also ships nm:mc-rmot / nm:rmot-supers
=2 attribution rows.
swiftc: settle 100.2→31.3s, resolveMethodOnType 1,912→202µs, wall
183.5→97.8s (was 185s at the head-to-head; cbm's same-box number is
119.1s). Gates: swiftc old-vs-new dump byte-identical (1,837,235
rows), swiftc pooled-vs-CODEGRAPH_NO_PARALLEL_RESOLVE=1 identical
(the generation-semantics risk surface), dubbo old-vs-new identical
(49k Java instance-method hits share both paths), Alamofire
identical; suite 2,689 ×2 with CODEGRAPH_KERNEL_EXPECT=1.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The kong/tokio matcher-chain residue attributed (nm:mc-* sub-stage rows,
shipped here too): matchMethodCall's cost is ~entirely
inferLocalReceiverType — 61µs per miss on kong, 99% miss rate (39k `self:`
calls hunting a local declaration Lua never writes), re-scanning the same
scope lines for every ref.
Two pure memos, both semantics-preserving by construction:
- Compiled-pattern memo: localReceiverTypePatterns/phpPropertyTypePatterns
built 2-4 fresh RegExp objects per call; patterns are a pure function of
(language, receiver) and non-global, so instances are shared via a
FIFO-capped map (no per-get mutation — the §7a.6 LRU-churn lesson).
- Incremental scan memo: refs for the same (file, scope, receiver) arrive
in ~ascending line order and the backward declaration scan is a pure
function of immutable file lines — a per-context watermark scans each
line once per key (query(c) = highest match in [start..c]; monotonic
calls extend the watermark over (hi..c]; non-monotonic calls fall back
to the plain bounded scan). componentScoped (CFML/PHP whole-file sweep)
is keyed out. States drop with the context's file caches via
clearNameMatcherMemos, wired into ReferenceResolver.clearCaches.
kong mc-infer misses 61→20µs (2.4s→0.8s combined); fresh index 3.43 →
3.03-3.20s (−8%; 4.07 → 3.14 cumulative with #1391). tokio unchanged
(tight scopes). Gates: dubbo (49k Java instance-method HITS ride this
scan), kong, tokio, Fusion dumps all byte-identical; suite 2,689 ×2 with
CODEGRAPH_KERNEL_EXPECT=1.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The full-README competitor matrix ranked lua/kong as the largest
legitimate fresh-index gap (2.71×). Stage attribution (RESOLVE_PROFILE=2)
pinned it: resolveLuaRequire ran getAllFiles().filter(endsWith) FOUR
times per require ref — ~7.5k string suffix scans each, measured at
~0.9ms/ref, hit or miss (2.7s combined over kong's 3k requires).
Replace the per-ref full-list scans with a per-context basename →
file-paths index (the cobolCopybookIndexes pattern). Buckets preserve
getAllFiles() iteration order, so each suffix's candidate filter yields
exactly the array the full scan produced — identical matches, identical
stable sort, identical winner, dump-proven.
kong: 4.07-4.27 → 3.40-3.63s (−16%). Gates: kong old-vs-new dump
byte-identical (157,650 rows), kong pooled-vs-sequential identical,
Fusion (luau instance-path requires) old-vs-new identical; suite
2,689 ×2 with CODEGRAPH_KERNEL_EXPECT=1.
Also registers cobolCopybookIndexes in clearImportResolverMemos — it was
never dropped on cache clears, so post-sync copybook lookups could serve
a stale file list; cache-drop is the always-safe direction.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The 9-language competitor matrix exposed tokio as the worst fresh-index
gap: 77% of its wall was resolution running SEQUENTIALLY — 56k Rust refs
sit under the fixed 150k pool gate while costing 36µs each (9× Go's
4µs/ref on prometheus). A ref-count gate can't see per-ref cost.
After each sequential batch the loop now projects the remaining
sequential settle from the measured rate and boots the pool mid-run when
it clears 400ms. The switch rides machinery that already existed: pool
boot is async and fan-out engages only when ready, admission order is
mode-independent, and the #1320 edges-before-fanout invariant holds at
every batch boundary regardless of when the pool arrives. Up-front
engagement at >=150k refs is unchanged; 2-core/low-memory hosts still
decline inside tryCreate's sizing; CODEGRAPH_NO_PARALLEL_RESOLVE still
disables; downgrade permanence is preserved (one engage attempt per run).
Measured (n=3, interleaved, caffeinated): tokio 3.06-3.12 → 2.40-2.57s
(resolution 2,443→~1,330ms); express (tiny control) unchanged with zero
engagements; dubbo unchanged (ref-count path). Gates: tokio + excalidraw
adaptive-vs-sequential dumps byte-identical (87,302 / 89,903 rows),
dubbo dump identical to the session baseline, suite 2,689 ×2 with
CODEGRAPH_KERNEL_EXPECT=1. Known follow-up: sampling the rate mid-first-
batch would close the remaining ~0.3s to the forced-engage ceiling.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Store-arc round 2 (#1388 follow-up). The synthesis pool barrier on dubbo
carried ~1.4s of passes that provably could not emit an edge for the
project: reactRenderEdges fanned out over every class before checking for
a render method (now: one indexed name lookup bounds candidates — not a
language gate, Java Litho-style render+setState still matches);
expo/rn cross-platform pairing streamed every method row without the
languages their edges require (now registry-gated: expo needs swift AND
kotlin file-languages, rn needs a JS-family caller for isBridge);
mybatis built its full java-method index before discovering there were no
mapper-XML methods (now collects the XML side first). ifaceEdges — real
work — stops re-fetching a hub interface's methods once per implementer
and skips supertype-less classes before any per-class lookup.
dubbo warm wall 8.49-8.79 → 8.14-8.24s (n=3/arm, caffeinated); barrier
784→435ms; the full removed pass work lands on low-core envelopes where
synthesis runs sequentially. Dumps byte-identical: dubbo old-vs-new,
pooled-vs-sequential, kernel-vs-wasm (441,270 rows) + excalidraw JSX-live
control (89,903 rows, 46 react-render edges reproduced). Suite 2,689 ×2
with CODEGRAPH_KERNEL_EXPECT=1.
Also ships the diagnostics that located the round (zero cost when off):
CODEGRAPH_RESOLVE_PROFILE=2 attributes per-ref time to resolveOne's
strategies (stage:*) and the name-matcher's sub-matchers (nm:*);
CODEGRAPH_SYNTH_TIMINGS now prints the store worker's decode-vs-SQL
split. Killed by measurement, recorded in the PR: import-failure negative
cache (both-outcome names exist — static imports resolve via
instance-method on jvm-miss), jvm-miss early return (1,939 later-strategy
edges), jsxEdges language gate (Java generics text produces jsx edges),
and §4d buffer→bind on Spring repos (extract() hook forces the decoded
path — kernel=0 bundles measured).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Post-R7b store-arc round 1, found by the dubbo warm-wall decomposition
(the cbm bar): resolution's loop-stage profile showed settle=3.0s — the
main thread idling on TWO resolver workers on an 11-core Mac. Pool sizing
logged `size=2 (budget=1068MB)`: memoryBudgetBytes() falls back to
os.freemem() when uncontained, and macOS keeps RAM deliberately full of
reclaimable cache, so freemem reads ~1GB on a mostly-idle 64GB machine.
The memory term then capped the pool at 2 where the CPU term allowed 6 —
the macOS sibling of §7a.1's os.cpus() cpuset-blindness (that round fixed
the CPU term; this fixes the memory term).
Fix: darwinMemoryAvailable() reads /usr/bin/vm_stat once per sizing call
and reports free + inactive + speculative + purgeable pages — what
Activity Monitor calls available, the same reclaimable-inclusive
convention the Linux branch already uses by crediting inactive_file back.
Parse failure → null → freemem fallback; Linux/cgroup and Windows paths
untouched.
Measured (dubbo 4,402 files, warm, caffeinated, n=3 each): pool now
self-sizes to 6 (budget 5.7-6.3GB) — wall 8.62-8.83s vs 9.67-10.87s
baseline, resolution phase 6.9→5.3s, loop settle 3.0→1.9s. Matches the
CODEGRAPH_RESOLVE_WORKERS=6 probe exactly (probe-before-build). Dumps
byte-identical pool-6 vs sequential (441,270 lines). Second consumer
unblocked: the cFnPtr LRU cache cap no longer spuriously degrades to 128
on Macs (its full-cache tier is worth ~60s at kernel scale).
Suite: resolver-pool-sizing gains a darwin-gated reclaimable-pages test +
an off-darwin null pin; full suite 2,689 green ×2 with
CODEGRAPH_KERNEL_EXPECT=1.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Store-architecture arc round 2. The batched resolution loop reads
unresolved_refs ONLY through the status index + the PK keyset pager;
the other five ref indexes (from_node, name, file_path, from_name,
failed_tail) serve sync-time paths — yet every per-batch DELETE of
resolved refs maintained all of them, the biggest single main-thread
stage on the dubbo profile (deletes 1.2s of a 5.4s resolution phase)
and 50-81s at kernel scale.
beginBulkRefLoad/endBulkRefLoad on DatabaseConnection, threaded as
refIndexLoad hooks next to the existing bulkEdgeLoad pair with the
same minRefsForPool gate (small syncs never pay): drop the five for
the loop, rebuild each in one scan at the end — where the table holds
only the surviving FAILED refs (resolved rows are deleted by then),
so the recreate is near-free. Crash inside the window heals on the
next open (schema.sql re-applies CREATE INDEX IF NOT EXISTS).
Measured:
- dubbo: deletes 1.2 → 0.2s, marks 0.6 → 0.3s, recreate 219ms; wall
~8.5s flat — the freed main-lane time shifts into settle (the worker
lane now binds the double-buffer at medium scale).
- Linux kernel 8c: resolution 423.4 → 275.9s (deletes 50-81 → 3.2s,
backpressure 16.8 → 7.4s — fewer index writes mean less WAL and
cheaper folds), ref recreate 10.3s. Envelope ≈ 11.0min, from the
14.8min pre-arc best; <10min-on-8c now needs ~1 more minute.
Gates: dubbo/gson dumps byte-identical; linux counts exact
2,049,153/6,413,518 and dump sha 6dd1185b… reproduced (10,446,478
lines); full suite green ×2 (153 files / 2588 tests).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Task #5 step 2. The fuse-then-link refactor (#1364) left the extraction
sweep as a clean per-file boundary: raw text in → collected facts out.
This ports that sweep to the native kernel: `cfnptr_scan_files`
(codegraph-kernel/src/cfnptr.rs) strips and scans a batch of 16 files
per NAPI call, and the TS side only reads files, ships batches, interns
the returned facts, and resolves include paths. The JS sweep remains as
the fallback (no binary, feature detection against older binaries,
CODEGRAPH_KERNEL=0, or CODEGRAPH_KERNEL_CFNPTR=0).
Parity discipline: the JS regexes are the spec, so the scanners are
hand-rolled byte machines reproducing that engine — ASCII \w/\b next to
UNICODE \s (NBSP/U+2000-200A/FEFF decoded from UTF-8), alternation
order, lastIndex resume, and the observable backtracking dimensions
(INIT/ARRAY modifier and struct/star/bracket optionals, DISPATCH's
greedy segment loop); greedy shortcuts only where backtracking provably
can't rescue a match. The native stripper blanks per UTF-16 code unit,
so its output is string-identical to the TS stripper — pinned by a new
kernel arm on the strip differential oracle (fixtures + 500 seeded
random cases).
Gates, all green: new differential suite (adversarial fixture project —
CRLF, NBSP, continuations, decoy strings, unterminated comments,
backtracking shapes — indexed native-vs-JS: identical edge streams,
plus a record-level scanner check); repo differential on
git/redis/vim/SameBoy (identical, 705/852/433/180 edges); probe-hash on
the live linux kernel DB reproduced f6e1713d… (279,335 rows); linux
init counts exact 2,049,153/6,413,518; dump sha 6dd1185b… reproduced
(10,446,478 lines); full suite green ×2 (153 files / 2588 tests).
Measured (8c cg1212, quiet host): cFnPtr sub A=47.9s B=1.1 C=40.9
D=24.1 E=36.8 = 150.9s vs step 1's 179s and the pre-arc 230s (−34%
cumulative); the sweep itself halved (94.5→47.9s, JS strips
132.4k→68.9k). callback-synthesis phase 199.9→171.1s. E's attributed
wall grew from overlap shift under parallel synthesis; the phase total
is the honest number. Full record: plan §7a.10.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Task #5 step 1 (plan §7a.8/§7a.9). The C/C++ function-pointer dispatch
synthesizer swept every file's text four times (typedefs, registrations,
propagation, dispatch); at kernel scale the all-or-nothing source cache
declines, so that was 4.4 read+strips per file — 78s of the ~230s pass.
Now ONE extraction sweep reads+strips each file once and collects typedef
names, struct-node field declarations (structurally parsed, fn-pointer
classification deferred until the typedef sets are complete), resolved
includes, an alias-shaped-macro name set, and per-file survival filters
(init type tokens, array element types, inline-struct summaries,
field-assign pairs, dispatch fields/array names — interned, a few MB on
linux). The linking stages then replay the ORIGINAL pass bodies verbatim:
struct layouts register in kind-scan order (same-name precedence is
order-sensitive), and registration/propagation/dispatch run only for
files their filter proves can have side effects, lazily re-stripping just
those. Filters only over-approximate (full-file no-skip scans ⊇ the real
passes' jump-cursor scans), and a filtered-out file is one where every
match fails the pass's own gates before any side effect — parity by
construction. Macro tables stay lazy: a sizing probe found 6.1M #define
lines on linux (amdgpu register headers), ruling out retention.
Measured (8c cg1212, quiet host, fresh kernel init): cFnPtr pass 230s →
179s (−22%); strips 283.5k → 132.4k (4.44 → 2.08/file, 78 → 46.6s);
dispatch stage 95 → 18.5s; callback-synthesis phase 250 → 199.9s.
Standalone probe on the live DB: 139 → 122s.
Byte-parity gates, all green: probe-hash identical on the live kernel DB
(279,335 edge rows both builds); git/redis/vim/SameBoy full dumps
byte-identical old-vs-new (macro tables, commands.def, #ifdef
include-units, inline structs, bare arrays exercised); kernel-parity
0-diffs on git/redis/fmt/protobuf, deferral unchanged; linux counts
exact 2,049,153/6,413,518 and dump sha 6dd1185b… reproduced
(10,446,478 lines); full suite green ×2 (152 files / 2563 tests).
Step 2 (native per-file extractor) now has its boundary: the extraction
sweep, raw text in → records out.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The §7a.6 anomaly probed to its mechanism with five discriminating runs
(§7a.7 table): main-thread B-tree writes triple under attached readers
because READERS PIN WAL checkpoint progress — the deep WAL taxes every
writer page operation (deletes 42.6s pool-off vs 118.8s pool-4 on
identical hardware; an aggressive 64MB valve recovers the writes but
overpays +129s in full-park folds; the v2 cache resurrection was
falsified — long-tail name traffic is uncacheable at any capacity).
Fix: workers close and reopen their read-only connections every 8
batches at the double-buffer's worker-idle boundary
(ResolverPool.recycleWorkers + QueryBuilder.rebind + a cadence call).
Reopens are sub-millisecond, resolver caches survive (only prepared
statements re-prepare), and the existing checkpoints advance instead of
parking. Failed recycle downgrades to sequential, same as a failed
fan-out.
Measured (8c pool-4, linux v7.2-rc2, cadence 25 → 8 iterated):
resolution superphase 715.0 → 633.6s (−11.4%), envelope best 14.8min,
recreate 59.7 → 45.3s. Byte-neutral everywhere: git dumps byte-identical
old-vs-new, linux dump sha 6dd1185b reproduced (10,446,478 lines),
counts 2,049,153/6,413,518, suite 2517 green. 2c unchanged by
construction (no pool → no recycling).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
cFnPtrEdges was 86% of kernel-scale synthesis (306s, §7a.3). Standalone
probe iterations against the live kernel DB attributed and fixed:
- sliceLines split the ENTIRE file per node (~1.6M full-file splits across
the B/D/E sweeps) — split once per file, slice from the line array
(D 46.2->20.5s, E 94.5->69.1s).
- The 128-entry strip cache re-stripped every file 4.4x across the four
file sweeps (71.8s). Sizing is now memory-budget-aware and ALL-OR-NOTHING
with 5% slack over the file count: a partial LRU on cyclic sweeps thrashes
to ~0% hit (measured twice: cap ~61k and cap == files both thrashed
against 63.8k + includes). Full-cache strips: exactly 1.0/file, 15.5s.
- Pass D pre-gates field names on fieldToStructs before the two regex type
resolutions — a->f = b->g matches every struct-field assignment in the
tree, ~99% of them data fields.
- recvTypeIn/varTypeIn compiled a new RegExp per call - cached per name.
- Line numbers counted incrementally per match (ascending indexes) instead
of splitting the body prefix per match; cursor rewinds between the two
dispatch scans.
- CODEGRAPH_SYNTH_TIMINGS now prints per-sweep walls + read/strip/nodes
accounting for this pass.
278.8s -> 134.8s standalone at the 2c envelope. Output identity proven at
full scale: probe edge set (merge-dedup applied, canonical sort) SHA256
21c2a971... == the original code's 274,762 edges extracted from the live
kernel DB. Pass suite 15/15.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The §7a.2 per-ref profile overturned the assumption the whole arc was
built on: resolveOne owns only ~93s of the kernel-scale ~433s batch loop.
Loop-stage attribution (CODEGRAPH_RESOLVE_PROFILE, shipped here) named the
rest: backpressure folds 111.2s, count guard 93.9s, batch reads 54.6s,
deletes/inserts/marks ~84s, settle 85.7s.
- Non-progress guard O(remaining)→O(1): the per-batch COUNT(*) walked every
remaining pending row (O(N²/batch) per run, 93.9s). The cleanup queries
now return summed SQLite , and zero-removals-from-claimed-work
is the guard signal — the DIRECT evidence the count diff inferred (a
mismatched-name resolver makes keyed cleanup no-op ⇒ changes=0). A real
COUNT runs only on that suspicious path and arbitrates exactly as before.
- Batch reads OFFSET→keyset (54.6s→O(batch)): OFFSET re-walked the
accumulated failed-row prefix every read; seeking past the last-seen
rowid is prefix-independent and enumeration-order identical.
- WAL valve caps scale with DB size (env still wins): every fold re-writes
hot pages (#1231 in bounded form — 111.2s at the flat 256MB cap);
soft=clamp(dbSize/4, 256MB, 2GB) trades ~4× fewer folds for a transient
WAL ≈ project size.
- CODEGRAPH_RESOLVE_PROFILE: per-outcome resolveOne histogram + loop-stage
attribution, main + workers, off by default.
Gates: dubbo dump byte-identical; suite 2,491 passed / 4 skipped (kernel
required). Kernel-scale payoff run lands in the plan doc next.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>