From cd97bb37d201b58c130e1e9a669c474485844ffa Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sat, 10 Oct 2026 01:51:29 +0000 Subject: [PATCH 1/6] fix(context): call paths label synthesized hops like explore, not as events (#2428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two surfaces label a synthesized (dynamic-dispatch) `calls` hop: codegraph_explore's Flow and dynamic-dispatch links plus the codegraph_node trail (`dynamic: interface → impl @file:line`), and ContextBuilder's "## Call paths", which `codegraph context` prints (`→[…]`). Each kept its own list. The call paths' list stopped at callback, react-render, jsx-render, vue-handler, http-client, queue-job and socket messages, so every other synthesizer fell through to `event ${m.event ?? ''}`: an interface-impl hop read `→[event @src/pdf-exporter.ts:5]`, and so did a C++ override, a redux thunk, a gin middleware chain or a closure collection. The wording now lives once, in src/graph/synthesized-hop.ts (`describeSynthesizedHop`: a summary, the detail only the call paths print, and the wiring site), as AGENTS.md asks of a derivation two surfaces render. ToolHandler renders `dynamic: @site`, the call paths ` @site`. Explore and the node trail print exactly what they printed before; the call paths keep their wording for the kinds they already labelled, including the queue name, a socket's direction and the HTTP clause, and now name the rest the way explore does. A bus event reads `bus event` there too, and the section's footnote no longer says hops are marked `[callback/event …]`. The helper also holds the wording #2414 gives a Go method a struct gets by embedding (`metadata.promotedInto`), so that arm moves here too. The unused long `label` form of ToolHandler.synthEdgeNote (last read by the removed codegraph_trace) is dropped. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- CHANGELOG.md | 1 + __tests__/synthesized-hop.test.ts | 119 ++++++++++++++++++++++++ src/context/index.ts | 35 +++----- src/graph/synthesized-hop.ts | 82 +++++++++++++++++ src/mcp/tools.ts | 145 ++++-------------------------- 6 files changed, 229 insertions(+), 155 deletions(-) create mode 100644 __tests__/synthesized-hop.test.ts create mode 100644 src/graph/synthesized-hop.ts diff --git a/AGENTS.md b/AGENTS.md index fd5daee8ef..d46e3cc675 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,7 @@ The public API surface is `src/index.ts` — the `CodeGraph` class wires all the - `src/extraction/` — `ExtractionOrchestrator`, tree-sitter wrappers, per-language extractors under `languages/` (one file per language), plus standalone extractors for non-tree-sitter formats (`svelte-extractor.ts`, `vue-extractor.ts`, `liquid-extractor.ts`, `dfm-extractor.ts` for Delphi). `parse-worker.ts` runs heavy parsing off the main thread. - `src/resolution/` — `ReferenceResolver` orchestrates `import-resolver.ts` (with `path-aliases.ts` for tsconfig path aliases + cargo workspace member globs), `name-matcher.ts`, and `frameworks/` (Express, Laravel, Rails, FastAPI, Django, Flask, Spring, Gin, Axum, ASP.NET, Vapor, React Router, Next.js — `nextjs.ts`: pages and `route.ts` handlers from files, `router.push` / `redirect` / `NextResponse.redirect` as `navigates` edges, with `next-router-synthesizer.ts` for `` — Expo Router, SvelteKit, Vue/Nuxt, Cargo workspaces). Frameworks emit `route` nodes and `references` edges. `callback-synthesizer.ts` holds the whole-graph synthesis passes (`SYNTH_PASSES`, merged in registry order — first-seen wins a duplicate pair) with the language gates; `tier-synthesizer.ts` is the cross-tier pass (a client's literal `fetch`/`axios` path onto its own route, a queue job onto its consumer, a bus / socket event onto its handler — `channel`, `tier`, `registeredAt` on every edge; registered before the in-process emitter pass so its more specific edge wins); `synth-utils.ts` has the helpers they share (`enclosingFn`, `enclosingValue`, `makeLineAt`). Express's `postExtract` composes `app.use('/prefix', router)` mounts onto a mounted file's route names, idempotently (the original path stays in `qualifiedName`). - `src/graph/` — `GraphTraverser` (BFS/DFS, impact radius, path finding) and `GraphQueryManager` (high-level queries), plus the shared query-time derivations more than one surface renders: `named-symbol-flow.ts` (the one path finder, behind `codegraph_explore`'s Flow section and the viewer's Flow strip), `dynamic-boundary-report.ts` (where the graph stops), `type-hierarchy.ts` (ancestors/subtypes and the implementation count explore prints and the viewer draws), - `dead-code.ts` (unreferenced symbols, and every reason a candidate is NOT claimed). A derivation that two callers render must live here, not in `ToolHandler` — two derivations eventually disagree. + `dead-code.ts` (unreferenced symbols, and every reason a candidate is NOT claimed), `synthesized-hop.ts` (how a synthesized hop reads, in explore's Flow, the `codegraph_node` trail and ContextBuilder's call paths — a new synthesizer's wording goes here). A derivation that two callers render must live here, not in `ToolHandler` — two derivations eventually disagree. - `src/context/` — `ContextBuilder` + formatter for markdown/JSON output. - `src/search/` — full-text query parser and helpers for FTS5. - `src/sync/` — `FileWatcher` (native FSEvents/inotify/RDCW) with debounce + filter, and git-hook helpers. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c748563ff..3ff4b07c95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Indexing a project that includes large bundled JavaScript files, such as a copy of pdf.js or d3, is fast again: since 1.6.2, resolving the calls in a JavaScript or TypeScript file re-read the file's text above each call, so a single bundled library could add many seconds to an index. The graph it builds is unchanged. Thanks @bompus for the report. (#2334) - Indexing a project that ships a large minified JavaScript file, such as the GraphiQL bundle inside go-ethereum, no longer stalls at "Resolving refs" when CodeGraph runs on Node 22, and linking such a file is faster on any Node version. The graph it builds is unchanged. - The time `codegraph init` and `codegraph index` print beside the node and edge counts now covers the whole run, resolving references and linking included, and `codegraph sync` reports its whole run the same way. Before, it counted only reading and parsing the files, which can be a small part of an index, so a slow index looked fast. Thanks @bompus for the report. (#2334) +- The call paths `codegraph context` prints now say what bridges each dynamic-dispatch hop, in the same words `codegraph_explore` uses: a call through an interface reads `interface → impl`, and a C++ override, a Redux thunk or a Gin middleware chain is named as one. Before, every hop of a kind it didn't know was labeled an "event", so a call through an interface looked like an event being emitted. - In Rust, code that uses an enum only through its variants, like `mode::Mode::A` in an expression, a `Mode::B =>` match arm or `Self::A` inside the enum's own `impl`, now shows up in that enum's callers and impact, linked to the enum that actually declares the variant rather than to a same-named type elsewhere. Standard-library variants like `Ordering::Less` or `Option::Some` and associated items like `Foo::new()` or `Foo::MAX` don't count as using a project enum. Re-index Rust projects after upgrading. Thanks @mg-mg-mg for the report and @danusha2345. (#2328) - Axum routes now link to their handler when rustfmt wraps the `.route(` call so the handler sits on a later line, and when the handler's name also appears elsewhere on the line, as in `delete(handlers::user::delete)` or `.route("/login", post(handlers::user::login))`; Actix's `web::resource(…)` and `.route(…)` registrations get the same fix. A call inside a closure handler is no longer mistaken for a route, and an Actix resource no longer takes the next route's handler as its own. Thanks @mg-mg-mg for the report and @danusha2345. (#2326) - In JavaScript and TypeScript, the functions written inside a named object literal are now symbols of their own even when the object isn't exported: a plain `const api = { load() {…} }`, an object declared inside an IIFE or a function, and a namespace hung on the page or on another object, like `window.App = { init() {…} }`, `App.utils = {…}` or `dw_page = {…}`. Calls such as `api.load()`, `window.App.init()`, `App.utils.pad()`, a sibling's `this.render()`, and `App.init()` from another script on the page now reach them, the calls made inside a member belong to that member instead of the object, and a bare `init()` is no longer taken for `App.init`, so script-tag apps no longer lose most of their code from `callers` and impact. A member's qualified name now carries its object, exported or not (`api::load`, `window.App::init`), so asking for `App.init` finds it. Re-index JavaScript and TypeScript projects after upgrading. Thanks @tkhoaaa for the report and @danusha2345. (#2300) diff --git a/__tests__/synthesized-hop.test.ts b/__tests__/synthesized-hop.test.ts new file mode 100644 index 0000000000..22a17ed8b1 --- /dev/null +++ b/__tests__/synthesized-hop.test.ts @@ -0,0 +1,119 @@ +/** + * One wording for a synthesized (dynamic-dispatch) hop, on both surfaces that + * print one: codegraph_explore's Flow and the codegraph_node trail + * (`dynamic: …`), and ContextBuilder's "## Call paths" (`→[…]`, what + * `buildContext` and `codegraph context` print). The call paths used to keep + * their own list, and every synthesizer missing from it — interface dispatch, + * a C++ override, a redux thunk — read as an "event". + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import CodeGraph from '../src/index'; +import { ToolHandler } from '../src/mcp/tools'; +import { describeSynthesizedHop } from '../src/graph/synthesized-hop'; +import type { Edge } from '../src/types'; + +describe('an interface-impl hop on both surfaces', () => { + const query = 'runExport exportReport renderPdf'; + let dir: string; + let cg: CodeGraph; + // Assertions read RAW codegraph_explore output; managed offload would replace it. + let prevOffloadDisable: string | undefined; + + beforeAll(async () => { + prevOffloadDisable = process.env.CODEGRAPH_OFFLOAD_DISABLE; + process.env.CODEGRAPH_OFFLOAD_DISABLE = '1'; + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-synth-hop-')); + // One file each: ContextBuilder keeps only a few nodes per file, and with + // everything in one file the interface method is the node it drops. + const files: Record = { + 'src/exporter.ts': `export interface Exporter { + exportReport(data: string): string; +} +`, + 'src/pdf-exporter.ts': `import type { Exporter } from './exporter'; +import { renderPdf } from './render'; + +export class PdfExporter implements Exporter { + exportReport(data: string): string { + return renderPdf(data); + } +} +`, + 'src/render.ts': `export function renderPdf(data: string): string { + return '%PDF ' + data; +} +`, + 'src/run.ts': `import type { Exporter } from './exporter'; + +export function runExport(exporter: Exporter): string { + return exporter.exportReport('q3'); +} +`, + }; + for (const [rel, body] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), body); + } + cg = CodeGraph.initSync(dir); + await cg.indexAll(); + }); + + afterAll(() => { + cg?.close(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + if (prevOffloadDisable === undefined) delete process.env.CODEGRAPH_OFFLOAD_DISABLE; + else process.env.CODEGRAPH_OFFLOAD_DISABLE = prevOffloadDisable; + }); + + it('ContextBuilder call paths label it interface dispatch, not "event"', async () => { + // Precondition: the hop is a real synthesized edge, not a static call. + const [iface] = cg.getNodesByName('exportReport').filter((n) => n.qualifiedName === 'Exporter::exportReport'); + expect(cg.getOutgoingEdges(iface!.id).map((e) => e.metadata?.synthesizedBy)).toContain('interface-impl'); + + const md = String(await cg.buildContext(query, { format: 'markdown' })); + expect(md).toContain('## Call paths'); + const callPaths = md.slice(md.indexOf('## Call paths')); + expect(callPaths).toContain( + '- runExport → exportReport →[interface → impl @src/pdf-exporter.ts:5] exportReport → renderPdf' + ); + expect(callPaths).not.toContain('→[event'); + }); + + it('codegraph_explore tags the same hop with the same words', async () => { + const res = await new ToolHandler(cg).execute('codegraph_explore', { query }); + expect(res.content?.[0]?.text).toContain('↓ dynamic: interface → impl @src/pdf-exporter.ts:5'); + }); +}); + +describe('describeSynthesizedHop', () => { + const at = 'src/wire.ts:7'; + const edge = (metadata: Record): Edge => + ({ source: 'a', target: 'b', kind: 'calls', provenance: 'heuristic', metadata: { registeredAt: at, ...metadata } }); + + it.each([ + // Synthesizers the call paths used to call an "event". + [{ synthesizedBy: 'interface-impl', via: 'handle' }, 'interface → impl', ''], + [{ synthesizedBy: 'interface-impl', via: 'Report', promotedInto: 'scrapeLoop' }, 'interface → method promoted into scrapeLoop', ''], + [{ synthesizedBy: 'closure-collection', field: 'validators' }, 'runs `validators` handlers', ''], + [{ synthesizedBy: 'cpp-override', via: 'Run' }, 'cpp override', ''], + [{ synthesizedBy: 'redux-thunk', via: 'fetchUser' }, 'redux thunk', ''], + [{ synthesizedBy: 'gin-middleware-chain', via: 'Auth' }, 'gin middleware chain', ''], + [{ synthesizedBy: 'event-bus', channel: 'event', event: 'user.created' }, 'bus event `user.created`', ''], + // The detail only the call paths print. + [{ synthesizedBy: 'queue-job', event: 'send-email', queue: 'emails' }, 'queue job `send-email`', ' on `emails`'], + [{ synthesizedBy: 'event-bus', channel: 'socket', event: 'chat', tier: 'client→server' }, 'socket message `chat`', ' → server'], + [{ synthesizedBy: 'http-client', method: 'POST', href: '/api/users' }, 'HTTP POST /api/users', " — the client's call onto its own route"], + ])('%o reads "%s"', (metadata, summary, detail) => { + expect(describeSynthesizedHop(edge(metadata))).toEqual({ summary, detail, registeredAt: at }); + }); + + it('is null for a static edge and for a heuristic edge no synthesizer made', () => { + expect(describeSynthesizedHop({ source: 'a', target: 'b', kind: 'calls', provenance: 'tree-sitter' })).toBeNull(); + expect(describeSynthesizedHop({ ...edge({ synthesizedBy: 'callback', via: 'on' }), provenance: undefined })).toBeNull(); + expect(describeSynthesizedHop(edge({ confidence: 0.5 }))).toBeNull(); + expect(describeSynthesizedHop(null)).toBeNull(); + }); +}); diff --git a/src/context/index.ts b/src/context/index.ts index acf1cf5c35..4f924eb40a 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -28,6 +28,7 @@ import { validatePathWithinRoot, isConfigLeafNode } from '../utils'; import { isTestFile, extractSearchTerms, scorePathRelevance, getStemVariants, isDistinctiveIdentifier } from '../search/query-utils'; import { LOW_CONFIDENCE_MARKER } from './markers'; import { findNamedCopybooks, isCopybookInclude } from '../graph/cobol-copybooks'; +import { describeSynthesizedHop } from '../graph/synthesized-hop'; /** * Most top-level declarations of one copybook that become entry points — its @@ -403,31 +404,17 @@ export class ContextBuilder { const name = (id: string): string => subgraph.nodes.get(id)?.name ?? id; // Synthesized (dynamic-dispatch) hops are real `calls` edges but invisible to - // static parsing — mark them inline so the agent sees WHERE the callback was - // wired up (`registered @file:line`) instead of grepping for it. Keyed by - // "source>target". + // static parsing — mark them inline so the agent sees WHAT bridged the hop + // and WHERE it was wired up (`@file:line`) instead of grepping for it. The + // wording is `graph/synthesized-hop.ts`'s, the same explore's Flow uses, plus + // the detail this roomier line has space for. Keyed by "source>target". const synthByPair = new Map(); for (const e of subgraph.edges) { - if (e.kind !== 'calls' || e.provenance !== 'heuristic') continue; - const m = e.metadata as Record | undefined; - if (!m?.synthesizedBy) continue; - const at = typeof m.registeredAt === 'string' ? ` @${m.registeredAt}` : ''; - const label = m.synthesizedBy === 'callback' - ? `callback via ${m.via ? `\`${String(m.via)}\`` : 'registrar'}${at}` - : m.synthesizedBy === 'react-render' - ? `React re-render via setState${at}` - : m.synthesizedBy === 'jsx-render' - ? `renders <${String(m.via || 'child')}>` - : m.synthesizedBy === 'vue-handler' - ? `Vue @${String(m.event || 'event')} handler` - : m.synthesizedBy === 'http-client' - ? `HTTP ${String(m.method || 'GET')} ${String(m.href || '')} — the client's call onto its own route${at}` - : m.synthesizedBy === 'queue-job' - ? `queue job ${m.event ? `\`${String(m.event)}\`` : ''}${m.queue ? ` on \`${String(m.queue)}\`` : ''}${at}` - : m.synthesizedBy === 'event-bus' && m.channel === 'socket' - ? `socket message ${m.event ? `\`${String(m.event)}\`` : ''}${m.tier === 'client→server' ? ' → server' : m.tier === 'server→client' ? ' → client' : ''}${at}` - : `event ${m.event ? `\`${String(m.event)}\`` : ''}${at}`; - synthByPair.set(`${e.source}>${e.target}`, label); + if (e.kind !== 'calls') continue; + const hop = describeSynthesizedHop(e); + if (!hop) continue; + const at = hop.registeredAt ? ` @${hop.registeredAt}` : ''; + synthByPair.set(`${e.source}>${e.target}`, `${hop.summary}${hop.detail}${at}`); } const renderChain = (c: string[]): string => { let s = name(c[0]!); @@ -447,7 +434,7 @@ export class ContextBuilder { ...kept.map((c) => `- ${renderChain(c)}`), '', hasSynth - ? '_Hops marked `[callback/event …]` are dynamic dispatch bridged by codegraph (with the registration site); the rest are direct calls. codegraph_node any symbol for its body._' + ? '_Hops marked `[…]` are dynamic dispatch bridged by codegraph (with the registration site); the rest are direct calls. codegraph_node any symbol for its body._' : '_codegraph_node any symbol above for its source + its own callers/callees._', ]; return '\n' + lines.join('\n') + '\n'; diff --git a/src/graph/synthesized-hop.ts b/src/graph/synthesized-hop.ts new file mode 100644 index 0000000000..741749c7ae --- /dev/null +++ b/src/graph/synthesized-hop.ts @@ -0,0 +1,82 @@ +/** + * How a synthesized hop reads — what bridged the call, and where it was wired. + * + * Static parsing cannot see a handler registered in one place and run in + * another, so the resolver synthesizes the `calls` edge across it + * (`provenance: 'heuristic'`, `metadata.synthesizedBy`, and usually a + * `metadata.registeredAt` wiring site). Whatever prints that hop has to say what + * bridged it, or the hop reads as a direct call — or as the wrong mechanism. + * + * Two surfaces print one. `codegraph_explore` (its Flow section and its + * dynamic-dispatch links) and the `codegraph_node` trail tag the hop + * `dynamic: callback via \`subscribe\` @src/store.ts:4`; ContextBuilder's + * "## Call paths" (`buildContext`, `codegraph context`) writes it inside the + * arrow, `→[callback via \`subscribe\` @src/store.ts:4]`. Each used to keep its + * own list, and the shorter one called every synthesizer it did not know — + * interface dispatch, a C++ override, a redux thunk — an "event". So the + * wording lives here once, and each surface renders it. + */ + +import type { Edge } from '../types'; + +export interface SynthesizedHop { + /** What bridged the hop: "callback via `subscribe`", "interface → impl", "redux thunk". */ + summary: string; + /** + * What the call-paths rendering adds after the summary — the queue a job runs + * on, a socket message's direction, what an HTTP hop is; '' when nothing. + */ + detail: string; + /** Where the hop was wired up (`file:line`), when the synthesizer recorded it. */ + registeredAt?: string; +} + +/** + * Describe a synthesized (dynamic-dispatch) edge; null for an ordinary static + * one. A synthesizer not named below still reads as dynamic dispatch, by its own + * name and with its wiring site — never as a bare call or a borrowed label. + */ +export function describeSynthesizedHop(edge: Edge | null | undefined): SynthesizedHop | null { + const m = edge?.provenance === 'heuristic' ? (edge.metadata as Record | undefined) : undefined; + const by = m?.synthesizedBy; + if (!m || typeof by !== 'string') return null; + const registeredAt = typeof m.registeredAt === 'string' ? m.registeredAt : undefined; + const hop = (summary: string, detail = ''): SynthesizedHop => ({ summary, detail, registeredAt }); + const quoted = (value: unknown, otherwise: string): string => (value ? `\`${String(value)}\`` : otherwise); + switch (by) { + case 'callback': + return hop(`callback via ${quoted(m.via, 'a registrar')}`); + case 'http-client': { + const request = `${String(m.method ?? 'GET')} ${String(m.href ?? '')}`.trim(); + return hop(`HTTP ${request}`, " — the client's call onto its own route"); + } + case 'queue-job': + return hop(`queue job ${quoted(m.event, 'a job')}`, m.queue ? ` on \`${String(m.queue)}\`` : ''); + case 'event-bus': { + const what = m.channel === 'socket' ? 'socket message' : 'bus event'; + const direction = m.tier === 'client→server' ? ' → server' : m.tier === 'server→client' ? ' → client' : ''; + return hop(`${what} ${quoted(m.event, 'an event')}`, direction); + } + case 'event-emitter': + return hop(`event ${quoted(m.event, 'an event')}`); + case 'react-render': + return hop('React re-render via setState'); + case 'jsx-render': + return hop(`renders ${m.via ? `<${String(m.via)}>` : 'a child component'}`); + case 'vue-handler': + return hop(`Vue ${m.event ? `@${String(m.event)}` : 'a template event'} handler`); + case 'interface-impl': + // Go: the implementing struct gets the method from a type it embeds. + if (typeof m.promotedInto === 'string') return hop(`interface → method promoted into ${m.promotedInto}`); + return hop('interface → impl'); + case 'closure-collection': + return hop(`runs ${quoted(m.field, 'a collection')} handlers`); + case 'fn-pointer-dispatch': + return hop(`fn-pointer ${m.via ? String(m.via) : ''}`); + case 'goframe-route': + return hop(`GoFrame route ${m.route ? String(m.route) : ''}`); + default: + // redux-thunk, cpp-override, gin-middleware-chain, flutter-build, … + return hop(by.replace(/-/g, ' ')); + } +} diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index fa8f35425b..8aebac9588 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -53,6 +53,7 @@ import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, C import { guardLabel, guardsForFileSync, siteKey, supportsBranchGuards, warmBranchGuardGrammars } from '../graph/branch-guards'; import { findDynamicBoundaries, type BoundarySite } from '../graph/dynamic-boundary-report'; import { countImplementers } from '../graph/type-hierarchy'; +import { describeSynthesizedHop } from '../graph/synthesized-hop'; import { isCopybookInclude, MAX_COPYBOOK_INCLUDES, type NamedCopybook } from '../graph/cobol-copybooks'; import { findAllSymbols, @@ -3304,12 +3305,6 @@ export class ToolHandler { return this.answerResult(cg, this.truncateOutput(sections.join('\n') + filterNote), answerPaths); } - /** - * Describe a synthesized (dynamic-dispatch) edge for human output: how the - * callback was wired up — the bridge static parsing can't see. Returns null - * for ordinary static edges. Used by trace + the node trail so a synthesized - * hop reads as "registered via onUpdate at App.tsx:3148", not a bare arrow. - */ /** * The branch conditions a flow hop's call site runs under, read from the * caller's source now (`graph/branch-guards.ts`); '' when unconditional, @@ -3333,125 +3328,17 @@ export class ToolHandler { } } - private synthEdgeNote(edge: Edge | null): { label: string; compact: string; registeredAt?: string } | null { - if (!edge || edge.provenance !== 'heuristic') return null; - const m = edge.metadata as Record | undefined; - const registeredAt = typeof m?.registeredAt === 'string' ? m.registeredAt : undefined; - const at = registeredAt ? ` @${registeredAt}` : ''; - if (m?.synthesizedBy === 'callback') { - const via = m.via ? `\`${String(m.via)}\`` : 'a registrar'; - const field = m.field ? ` on .${String(m.field)}` : ''; - return { - label: `callback — registered via ${via}${field} (dynamic dispatch)`, - compact: `dynamic: callback via ${via}${at}`, - registeredAt, - }; - } - if (m?.synthesizedBy === 'http-client') { - const req = `${String(m.method ?? 'GET')} ${String(m.href ?? '')}`.trim(); - return { - label: `HTTP request \`${req}\` — the client's call onto its own route (cross-tier)`, - compact: `dynamic: HTTP ${req}${at}`, - registeredAt, - }; - } - if (m?.synthesizedBy === 'queue-job') { - const job = m.event ? `\`${String(m.event)}\`` : 'a job'; - const queue = m.queue ? ` on queue \`${String(m.queue)}\`` : ''; - return { - label: `queue job ${job}${queue} — producer → consumer (cross-tier)`, - compact: `dynamic: queue job ${job}${at}`, - registeredAt, - }; - } - if (m?.synthesizedBy === 'event-bus') { - const ev = m.event ? `\`${String(m.event)}\`` : 'an event'; - const what = m.channel === 'socket' ? 'socket message' : 'bus event'; - const dir = m.tier === 'client→server' ? ', client → server' : m.tier === 'server→client' ? ', server → client' : ''; - return { - label: `${what} ${ev} — emit → handler${dir} (dynamic dispatch)`, - compact: `dynamic: ${what} ${ev}${at}`, - registeredAt, - }; - } - if (m?.synthesizedBy === 'event-emitter') { - const ev = m.event ? `\`${String(m.event)}\`` : 'an event'; - return { - label: `event ${ev} — emit → handler (dynamic dispatch)`, - compact: `dynamic: event ${ev}${at}`, - registeredAt, - }; - } - if (m?.synthesizedBy === 'react-render') { - return { - label: `React re-render — \`setState\` re-runs render() (dynamic dispatch)`, - compact: `dynamic: React re-render via setState${at}`, - registeredAt, - }; - } - if (m?.synthesizedBy === 'jsx-render') { - const child = m.via ? `<${String(m.via)}>` : 'a child component'; - return { - label: `renders ${child} (JSX child — dynamic dispatch)`, - compact: `dynamic: renders ${child}`, - registeredAt, - }; - } - if (m?.synthesizedBy === 'vue-handler') { - const ev = m.event ? `@${String(m.event)}` : 'a template event'; - return { - label: `Vue template handler — bound to ${ev} (dynamic dispatch)`, - compact: `dynamic: Vue ${ev} handler`, - registeredAt, - }; - } - if (m?.synthesizedBy === 'interface-impl' && typeof m.promotedInto === 'string') { - // Go: the implementing struct gets this method from a type it embeds. - return { - label: `interface dispatch — runs the method \`${m.promotedInto}\` gets by embedding (dynamic dispatch)`, - compact: `dynamic: interface → method promoted into ${m.promotedInto}${at}`, - registeredAt, - }; - } - if (m?.synthesizedBy === 'interface-impl') { - return { - label: `interface/abstract dispatch — runs the implementation override (dynamic dispatch)`, - compact: `dynamic: interface → impl${at}`, - registeredAt, - }; - } - if (m?.synthesizedBy === 'closure-collection') { - const field = m.field ? `\`${String(m.field)}\`` : 'a collection'; - return { - label: `closure collection — runs handlers appended to ${field} (dynamic dispatch)`, - compact: `dynamic: runs ${field} handlers${at}`, - registeredAt, - }; - } - if (m?.synthesizedBy === 'fn-pointer-dispatch') { - const via = m.via ? `\`${String(m.via)}\`` : 'a function pointer'; - return { - label: `function-pointer dispatch via ${via} (dynamic dispatch)`, - compact: `dynamic: fn-pointer ${m.via ? String(m.via) : ''}${at}`, - registeredAt, - }; - } - if (m?.synthesizedBy === 'goframe-route') { - const route = m.route ? `\`${String(m.route)}\`` : 'a route'; - return { - label: `GoFrame route ${route} — reflective Bind → controller method (dynamic dispatch)`, - compact: `dynamic: GoFrame route ${m.route ? String(m.route) : ''}${at}`, - registeredAt, - }; - } - // Generic fallback for any other synthesizer (redux-thunk, gin-middleware-chain, - // flutter-build, …): a synthesized hop must never read as a bare static `calls`. - // It's a dynamic-dispatch bridge — label it as one and keep its wiring site. - if (typeof m?.synthesizedBy === 'string') { - const kind = m.synthesizedBy.replace(/-/g, ' '); - return { label: `${kind} (dynamic dispatch)`, compact: `dynamic: ${kind}${at}`, registeredAt }; - } - return null; + /** + * The tag a synthesized (dynamic-dispatch) hop carries in explore's Flow and + * dynamic-dispatch links and in the codegraph_node trail, so it reads as + * "dynamic: callback via `onUpdate` @App.tsx:3148", not a bare arrow. Null + * for an ordinary static edge. The wording is `graph/synthesized-hop.ts`'s, + * shared with ContextBuilder's call paths. + */ + private synthEdgeNote(edge: Edge | null): string | null { + const hop = describeSynthesizedHop(edge); + if (!hop) return null; + return `dynamic: ${hop.summary}${hop.registeredAt ? ` @${hop.registeredAt}` : ''}`; } /** @@ -3506,8 +3393,7 @@ export class ToolHandler { const key = `${src.name}>${tgt.name}`; if (synthSeen.has(key)) continue; synthSeen.add(key); - const note = this.synthEdgeNote(edge); - synthLines.push(`- ${src.name} → ${tgt.name} [${note ? note.compact : edge.kind}]`); + synthLines.push(`- ${src.name} → ${tgt.name} [${this.synthEdgeNote(edge) ?? edge.kind}]`); } } return synthLines; @@ -3640,9 +3526,8 @@ export class ToolHandler { for (let i = 0; i < best!.length; i++) { const step = best![i]!; if (step.edge) { - const sy = this.synthEdgeNote(step.edge); const when = i > 0 ? this.whenLabel(cg, best![i - 1]!.node, step.edge) : ''; - out.push(` ↓ ${sy ? sy.compact : step.edge.kind}${when ? ` (when ${when})` : ''}`); + out.push(` ↓ ${this.synthEdgeNote(step.edge) ?? step.edge.kind}${when ? ` (when ${when})` : ''}`); } out.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`); } @@ -8042,7 +7927,7 @@ export class ToolHandler { const fmt = (e: { node: Node; edge: Edge }) => { const base = `${e.node.name} (${e.node.filePath}:${e.node.startLine})`; const synth = this.synthEdgeNote(e.edge); - return synth ? `${base} [${synth.compact}]` : base; + return synth ? `${base} [${synth}]` : base; }; const collect = (edges: Array<{ node: Node; edge: Edge }>): Array<{ node: Node; edge: Edge }> => { const seen = new Set([node.id]); From b994365b1294b8f0b408dd9c93a1d161fbc0792d Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sat, 10 Oct 2026 04:04:20 +0000 Subject: [PATCH 2/6] fix(sync): a route rendering a same-file lazy value follows its module, and a new component gets its JSX edges (#2452) A route that renders `const Docs = lazy(() => import('./pages/Docs'))` binds to the component that module exports, and to the declaration while the module is missing or exports none (#2400's declaredComponent). The reference had resolved, so no failed-ref retry saw it, and CG-33's rebind reached it only when the module's component shared the declaration's name: a sync that added the module, gave it its default export or moved that export left the route on its old answer. A data router's `lazy` route kept a moved default export the same way. FrameworkResolver.lazyModules names the modules a route's answer reads, including the files a barrel forwards it through (#2436); React implements it for lazy-import references and the same-file lazy values Pattern 1 reads. After the CG-33 rebind, sync puts back the route references whose modules it added or changed (reopenRouteModuleReaders), in the order their edges were written, for the orphan sweep. The jsx-render refresh read only each changed file's own content, so a new component without JSX of its own never got the edge from `` in an unchanged file. A sync whose definition delta holds a JSX-renderable node now refreshes synthesis. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + __tests__/sync-lazy-route-value.test.ts | 328 ++++++++++++++++++++++++ docs/design/framework-coverage.md | 14 + src/db/queries.ts | 40 +++ src/index.ts | 27 +- src/resolution/callback-synthesizer.ts | 12 + src/resolution/frameworks/react.ts | 62 ++++- src/resolution/index.ts | 36 +++ src/resolution/types.ts | 10 + 9 files changed, 522 insertions(+), 9 deletions(-) create mode 100644 __tests__/sync-lazy-route-value.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff4b07c95..825c1ed1d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph_explore`'s Flow now goes through the implementation your query names when an interface method has several that lead to the same place. Before, it took whichever one came first in the index: asking about prometheus's `Engine.execEvalStmt Queryable.Querier fanout.Querier NewMergeQuerier` traced the call through the TSDB's `DB.Querier` and left the `fanout.Querier` you named off the Flow. Any two routes of the same length are now settled this way, in favor of the one that passes through more of the symbols you named. - In C and C++, a struct, class, union or enum defined in a variable declaration, like `struct Foo { … } foo;`, is now indexed with its methods and enum values, next to the variable. Before, at most the variable was, so the type was missing from search and `codegraph_explore`, a class deriving from it had no base, and calls to its methods linked to nothing: in redis, the Sentinel's `sentinelState` and `redis-cli`'s `config` struct were missing. A struct, union or enum with no name of its own now takes the name of the variable it declares, so redis's `static struct { … } SPT;` is found as `SPT`, and one declared inside a function is named after its variable instead of ``. The comment above the declaration becomes the type's description. Re-index C and C++ projects after upgrading. - In TypeScript React code, a styled-components or emotion component declared with a type argument, like `const Wrapper = styled.div` followed by its CSS, or `styled(Base)`, is now indexed as a component, as one written without a type argument already was. Before, it was indexed as a plain constant, so `codegraph callers` and impact found none of the code that renders it, and its tags could link to another file's component of the same name instead: in outline/outline, `NudeButton` and `HStack` had no callers, and `` tags linked to a different `Text` than the one each file imports. Re-index TypeScript React projects after upgrading. +- `codegraph sync` and the file watcher now move a React Router route to its page when the router file loads that page lazily into a value of its own, like `const Docs = lazy(() => import('./pages/Docs'))` rendered by `} />`, and the page's file is added after the router was indexed, comes back after being deleted, or gets or changes its default export in a later edit. Before, the route kept pointing at the `Docs` declaration, or at the page's old component, until the router file itself changed or the project was re-indexed, unless the page's component happened to share the declaration's name. The same now holds for a `lazy: () => import('./pages/Docs')` route whose page switches its default export to another component. Re-index React projects after upgrading to correct routes an earlier version left behind. +- `codegraph sync` and the file watcher now link a component to the JSX that renders it, like ``, when the component is added in a file with no JSX of its own, as in `export default function Team() { return null; }`. Before, the link waited for an unrelated change to a file with JSX, or for a re-index, so the new component looked unused. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/sync-lazy-route-value.test.ts b/__tests__/sync-lazy-route-value.test.ts new file mode 100644 index 0000000000..1bd658cbc3 --- /dev/null +++ b/__tests__/sync-lazy-route-value.test.ts @@ -0,0 +1,328 @@ +/** + * A sync redraws what a route renders when the module behind it changes. + * + * A route that renders a value its own file declares — + * `const Docs = lazy(() => import('./pages/Docs'))` and `} />` — binds to the component that module exports, and to + * the declaration while the module is missing or exports none (#2400). The + * reference resolved either way, so nothing parked it for the failed-ref + * retry, and the rebind of the names a sync adds (CG-33) only reaches it when + * the module's component is named like the declaration. So a module added, + * given its default export, or switched to another component after the router + * was indexed left the route on its old answer until the router file changed. + * + * The other half is the JSX a component renders: `` links to the + * `Team` a sync adds only if the sync redraws the synthesized edges, and the + * file a component is added in decided that alone, by its own markup. A + * component without any (`return null`, a wrapper) passed none of those + * checks. + * + * Every case compares the synced graph with a fresh index of the same files + * in a folder of its own: indexing again over an existing index skips its + * unchanged files, so it is no stand-in for one. + */ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; +import { ReferenceResolver } from '../src/resolution'; + +type Files = Record; + +const pkg = JSON.stringify({ name: 'app', dependencies: { react: '^18', 'react-router-dom': '^6' } }); +const page = (name: string, exported = 'export default') => `${exported} function ${name}() { return null; }\n`; + +/** The router of the report: two pages it loads lazily into values of its own. */ +const APP = `import { lazy } from 'react'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +const Team = lazy(() => import('./pages/Team')); +const Docs = lazy(() => import('./pages/Docs')); +export function App() { + return ( + + + } /> + } /> + + + ); +} +`; + +let root: string | undefined; +let cg: CodeGraph | undefined; + +afterEach(() => { + vi.restoreAllMocks(); + cg?.close(); + cg = undefined; + if (root) fs.rmSync(root, { recursive: true, force: true }); + root = undefined; +}); + +function write(dir: string, files: Files): void { + for (const [file, text] of Object.entries(files)) { + const full = path.join(dir, file); + if (text === null) { + fs.rmSync(full, { force: true }); + continue; + } + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, text); + } +} + +/** Every node and edge by natural key, edge metadata included, so two indexes of the same files compare directly. */ +function graphOf(graph: CodeGraph): string[] { + const keys = new Map( + graph.getFiles() + .flatMap((file) => graph.getNodesInFile(file.path)) + .map((n) => [n.id, `${n.kind} ${n.filePath}:${n.qualifiedName}:${n.startLine}`] as const) + ); + const edges = graph.getOutgoingEdgesFrom([...keys.keys()]).map((e) => + `${keys.get(e.source)} -${e.kind}-> ${keys.get(e.target) ?? e.target} ${JSON.stringify(e.metadata ?? null)} @${e.line ?? ''}:${e.column ?? ''}`); + return [...[...keys.values()].map((k) => `node ${k}`), ...edges].sort(); +} + +/** What each route renders: `::`, a layout marked. */ +function routeLinks(graph: CodeGraph): Record { + const links: Record = {}; + for (const route of graph.getNodesByKind('route')) { + links[route.name] = [ + ...(links[route.name] ?? []), + ...graph.getOutgoingEdges(route.id) + .filter((e) => e.kind !== 'contains') + .map((e) => { + const target = graph.getNode(e.target); + return `${e.metadata?.layout ? 'layout ' : ''}${target?.filePath}::${target?.name}`; + }), + ].sort(); + } + return links; +} + +/** ` -> ` for every JSX render edge. */ +function renders(graph: CodeGraph): string[] { + const out: string[] = []; + for (const file of graph.getFiles()) { + for (const node of graph.getNodesInFile(file.path)) { + for (const e of graph.getOutgoingEdges(node.id)) { + if (e.metadata?.synthesizedBy !== 'jsx-render') continue; + const child = graph.getNode(e.target); + out.push(`${node.name} -> ${child?.filePath}::${child?.name}`); + } + } + } + return out.sort(); +} + +/** The graph of a fresh index of `files`, in a folder of its own. */ +async function freshGraph(files: Files): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-lazy-route-fresh-')); + try { + write(dir, Object.fromEntries(Object.entries(files).filter(([, text]) => text !== null))); + const fresh = await CodeGraph.init(dir, { index: true }); + try { + return graphOf(fresh); + } finally { + fresh.close(); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +/** + * Index `initial`, then write each step and sync after it — with `scoped`, a + * sync of only the step's files, as the file watcher runs it. Returns the + * final files. + */ +async function indexThenSync(initial: Files, ...steps: Files[]): Promise { + return indexThenSyncWith({ scoped: false }, initial, ...steps); +} + +async function indexThenSyncWith(opts: { scoped: boolean }, initial: Files, ...steps: Files[]): Promise { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-lazy-route-')); + write(root, initial); + cg = await CodeGraph.init(root, { index: true }); + const files = { ...initial }; + for (const step of steps) { + write(root, step); + Object.assign(files, step); + await cg.sync(opts.scoped ? { paths: Object.keys(step) } : {}); + } + return files; +} + +describe('sync redraws a route that renders a same-file lazy value when its module changes', () => { + it.each([false, true])('when the modules are added after the router (scoped=%s)', async (scoped) => { + const files = await indexThenSyncWith( + { scoped }, + { 'package.json': pkg, 'src/App.tsx': APP }, + { 'src/pages/Team.tsx': page('Team'), 'src/pages/Docs.tsx': page('DocsPage') } + ); + expect(routeLinks(cg!)).toEqual({ + '/team': ['src/pages/Team.tsx::Team'], + // The page's component is named unlike the declaration, so only the + // module it loads leads to it. + '/docs': ['src/pages/Docs.tsx::DocsPage'], + }); + expect(renders(cg!)).toEqual(['App -> src/pages/Team.tsx::Team']); + expect(cg!.getPendingReferenceCount()).toBe(0); + expect(graphOf(cg!)).toEqual(await freshGraph(files)); + }, 60_000); + + it('when an edit gives the module its default export', async () => { + const files = await indexThenSync( + { 'package.json': pkg, 'src/App.tsx': APP, 'src/pages/Team.tsx': page('Team'), 'src/pages/Docs.tsx': page('DocsPage', 'export') }, + // No name appears or goes: only the export changes. + { 'src/pages/Docs.tsx': `${page('DocsPage', 'export')}export default DocsPage;\n` } + ); + expect(routeLinks(cg!)['/docs']).toEqual(['src/pages/Docs.tsx::DocsPage']); + expect(graphOf(cg!)).toEqual(await freshGraph(files)); + }, 60_000); + + it('when the module’s default export moves to another component', async () => { + const files = await indexThenSync( + { 'package.json': pkg, 'src/App.tsx': APP, 'src/pages/Team.tsx': page('Team'), 'src/pages/Docs.tsx': page('DocsPage') }, + // The old component stays, so the route's edge follows it through the re-index. + { 'src/pages/Docs.tsx': `${page('DocsPage', 'export')}${page('Documentation')}` } + ); + expect(routeLinks(cg!)['/docs']).toEqual(['src/pages/Docs.tsx::Documentation']); + expect(graphOf(cg!)).toEqual(await freshGraph(files)); + }, 60_000); + + it('when deleted modules come back', async () => { + const pages = { 'src/pages/Team.tsx': page('Team'), 'src/pages/Docs.tsx': page('DocsPage') }; + const files = await indexThenSync( + { 'package.json': pkg, 'src/App.tsx': APP, ...pages }, + { 'src/pages/Team.tsx': null, 'src/pages/Docs.tsx': null } + ); + // While they are gone, each route is the declaration it renders. + expect(routeLinks(cg!)).toEqual({ '/team': ['src/App.tsx::Team'], '/docs': ['src/App.tsx::Docs'] }); + expect(graphOf(cg!)).toEqual(await freshGraph(files)); + + write(root!, pages); + await cg!.sync(); + expect(routeLinks(cg!)).toEqual({ '/team': ['src/pages/Team.tsx::Team'], '/docs': ['src/pages/Docs.tsx::DocsPage'] }); + expect(graphOf(cg!)).toEqual(await freshGraph({ ...files, ...pages })); + }, 60_000); + + it('when the page behind a barrel is added, then switches its default export', async () => { + const files = await indexThenSync( + { + 'package.json': pkg, + 'src/App.tsx': APP, + 'src/pages/Team.tsx': page('Team'), + // `./pages/Docs` is a barrel; the page it forwards comes later. + 'src/pages/Docs/index.ts': "export { default } from './DocsPage';\n", + }, + { 'src/pages/Docs/DocsPage.tsx': page('DocsPage') } + ); + expect(routeLinks(cg!)['/docs']).toEqual(['src/pages/Docs/DocsPage.tsx::DocsPage']); + expect(graphOf(cg!)).toEqual(await freshGraph(files)); + + // The barrel is unchanged; only the page behind it moves its default export. + const edit = { 'src/pages/Docs/DocsPage.tsx': `${page('DocsPage', 'export')}${page('Documentation')}` }; + write(root!, edit); + await cg!.sync(); + expect(routeLinks(cg!)['/docs']).toEqual(['src/pages/Docs/DocsPage.tsx::Documentation']); + expect(graphOf(cg!)).toEqual(await freshGraph({ ...files, ...edit })); + }, 60_000); + + it('in a route table another file hands the router (codedthemes’ admin templates)', async () => { + const files = await indexThenSync( + { + 'package.json': JSON.stringify({ name: 'berry', dependencies: { react: '^18', 'react-router-dom': '^7' } }), + 'src/routes/index.jsx': "import { createBrowserRouter } from 'react-router-dom';\nimport MainRoutes from './MainRoutes';\n\nconst router = createBrowserRouter([MainRoutes]);\n\nexport default router;\n", + 'src/routes/MainRoutes.jsx': `import { lazy } from 'react'; +import MainLayout from '../layout/MainLayout'; +import Loadable from '../ui-component/Loadable'; + +const DashboardDefault = Loadable(lazy(() => import('../views/dashboard/Default'))); + +const MainRoutes = { + path: '/', + element: , + children: [{ path: 'dashboard/default', element: }] +}; + +export default MainRoutes; +`, + 'src/layout/MainLayout.jsx': 'export default function MainLayout() {\n return
;\n}\n', + 'src/ui-component/Loadable.jsx': 'export default function Loadable(Component) {\n return (props) => ;\n}\n', + }, + { 'src/views/dashboard/Default.jsx': page('DashboardDefaultPage') } + ); + expect(routeLinks(cg!)['/dashboard/default']).toEqual([ + 'layout src/layout/MainLayout.jsx::MainLayout', + 'src/views/dashboard/Default.jsx::DashboardDefaultPage', + ]); + expect(graphOf(cg!)).toEqual(await freshGraph(files)); + }, 60_000); + + it('for a data router’s lazy route whose module switches its default export', async () => { + const files = await indexThenSync( + { + 'package.json': pkg, + 'src/router.tsx': "import { createBrowserRouter } from 'react-router-dom';\nexport const router = createBrowserRouter([{ path: '/docs', lazy: () => import('./pages/Docs') }]);\n", + 'src/pages/Docs.tsx': page('DocsPage'), + }, + { 'src/pages/Docs.tsx': `${page('DocsPage', 'export')}${page('Documentation')}` } + ); + expect(routeLinks(cg!)['/docs']).toEqual(['src/pages/Docs.tsx::Documentation']); + expect(graphOf(cg!)).toEqual(await freshGraph(files)); + }, 60_000); + + it('and re-opens nothing for a module no route loads', async () => { + await indexThenSync({ 'package.json': pkg, 'src/App.tsx': APP, 'src/pages/Team.tsx': page('Team'), 'src/pages/Docs.tsx': page('DocsPage') }); + const reopen = vi.spyOn(ReferenceResolver.prototype, 'reopenRouteModuleReaders'); + write(root!, { 'src/util.ts': 'export function slug(s: string) { return s.toLowerCase(); }\n' }); + await cg!.sync(); + expect(reopen).toHaveBeenCalledTimes(1); + expect(reopen.mock.results[0]!.value).toBe(0); + + // The router's own edit resolves its references itself. + write(root!, { 'src/App.tsx': APP.replace('/docs', '/documentation'), 'src/pages/Docs.tsx': page('Documentation') }); + await cg!.sync(); + expect(reopen.mock.results[1]!.value).toBe(0); + expect(routeLinks(cg!)['/documentation']).toEqual(['src/pages/Docs.tsx::Documentation']); + }, 60_000); +}); + +describe('sync draws the JSX edges to a component it adds', () => { + it('when another file imports and renders it', async () => { + const files = await indexThenSync( + { 'package.json': pkg, 'src/Shell.tsx': "import Team from './Team';\nexport function Shell() {\n return
;\n}\n" }, + // A component without markup of its own. + { 'src/Team.tsx': page('Team') } + ); + expect(renders(cg!)).toEqual(['Shell -> src/Team.tsx::Team']); + expect(graphOf(cg!)).toEqual(await freshGraph(files)); + }, 60_000); + + it('when another file renders it by name alone', async () => { + const files = await indexThenSync( + { 'package.json': pkg, 'src/Shell.tsx': 'export function Shell() {\n return
;\n}\n' }, + { 'src/Badge.tsx': 'export const Badge = () => null;\n' } + ); + expect(renders(cg!)).toEqual(['Shell -> src/Badge.tsx::Badge']); + expect(graphOf(cg!)).toEqual(await freshGraph(files)); + }, 60_000); + + it('but leaves them alone for a new name no tag can render', async () => { + await indexThenSync({ + 'package.json': pkg, + 'src/Shell.tsx': "import Team from './Team';\nexport function Shell() {\n return
;\n}\n", + 'src/Team.tsx': page('Team'), + 'src/limits.ts': 'export function clamp(n: number) { return Math.min(n, 3); }\n', + }); + const phases: string[] = []; + // A value and a lowercase function: neither is a component. + write(root!, { 'src/limits.ts': 'export const Limits = { max: 3 };\nexport function clamp(n: number) { return Math.min(n, Limits.max); }\nexport function floor(n: number) { return n; }\n' }); + await cg!.sync({ onProgress: (p) => phases.push(p.phase) }); + expect(phases).not.toContain('linking'); + expect(renders(cg!)).toEqual(['Shell -> src/Team.tsx::Team']); + }, 60_000); +}); diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index 14218f3069..cff08ecdc6 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -330,6 +330,20 @@ Each of these cost real debugging time; they are not hypothetical. calls again, failed or not; without it the synced index keeps an answer a full index does not have. `sync-navigation-retry.test.ts` runs all seven routers through it, and asserts each router's calls end in a declared tail. +17. **A sync revisits a resolved route only by the names it carries.** A route + that renders a same-file `const Docs = lazy(() => import('./pages/Docs'))` + binds to the component that module exports, and to the declaration while + the module is missing or exports none (`declaredComponent`). The reference + resolved, so the failed-ref retry never sees it, and the rebind of the + names a sync defines (CG-33) reaches it only when the module's component + is named like the declaration: on minimal-ui-kit/material-kit-react, + `/user` stayed on its `UserPage` declaration after `src/pages/user.tsx` + (`Page`) came back. A router whose `resolve()` reads modules' content to + answer a route says which ones (`FrameworkResolver.lazyModules`), barrels it + follows included, and a sync that adds or edits one of them puts the + route's reference back for its resolution sweep. The same holds for the + JSX a component renders: a sync that adds a component redraws the + `jsx-render` edges, since a tag in a file it never touched may name it. --- diff --git a/src/db/queries.ts b/src/db/queries.ts index 783f0740a8..de9b1dc8c5 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -4097,6 +4097,46 @@ export class QueryBuilder { return out; } + /** + * The resolution edges out of route nodes that a change to `filePaths` can + * move: from a route in another file, to a node of one of `filePaths`, or to + * a value declared beside the route — what a route rendering + * `const Docs = lazy(() => import('./pages/Docs'))` binds to while that + * module is missing. Returned with the route's file and language, which a + * resurrection needs, in the order they were written, so references put + * back resolve in that order again. Synthesized edges carry no reference to + * resurrect and are left out. + */ + getRouteEdgesMovedBy(filePaths: readonly string[]): Array { + if (filePaths.length === 0) return []; + const files = JSON.stringify(filePaths); + const rows = this.db + .prepare( + `SELECT e.*, src.file_path AS source_file_path, src.language AS source_language + FROM nodes src + JOIN edges e ON e.source = src.id + JOIN nodes tgt ON tgt.id = e.target + WHERE src.kind = 'route' + AND e.kind != 'contains' + AND (e.provenance IS NULL OR e.provenance != 'heuristic') + AND src.file_path NOT IN (SELECT value FROM json_each(?)) + AND (tgt.file_path IN (SELECT value FROM json_each(?)) + OR (tgt.file_path = src.file_path AND tgt.kind IN ('constant', 'variable'))) + ORDER BY e.id` + ) + .all(files, files) as Array; + return rows.map((row) => ({ + ...rowToEdge(row), + edgeId: row.id, + sourceFilePath: row.source_file_path, + sourceLanguage: row.source_language, + })); + } + /** Delete edges by primary key — the rebind pass's half of a re-resolution. */ deleteEdgesByIds(edgeIds: number[]): number { if (edgeIds.length === 0) return 0; diff --git a/src/index.ts b/src/index.ts index 5bf49cca2c..6fa4b8b697 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,7 +52,7 @@ import { createResolver, ResolutionResult, } from './resolution'; -import { hasSynthesisPattern } from './resolution/callback-synthesizer'; +import { hasSynthesisPattern, isJsxChildCandidate } from './resolution/callback-synthesizer'; import { GraphTraverser, GraphQueryManager } from './graph'; import { findNamedCopybooks, type NamedCopybook } from './graph/cobol-copybooks'; import { ContextBuilder, createContextBuilder } from './context'; @@ -940,6 +940,19 @@ export class CodeGraph { } }); + // A component a tag in an unchanged file may render: `` links + // to the `Team` this sync adds. The gate above reads each changed file + // on its own, and a component with no markup of its own (`return null`, + // a wrapper) matches none of its patterns. Removing one needs no check: + // a component a tag rendered had synthesized edges into its file. + if (!refreshSynthesis && result.definitionDelta && result.changedFilePaths) { + const delta = new Set(result.definitionDelta); + if (this.queries.getNodesByFiles(result.changedFilePaths).some((n) => delta.has(n.name) && isJsxChildCandidate(n))) { + refreshSynthesis = true; + this.queries.setMetadata('synthesis_pending', '1'); + } + } + // Fold the store phase's WAL BEFORE the post-store reads below // (resolution reads on the main thread) — same rationale as // indexAll's fold between store and resolution. @@ -1118,6 +1131,18 @@ export class CodeGraph { } } + // The same for a route whose answer is read from a module: one that + // renders `const Docs = lazy(() => import('./pages/Docs'))` binds to + // the component the module exports, so it moves when the module is + // added or edited, though no name it carries changes. + if (filesChanged && result.changedFilePaths) { + const tModules = Date.now(); + const reopened = this.resolver.reopenRouteModuleReaders(result.changedFilePaths); + if (process.env.CODEGRAPH_SYNTH_TIMINGS) { + console.error(`[phase-timing] sync-route-modules: ${Date.now() - tModules}ms (${reopened} refs re-opened)`); + } + } + // Orphan sweep (#1187). A resolution pass that dies mid-run — the #850 // daemon liveness watchdog's SIGKILL (#1122), Ctrl-C, a crash — leaves // the refs it never reached in unresolved_refs, and the git-scoped fast diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 72704a0769..237c7007ea 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -1702,6 +1702,18 @@ const JSX_CHILD_KINDS = new Set(['component', 'function', 'class']); */ const JSX_CHILD_LANGUAGES = [...JS_FAMILY, 'vue', 'svelte']; +/** + * Whether a JSX tag can render `node` (`jsxChild`): a script's component, + * function or class named with a tag's capital, or any component, which a + * default import names under a name of its own. A tag in a file a sync never + * touched may name one the sync adds, so adding one redraws the JSX edges. + * (A `.vue` or `.svelte` file redraws them on any change.) + */ +export function isJsxChildCandidate(node: Pick): boolean { + return JSX_CHILD_KINDS.has(node.kind) && JS_FAMILY.includes(node.language) && + (node.kind === 'component' || /^[A-Z]/.test(node.name)); +} + function languageForJsxFile(file: string): Language { if (file.endsWith('.tsx')) return 'tsx'; if (/\.[cm]?ts$/.test(file)) return 'typescript'; diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index 50cc178fe1..51a80549b2 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -200,6 +200,22 @@ export const reactResolver: FrameworkResolver = { crossFileNodes(context: ResolutionContext) { return { kind: 'route', owns: isTableRoute, ...tableRoutes(context) }; }, + + /** + * The modules a route's answer is read from: a lazily loaded page or layout + * (`lazy-import:` references), or the one a same-file lazy value the route + * renders loads — the references Pattern 1 asks `declaredComponent` about — + * with the modules a barrel there forwards the lookup to. + */ + lazyModules(ref: UnresolvedRef, context: ResolutionContext): readonly string[] { + const name = ref.referenceName; + let spec: string | null | undefined; + if (name.startsWith(LAZY_ROUTE_PREFIX)) spec = name.slice(LAZY_ROUTE_PREFIX.length); + else if (name.startsWith(LAYOUT_PREFIX + LAZY_ROUTE_PREFIX)) spec = name.slice(LAYOUT_PREFIX.length + LAZY_ROUTE_PREFIX.length); + else if ((ref.language === 'tsx' || ref.language === 'jsx') && isPascalCase(name) && !isBuiltInType(name) && + ref.fromNodeId.startsWith(`route:${ref.filePath}:`)) spec = declaredLoader(name, ref.filePath, context); + return spec ? lazyRouteFiles(spec, ref.filePath, context) : []; + }, }; const LAZY_ROUTE_PREFIX = 'lazy-import:'; @@ -397,11 +413,24 @@ function readObjectPath(text: string, at: number, keys: string[]): string | null return null; } +/** The file a route's lazily loaded module is, from the file that loads it. */ +function lazyModuleFile(spec: string, fromFile: string, context: ResolutionContext): string | null { + return resolveImportPath(spec, fromFile, 'typescript', context); +} + /** The component a lazy route module renders: its default export, else its `Component` export. */ -function lazyRouteComponent(spec: string, fromFile: string, context: ResolutionContext): string | null { - const file = resolveImportPath(spec, fromFile, 'typescript', context); +function lazyRouteComponent(spec: string, fromFile: string, context: ResolutionContext, reads?: Set): string | null { + const file = lazyModuleFile(spec, fromFile, context); if (!file) return null; - return exportedComponent(file, 'default', context) ?? exportedComponent(file, 'Component', context); + return exportedComponent(file, 'default', context, new Set(), reads) ?? + exportedComponent(file, 'Component', context, new Set(), reads); +} + +/** The files `lazyRouteComponent` reads: the module, and the ones a barrel forwards it to. */ +function lazyRouteFiles(spec: string, fromFile: string, context: ResolutionContext): string[] { + const reads = new Set(); + lazyRouteComponent(spec, fromFile, context, reads); + return [...reads]; } /** What a module can export as a component: a declaration, or a value holding one. */ @@ -418,10 +447,17 @@ const MAX_REEXPORT_HOPS = 4; * './page'`, or `export * from './page'`, which forwards every name but the * default. As in JavaScript, a name the file exports itself hides one an * `export *` would forward, and a name two `export *` modules both forward is - * exported by neither. + * exported by neither. `reads` collects every file the lookup reads. */ -function exportedComponent(file: string, name: string, context: ResolutionContext, visited: ReadonlySet = new Set()): string | null { +function exportedComponent( + file: string, + name: string, + context: ResolutionContext, + visited: ReadonlySet = new Set(), + reads?: Set +): string | null { if (visited.has(file) || visited.size > MAX_REEXPORT_HOPS) return null; + reads?.add(file); const local = ownExport(stripCommentsForRegex(context.readFile(file) ?? '', 'typescript'), name); if (local) return context.getNodesInFile(file).find((n) => n.name === local && EXPORTED_COMPONENT_KINDS.has(n.kind))?.id ?? null; const language = scriptLanguage(file); @@ -429,7 +465,7 @@ function exportedComponent(file: string, name: string, context: ResolutionContex const through = new Set(visited).add(file); const forwarded = (source: string, exported: string): string | null => { const target = resolveImportPath(source, file, language, context); - return target ? exportedComponent(target, exported, context, through) : null; + return target ? exportedComponent(target, exported, context, through, reads) : null; }; for (const re of reExports) { if (re.kind === 'named' && re.exportedName === name) return forwarded(re.source, re.originalName); @@ -469,13 +505,23 @@ function ownExport(source: string, name: string): string | null { * usual way). */ function declaredComponent(name: string, filePath: string, context: ResolutionContext): string | null | undefined { + const spec = declaredLoader(name, filePath, context); + if (spec === undefined) return undefined; + return spec ? lazyRouteComponent(spec, filePath, context) : null; +} + +/** + * The module a value `filePath` declares as `name` loads lazily — the + * `'./x'` of `const X = lazy(() => import('./x'))`, wrapped or not — null + * for a value that loads none, undefined when the file declares no such value. + */ +function declaredLoader(name: string, filePath: string, context: ResolutionContext): string | null | undefined { const own = (context.getNodesInFileNamed?.(filePath, name) ?? context.getNodesInFile(filePath).filter((n) => n.name === name)) .find((n) => n.kind === 'constant' || n.kind === 'variable'); if (!own) return undefined; const lines = context.getFileLines?.(filePath) ?? context.readFile(filePath)?.split(/\r?\n/) ?? []; const text = lines.slice(own.startLine - 1, own.endLine).join('\n'); - const spec = /=>\s*import\s*\(\s*["']([^"']+)["']\s*\)/.exec(text)?.[1]; - return spec ? lazyRouteComponent(spec, filePath, context) : null; + return /=>\s*import\s*\(\s*["']([^"']+)["']\s*\)/.exec(text)?.[1] ?? null; } /** diff --git a/src/resolution/index.ts b/src/resolution/index.ts index e04f9a274d..ccec8c630d 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -458,6 +458,42 @@ export class ReferenceResolver { return changed; } + /** + * Put back in the pending set the route references whose answer reads a + * module in `changedFilePaths` (`FrameworkResolver.lazyModules`), for a + * sync's resolution sweep. Returns the number put back. + * + * A route that renders a same-file `const Docs = lazy(() => + * import('./pages/Docs'))` binds to the declaration while the module is + * missing, and nothing revisited it once the module was added: the + * reference had resolved, so it was never parked for the failed-ref retry, + * and the rebind of the names a sync defines (CG-33) reaches it only when + * the module's component is named like the declaration. An edit that gives + * the module its default export, or moves it to another component, changes + * the answer the same way: a route's edge follows its old target through + * the module's re-index. So does a page behind a barrel the module + * forwards. References written in a changed file were resolved against the + * new files already. + */ + reopenRouteModuleReaders(changedFilePaths: readonly string[]): number { + const readers = this.frameworks.filter((f) => f.lazyModules); + if (readers.length === 0 || changedFilePaths.length === 0) return 0; + const changed = new Set(changedFilePaths); + const edgeIds: number[] = []; + const refs: UnresolvedReference[] = []; + for (const edge of this.queries.getRouteEdgesMovedBy(changedFilePaths)) { + const ref = resurrectRefFromDroppedEdge(edge); + if (!ref) continue; + const asked: UnresolvedRef = { ...ref, filePath: edge.sourceFilePath, language: edge.sourceLanguage }; + const reads = readers.some((f) => f.lazyModules!(asked, this.context).some((file) => changed.has(file))); + if (!reads) continue; + edgeIds.push(edge.edgeId); + refs.push(ref); + } + if (refs.length > 0) this.queries.replaceResolutionEdgesWithUnresolvedRefs(edgeIds, refs); + return refs.length; + } + /** * Pre-build lightweight caches for resolution. * Node lookups are now handled by indexed SQLite queries instead of diff --git a/src/resolution/types.ts b/src/resolution/types.ts index 52d62be2ac..eb17e40e82 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -371,6 +371,16 @@ export interface FrameworkResolver { * calls this describes back for its resolution sweep. */ navigation?: NavigationCalls; + /** + * The modules whose content `resolve()` reads to answer `ref`, a route + * node's reference — none for most. A React route that renders a same-file + * `const Docs = lazy(() => import('./pages/Docs'))` binds to the component + * that module exports, through any barrel that forwards it, and to the + * declaration while the module is missing or exports none: the answer + * changes with those files although no name the reference carries does, so + * a sync that adds or changes one of them resolves the reference again. + */ + lazyModules?(ref: UnresolvedRef, context: ResolutionContext): readonly string[]; } /** From b8b3eb9778b9867e542d5845494f4468faff0f91 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sat, 10 Oct 2026 04:04:59 +0000 Subject: [PATCH 3/6] fix(cpp): a range-based for loop's variable has the type the loop declares (#2439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C++ receiver inference read a declaration only when the declared name was followed by `;`, `=`, `,`, `)`, `[`, `{` or `(`, so a range-based for's `Type name :` was never read. In protocolbuffers/protobuf's conformance runner, `suite->SetVerbose(…)`, `suite->RunSuite(…)` and `suite->GetFailureListFlagName()` inside `for (ConformanceTestSuite *suite : suites)` fell through to Strategy 3's guess by the receiver's name, and in RocksDB `log->file()` on a `log::Writer` loop variable went to `BlobLogWriter::file`. A line the declarator regex doesn't match is now read as a range-based for when its `for (` header declares the receiver before a single `:` (not `::`), and the loop's variable is taken only when the call is inside the loop's body: past the header, up to the `}` closing a braced body or the end of a single-statement body, with comments and literals skipped. After the loop, or in a later function, the same name is another variable, and the scan goes on as before. A loop over `auto` elements has nothing to read and keeps the existing fallback. Bit-fields and the scope operator are never read as a loop's declaration. A loop's declaration is recorded like any other (#2413's noteCppDeclaration), so the std-type gate applies to `for (const std::string& s : …)` too, and a loop that doesn't enclose the call still marks the receiver as shadowed. With #2421, the 13 conformance runner calls reach ConformanceTestSuite's methods at 0.9. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/cpp-range-for-receiver.test.ts | 446 +++++++++++++++++++++++ src/resolution/name-matcher.ts | 114 +++++- 3 files changed, 558 insertions(+), 3 deletions(-) create mode 100644 __tests__/cpp-range-for-receiver.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 825c1ed1d8..37ae0d53fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a class, struct or method now keeps the namespaces and the class it is declared in when the parser misreads something earlier in the file, such as an unknown macro in front of a member or in a class header. Before, everything after the misread code could lose them, or land inside a class it isn't in: in protocolbuffers/protobuf, `FieldDescriptor` was indexed without `google::protobuf::`, and in RocksDB, the `Opts` struct declared inside a cache table class was indexed outside it, so finding a class's base classes and the methods called on its objects fell back to guessing by name. Methods that had come loose from their class are its members again, and classes the misread code had hidden, like leveldb's POSIX file and environment classes, are now indexed. Re-index C++ projects after upgrading. - In C++, a call on a variable, parameter or member declared as a standard-library or other outside type with a lowercase name, like `std::string`, `std::vector`, `std::set` or `absl::flat_hash_set`, no longer links to a project method that only shares the method's name. In google/leveldb, `saved_key_.clear()` on a `std::string saved_key_;` reached `Slice::clear`, and in protocolbuffers/protobuf, `proto.append(…)` on a `std::string` reached an unrelated `LeftoverBuffer::append`. The same goes for `->` through a raw pointer like `std::string* out`, while `->` through a smart pointer, iterator or optional still links to the type it holds. Comments also no longer pass for a variable's declaration, so more calls now link to the method of the type the variable is really declared with. Re-index C++ projects after upgrading. - In C++, a class written with attribute macros the parser can't read now keeps its members. That covers several macros between `class` and the class name, like protocolbuffers/protobuf's generated `class PROTOBUF_EXPORT PROTOBUF_FUTURE_ADD_EARLY_WARN_UNUSED Any final`, a macro with arguments there, like RocksDB's `struct ALIGN_AS(64U) HandleImpl`, or one before a template specialization, and macros on a member: in front of it (`PROTOBUF_FUTURE_ADD_EARLY_NODISCARD`), between a pointer and its name (`PROTOBUF_NONNULL`), after its parameters or its name (`ABSL_ATTRIBUTE_LIFETIME_BOUND`, google/leveldb's `LOCKS_EXCLUDED(mu_)` and `GUARDED_BY(mu_)`), or alone on a line above a comment ({fmt}'s `FMT_BEGIN_EXPORT`). Before, protobuf's generated message classes were missing and their members, like `GetDescriptor`, `GetReflection` and `unknown_fields`, were indexed as functions outside any class, and leveldb's annotated methods were indexed under the annotation's name, so calls to them linked to nothing. Re-index C++ projects after upgrading. +- In C++, a call on a range-based `for` loop's variable, like `suite->SetVerbose(verbose)` inside `for (ConformanceTestSuite *suite : suites)`, now links to the method of the type the loop declares. Before, the loop's declaration was never read, so the call was matched by the variable's name alone: in RocksDB, `log->file()` on a `log::Writer` went to `BlobLogWriter::file`, and a loop variable named like a member of its class was taken for that member. The variable counts only inside its loop, and loops over `auto` elements resolve as before. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In C and C++, a call written from the global scope, like `::leveldb::RepairDB(…)` or `::hpb::CreateMessage(…)`, now links to the function of exactly that name, and one through a namespace alias, like protobuf's `::_pbi::…` calls, to the function in the aliased namespace. Before, these calls were dropped unless the repository also held Swift and Objective-C files, and even then a name spelled out in full, like `::google::protobuf::internal::TSanWrite(…)`, linked to nothing, while `::operator delete(…)` linked to one class's own `operator delete`. A global call never links to a function or method of the same name inside a namespace or class, such as a wrapper `open()` that calls the C library's `::open(…)`, and `::std::…` and `::testing::…` calls stay unlinked unless that library's source is part of your project. Re-index C and C++ projects after upgrading. diff --git a/__tests__/cpp-range-for-receiver.test.ts b/__tests__/cpp-range-for-receiver.test.ts new file mode 100644 index 0000000000..67da5c2f98 --- /dev/null +++ b/__tests__/cpp-range-for-receiver.test.ts @@ -0,0 +1,446 @@ +/** + * A C++ range-based for loop declares the receiver its body calls through. + * + * protocolbuffers/protobuf's conformance runner loops `for + * (ConformanceTestSuite *suite : suites)` and calls `suite->SetVerbose(…)`, + * `suite->RunSuite(…)` and `suite->GetFailureListFlagName()` in the body. + * Receiver inference reads a declaration only when the declared name is + * followed by `;`, `=`, `,`, `)`, `[`, `{` or `(`, so it never read a loop's + * `Type name :` and the calls fell through to a guess by the receiver's name. + * A guess can still land on the right method by a shared word, so these tests + * check how the edge was made (the typed path's confidence), not only where + * it goes. + * + * The loop's variable is in scope in the loop's body only: after the loop, or + * in another function, a receiver of the same name is some other variable. + * A `for` in a comment or a string literal declares nothing, and a colon + * outside a loop's header never ends a declaration: not a bit-field's + * (`unsigned car : 1;`), nor the scope operator's (`void parts::Install()`). + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import type { Node } from '../src/types'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-range-for-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +function callable(cg: CodeGraph, qualifiedName: string): Node { + const node = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName); + if (!node) throw new Error(`no function or method ${qualifiedName}`); + return node; +} + +/** `calls` callees of a function or method, as `qualifiedName @confidence`, one per call site. */ +function calls(cg: CodeGraph, caller: string): string[] { + return cg + .getCallees(callable(cg, caller).id) + .filter((r) => r.edge.kind === 'calls') + .map((r) => `${r.node.qualifiedName} @${(r.edge.metadata as { confidence?: number } | undefined)?.confidence}`) + .sort(); +} + +/** protocolbuffers/protobuf's conformance runner, trimmed; `FlagParser` is a decoy `SetVerbose`. */ +const CONFORMANCE = { + 'conformance/suite.h': [ + '#include ', + 'namespace google {', + 'namespace protobuf {', + 'class ConformanceTestRunner {', + ' public:', + ' virtual ~ConformanceTestRunner() = default;', + '};', + 'class ConformanceTestSuite {', + ' public:', + ' void SetVerbose(bool verbose) { verbose_ = verbose; }', + ' std::string GetFailureListFlagName() { return failure_list_flag_name_; }', + ' bool RunSuite(ConformanceTestRunner* runner, std::string* output,', + ' const std::string& filename);', + ' private:', + ' bool verbose_ = false;', + ' std::string failure_list_flag_name_;', + '};', + 'class FlagParser {', + ' public:', + ' void SetVerbose(bool verbose) {}', + '};', + '} // namespace protobuf', + '} // namespace google', + '', + ].join('\n'), + 'conformance/suite.cc': [ + '#include "conformance/suite.h"', + 'namespace google {', + 'namespace protobuf {', + 'bool ConformanceTestSuite::RunSuite(ConformanceTestRunner* runner, std::string* output,', + ' const std::string& filename) {', + ' return true;', + '}', + '} // namespace protobuf', + '} // namespace google', + '', + ].join('\n'), + 'conformance/runner.cc': [ + '#include ', + '#include ', + '#include "conformance/suite.h"', + 'namespace google {', + 'namespace protobuf {', + 'int ForkPipeRunnerMain(int argc, char* argv[], const std::vector& suites) {', + ' bool verbose = false;', + ' for (ConformanceTestSuite *suite : suites) {', + ' std::string failure_list_filename;', + ' for (int arg = 1; arg < argc; ++arg) {', + ' if (strcmp(argv[arg], suite->GetFailureListFlagName().c_str()) == 0) {', + ' failure_list_filename = argv[arg];', + ' }', + ' }', + ' suite->SetVerbose(verbose);', + ' std::string output;', + ' ConformanceTestRunner* runner = nullptr;', + ' verbose = verbose && suite->RunSuite(runner, &output,', + ' failure_list_filename);', + ' }', + ' return 0;', + '}', + '} // namespace protobuf', + '} // namespace google', + '', + ].join('\n'), +}; + +/** Two `Start`s no receiver name below points at, and a class whose member is named like a loop variable. */ +const GARAGE = { + 'garage/vehicle.h': [ + '#include ', + 'namespace garage {', + 'class Engine {', + ' public:', + ' void Start();', + ' int Torque() const;', + '};', + 'class Car {', + ' public:', + ' void Start();', + '};', + 'namespace parts {', + 'class Part {', + ' public:', + ' void Fit();', + '};', + 'void Install();', + '} // namespace parts', + '} // namespace garage', + '', + ].join('\n'), + 'garage/vehicle.cc': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'void Engine::Start() {}', + 'int Engine::Torque() const { return 0; }', + 'void Car::Start() {}', + 'void parts::Part::Fit() {}', + '} // namespace garage', + '', + ].join('\n'), + 'garage/garage.h': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'class Garage {', + ' public:', + ' void Inspect(const std::vector& engines);', + ' void Service();', + ' private:', + ' Car* car;', + '};', + '} // namespace garage', + '', + ].join('\n'), + 'garage/garage.cc': [ + '#include "garage/garage.h"', + 'namespace garage {', + 'void Garage::Inspect(const std::vector& engines) {', + ' for (Engine* car : engines) car->Start();', + '}', + 'void Garage::Service() {', + ' car->Start();', + '}', + '} // namespace garage', + '', + ].join('\n'), +}; + +describe('a C++ range-based for declares its loop variable', () => { + it('reproduction: protobuf\'s conformance runner calls the suite\'s methods through the typed path', async () => { + const cg = await indexed(CONFORMANCE); + try { + // The guess by the receiver's name lands on the same methods (0.65 / + // 0.7); the loop's declared type makes them certain. + expect(calls(cg, 'google::protobuf::ForkPipeRunnerMain')).toEqual([ + 'google::protobuf::ConformanceTestSuite::GetFailureListFlagName @0.9', + 'google::protobuf::ConformanceTestSuite::RunSuite @0.9', + 'google::protobuf::ConformanceTestSuite::SetVerbose @0.9', + ]); + } finally { + cg.close(); + } + }); + + it('a loop variable that hides a member is the element, not the member', async () => { + const cg = await indexed(GARAGE); + try { + // Reading on past the loop reached the member `Car* car;` in the header. + expect(calls(cg, 'garage::Garage::Inspect')).toEqual(['garage::Engine::Start @0.9']); + } finally { + cg.close(); + } + }); + + it('after the loop, and in another function, a same-named receiver is not the loop\'s variable', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/fleet.cc': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'void Drive(Car* engine, const std::vector& engines) {', + ' for (Engine* engine : engines) {', + ' engine->Start();', + ' }', + ' engine->Start();', + '}', + 'void Wash(Car* engine, const std::vector& engines) {', + ' for (Engine* engine : engines) engine->Start();', + ' engine->Start();', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'garage::Drive')).toEqual(['garage::Car::Start @0.9', 'garage::Engine::Start @0.9']); + // A loop without braces ends with its one statement. + expect(calls(cg, 'garage::Wash')).toEqual(['garage::Car::Start @0.9', 'garage::Engine::Start @0.9']); + // Garage::Service's `car` is the member, though Inspect's loop above it names one `car`. + expect(calls(cg, 'garage::Garage::Service')).toEqual(['garage::Car::Start @0.9']); + } finally { + cg.close(); + } + }); + + it('reads references, pointers, qualified and global-scope types, and a header split over lines', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/shop.cc': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'void ByReference(std::vector& engines) {', + ' for (Engine& e : engines) e.Start();', + '}', + 'void ByConstReference(const std::vector& engines) {', + ' for (const Engine& e : engines) {', + ' if (e.Torque() > 0) {}', + ' }', + '}', + 'void StarOnName(const std::vector& engines) {', + ' for (Engine *e : engines)', + ' e->Start();', + '}', + 'void Qualified(const std::vector& list) {', + ' for (::garage::parts::Part* p : list) p->Fit();', + '}', + 'void SplitHeader(const std::vector& engines) {', + ' for (Engine* first_engine_in_the_list :', + ' engines) {', + ' first_engine_in_the_list->Start();', + ' }', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'garage::ByReference')).toEqual(['garage::Engine::Start @0.9']); + expect(calls(cg, 'garage::ByConstReference')).toEqual(['garage::Engine::Torque @0.9']); + expect(calls(cg, 'garage::StarOnName')).toEqual(['garage::Engine::Start @0.9']); + expect(calls(cg, 'garage::Qualified')).toEqual(['garage::parts::Part::Fit @0.9']); + expect(calls(cg, 'garage::SplitHeader')).toEqual(['garage::Engine::Start @0.9']); + } finally { + cg.close(); + } + }); + + it('a brace in a comment or a string literal does not end the loop', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/braces.cc': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'void Braces(const std::vector& engines) {', + ' for (Engine* e : engines) {', + ' // A comment\'s } is not the loop\'s.', + ' const char* close = "}";', + ' /* nor is } this one', + ' } or this */', + ' char brace = \'}\';', + ' e->Start();', + ' }', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'garage::Braces')).toEqual(['garage::Engine::Start @0.9']); + } finally { + cg.close(); + } + }); + + it('a nested loop\'s call is inside the outer loop\'s body', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/nested.cc': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'void Nested(const std::vector& list) {', + ' for (parts::Part* p : list) {', + ' for (int i = 0; i < 2; ++i) {', + ' if (i) { p->Fit(); }', + ' }', + ' }', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'garage::Nested')).toEqual(['garage::parts::Part::Fit @0.9']); + } finally { + cg.close(); + } + }); +}); + +describe('what declares no loop variable', () => { + it('a `for` in a comment or a string literal', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/notes.cc': [ + '#include ', + '#include "garage/vehicle.h"', + 'namespace garage {', + 'void Commented(Car* car) {', + ' // for (Engine* car : engines) {', + ' car->Start();', + '}', + 'void Inline(Car* car) {', + ' /* for (Engine* car : engines) { */ car->Start();', + '}', + 'std::string Template(Car* car) {', + ' std::string code = "for (Engine* car : engines) {";', + ' car->Start();', + ' return code + "}";', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + expect(calls(cg, 'garage::Commented')).toEqual(['garage::Car::Start @0.9']); + expect(calls(cg, 'garage::Inline')).toEqual(['garage::Car::Start @0.9']); + expect(calls(cg, 'garage::Template')).toEqual(['garage::Car::Start @0.9']); + } finally { + cg.close(); + } + }); + + it('a bit-field', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/lot.h': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'struct Slot {', + ' unsigned car : 1;', + '};', + 'class Lot {', + ' public:', + ' void Park();', + ' private:', + ' Car* car;', + '};', + '} // namespace garage', + '', + ].join('\n'), + 'garage/lot.cc': [ + '#include "garage/lot.h"', + 'namespace garage {', + 'void Lot::Park() {', + ' car->Start();', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + // The member `Car* car;`, not Slot's `unsigned car : 1;` above it. + expect(calls(cg, 'garage::Lot::Park')).toEqual(['garage::Car::Start @0.9']); + } finally { + cg.close(); + } + }); + + it('a name followed by the scope operator', async () => { + const cg = await indexed({ + ...GARAGE, + 'garage/bay.h': [ + '#include "garage/vehicle.h"', + 'namespace garage {', + 'class Bay {', + ' public:', + ' void Open();', + ' void Fit(const std::vector& list);', + ' private:', + ' Car* parts;', + '};', + '} // namespace garage', + '', + ].join('\n'), + 'garage/bay.cc': [ + '#include "garage/bay.h"', + 'namespace garage {', + 'void parts::Install() {}', + 'void Bay::Open() {', + ' parts->Start();', + '}', + 'void Bay::Fit(const std::vector& list) {', + ' for (parts::Part* part : list) parts->Start();', + '}', + '} // namespace garage', + '', + ].join('\n'), + }); + try { + // `void parts::Install()` declares no `parts`; the member does. + expect(calls(cg, 'garage::Bay::Open')).toEqual(['garage::Car::Start @0.9']); + // `parts::Part` declares the loop's `part`; the receiver `parts` is still the member. + expect(calls(cg, 'garage::Bay::Fit')).toEqual(['garage::Car::Start @0.9']); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 58618bb5b2..c25714a7f4 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -8876,13 +8876,109 @@ function normalizeCppTypeName(typeName: string): string | null { // `Type*receiver`, `Type receiver`, etc., REQUIRING a declarator terminator // (`;`, `=`, `,`, `)`, `[`, `{`, `(`, or end-of-line) after the receiver. The // terminator rules out uses like `return receiver->m()` where the preceding -// token is a keyword, not a type. +// token is a keyword, not a type. A range-based for's `Type receiver :` is read +// by cppRangeForDeclaration instead. function buildDeclaratorRegex(escapedReceiver: string): RegExp { return new RegExp( `([A-Za-z_][\\w:]*(?:\\s*<[^;=(){}]+>)?(?:\\s*[*&]+)?)\\s*\\b${escapedReceiver}\\b\\s*(?=[;=,)\\[{(]|$)`, ); } +// A range-based for's declaration, `for (const Foo& name :`, up to the single +// colon (not `::`) that ends it. What may precede the type — `const`, a +// global-scope `::` — is never an initializer, a call or a member access. +const CPP_RANGE_FOR_DECLARATION = /\bfor\s*\(([^;=(){}?."']*?)\b([A-Za-z_]\w*)\s*:(?!:)/g; +// The type a declaration ends with, read as the declarator regex reads it. +const CPP_DECLARED_TYPE_TAIL = /([A-Za-z_][\w:]*(?:\s*<[^;=(){}]+>)?(?:\s*[*&]+)?)\s*$/; + +/** + * The type the range-based for on `line` (comments blanked) declares + * `receiverName` with — `for (ConformanceTestSuite *suite : suites)` — and + * the column of the header's `(`. The declarator regex never reads one: a + * loop's name is followed by its `:`. (Neither is a bit-field, `unsigned car + * : 1;`, which no method is called on.) + */ +function cppRangeForDeclaration(line: string, receiverName: string): { type: string; paren: number } | null { + if (!line.includes('for')) return null; + for (const m of line.matchAll(CPP_RANGE_FOR_DECLARATION)) { + if (m[2] !== receiverName) continue; + const tail = m[1]!.match(CPP_DECLARED_TYPE_TAIL); + return tail ? { type: tail[1]!, paren: m.index! + m[0].indexOf('(') } : null; + } + return null; +} + +/** Where the C or C++ string or character literal opening at `text[start]` closes (the line's end if it doesn't). */ +function cppLiteralEnd(text: string, start: number): number { + for (let i = start + 1; i < text.length; i++) { + if (text[i] === '\\') i++; + else if (text[i] === text[start]) return i; + } + return text.length; +} + +/** + * Is column `useColumn` of line `useLine` inside the body of the `for` whose + * header opens at `lines[line][paren]`? A range-based for's variable is in + * scope there and nowhere else: after the loop, or in a later function, the + * same name is some other variable. The body runs to the `}` closing a braced + * body, or to the end of its one statement — taken to be the first `;` or + * closing `}` at its own depth, so an `else` after it is not counted. + * Comments and literals are skipped, and a `for` inside one (commented-out + * code, a code generator's template) has no body. + */ +function cppForBodyEncloses(lines: readonly string[], line: number, paren: number, useLine: number, useColumn: number): boolean { + let phase: 'before' | 'header' | 'gap' | 'block' | 'statement' = 'before'; + let depth = 0; + let comment = false; + for (let l = line; l <= useLine && l < lines.length; l++) { + const text = lines[l]!; + const end = l === useLine ? Math.min(useColumn, text.length) : text.length; + for (let c = 0; c < end; c++) { + const ch = text[c]!; + if (comment) { + if (ch === '*' && text[c + 1] === '/') { + comment = false; + c++; + } + continue; + } + if (ch === '/' && text[c + 1] === '/') break; + if (ch === '/' && text[c + 1] === '*') { + comment = true; + c++; + continue; + } + // (A `'` after a digit separates digits: `1'000'000`.) + if (ch === '"' || (ch === '\'' && !/\d/.test(text[c - 1] ?? ''))) { + c = cppLiteralEnd(text, c); + continue; + } + if (phase === 'before') { + if (l === line && c === paren) { + phase = 'header'; + depth = 1; + } + continue; + } + if (phase === 'header') { + if (ch === '(') depth++; + else if (ch === ')' && --depth === 0) phase = 'gap'; + continue; + } + if (phase === 'gap') { + if (/\s/.test(ch)) continue; + phase = ch === '{' ? 'block' : 'statement'; + } + if (ch === '(' || ch === '[' || ch === '{') depth++; + else if (ch === ')' || ch === ']') depth--; + else if (ch === '}' && --depth <= 0) return false; + else if (ch === ';' && depth <= 0 && phase === 'statement') return false; + } + } + return phase !== 'before' && phase !== 'header'; +} + /** What C++ receiver inference made of a receiver's declared type. */ interface CppReceiverDeclaration { /** @@ -9128,8 +9224,20 @@ function inferCppReceiverType( // receiver's: one found further up may be another variable. found.shadowed = true; } - } else if (found && cppRebindsReceiver(line, escapedReceiver)) { - found.shadowed = true; + } else { + // `for (ConformanceTestSuite *suite : suites)`, when the call is in the + // loop's body. A loop over `auto` elements has no initializer to read, + // so the scan goes on as for an `auto` local whose initializer is unreadable. + const loopVar = cppRangeForDeclaration(line, receiverName); + if (loopVar && cppForBodyEncloses(lines, i, loopVar.paren, callLineIndex, ref.column)) { + const normalized = normalizeCppTypeName(loopVar.type); + if (normalized && normalized !== 'auto') { + const inCallerScope = isCppCallersDeclaration(ref.filePath, i + 1, ref, context); + noteCppDeclaration(found, loopVar.type, inCallerScope); + return cppDeclaredType(loopVar.type, normalized, inCallerScope, ref, context, found); + } + } + if (found && cppRebindsReceiver(line, escapedReceiver)) found.shadowed = true; } } From 33c1436130f5b7889ef3e3a020dd9ef2a4af009b Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sat, 10 Oct 2026 04:11:59 +0000 Subject: [PATCH 4/6] fix(js,ts): a default import is what `export default` declares, not the first exported function (#2432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A default import resolved to the module's first exported function or component whenever the `export default` statement wrote a declaration (`export default function Vans()`, `export default class Store`): only a separate `export default NAME` statement was read. A React Router 6.4+ data-router page exports its `loader` or `action` above the page component, so `import Vans, { loader as vansLoader } from './Vans'` bound the route `/vans` (and every call through `Vans`) to `loader`. The resolver now reads the file's own `export default` statement from comment-stripped code. A declaration it writes is found by position, a binding it names by name, and an anonymous function, class or arrow function is no node at all (the guess took an exported function beside it, or one nested inside it). The statement also beats an exported styled/memo component above it. Every line-initial statement is read and the first one that names a node of the file wins, so template text that only reads like a statement names nothing. Strings are not masked: a masker loses step on a template whose `${…}` holds another template or a regex with a backtick, and on outline it blanked the real statement below one. Only an expression default (`memo(Card)`, `new Service()`), a binding the file doesn't declare, or no statement keeps the old guess. `require('./x').default` (#2433) reads the same statement first, so it reaches the declared default too. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/default-export-declaration.test.ts | 274 +++++++++++++++++++ src/resolution/import-resolver.ts | 172 +++++++++--- 3 files changed, 405 insertions(+), 42 deletions(-) create mode 100644 __tests__/default-export-declaration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 37ae0d53fa..1af3e88544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In TypeScript React code, a styled-components or emotion component declared with a type argument, like `const Wrapper = styled.div` followed by its CSS, or `styled(Base)`, is now indexed as a component, as one written without a type argument already was. Before, it was indexed as a plain constant, so `codegraph callers` and impact found none of the code that renders it, and its tags could link to another file's component of the same name instead: in outline/outline, `NudeButton` and `HStack` had no callers, and `` tags linked to a different `Text` than the one each file imports. Re-index TypeScript React projects after upgrading. - `codegraph sync` and the file watcher now move a React Router route to its page when the router file loads that page lazily into a value of its own, like `const Docs = lazy(() => import('./pages/Docs'))` rendered by `} />`, and the page's file is added after the router was indexed, comes back after being deleted, or gets or changes its default export in a later edit. Before, the route kept pointing at the `Docs` declaration, or at the page's old component, until the router file itself changed or the project was re-indexed, unless the page's component happened to share the declaration's name. The same now holds for a `lazy: () => import('./pages/Docs')` route whose page switches its default export to another component. Re-index React projects after upgrading to correct routes an earlier version left behind. - `codegraph sync` and the file watcher now link a component to the JSX that renders it, like ``, when the component is added in a file with no JSX of its own, as in `export default function Team() { return null; }`. Before, the link waited for an unrelated change to a file with JSX, or for a re-index, so the new component looked unused. +- In JavaScript and TypeScript, a default import now links to what the module's `export default` statement exports, wherever the statement sits in the file. Before, a module that declares its default, like `export default function Vans() {…}` or `export default class Store {…}`, had its default import linked to the first function it exports instead: in a React Router data-router app, where a page exports its `loader` or `action` above the page component, `import Vans, { loader as vansLoader } from './pages/Vans'` made the route `/vans` and every use of `Vans` lead to the loader. A component file that also exports a styled or memoized component no longer hands its default import to that component, and an anonymous default, like `export default function () {…}` or `export default () => …`, is no longer linked to a function exported beside it or nested inside it. Re-index JavaScript and TypeScript projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/default-export-declaration.test.ts b/__tests__/default-export-declaration.test.ts new file mode 100644 index 0000000000..ab39db1c2a --- /dev/null +++ b/__tests__/default-export-declaration.test.ts @@ -0,0 +1,274 @@ +/** + * A default import is the declaration the module's `export default` statement + * names, wherever it sits in the file — not the first exported function. + * + * A React Router 6.4+ data-router page exports its loader or action above the + * page component (`export function loader() {…}` then `export default function + * Vans() {…}`), and `import Vans, { loader as vansLoader } from './Vans'` bound + * `Vans` to `loader`: the route `/vans` rendered the loader, and every call + * through the default import went there too. A component file that exports a + * styled or memo component lost its default function or binding to that + * component, and an anonymous default (`export default function () {…}`, + * `export default () => …`) took whatever exported function came first — even + * one nested inside the default itself. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +const files: Record = { + 'package.json': JSON.stringify({ name: 'vanlife', private: true, dependencies: { react: '^18', 'react-router-dom': '^6.4' } }), + 'src/index.jsx': `import { RouterProvider, createBrowserRouter, createRoutesFromElements, Route } from "react-router-dom" +import Vans, { loader as vansLoader } from "./pages/Vans/Vans" +import HostVans, { loader as hostVansLoader } from "./pages/Host/HostVans" +import Login, { action as loginAction } from "./pages/Login" + +const router = createBrowserRouter(createRoutesFromElements( + + } action={loginAction} /> + } loader={vansLoader} /> + } loader={hostVansLoader} /> + +)) + +export default function App() { + return +} +`, + 'src/api.js': `export function getVans() { + return fetch("/api/vans") +} +`, + 'src/pages/Vans/Vans.jsx': `import { useLoaderData, defer } from "react-router-dom" +import { getVans } from "../../api" + +export function loader() { + return defer({ vans: getVans() }) +} + +export default function Vans() { + const dataPromise = useLoaderData() + function renderVanElements(vans) { + return vans.map((van) =>

{van.name}

) + } + return
{renderVanElements(dataPromise.vans)}
+} +`, + 'src/pages/Host/HostVans.jsx': `import { useLoaderData } from "react-router-dom" +import { getVans } from "../../api" + +export const loader = async () => getVans() + +export default function HostVans() { + return
    {useLoaderData().length}
+} +`, + 'src/pages/Login.jsx': `import { useActionData } from "react-router-dom" + +export async function action({ request }) { + return request.formData() +} + +export default function Login() { + const data = useActionData() + return
{data}
+} +`, + // A Next.js API route: a helper exported above the default handler. + 'src/api/vans.ts': `export function listVans() { + return [] +} + +export default async function handler(req: unknown, res: { json(v: unknown): void }) { + res.json(listVans()) +} +`, + 'src/store.ts': `export function createStore() { + return {} +} + +export default class Store { + read() { + return 1 + } +} +`, + // A regex with a backtick inside a template literal's interpolation + // (outline's headingToSlug): a string masker loses step there and blanked + // the statement below it. + 'src/slug.ts': `export function escapeHtml(text: string) { + return text +} + +function safeSlugify(text: string) { + return \`h-\${escapeHtml(text).replace(/[\`]/g, "")}\` +} + +export default function headingToSlug(text: string, index = 0) { + return index === 0 ? safeSlugify(text) : \`\${safeSlugify(text)}-\${index}\` +} +`, + // A line break between the statement and the declaration it writes. + 'src/stream.js': `export function emit() { + return 1 +} + +export default +function* stream() { + yield emit() +} +`, + // An exported styled component above the default function, or the binding. + 'src/components/Title.tsx': `import styled from "styled-components" + +export const Heading = styled.h1\` + font-size: 2rem; +\` + +export default function Title() { + return Vans +} +`, + 'src/components/Banner.tsx': `import styled from "styled-components" + +export const Strip = styled.div\` + display: flex; +\` + +const Banner = () => + +export default Banner +`, + // Anonymous defaults name no declaration of their own. + 'src/anonymous.js': `export function helper() { + return 1 +} + +export default function () { + function inner() { + return helper() + } + return inner() +} +`, + 'src/arrow.jsx': `export function useThing() { + return 1 +} + +export default () => { + const handle = () => useThing() + return