From 3151d5c46970fff87d9b1610e9e79a924ed2398c Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 11:23:58 +0000 Subject: [PATCH 01/20] =?UTF-8?q?fix(vapor):=20closure,=20webSocket=20and?= =?UTF-8?q?=20on(=E2=80=A6)=20routes=20are=20routes=20(#2157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only `.METHOD(…, use: handler)` registrations were read. Now also: - trailing-closure routes, `app.get("hello", ":name") { req in … }`, and `app.webSocket("chat") { req, ws in … }` (method WS), `routes.on(.GET, "x") { … }` — statement-start only, so `if let v = req.parameters.get("x") {` and an HTTP client's `req.client.get("https://…") { … }` don't count; - `routes.on(.POST, "x", body: .collect(…), use: handler)`, with only unlabeled string arguments as path segments. Routes main → this branch: SwiftPackageIndex-Server 13→34, vapor 6→265 (its tests), swiftchat 0→1, SteamPress 27→27, penny-bot 0→0. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/vapor-closure-routes.test.ts | 70 ++++++++++++++++++++++++++ src/resolution/frameworks/swift.ts | 47 +++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 __tests__/vapor-closure-routes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5addf176a3..57be2d1ae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Vapor routes whose handler is a trailing closure (`app.get("hello") { req in … }`), WebSocket routes (`app.webSocket("chat") { … }`) and `routes.on(.POST, "x", use: handler)` registrations are now found. Only routes with a `use:` handler were read before: the Swift Package Index server went from 13 routes to 34, and a WebSocket-only chat server from none to one. - Play projects kept in subdirectories are recognized, so each one's `conf/routes` is read. A repository with no Play build at its root, like playframework's samples, went from no routes to over 130. - Rails routes are now read with their nesting: `namespace` and `scope` add their path and controller module, nested `resources` sit under their parent's `:id`, and `member` / `collection` blocks add their actions. A namespaced route now links to its own module's controller. Routes in a Rails engine's `config/routes.rb` are found too: solidus went from no routes to over 600, and mastodon's route-to-action links nearly tripled. - Flask routes registered with `add_url_rule(…)` are now found and linked to their view function or class-based view. So are routes registered through a project's own helper that passes a list of paths and a `view_func=`. flaskbb, which registers every view that way, went from no routes to over 100. diff --git a/__tests__/vapor-closure-routes.test.ts b/__tests__/vapor-closure-routes.test.ts new file mode 100644 index 0000000000..ba875649ee --- /dev/null +++ b/__tests__/vapor-closure-routes.test.ts @@ -0,0 +1,70 @@ +/** + * A Vapor route whose handler is a trailing closure — `app.get("hello") { req + * in … }`, `app.webSocket("chat") { req, ws in … }` — and a `routes.on(.POST, + * "x", use:)` registration are routes too; only `use:` handlers were read. + * A `{` that opens an `if` (`if let v = req.parameters.get("x") {`) or an HTTP + * client's `req.client.get("https://…") { … }` is not one. + */ +import { describe, it, expect, afterAll, beforeAll } 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; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-vapor-closure-')); + const files: Record = { + 'Package.swift': `// swift-tools-version:5.9 +import PackageDescription +let package = Package(name: "App", dependencies: [.package(url: "https://github.com/vapor/vapor.git", from: "4.0.0")]) +`, + 'Sources/App/routes.swift': `import Vapor + +func routes(_ app: Application) throws { + app.get("hello", ":name") { req async throws -> String in + guard let name = req.parameters.get("name") else { throw Abort(.badRequest) } + return "Hello, \\(name)" + } + app.webSocket("chat") { req, client in + client.send("hi") + } + let todos = app.grouped("todos") + todos.on(.POST, "import", body: .collect(maxSize: "1mb"), use: importTodos) +} + +func importTodos(req: Request) async throws -> HTTPStatus { .ok } + +func lookup(req: Request) async throws -> String { + if let owner = req.parameters.get("owner") { + return owner + } + let res = try await req.client.get("https://example.com") { out in + try out.query.encode(["q": "x"]) + } + return "\\(res.status)" +} +`, + }; + 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); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('Vapor closure and on(…) routes', () => { + it('are routes; an if-let body and an HTTP client call are not', () => { + const names = cg.getNodesByKind('route').map((r) => r.name).sort(); + expect(names).toEqual(['GET /hello/:name', 'POST /todos/import', 'WS /chat']); + const post = cg.getNodesByKind('route').find((r) => r.name === 'POST /todos/import')!; + expect(cg.getOutgoingEdges(post.id).map((e) => cg.getNode(e.target)!.name)).toContain('importTodos'); + }); +}); diff --git a/src/resolution/frameworks/swift.ts b/src/resolution/frameworks/swift.ts index 5665bba956..8c91bf95dc 100644 --- a/src/resolution/frameworks/swift.ts +++ b/src/resolution/frameworks/swift.ts @@ -330,6 +330,53 @@ export const vaporResolver: FrameworkResolver = { } } + // `routes.on(.POST, "x", use: handler)` names its method as the first argument. + // Arguments may hold one level of parentheses (`body: .collect(maxSize: "1mb")`); + // only unlabeled string arguments are path segments. + const onRegex = /\b(\w+)\.on\s*\(\s*\.([A-Z]+)\s*,\s*((?:(?:[^,()]|\([^()]*\))+,)*\s*)use:\s*([A-Za-z_][\w.]*)/g; + const pathArgs = (argText: string) => argText.split(',').filter((a) => /^\s*"[^"]*"\s*$/.test(a)).join(','); + while ((match = onRegex.exec(safe)) !== null) { + const [, receiver, method, segsStr, handlerExpr] = match; + const line = safe.slice(0, match.index).split('\n').length; + const routePath = (groupPrefix.get(receiver!) ?? '') + segJoin('', pathArgs(segsStr!)) || '/'; + const id = `route:${filePath}:${line}:${method}:${routePath}`; + nodes.push({ + id, kind: 'route', name: `${method} ${routePath}`, qualifiedName: `${filePath}::route:${routePath}`, + filePath, startLine: line, endLine: line, startColumn: 0, endColumn: match[0].length, language: 'swift', updatedAt: now, + }); + const handlerName = vaporHandlerRef(handlerExpr!, receiverTypes); + if (handlerName) references.push({ fromNodeId: id, referenceName: handlerName, referenceKind: 'references', line, column: 0, filePath, language: 'swift' }); + } + + // A route whose handler is a trailing closure — `app.get("hello") { req in … }`, + // `app.webSocket("chat") { req, ws in … }`, `routes.on(.GET, "x") { … }`. It has + // no handler symbol; the closure's calls belong to the function registering it. + // An HTTP client's `req.client.get("https://…") { … }` is not a route. + const closureRegex = /\b(\w+)\.(get|post|put|patch|delete|head|options|webSocket|on)\s*\(([^()]*)\)\s*\{/g; + while ((match = closureRegex.exec(safe)) !== null) { + const [, receiver, verb, args] = match; + if (/\buse:/.test(args!) || receiver === 'client' || /^\s*"https?:/.test(args!)) continue; + // A route registration is a statement: `if let v = req.parameters.get("x") {` + // opens the `if` body, not a trailing closure. + const lineStart = safe.lastIndexOf('\n', match.index) + 1; + if (!/^\s*(?:(?:try|await)\s+)*$/.test(safe.slice(lineStart, match.index))) continue; + let method = verb === 'webSocket' ? 'WS' : verb!.toUpperCase(); + let segs = args!; + if (verb === 'on') { + const on = /^\s*\.([A-Z]+)\s*,?(.*)$/s.exec(args!); + if (!on) continue; + method = on[1]!; + segs = on[2]!; + } + const line = safe.slice(0, match.index).split('\n').length; + const routePath = (groupPrefix.get(receiver!) ?? '') + segJoin('', segs) || '/'; + nodes.push({ + id: `route:${filePath}:${line}:${method}:${routePath}`, kind: 'route', name: `${method} ${routePath}`, + qualifiedName: `${filePath}::route:${routePath}`, filePath, startLine: line, endLine: line, + startColumn: 0, endColumn: match[0].length, language: 'swift', updatedAt: now, + }); + } + return { nodes, references }; }, }; From 9269f92dd5ff041a737908c747dd48129fd84294 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 11:30:56 +0000 Subject: [PATCH 02/20] fix(aspnet): FastEndpoints routes, and minimal API paths get a leading slash (#2158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FastEndpoints: an endpoint class (`: Endpoint<…>`, `EndpointWithoutRequest`, `Ep.…`) declares `Get(…)` / `Post(…)` in `Configure()`; each becomes a route linked to the class's own HandleAsync / ExecuteAsync (the bare-member scope from #2151 keeps two `List` endpoints apart). A constant path (`CreateContributorRequest.Route`) is read in postExtract from wherever the class declares it; `$"/{nameof(Project)}s"` reads as `/Projects`. - `app.MapGet("api/todos", …)` is named `/api/todos`. A/B: ardalis/CleanArchitecture 0 → 24 routes (+24 edges); eShopOnWeb and eShop only re-key routes whose path gained a leading slash; realworld and jasontaylordev/CleanArchitecture byte-identical. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/fastendpoints-routes.test.ts | 104 +++++++++++++++++++ src/resolution/frameworks/csharp.ts | 137 +++++++++++++++++++------ 3 files changed, 211 insertions(+), 31 deletions(-) create mode 100644 __tests__/fastendpoints-routes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 57be2d1ae2..178cfd1f9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- ASP.NET apps built with FastEndpoints now have their routes: each endpoint class's `Get(…)` / `Post(…)` in `Configure()` becomes a route linked to its own `HandleAsync` / `ExecuteAsync`, including paths kept in a request class's `Route` constant. ardalis/CleanArchitecture went from no routes to 24. Minimal API routes written without a leading slash (`app.MapGet("api/todos", …)`) are now named `/api/todos`. - Vapor routes whose handler is a trailing closure (`app.get("hello") { req in … }`), WebSocket routes (`app.webSocket("chat") { … }`) and `routes.on(.POST, "x", use: handler)` registrations are now found. Only routes with a `use:` handler were read before: the Swift Package Index server went from 13 routes to 34, and a WebSocket-only chat server from none to one. - Play projects kept in subdirectories are recognized, so each one's `conf/routes` is read. A repository with no Play build at its root, like playframework's samples, went from no routes to over 130. - Rails routes are now read with their nesting: `namespace` and `scope` add their path and controller module, nested `resources` sit under their parent's `:id`, and `member` / `collection` blocks add their actions. A namespaced route now links to its own module's controller. Routes in a Rails engine's `config/routes.rb` are found too: solidus went from no routes to over 600, and mastodon's route-to-action links nearly tripled. diff --git a/__tests__/fastendpoints-routes.test.ts b/__tests__/fastendpoints-routes.test.ts new file mode 100644 index 0000000000..43ba746c1e --- /dev/null +++ b/__tests__/fastendpoints-routes.test.ts @@ -0,0 +1,104 @@ +/** + * FastEndpoints routes: an endpoint class declares its verb and path in + * `Configure()` — `Get("/Contributors")`, `Post(CreateContributorRequest.Route)` + * with the constant in the request's own file — and handles the request in + * its own `HandleAsync` / `ExecuteAsync`. ardalis/CleanArchitecture had no + * routes at all. A minimal API's `app.MapGet("api/todos", …)` serves + * `/api/todos`. + */ +import { describe, it, expect, afterAll, beforeAll } 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; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fastendpoints-')); + const files: Record = { + 'src/Web/Contributors/Create.CreateContributorRequest.cs': `namespace Web.Contributors; + +public class CreateContributorRequest +{ + public const string Route = "/Contributors"; + public string Name { get; set; } = ""; +} +`, + 'src/Web/Contributors/Create.cs': `namespace Web.Contributors; + +public class Create : Endpoint +{ + public override void Configure() + { + Post(CreateContributorRequest.Route); + AllowAnonymous(); + } + + public override async Task ExecuteAsync(CreateContributorRequest request, CancellationToken ct) + { + } +} +`, + 'src/Web/Contributors/List.cs': `namespace Web.Contributors; + +public class List : EndpointWithoutRequest +{ + public override void Configure() + { + Get("/Contributors"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken ct) + { + } +} +`, + 'src/Web/Projects/List.cs': `namespace Web.Projects; + +public class List : EndpointWithoutRequest +{ + public override void Configure() + { + Get($"/{nameof(Project)}s"); + } + + public override async Task HandleAsync(CancellationToken ct) + { + } +} +`, + 'src/Api/Program.cs': `var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); +app.MapGet("api/todos", GetTodos); +`, + }; + 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); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +const handlerOf = (file: string): string[] => { + const route = cg.getNodesByKind('route').find((r) => r.filePath === file)!; + return cg.getOutgoingEdges(route.id).map((e) => cg.getNode(e.target)!.qualifiedName); +}; + +describe('FastEndpoints and minimal API routes', () => { + it('names each endpoint by its path, constant paths included, and links its own handler', () => { + const names = cg.getNodesByKind('route').map((r) => r.name).sort(); + expect(names).toEqual(['GET /Contributors', 'GET /Projects', 'GET /api/todos', 'POST /Contributors']); + expect(handlerOf('src/Web/Contributors/Create.cs')).toEqual(['Web.Contributors::Create::ExecuteAsync']); + // Two classes named `List`: each route reaches its own class's handler. + expect(handlerOf('src/Web/Contributors/List.cs')).toEqual(['Web.Contributors::List::HandleAsync']); + expect(handlerOf('src/Web/Projects/List.cs')).toEqual(['Web.Projects::List::HandleAsync']); + }); +}); diff --git a/src/resolution/frameworks/csharp.ts b/src/resolution/frameworks/csharp.ts index 765acbd658..51040c20b4 100644 --- a/src/resolution/frameworks/csharp.ts +++ b/src/resolution/frameworks/csharp.ts @@ -191,7 +191,9 @@ export const aspnetResolver: FrameworkResolver = { // Minimal APIs: app.MapGet("/path", handler) const minimalRegex = /\.Map(Get|Post|Put|Patch|Delete)\s*\(\s*"([^"]+)"\s*,\s*([^,)]+)/g; while ((match = minimalRegex.exec(safe)) !== null) { - const [, verb, routePath, handlerExpr] = match; + const [, verb, rawPath, handlerExpr] = match; + // `app.MapGet("api/todos", …)` serves `/api/todos`. + const routePath = joinCsPath('', rawPath!); const method = verb!.toUpperCase(); const line = safe.slice(0, match.index).split('\n').length; @@ -272,45 +274,118 @@ export const aspnetResolver: FrameworkResolver = { }); } + // FastEndpoints: an endpoint class (`: Endpoint`, + // `EndpointWithoutRequest`, `Ep.Req<…>.Res<…>`) declares its verb and path in + // `Configure()` — `Get("/Contributors")`, `Post(CreateContributorRequest.Route)` + // — and handles the request in its own `HandleAsync` / `ExecuteAsync`. A + // constant path (`X.Route`, usually in the request's own file) is read in + // postExtract; until then the route is named by the expression. + const endpointClass = /\bclass\s+([A-Za-z_]\w*)[^{;]*?:\s*(?:FastEndpoints\.)?(?:Endpoint(?:WithoutRequest|WithoutResponse)?\b|Ep\.)/g; + while ((match = endpointClass.exec(safe)) !== null) { + const body = safe.slice(match.index); + const configure = /\bvoid\s+Configure\s*\(\s*\)\s*\{/.exec(body); + if (!configure) continue; + const handler = /\b(HandleAsync|ExecuteAsync)\s*\(/.exec(body)?.[1]; + const verbRegex = /\b(Get|Post|Put|Patch|Delete)\s*\(\s*([^;]*?)\s*\)\s*;/g; + verbRegex.lastIndex = configure.index; + const configureEnd = configure.index + 3000; + let verb: RegExpExecArray | null; + while ((verb = verbRegex.exec(body)) !== null && verb.index < configureEnd) { + const method = verb[1]!.toUpperCase(); + const args = verb[2]!; + // `$"/{nameof(Project)}s"` is `/Projects`. + const literals = [...args.matchAll(/"([^"]+)"/g)].map((l) => joinCsPath('', l[1]!.replace(/\{\s*nameof\s*\(\s*(\w+)\s*\)\s*\}/g, '$1'))); + const constant = /^([A-Za-z_]\w*)\.([A-Za-z_]\w*)$/.exec(args); + const targets = literals.length > 0 ? literals.map((p) => ({ name: p, key: p })) : constant ? [{ name: `${constant[1]}.${constant[2]}`, key: `const:${constant[1]}.${constant[2]}` }] : []; + const line = safe.slice(0, match.index + verb.index).split('\n').length; + for (const t of targets) { + const id = `route:${filePath}:${line}:${method}:${t.name}`; + nodes.push({ + id, kind: 'route', name: `${method} ${t.name}`, + qualifiedName: `${filePath}::fastendpoint:${method}:${t.key}`, + filePath, startLine: line, endLine: line, startColumn: 0, endColumn: 0, language: 'csharp', updatedAt: now, + }); + if (handler) references.push({ fromNodeId: id, referenceName: handler, referenceKind: 'references', line, column: 0, filePath, language: 'csharp' }); + } + // A second verb call (`Get(...); Post(...)`) is a second route; stop at Configure's end. + if (/\n\s*\}\s*\n/.test(body.slice(configure.index, verb.index))) break; + } + } + return { nodes, references }; }, - /** - * The endpoint-group prefix convention, read once from the app: the - * `MapGroup($"/api/{groupName}")` that registers every `IEndpointGroup` - * (or `EndpointGroupBase`) under a head — `/api/` — before the class name. - * A group route extracted as `POST /TodoItems` becomes `POST /api/TodoItems`; - * a class with its own `RoutePrefix` literal already has its path. Idempotent: - * `qualifiedName` keeps the group and the sub-path. - */ + /** Route names only the whole repository can give: FastEndpoints constant paths, endpoint-group prefixes. */ postExtract(context: ResolutionContext): Node[] { - let head: string | null = null; - let looked = 0; - for (const file of context.getAllFiles()) { - if (!file.endsWith('.cs')) continue; - const content = context.readFile(file); - if (!content || !content.includes('MapGroup')) continue; - if (++looked > 400) break; - const m = /\$"([^"{]*)\{\s*(?:groupName|type\.Name|name|prefix)\s*\}"/.exec(content) ?? /MapGroup\(\s*\$"([^"{]*)\{/.exec(content); + return [...fastEndpointConstantRoutes(context), ...endpointGroupRoutes(context)]; + }, +}; + +/** + * A FastEndpoints route named by a constant (`Post(CreateContributorRequest.Route)`) + * gets the constant's value: `public const string Route = "/Contributors";` in + * that class, wherever it is declared. Idempotent — the qualified name keeps the + * expression. + */ +function fastEndpointConstantRoutes(context: ResolutionContext): Node[] { + const updates: Node[] = []; + for (const route of context.getNodesByKind('route')) { + if (route.language !== 'csharp') continue; + const q = /::fastendpoint:([A-Z]+):const:([A-Za-z_]\w*)\.([A-Za-z_]\w*)$/.exec(route.qualifiedName); + if (!q) continue; + const [, method, owner, field] = q; + let value: string | null = null; + for (const decl of context.getNodesByName(owner!)) { + if (decl.language !== 'csharp' || (decl.kind !== 'class' && decl.kind !== 'struct')) continue; + const text = context.readFile(decl.filePath) ?? ''; + const m = new RegExp(`\\b(?:const\\s+string|static\\s+readonly\\s+string|static\\s+string)\\s+${field}\\s*=\\s*"([^"]+)"`).exec(text); if (m) { - head = m[1]!; + value = m[1]!; break; } } - if (!head || head === '/' || head === '') return []; - const updates: Node[] = []; - for (const route of context.getNodesByKind('route')) { - if (route.language !== 'csharp') continue; - const q = /::group:([A-Za-z_]\w*):([A-Z]+):(.*)$/.exec(route.qualifiedName); - if (!q) continue; - const content = context.readFile(route.filePath); - if (content && /\bRoutePrefix\s*(?:=>|=)\s*"/.test(content)) continue; - const name = `${q[2]} ${joinCsPath(head.replace(/\/+$/, '') + '/' + q[1], q[3]!)}`; - if (name !== route.name) updates.push({ ...route, name }); + if (!value) continue; + const name = `${method} ${joinCsPath('', value)}`; + if (name !== route.name) updates.push({ ...route, name }); + } + return updates; +} + +/** + * The endpoint-group prefix convention, read once from the app: the + * `MapGroup($"/api/{groupName}")` that registers every `IEndpointGroup` + * (or `EndpointGroupBase`) under a head — `/api/` — before the class name. + * A group route extracted as `POST /TodoItems` becomes `POST /api/TodoItems`; + * a class with its own `RoutePrefix` literal already has its path. Idempotent: + * `qualifiedName` keeps the group and the sub-path. + */ +function endpointGroupRoutes(context: ResolutionContext): Node[] { + let head: string | null = null; + let looked = 0; + for (const file of context.getAllFiles()) { + if (!file.endsWith('.cs')) continue; + const content = context.readFile(file); + if (!content || !content.includes('MapGroup')) continue; + if (++looked > 400) break; + const m = /\$"([^"{]*)\{\s*(?:groupName|type\.Name|name|prefix)\s*\}"/.exec(content) ?? /MapGroup\(\s*\$"([^"{]*)\{/.exec(content); + if (m) { + head = m[1]!; + break; } - return updates; - }, -}; + } + if (!head || head === '/' || head === '') return []; + const updates: Node[] = []; + for (const route of context.getNodesByKind('route')) { + if (route.language !== 'csharp') continue; + const q = /::group:([A-Za-z_]\w*):([A-Z]+):(.*)$/.exec(route.qualifiedName); + if (!q) continue; + const content = context.readFile(route.filePath); + if (content && /\bRoutePrefix\s*(?:=>|=)\s*"/.test(content)) continue; + const name = `${q[2]} ${joinCsPath(head.replace(/\/+$/, '') + '/' + q[1], q[3]!)}`; + if (name !== route.name) updates.push({ ...route, name }); + } + return updates; +} /** Join a class-level [Route] prefix and an action's path into one normalized `/path`. */ function joinCsPath(prefix: string, sub: string): string { From 5ba3674629ccb6773ce1e1d38655435e210271b1 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 11:36:02 +0000 Subject: [PATCH 03/20] fix(resolution): a framework-resolved edge names its resolver (#2159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README said every bridge hop carries `metadata.synthesizedBy`, but only the synthesized channels (rn-event-channel, fabric-native-impl) did; a resolver-made hop (Swift↔ObjC, RN legacy / TurboModules, Expo Modules JS) carried only `resolvedBy: 'framework'`, and `expo-module-extract` named nothing that exists. The framework loop now adds `metadata.framework` (the resolver's name: swift-objc-bridge, react-native-bridge, expo-modules-js, fabric-view, rails, laravel, …) and the README says what each kind carries. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 2 +- __tests__/framework-edge-name.test.ts | 57 +++++++++++++++++++++++++++ src/resolution/index.ts | 6 ++- 4 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 __tests__/framework-edge-name.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 178cfd1f9e..12ec8199cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- A link made by a framework or bridge resolver now records which one made it (`metadata.framework`, such as `swift-objc-bridge` or `react-native-bridge`), as the README describes. Before, only synthesized event and view channels were named. - ASP.NET apps built with FastEndpoints now have their routes: each endpoint class's `Get(…)` / `Post(…)` in `Configure()` becomes a route linked to its own `HandleAsync` / `ExecuteAsync`, including paths kept in a request class's `Route` constant. ardalis/CleanArchitecture went from no routes to 24. Minimal API routes written without a leading slash (`app.MapGet("api/todos", …)`) are now named `/api/todos`. - Vapor routes whose handler is a trailing closure (`app.get("hello") { req in … }`), WebSocket routes (`app.webSocket("chat") { … }`) and `routes.on(.POST, "x", use: handler)` registrations are now found. Only routes with a `use:` handler were read before: the Swift Package Index server went from 13 routes to 34, and a WebSocket-only chat server from none to one. - Play projects kept in subdirectories are recognized, so each one's `conf/routes` is read. A repository with no Play build at its root, like playframework's samples, went from no routes to over 130. diff --git a/README.md b/README.md index cd6c7bb442..ae3750c3ba 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,7 @@ Real iOS and React Native codebases live across multiple languages — a Swift c | Expo Modules | expo-haptics | expo-camera | expo SDK sweep (7 packages) | | Fabric / Paper views | [react-native-segmented-control](https://github.com/react-native-segmented-control/segmented-control) | [react-native-screens](https://github.com/software-mansion/react-native-screens) | [react-native-skia](https://github.com/Shopify/react-native-skia) | -Each bridge emits edges tagged `provenance:'heuristic'` with `metadata.synthesizedBy:` set to a stable channel name (e.g. `swift-objc-bridge`, `rn-event-channel`, `fabric-native-impl`, `expo-module-extract`), so the agent can tell at a glance how a hop got into the graph. +Every bridge hop says how it got into the graph. A hop matched by a bridge resolver carries `metadata.resolvedBy: 'framework'` and `metadata.framework` naming the resolver (`swift-objc-bridge`, `react-native-bridge`, `expo-modules-js`, `fabric-view`). A synthesized channel is tagged `provenance:'heuristic'` with `metadata.synthesizedBy` (`rn-event-channel`, `fabric-native-impl`). --- diff --git a/__tests__/framework-edge-name.test.ts b/__tests__/framework-edge-name.test.ts new file mode 100644 index 0000000000..b2081c82e6 --- /dev/null +++ b/__tests__/framework-edge-name.test.ts @@ -0,0 +1,57 @@ +/** + * A link a framework or bridge resolver made names that resolver on the edge + * (`metadata.framework`), as a synthesized channel names itself with + * `synthesizedBy`: a Swift → Objective-C hop says `swift-objc-bridge`. + */ +import { describe, it, expect, afterAll, beforeAll } 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; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-framework-edge-')); + const files: Record = { + 'Sources/Cache.h': `#import +@interface Cache : NSObject +- (id)fetchEntryForKey:(NSString *)key; +@end +`, + 'Sources/Cache.m': `#import "Cache.h" +@implementation Cache +- (id)fetchEntryForKey:(NSString *)key { + return nil; +} +@end +`, + 'Sources/Caller.swift': `import Foundation + +func load(cache: Cache) { + _ = cache.fetchEntry(forKey: "x") +} +`, + }; + 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); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('framework-resolved edges', () => { + it('name the resolver that made them', () => { + const caller = cg.getNodesInFile('Sources/Caller.swift').find((n) => n.name === 'load')!; + const bridged = cg.getOutgoingEdges(caller.id).find((e) => cg.getNode(e.target)?.name === 'fetchEntryForKey:'); + expect(bridged).toBeDefined(); + expect(bridged!.metadata?.resolvedBy).toBe('framework'); + expect(bridged!.metadata?.framework).toBe('swift-objc-bridge'); + }); +}); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index f11a525d12..8b51ff37f4 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -1160,7 +1160,11 @@ export class ReferenceResolver { const tFw = this.profileStages ? process.hrtime.bigint() : 0n; let fwEarly: ResolvedRef | null = null; for (const framework of this.frameworks) { - const result = this.gateFrameworkLanguage(framework.resolve(ref, this.context), ref); + const resolved = this.gateFrameworkLanguage(framework.resolve(ref, this.context), ref); + // Name the resolver on the edge (`metadata.framework`): a Swift→ObjC or + // React Native bridge hop says how it got into the graph, as a + // synthesized edge's `synthesizedBy` does. + const result = resolved ? { ...resolved, metadata: { ...resolved.metadata, framework: framework.name } } : null; if (result) { if (result.confidence >= 0.9) { fwEarly = result; // High confidence, return immediately (below) From 7aa124c0aa2f608341746fd0e3eccbdf4bbd6211 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 11:46:04 +0000 Subject: [PATCH 04/20] fix(scala): a bare call reached by name alone must be in reach (#2160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read at the site: - a later link of a chain (the extractor keeps one receiver level; the line shows the dot) reaches a member whose owner the receiver chain is named after (call / type arguments dropped: `proc("x").call()` → proc, `checker.value.onWrite` → checker), or a same-file member; never a package object's function unless it is a Scala 3 `extension` method; - a name the enclosing def binds (parameter, val/var/def, lambda / for param) is only a def inside that function; - otherwise a member of the types around it, an anonymous subclass's bases (`new OptionParser[C]("x") { head(…) }`), their extends/with supertypes (source-read heads), same file, or an imported object (`import Foo._`, `import Foo.{bar}`); a value import (`import builder._`) leaves the file unjudged. A/B (edges removed / added): cats -1693/+678, sttp -887/+271, munit -265/+175, scopt -245/+78, os-lib -115/+43. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/scala-bare-call-scope.test.ts | 90 +++++++++++ src/resolution/name-matcher.ts | 191 ++++++++++++++++++++++++ 3 files changed, 282 insertions(+) create mode 100644 __tests__/scala-bare-call-scope.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 12ec8199cb..ec0b964bec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- In Scala, a call reached by its name alone now stays in reach. A name that the enclosing function binds as a parameter or local (`f(true)` for `f: A => B`) is that local. A call later in a chain, like `fa.iterator.map(…)`, reaches only a member of what its receiver is named after. A plain call reaches only the enclosing class and its supertypes (an anonymous subclass's base included), a companion, or an imported object. On cats, parameter calls like `f(a)` used to land on a case class's field `f` over 400 times, and chained `.map(…)` on one ops class's `map`. - A link made by a framework or bridge resolver now records which one made it (`metadata.framework`, such as `swift-objc-bridge` or `react-native-bridge`), as the README describes. Before, only synthesized event and view channels were named. - ASP.NET apps built with FastEndpoints now have their routes: each endpoint class's `Get(…)` / `Post(…)` in `Configure()` becomes a route linked to its own `HandleAsync` / `ExecuteAsync`, including paths kept in a request class's `Route` constant. ardalis/CleanArchitecture went from no routes to 24. Minimal API routes written without a leading slash (`app.MapGet("api/todos", …)`) are now named `/api/todos`. - Vapor routes whose handler is a trailing closure (`app.get("hello") { req in … }`), WebSocket routes (`app.webSocket("chat") { … }`) and `routes.on(.POST, "x", use: handler)` registrations are now found. Only routes with a `use:` handler were read before: the Swift Package Index server went from 13 routes to 34, and a WebSocket-only chat server from none to one. diff --git a/__tests__/scala-bare-call-scope.test.ts b/__tests__/scala-bare-call-scope.test.ts new file mode 100644 index 0000000000..9f24022c2d --- /dev/null +++ b/__tests__/scala-bare-call-scope.test.ts @@ -0,0 +1,90 @@ +/** + * A bare Scala name reached by name alone must be in reach: + * - a parameter or local binds it: `f(true)` with `f: A => B` is not a case + * class's field `f` (cats: 438 times); + * - a later link of a chain (`fa.iterator.map(f)`) reaches a member of what + * the receiver is named after (`basicRequest.send` → Request), never a + * package object's function; + * - otherwise a member of the types around it — an anonymous subclass's base + * included (`new OptionParser[C]("x") { head("x") }`) — of their supertypes, + * or of an imported object. + */ +import { describe, it, expect, afterAll, beforeAll } 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; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-scala-scope-')); + const files: Record = { + 'src/main/scala/app/FreeT.scala': `package app + +final case class FlatMapped[A](f: A => A) +`, + 'src/main/scala/app/LazyOps.scala': `package app + +class LazyOps[A](xs: List[A]) { + def map[B](g: A => B): List[B] = xs.map(g) +} +`, + 'src/main/scala/app/OptionParser.scala': `package app + +abstract class OptionParser[C](name: String) { + def head(xs: String*): Unit = () +} +`, + 'src/main/scala/app/Base.scala': `package app + +trait Base { + def helper(): Int = 1 +} +`, + 'src/main/scala/app/Request.scala': `package app + +class Request { + def send(backend: String): String = backend +} +`, + 'src/main/scala/app/Use.scala': `package app + +object Use extends Base { + def tabulate[A](f: Boolean => A): (A, A) = (f(true), f(false)) + def twice(fa: List[Int]): List[Int] = fa.iterator.map(_ + 1).toList + def run(basicRequest: Request): String = basicRequest.send("b") + def local(): Int = helper() + val parser = new OptionParser[Int]("scopt") { + head("scopt", "3.x") + } +} +`, + }; + 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); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('bare Scala calls', () => { + it('stay with locals, the receiver’s type, the class hierarchy and anonymous bases', () => { + const ids = cg.getNodesInFile('src/main/scala/app/Use.scala').map((n) => n.id); + const targets = cg + .getOutgoingEdgesFrom(ids) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!.qualifiedName); + expect(targets.some((t) => t.endsWith('FlatMapped::f'))).toBe(false); + expect(targets.some((t) => t.endsWith('LazyOps::map'))).toBe(false); + expect(targets.some((t) => t.endsWith('Request::send'))).toBe(true); + expect(targets.some((t) => t.endsWith('Base::helper'))).toBe(true); + expect(targets.some((t) => t.endsWith('OptionParser::head'))).toBe(true); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index fec82b5016..777024db03 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1432,6 +1432,191 @@ function dartHeadOf(decl: Node, context: ResolutionContext): { supers: string[]; }; } +const SCALA_TYPE_KINDS: ReadonlySet = new Set(['class', 'trait', 'interface', 'enum', 'struct', 'module', 'namespace']); +const SCALA_MEMBER_KINDS: ReadonlySet = new Set(['method', 'field', 'property', 'variable', 'constant']); +const SCALA_SUPERS = new WeakMap>(); +const SCALA_IMPORTS = new WeakMap; members: Set }>>(); + +/** + * Whether a bare Scala name can mean the member `n`, read at its site. Three + * shapes: + * - a later link of a chain (`fa.iterator.map(f)` — the extractor keeps one + * receiver level, the line still shows the dot): the receiver must be named + * after `n`'s owner (`Foo.bar` on object Foo). cats's chained `.map(…)` went + * to a lazy-list ops class's `map` 186 times; + * - a name the enclosing definition binds — a parameter `f: A => B`, a + * `val` — is that local: `f(true)` is not a case class's field `f` (396); + * - otherwise a member of the types around it or their `extends` / `with` + * supertypes, of a companion, of the same file, or of an object the file + * imports (`import Foo._`, `import Foo.{bar}`). + */ +function isScalaMemberInScope(n: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { + const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split('\n')[ref.line - 1]; + if (line === undefined) return true; + const name = ref.referenceName; + const at = new RegExp(`(?= local.startLine && n.endLine <= local.endLine; + if (!SCALA_MEMBER_KINDS.has(n.kind)) return true; + const cut = n.qualifiedName.lastIndexOf('::'); + if (cut < 0 || n.filePath === ref.filePath) return true; + const owner = n.qualifiedName.slice(0, cut).split('::').pop()!; + const imports = scalaImportsOf(ref.filePath, context); + if (imports.owners.has(owner) || imports.members.has(`${owner}.${name}`)) return true; + // `import builder._` brings in a VALUE's members, of a type the file doesn't say. + if ([...imports.owners].some((o) => /^[a-z]/.test(o))) return true; + const around = context + .getNodesInFile(ref.filePath) + .filter((t) => SCALA_TYPE_KINDS.has(t.kind) && t.startLine <= ref.line && t.endLine >= ref.line); + if (around.length === 0) return true; + const seen = new Set(); + const queue = [...around.map((t) => t.name), ...scalaAnonymousBases(ref, context)]; + while (queue.length > 0 && seen.size < 60) { + const typeName = queue.shift()!; + if (seen.has(typeName)) continue; + seen.add(typeName); + if (typeName === owner) return true; + queue.push(...scalaSupertypesOf(typeName, context)); + } + return false; +} + +/** + * The types an anonymous class around a Scala site instantiates — `new + * scopt.OptionParser[Config]("scopt") { head("scopt") }` puts OptionParser's + * members in scope. Read backwards over the open braces above the site. + */ +function scalaAnonymousBases(ref: UnresolvedRef, context: ResolutionContext): string[] { + const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split('\n') ?? []; + const bases: string[] = []; + let depth = 0; + for (let i = ref.line - 1; i >= 0 && i >= ref.line - 400; i--) { + const text = lines[i]!; + for (let c = text.length - 1; c >= 0; c--) { + if (text[c] === '}') depth++; + else if (text[c] === '{') { + if (depth > 0) { depth--; continue; } + const head = /\bnew\s+([\w.]+(?:\s*\[[^\]]*\])?(?:\s*\([^)]*\))?(?:\s+with\s+[\w.]+(?:\s*\[[^\]]*\])?)*)\s*$/.exec(text.slice(0, c)); + if (head) for (const m of head[1]!.replace(/\[[^\]]*\]|\([^)]*\)/g, '').split(/\s+with\s+/)) bases.push(m.trim().split('.').pop()!); + } + } + } + return bases; +} + +/** + * The receiver a Scala `….name` is written on, as its dotted identifiers with + * call and type arguments dropped: `proc("bash").call()` → `proc`, + * `Alternative[List].unite` → `Alternative`, `checker.value.onWrite` → + * `checker.value`. Read backwards to the expression's start. + */ +function scalaReceiverName(before: string): string { + const text = before.replace(/\s*\.\s*$/, ''); + let out = ''; + let i = text.length - 1; + while (i >= 0) { + const ch = text[i]!; + if (ch === ')' || ch === ']') { + const open = ch === ')' ? '(' : '['; + let depth = 0; + for (; i >= 0; i--) { + if (text[i] === ch) depth++; + else if (text[i] === open && --depth === 0) break; + } + if (i < 0) return ''; + i--; + } else if (/[\w$.]/.test(ch)) { + out = ch + out; + i--; + } else break; + } + return out.replace(/^\.+|\.+$/g, ''); +} + +/** Whether a Scala function is declared in an `extension (…)` block. */ +function isScalaExtensionMethod(n: Node, context: ResolutionContext): boolean { + const lines = context.getFileLines?.(n.filePath) ?? context.readFile(n.filePath)?.split('\n') ?? []; + return lines.slice(Math.max(0, n.startLine - 4), n.startLine).some((l) => /^\s*extension\b/.test(l)); +} + +/** + * The definition around a Scala site that binds `name` itself — a parameter, a + * `val` / `var` / `def`, a lambda or `for` parameter — or null. + */ +function scalaLocalBinder(name: string, ref: UnresolvedRef, context: ResolutionContext): Node | null { + const fn = context + .getNodesInFile(ref.filePath) + .filter((f) => (f.kind === 'method' || f.kind === 'function') && f.startLine <= ref.line && f.endLine >= ref.line) + .sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0]; + if (!fn) return null; + const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split('\n') ?? []; + const text = lines.slice(fn.startLine - 1, ref.line).join('\n'); + const n = name.replace(/[$]/g, '\\$'); + const binds = new RegExp(`(?:[(,\\[]\\s*(?:implicit\\s+|using\\s+)?${n}\\s*:)|(?:\\b(?:val|var|def|lazy\\s+val)\\s+${n}\\b)|(?:(?|<-))|(?:\\(\\s*${n}\\s*(?:,[^)]*)?\\)\\s*=>)`).test(text); + return binds ? fn : null; +} + +/** The simple names a Scala type's declarations extend or mix in. */ +function scalaSupertypesOf(typeName: string, context: ResolutionContext): string[] { + let memo = SCALA_SUPERS.get(context); + if (!memo) SCALA_SUPERS.set(context, (memo = new Map())); + const hit = memo.get(typeName); + if (hit) return hit; + const names: string[] = [typeName]; + for (const decl of context.getNodesByName(typeName)) { + if (decl.language !== 'scala' || !SCALA_TYPE_KINDS.has(decl.kind)) continue; + const lines = context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split(/\r?\n/) ?? []; + const text = lines.slice(decl.startLine - 1, decl.startLine + 12).join(' '); + let depth = 0; + let flat = ''; + for (const ch of text) { + if (ch === '[' || ch === '(') depth++; + else if (ch === ']' || ch === ')') depth = Math.max(0, depth - 1); + else if (depth === 0) { + if (ch === '{' || ch === '=') break; + flat += ch; + } + } + const clause = /\bextends\b(.*)$/.exec(flat)?.[1] ?? ''; + for (const m of clause.matchAll(/([A-Za-z_][\w.]*)/g)) { + const simple = m[1]!.split('.').pop()!; + if (simple !== 'with' && simple !== 'derives' && simple !== typeName) names.push(simple); + } + } + memo.set(typeName, names.slice(1)); + return names.slice(1); +} + +/** A Scala file's `import a.b.Obj._` / `import a.b.Obj.*` owners and `import a.b.Obj.{x, y}` / `Obj.x` members. */ +function scalaImportsOf(file: string, context: ResolutionContext): { owners: Set; members: Set } { + let memo = SCALA_IMPORTS.get(context); + if (!memo) SCALA_IMPORTS.set(context, (memo = new Map())); + const hit = memo.get(file); + if (hit) return hit; + const found = { owners: new Set(), members: new Set() }; + for (const m of (context.readFile(file) ?? '').matchAll(/^\s*import\s+([\w.]+?)\.(?:(_|\*)|\{([^}]*)\}|([\w$]+))\s*$/gm)) { + const owner = m[1]!.split('.').pop()!; + if (m[2]) found.owners.add(owner); + else for (const member of (m[3] ?? m[4] ?? '').split(',')) { + const id = member.trim().split(/\s*=>\s*/)[0]!; + if (id === '_' || id === '*') found.owners.add(owner); + else if (id) found.members.add(`${owner}.${id}`); + } + } + memo.set(file, found); + return found; +} + const CSHARP_TYPE_KINDS: ReadonlySet = new Set(['class', 'interface', 'enum', 'struct', 'record']); const CSHARP_MEMBER_KINDS: ReadonlySet = new Set(['method', 'property', 'field', 'enum_member', 'constant', 'event']); const CSHARP_SUPERS = new WeakMap>(); @@ -2391,8 +2576,10 @@ export function matchByExactName( const objcShape = ref.language === 'objc' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*:*(?:\w+:)*$/.test(ref.referenceName) ? objcCallShape(ref, context) : null; const csharpBare = ref.language === 'csharp' && (ref.referenceKind === 'calls' || ref.referenceKind === 'references') && /^[A-Za-z_]\w*$/.test(ref.referenceName); + const scalaBare = ref.language === 'scala' && ref.referenceKind === 'calls' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName); const phpSelf = phpSelfReceiver(ref, context); const filtered = sameName.filter((n) => + !(scalaBare && !isScalaMemberInScope(n, ref, context)) && !(csharpBare && !isCsharpMemberInScope(n, ref, context)) && !(objcShape === 'c-call' && OBJC_MEMBER_KINDS.has(n.kind)) && !(objcShape === 'self-send' && !isObjcSelfSendTarget(n, ref, context)) && @@ -3520,6 +3707,8 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { OBJC_SUPERS.delete(context); CSHARP_SUPERS.delete(context); CSHARP_STATIC_USINGS.delete(context); + SCALA_SUPERS.delete(context); + SCALA_IMPORTS.delete(context); LUA_LOCALS.delete(context); PHP_FILE_SCOPES.delete(context); JAVA_STATIC_IMPORTS.delete(context); @@ -5552,6 +5741,7 @@ export function matchFuzzy( const objcShape = ref.language === 'objc' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*:*(?:\w+:)*$/.test(ref.referenceName) ? objcCallShape(ref, context) : null; const csharpBare = ref.language === 'csharp' && (ref.referenceKind === 'calls' || ref.referenceKind === 'references') && /^[A-Za-z_]\w*$/.test(ref.referenceName); + const scalaBare = ref.language === 'scala' && ref.referenceKind === 'calls' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName); const phpSelf = phpSelfReceiver(ref, context); // Names are case-sensitive in every language but a handful: Rust's // `Bytes` is not the method `bytes`, Python's builtin `dir(…)` not a class @@ -5574,6 +5764,7 @@ export function matchFuzzy( !(objcShape === 'c-call' && OBJC_MEMBER_KINDS.has(n.kind)) && !(objcShape === 'self-send' && !isObjcSelfSendTarget(n, ref, context)) && !(csharpBare && !isCsharpMemberInScope(n, ref, context)) && + !(scalaBare && !isScalaMemberInScope(n, ref, context)) && !(phpSelf && (n.kind !== 'method' || !isPhpMethodInScope(n, ref, phpSelf, context)))) .filter((n) => (ref.referenceKind !== 'references' && ref.referenceKind !== 'function_ref') || sameLanguageFamily(n.language, ref.language)); From 2d1b64413381342707fa2e69043a13f1f63f97dd Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 11:55:43 +0000 Subject: [PATCH 05/20] fix(extraction): Flow-typed JavaScript is read as TSX (#2161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tree-sitter-javascript can't parse Flow annotations; `render(): React.Node` ended a class early (segmented-control's component had no methods). A `.js` / `.jsx` whose leading comments carry `@flow` (not `@noflow`) now detects as `tsx`, and the TS extractor's offset-preserving preParse blanks Flow-only syntax for those files: exact object `{| |}`, a maybe type's `?`, an inexact object's bare `...`, `import typeof`, `opaque type`. preParse is hoisted before the kernel, so both paths parse the same bytes; kernel-parity now routes .js/.jsx through detectLanguage too (RN core Libraries: 0 diffs over 641 files). Grammar preload adds tsx whenever JavaScript is present. Parse errors over 400 RN-core Flow files: 10,626 (JS grammar) → 391 (TSX). A/B edges: RN core Libraries 29,649 → 38,194 (+12,639 / −4,094, the removals mostly `root.render(…)` → the one DrawerLayoutAndroid::render the old parse left, and edges re-keyed onto recovered methods); segmented-control +35/−5; express and react-native-netinfo byte-identical. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/flow-js.test.ts | 82 ++++++++++++++++++++++++++ scripts/kernel-parity.mjs | 7 ++- src/extraction/grammars.ts | 12 ++++ src/extraction/index.ts | 2 + src/extraction/languages/typescript.ts | 20 +++++++ 6 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 __tests__/flow-js.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ec0b964bec..740e4b56e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Flow-typed JavaScript files (those with `@flow` in their header comment) are now parsed like TypeScript, so type annotations like `render(): React.Node` no longer cut classes short. This mostly affects React Native code: on React Native's own libraries the graph gained over 8,000 links, and component classes keep their methods. Files without the pragma are unchanged. Re-index after upgrading. - In Scala, a call reached by its name alone now stays in reach. A name that the enclosing function binds as a parameter or local (`f(true)` for `f: A => B`) is that local. A call later in a chain, like `fa.iterator.map(…)`, reaches only a member of what its receiver is named after. A plain call reaches only the enclosing class and its supertypes (an anonymous subclass's base included), a companion, or an imported object. On cats, parameter calls like `f(a)` used to land on a case class's field `f` over 400 times, and chained `.map(…)` on one ops class's `map`. - A link made by a framework or bridge resolver now records which one made it (`metadata.framework`, such as `swift-objc-bridge` or `react-native-bridge`), as the README describes. Before, only synthesized event and view channels were named. - ASP.NET apps built with FastEndpoints now have their routes: each endpoint class's `Get(…)` / `Post(…)` in `Configure()` becomes a route linked to its own `HandleAsync` / `ExecuteAsync`, including paths kept in a request class's `Route` constant. ardalis/CleanArchitecture went from no routes to 24. Minimal API routes written without a leading slash (`app.MapGet("api/todos", …)`) are now named `/api/todos`. diff --git a/__tests__/flow-js.test.ts b/__tests__/flow-js.test.ts new file mode 100644 index 0000000000..1c77e14797 --- /dev/null +++ b/__tests__/flow-js.test.ts @@ -0,0 +1,82 @@ +/** + * A Flow-typed `.js` file (`// @flow` in its leading comments) is read with the + * TSX grammar: the JavaScript grammar can't parse its annotations, and a + * `render(): React.Node` cut the class around it short — segmented-control's + * component came out with no methods. Flow's own syntax (`{| |}`, `?T`, an + * inexact object's `...`, `import typeof`, `opaque type`) is blanked first. + * A `.js` without the pragma is untouched. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { detectLanguage } from '../src/extraction/grammars'; +import { blankFlowSyntax } from '../src/extraction/languages/typescript'; + +let root = ''; +let cg: CodeGraph; + +const FLOW = `/** + * @flow strict-local + * @format + */ +import typeof * as React from 'react'; +import * as React from 'react'; + +type Props = $ReadOnly<{| + values: ?Array, + onChange?: ?(event: {nativeEvent: {value: string}, ...}) => mixed, +|}>; + +opaque type Token = string; + +class SegmentedControl extends React.Component { + render(): React.Node { + return this._renderItems(); + } + + _renderItems(): React.Node { + return null; + } +} + +module.exports = SegmentedControl; +`; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-flow-')); + fs.mkdirSync(path.join(root, 'js'), { recursive: true }); + fs.writeFileSync(path.join(root, 'js/SegmentedControl.js'), FLOW); + fs.writeFileSync(path.join(root, 'js/plain.js'), `function plain() { return 1; }\nmodule.exports = plain;\n`); + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('Flow-typed JavaScript', () => { + it('is detected by its pragma, and only then', () => { + expect(detectLanguage('js/SegmentedControl.js', FLOW)).toBe('tsx'); + expect(detectLanguage('js/plain.js', 'function plain() {}')).toBe('javascript'); + expect(detectLanguage('js/x.js', '// @noflow\nconst a = 1;')).toBe('javascript'); + }); + + it('keeps its class and methods', () => { + const nodes = cg.getNodesInFile('js/SegmentedControl.js'); + const methods = nodes.filter((n) => n.kind === 'method').map((n) => n.name).sort(); + expect(methods).toEqual(['_renderItems', 'render']); + const render = nodes.find((n) => n.name === 'render')!; + const calls = cg.getOutgoingEdges(render.id).filter((e) => e.kind === 'calls').map((e) => cg.getNode(e.target)!.name); + expect(calls).toContain('_renderItems'); + }); + + it('blanks Flow-only syntax to spaces, keeping every offset', () => { + const out = blankFlowSyntax(FLOW, 'js/SegmentedControl.js'); + expect(out.length).toBe(FLOW.length); + expect(out).not.toMatch(/\{\||\|\}|import\s+typeof|opaque\s+type|:\s*\?Array/); + expect(blankFlowSyntax('const a = {| b |};', 'plain.js')).toBe('const a = {| b |};'); + }); +}); diff --git a/scripts/kernel-parity.mjs b/scripts/kernel-parity.mjs index db7cfa04c1..d02ab6d6c6 100644 --- a/scripts/kernel-parity.mjs +++ b/scripts/kernel-parity.mjs @@ -88,9 +88,12 @@ function collect(p, out) { const lang = EXTS.get(path.extname(p).toLowerCase()); // 'detect' (.h) resolves per file in the run loop; under --lang it rides // along whenever either C-family language is requested. + // A Flow-typed `.js` routes as tsx (detectLanguage reads its pragma), so + // it rides along when tsx is requested and is re-detected per file below. const passes = !langFilter || - (lang === 'detect' ? langFilter.has('c') || langFilter.has('cpp') : langFilter.has(lang)); + (lang === 'detect' ? langFilter.has('c') || langFilter.has('cpp') : langFilter.has(lang)) || + ((lang === 'javascript' || lang === 'jsx') && langFilter.has('tsx')); if (passes) out.push({ file: p, lang }); } } @@ -196,7 +199,7 @@ for (const { file, lang: extLang } of files) { const source = fs.readFileSync(file, 'utf8'); const rel = path.relative(ROOT, file); // `.h` resolves C vs C++ by content — the same call the indexer makes. - const lang = extLang === 'detect' ? detectLanguage(rel, source) : extLang; + const lang = extLang === 'detect' || extLang === 'javascript' || extLang === 'jsx' ? detectLanguage(rel, source) : extLang; if (!KERNEL_LANGS.has(lang)) continue; if (langFilter && !langFilter.has(lang)) continue; processed++; diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 84596d81c5..f0702a918d 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -548,6 +548,11 @@ export function detectLanguage(filePath: string, source?: string, overrides?: Re if (isErlangAppFile(filePath)) return 'erlang'; const lang = (overrides && overrides[ext]) || EXTENSION_MAP[ext] || 'unknown'; + // A Flow-typed `.js` (`// @flow` in its leading comments) parses as TSX: + // the JavaScript grammar can't read its annotations — `render(): React.Node` + // cut a class short — and TypeScript's syntax covers most of Flow's. + if ((lang === 'javascript' || lang === 'jsx') && source && hasFlowPragma(source)) return 'tsx'; + // .h files could be C, C++, or Objective-C — check source content if (lang === 'c' && ext === '.h' && source) { if (looksLikeCpp(source)) return 'cpp'; @@ -557,6 +562,13 @@ export function detectLanguage(filePath: string, source?: string, overrides?: Re return lang; } +/** Whether a JavaScript file's leading comments carry Flow's `@flow` pragma (and not `@noflow`). */ +export function hasFlowPragma(source: string): boolean { + const head = source.slice(0, 4096).replace(/^#![^\n]*\n/, ''); + const lead = /^(?:\s*(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/))*/.exec(head)?.[0] ?? ''; + return /@flow\b/.test(lead) && !/@noflow\b/.test(lead); +} + /** * A class/struct BASE CLAUSE — `struct Derived : Base {`, `class Foo final : * public Bar, private Baz {`, `struct D : ns::B {` — which is never valid diff --git a/src/extraction/index.ts b/src/extraction/index.ts index bb5b9c0fab..e71ffabe6b 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -801,6 +801,8 @@ export function preloadLanguagesForFiles( overrides?: Record ): Language[] { const languages = [...new Set(files.map((f) => detectLanguage(f, undefined, overrides)))]; + // A Flow-typed `.js` is read with the TSX grammar (see detectLanguage). + if ((languages.includes('javascript') || languages.includes('jsx')) && !languages.includes('tsx')) languages.push('tsx'); if (languages.includes('c')) { for (const ambiguous of ['cpp', 'objc'] as const) { if (!languages.includes(ambiguous)) languages.push(ambiguous); diff --git a/src/extraction/languages/typescript.ts b/src/extraction/languages/typescript.ts index c59b4ca7a3..a56e020574 100644 --- a/src/extraction/languages/typescript.ts +++ b/src/extraction/languages/typescript.ts @@ -1,5 +1,6 @@ import { getNodeText, getChildByField } from '../tree-sitter-helpers'; import type { LanguageExtractor } from '../tree-sitter-types'; +import { hasFlowPragma } from '../grammars'; import type { Node as SyntaxNode } from 'web-tree-sitter'; /** @@ -38,7 +39,26 @@ export function classifyTsClassMember(node: SyntaxNode): 'method' | 'property' { return 'property'; } +/** + * Flow's own syntax, blanked to spaces (offsets survive) so a Flow file reads + * as TSX: exact object types `{| a: T |}`, a maybe type's `?` (`x: ?string`, + * `Array`), an inexact object's `...`, `import typeof`, `opaque type`. Only files with the `@flow` + * pragma — the ones `detectLanguage` sends here from `.js`. + */ +export function blankFlowSyntax(source: string, filePath?: string): string { + if (!filePath || !/\.(?:jsx?|mjs|cjs)$/.test(filePath) || !hasFlowPragma(source)) return source; + return source + .replace(/\{\|/g, '{ ') + .replace(/\|\}/g, ' }') + .replace(/([:<,]\s*)\?(?=[\w$({[])/g, '$1 ') + .replace(/\bimport\s+typeof\b/g, (m) => 'import' + ' '.repeat(m.length - 'import'.length)) + .replace(/\bopaque(?=\s+type\b)/g, ' ') + // An inexact object type's bare `...` (`{a: T, ...}`) — never a value spread. + .replace(/\.\.\.(?=\s*[},])/g, ' '); +} + export const typescriptExtractor: LanguageExtractor = { + preParse: blankFlowSyntax, functionTypes: ['function_declaration', 'generator_function_declaration', 'arrow_function', 'function_expression', 'generator_function'], classTypes: ['class_declaration', 'abstract_class_declaration'], // `method_signature` is the interface/type-literal form of a method; without it From d03e4dbf6f90a0b2b32617abb8fe4746369f151e Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 12:07:48 +0000 Subject: [PATCH 06/20] fix(resolution): an unexported ES module binding is not reachable cross-file (#2162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sealed-module rule only covered modules exporting nothing. Per symbol now: a JS/TS top-level binding declared by its own statement (function, const/let/var, class, interface, type, enum) in an ES module is reachable from another file only if it's exported — the `export` keyword (node flag or its line), an `export { a as b }` list, `export default x` or `export default { a, b }`. Classic scripts, CommonJS, `declare global`, `.d.ts`, qualified members, object-literal members (zustand store actions) and `proto.x = function x()` are exempt. Applied at the reachability gate, so the winner is rejected rather than another promoted. A/B (edges removed / added): zod -229 (package `z` imports → a local `z`), excalidraw -66 (DOM `Element` → a test helper's type), kit -50, typeorm -47 (`import { ColumnMetadata } from "typeorm"` → a fixture's local), trpc -26, hono -8, axios -1; express byte-identical. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/esm-unexported-binding.test.ts | 77 ++++++++++++++++++++++++ src/resolution/name-matcher.ts | 61 ++++++++++++++++++- 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 __tests__/esm-unexported-binding.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 740e4b56e7..f8992f2bca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- In JavaScript and TypeScript, a module's top-level function, variable or type that the module doesn't export is no longer linked from other files. Every form of export still counts: the `export` keyword, `export { … }` lists, `export default`, and a default-exported object. On typeorm, test fixtures' local `ColumnMetadata` used to capture `import { ColumnMetadata } from "typeorm"`; on sveltekit, an unexported `resolve` collected other files' `resolve(…)` calls; on zod, a local `z` collected the `zod` package imports. - Flow-typed JavaScript files (those with `@flow` in their header comment) are now parsed like TypeScript, so type annotations like `render(): React.Node` no longer cut classes short. This mostly affects React Native code: on React Native's own libraries the graph gained over 8,000 links, and component classes keep their methods. Files without the pragma are unchanged. Re-index after upgrading. - In Scala, a call reached by its name alone now stays in reach. A name that the enclosing function binds as a parameter or local (`f(true)` for `f: A => B`) is that local. A call later in a chain, like `fa.iterator.map(…)`, reaches only a member of what its receiver is named after. A plain call reaches only the enclosing class and its supertypes (an anonymous subclass's base included), a companion, or an imported object. On cats, parameter calls like `f(a)` used to land on a case class's field `f` over 400 times, and chained `.map(…)` on one ops class's `map`. - A link made by a framework or bridge resolver now records which one made it (`metadata.framework`, such as `swift-objc-bridge` or `react-native-bridge`), as the README describes. Before, only synthesized event and view channels were named. diff --git a/__tests__/esm-unexported-binding.test.ts b/__tests__/esm-unexported-binding.test.ts new file mode 100644 index 0000000000..05573340e7 --- /dev/null +++ b/__tests__/esm-unexported-binding.test.ts @@ -0,0 +1,77 @@ +/** + * A top-level binding an ES module doesn't export can't be named from another + * file. sveltekit's `generate_manifest.js` keeps an unexported `resolve` that + * other files' `resolve(…)` calls went to; typeorm's test inputs' local + * `ColumnMetadata` took `import { ColumnMetadata } from "typeorm"`. Exported + * forms all still count: the `export` keyword, `export { a as b }`, + * `export default x`, `export default { a, b }`; so do classic scripts, + * CommonJS files and functions assigned onto a prototype. + */ +import { describe, it, expect, afterAll, beforeAll } 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; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-esm-unexported-')); + const files: Record = { + 'package.json': JSON.stringify({ name: 'app', type: 'module' }), + 'src/manifest.js': `import { join } from 'node:path'; +function resolve(event) { return join(event); } +export function manifest() { return resolve('x'); } +`, + 'src/adapters.js': `import { a } from './a.js'; +function getAdapter(name) { return name; } +export default { getAdapter }; +`, + 'src/listed.js': `import { a } from './a.js'; +function helper() { return 1; } +export { helper }; +`, + 'src/proto.js': `import { a } from './a.js'; +function Params() {} +const prototype = Params.prototype; +prototype.render = function render() { return ''; }; +export default Params; +`, + 'src/a.js': `export const a = 1; +`, + 'src/use.js': `import { helper } from './listed.js'; +import Params from './proto.js'; +export async function handle(event) { + await resolve(event); + getAdapter('xhr'); + helper(); + new Params().render(); +} +`, + }; + 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); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('unexported ES module bindings', () => { + it('are not reached from another file; every export form still is', () => { + const handle = cg.getNodesInFile('src/use.js').find((n) => n.name === 'handle')!; + const targets = cg + .getOutgoingEdges(handle.id) + .filter((e) => e.kind === 'calls') + .map((e) => `${cg.getNode(e.target)!.filePath}:${cg.getNode(e.target)!.name}`); + expect(targets).not.toContain('src/manifest.js:resolve'); + expect(targets).toContain('src/adapters.js:getAdapter'); + expect(targets).toContain('src/listed.js:helper'); + expect(targets).toContain('src/proto.js:render'); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 777024db03..42f98a0ccd 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -781,10 +781,68 @@ function isCrossFileReachable( return ( candidate.filePath === ref.filePath || !ESM_FAMILY.has(candidate.language) || - !isSealedModule(candidate.filePath, context) + (!isSealedModule(candidate.filePath, context) && !isUnexportedModuleBinding(candidate, context)) ); } +const ESM_BINDING_KINDS: ReadonlySet = new Set(['function', 'variable', 'constant', 'class', 'interface', 'type_alias', 'enum', 'component']); +const ESM_EXPORT_LISTS = new WeakMap }>>(); + +/** + * Whether `candidate` is a top-level binding of an ES module that the module + * doesn't export — declared without `export` and absent from its `export { … }` + * / `export default x` lists. No other file can name it. The sealed-module + * rule above covers files that export nothing; this is the same boundary per + * symbol: sveltekit's `generate_manifest.js` keeps an unexported `resolve` + * that twenty other files' `resolve(…)` calls went to. Classic scripts, + * CommonJS, `declare global` and `.d.ts` files, members of a class or + * namespace (qualified names), names a default-exported object literal lists, + * and anything not declared by a statement of its own (an object literal's + * member, `proto.x = function x() {}`) are exempt. + */ +function isUnexportedModuleBinding(candidate: Node, context: ResolutionContext): boolean { + if (candidate.isExported || !ESM_BINDING_KINDS.has(candidate.kind)) return false; + if (candidate.qualifiedName.includes('::') || /\.d\.[cm]?ts$/.test(candidate.filePath)) return false; + let memo = ESM_EXPORT_LISTS.get(context); + if (!memo) ESM_EXPORT_LISTS.set(context, (memo = new Map())); + let info = memo.get(candidate.filePath); + if (!info) { + const source = context.readFile(candidate.filePath) ?? ''; + const code = blankStringContents(stripCommentsForRegex(source, 'typescript')); + const module = (HAS_IMPORT_STATEMENT.test(code) || HAS_ESM_EXPORT.test(code)) && + !HAS_CJS_EXPORT.test(source) && !/\bdeclare\s+global\b/.test(code); + const names = new Set(); + if (module) { + for (const m of source.matchAll(/^[ \t]*export\s+(?:type\s+)?\{([^}]*)\}/gm)) { + for (const item of m[1]!.split(',')) { + const local = item.trim().replace(/^type\s+/, '').split(/\s+as\s+/)[0]!.trim(); + if (local) names.add(local); + } + } + for (const m of source.matchAll(/^[ \t]*export\s+(?:default|=)\s+([A-Za-z_$][\w$]*)\s*;?\s*$/gm)) names.add(m[1]!); + // `export default { getAdapter, adapters: known }` exposes its shorthand and value names. + for (const m of code.matchAll(/^[ \t]*export\s+default\s+\{([^}]*)\}/gm)) { + for (const item of m[1]!.split(',')) { + const value = item.includes(':') ? item.split(':').pop()! : item; + const id = /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(value)?.[1]; + if (id) names.add(id); + } + } + } + info = { module, names }; + memo.set(candidate.filePath, info); + } + if (!info.module || info.names.has(candidate.name)) return false; + const line = (context.getFileLines?.(candidate.filePath) ?? context.readFile(candidate.filePath)?.split('\n'))?.[candidate.startLine - 1] ?? ''; + // Its own line says `export` (a node's flag can miss a form), or it is + // `prototype.toString = function toString() {…}`, reached through instances. + if (/^\s*export\b/.test(line)) return false; + // Only a declaration statement is a module binding: an object literal's + // member (a zustand store action `setZipUri: (v) => set(…)`) is reached + // through the object, and so is `proto.x = function x() {}`. + return /^\s*(?:declare\s+)?(?:async\s+)?(?:function\*?|const|let|var|(?:abstract\s+)?class|interface|type|enum)\s/.test(line); +} + const LUA_LOCALS = new WeakMap>(); /** Whether a Lua variable or function is declared `local` (`local x = …`, `local function f`, `local a, x = …`). */ @@ -3709,6 +3767,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { CSHARP_STATIC_USINGS.delete(context); SCALA_SUPERS.delete(context); SCALA_IMPORTS.delete(context); + ESM_EXPORT_LISTS.delete(context); LUA_LOCALS.delete(context); PHP_FILE_SCOPES.delete(context); JAVA_STATIC_IMPORTS.delete(context); From cf449fdad732e345ff2ce49e821304f36e081cad Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 12:15:50 +0000 Subject: [PATCH 07/20] fix(resolution): a name destructured from a composable reaches what it returns (#2163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `const { getDefaultActivityRoute } = useDefaultActivity(); getDefaultActivityRoute()` bound the name locally, which ruled out every cross-file candidate, so the call resolved to nothing. A bare JS-family call through a name destructured (`{ key: alias }` included) from a call's result now resolves the callee — through the file's imports, else the same file, else the project's one function of that name (Nuxt's `~/` alias) — checks its source returns the key, and takes the function declared in its body, else the module-level one it returns. A later declaration at the call's scope shadows the binding. Returned object literals now count as exporting their names for #2162's unexported-binding rule. A/B edges added (removed): mealie +176 (-4), excalidraw +156 (`const { t } = useI18n()`), halo +58, kit +34 (-14, `validate` now the validator's own), elk +19; trpc byte-identical. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/destructured-call-result.test.ts | 70 +++++++++++++++++++ src/resolution/name-matcher.ts | 79 ++++++++++++++++++++-- 3 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 __tests__/destructured-call-result.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f8992f2bca..6503fe7786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- In JavaScript, TypeScript and Vue, a call through a name destructured from a composable or custom hook, like `const { getDefaultActivityRoute } = useDefaultActivity()` or `const { t } = useI18n()`, now reaches the function the hook returns. These calls used to resolve to nothing. mealie, elk, halo and excalidraw each gained dozens to over a hundred such links. - In JavaScript and TypeScript, a module's top-level function, variable or type that the module doesn't export is no longer linked from other files. Every form of export still counts: the `export` keyword, `export { … }` lists, `export default`, and a default-exported object. On typeorm, test fixtures' local `ColumnMetadata` used to capture `import { ColumnMetadata } from "typeorm"`; on sveltekit, an unexported `resolve` collected other files' `resolve(…)` calls; on zod, a local `z` collected the `zod` package imports. - Flow-typed JavaScript files (those with `@flow` in their header comment) are now parsed like TypeScript, so type annotations like `render(): React.Node` no longer cut classes short. This mostly affects React Native code: on React Native's own libraries the graph gained over 8,000 links, and component classes keep their methods. Files without the pragma are unchanged. Re-index after upgrading. - In Scala, a call reached by its name alone now stays in reach. A name that the enclosing function binds as a parameter or local (`f(true)` for `f: A => B`) is that local. A call later in a chain, like `fa.iterator.map(…)`, reaches only a member of what its receiver is named after. A plain call reaches only the enclosing class and its supertypes (an anonymous subclass's base included), a companion, or an imported object. On cats, parameter calls like `f(a)` used to land on a case class's field `f` over 400 times, and chained `.map(…)` on one ops class's `map`. diff --git a/__tests__/destructured-call-result.test.ts b/__tests__/destructured-call-result.test.ts new file mode 100644 index 0000000000..02b50e22db --- /dev/null +++ b/__tests__/destructured-call-result.test.ts @@ -0,0 +1,70 @@ +/** + * A call through a name destructured from a call's result — a composable or + * a custom hook — reaches the function that callee returns under that key: + * `const { getDefaultActivityRoute } = useDefaultActivity()` (mealie, where the + * function is a module-level one the composable returns), `const { t } = + * useI18n()`, `const { login } = useAuth()` (declared in the hook's body). + * The local binding used to rule out every cross-file candidate. + */ +import { describe, it, expect, afterAll, beforeAll } 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; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-destructured-')); + const files: Record = { + 'package.json': JSON.stringify({ name: 'app', dependencies: { vue: '^3' } }), + 'src/composables/use-default-activity.ts': `function getDefaultActivityRoute(key?: string): string { + return key ?? '/'; +} +export default function useDefaultActivity() { + return { getDefaultActivityRoute }; +} +`, + 'src/hooks/useAuth.ts': `export function useAuth() { + function login(user: string) { + return user; + } + const logout = () => null; + return { login, signOut: logout }; +} +`, + 'src/pages/index.ts': `import useDefaultActivity from '../composables/use-default-activity'; +import { useAuth } from '../hooks/useAuth'; +export function go() { + const { getDefaultActivityRoute } = useDefaultActivity(); + const { login, signOut: leave } = useAuth(); + login('ada'); + leave(); + return getDefaultActivityRoute('x'); +} +`, + }; + 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); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('names destructured from a call', () => { + it('reach what the callee returns under that key', () => { + const go = cg.getNodesInFile('src/pages/index.ts').find((n) => n.name === 'go')!; + const targets = cg + .getOutgoingEdges(go.id) + .filter((e) => e.kind === 'calls') + .map((e) => `${cg.getNode(e.target)!.filePath}:${cg.getNode(e.target)!.name}`); + expect(targets).toContain('src/composables/use-default-activity.ts:getDefaultActivityRoute'); + expect(targets).toContain('src/hooks/useAuth.ts:login'); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 42f98a0ccd..b9fb3180da 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -796,7 +796,8 @@ const ESM_EXPORT_LISTS = new WeakMap()]*>)?\s*\(/g; + const stackAt = (end: number): number[] => { + const stack: number[] = []; + for (let i = 0; i < end; i++) { + if (code[i] === '{') stack.push(i); + else if (code[i] === '}') stack.pop(); + } + return stack; + }; + const callScope = stackAt(code.length); + for (const m of [...code.matchAll(binding)].reverse()) { + let key: string | null = null; + for (const part of m[1]!.split(',')) { + const [k, v] = part.split(':').map((x) => x.trim().replace(/\s*=.*$/, '')); + if ((v ?? k) === ref.referenceName && /^[A-Za-z_$][\w$]*$/.test(k ?? '')) key = k!; + } + if (!key) continue; + if (!stackAt(m.index!).every((pos, i) => callScope[i] === pos)) continue; + const rest = code.slice(m.index! + m[0].length); + if (new RegExp(`\\b(?:const|let|var|function|class)\\s+(?:${name}\\b|\\{[^}]*\\b${name}\\b)`).test(rest)) return null; + const calleeName = m[2]!; + const imported = context.resolveImport?.({ ...ref, referenceName: calleeName, referenceKind: 'calls' }); + // Through the import; else the same file's; else the one function of that + // name in the project (an alias the import resolver can't follow, like + // Nuxt's `~/composables/…`) — the returned key is checked below either way. + const holders = context.getNodesByName(calleeName).filter((n) => + (n.kind === 'function' || n.kind === 'constant' || n.kind === 'variable') && sameLanguageFamily(n.language, ref.language)); + const callee = (imported && context.getNodeById?.(imported.targetNodeId)) ?? + holders.find((n) => n.filePath === ref.filePath) ?? + (holders.length === 1 ? holders[0] : undefined); + if (!callee || !sameLanguageFamily(callee.language, ref.language)) return null; + const calleeText = (context.getFileLines?.(callee.filePath) ?? context.readFile(callee.filePath)?.split('\n') ?? []) + .slice(callee.startLine - 1, callee.endLine).join('\n'); + if (!new RegExp(`\\breturn\\s*\\{[^]*?\\b${key}\\b`).test(calleeText)) return null; + const callable = (n: Node) => n.kind === 'function' || n.kind === 'method' || n.kind === 'constant' || n.kind === 'variable'; + const inFile = context.getNodesInFile(callee.filePath); + const inner = inFile.filter((n) => n.name === key && callable(n) && n.id !== callee.id && rangeWithin(n, callee) && + !inFile.some((f) => f.id !== callee.id && f.id !== n.id && (f.kind === 'function' || f.kind === 'method') && + rangeWithin(f, callee) && rangeWithin(n, f) && !sameRange(f, n))); + const top = inner.length > 0 ? inner : inFile.filter((n) => n.name === key && callable(n) && !n.qualifiedName.includes('::') && + !inFile.some((f) => (f.kind === 'function' || f.kind === 'method') && f.id !== n.id && rangeWithin(n, f) && !sameRange(f, n))); + const target = top.sort((a, b) => Number(b.kind === 'function') - Number(a.kind === 'function'))[0]; + if (!target) return null; + return { original: ref, targetNodeId: target.id, confidence: 0.85, resolvedBy: 'instance-method' }; + } + return null; +} + /** Bound action names need not have a same-named definition (selectors may * rename them). The resolver's symbol-existence prefilter must allow them. */ export function matchJsStoreBindingCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { From 37bcd5c0aeab354f76b892c1f6a3807c644de9e8 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 12:34:11 +0000 Subject: [PATCH 08/20] fix(react-router): nested, lazy and constant routes; per-app tsconfig aliases (#2165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Route tables compose nested paths: a child's `path` is relative to the path-bearing routes around it (`children` of a data router, nested `` elements), absolute ones reset. `lazy: () => import('./x')` (object and JSX) renders the module's default export (`Component` export as fallback) via a claimed `lazy-import:` ref. `path: paths.app.root.path` keeps its parts on the node's signature and is named in postExtract from the constant's object literal (found through the file's import). The route keeps its extracted id; `isReactRouterRoute` accepts it. An element wrapped in parentheses or a guard (``, ``, `*Provider`, `*Guard`) renders the first element inside. - Import aliases: the tsconfig / jsconfig nearest the importing file that declares `paths` is tried before the root's, so each monorepo app resolves its own `@/*` / `~/*`. A/B (edges removed / added): bulletproof-react -43/+367 (0 → 9 named, linked routes; cross-app imports fixed), react-native-reusables -103/+193, kit -184/+218, trpc -45/+71 (examples' `~/…` now their own), create-t3-turbo -1/+13; excalidraw, halo, obytes, realworld byte-identical. #1348's nested fixtures now expect composed paths (`/dashboard/settings`, `/data/prefs`). Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 2 + __tests__/react-router-nested-lazy.test.ts | 99 ++++++++++ __tests__/react-router.test.ts | 9 +- docs/design/framework-coverage.md | 2 +- src/resolution/frameworks/react-router.ts | 24 ++- src/resolution/frameworks/react.ts | 207 +++++++++++++++++++-- src/resolution/import-resolver.ts | 14 +- src/resolution/index.ts | 27 +++ src/resolution/types.ts | 5 + 9 files changed, 358 insertions(+), 31 deletions(-) create mode 100644 __tests__/react-router-nested-lazy.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6503fe7786..1b3e879b1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Imports through a tsconfig or jsconfig path alias now use the config nearest the importing file, so each app in a monorepo resolves its own `@/…` or `~/…` alias. On bulletproof-react and trpc's examples, those imports used to fail or land in a different app. +- React Router data-router routes are named by their full path: nested `children` paths are joined to their parent's, `path: paths.app.root.path` constants are read, and `lazy: () => import('./routes/x')` routes link to the module's component. A guard wrapper like `` no longer stands in for the page. On bulletproof-react, every route now has its real path and page. - In JavaScript, TypeScript and Vue, a call through a name destructured from a composable or custom hook, like `const { getDefaultActivityRoute } = useDefaultActivity()` or `const { t } = useI18n()`, now reaches the function the hook returns. These calls used to resolve to nothing. mealie, elk, halo and excalidraw each gained dozens to over a hundred such links. - In JavaScript and TypeScript, a module's top-level function, variable or type that the module doesn't export is no longer linked from other files. Every form of export still counts: the `export` keyword, `export { … }` lists, `export default`, and a default-exported object. On typeorm, test fixtures' local `ColumnMetadata` used to capture `import { ColumnMetadata } from "typeorm"`; on sveltekit, an unexported `resolve` collected other files' `resolve(…)` calls; on zod, a local `z` collected the `zod` package imports. - Flow-typed JavaScript files (those with `@flow` in their header comment) are now parsed like TypeScript, so type annotations like `render(): React.Node` no longer cut classes short. This mostly affects React Native code: on React Native's own libraries the graph gained over 8,000 links, and component classes keep their methods. Files without the pragma are unchanged. Re-index after upgrading. diff --git a/__tests__/react-router-nested-lazy.test.ts b/__tests__/react-router-nested-lazy.test.ts new file mode 100644 index 0000000000..e47dccdfc3 --- /dev/null +++ b/__tests__/react-router-nested-lazy.test.ts @@ -0,0 +1,99 @@ +/** + * A data router's nested, lazy, constant-named routes (bulletproof-react): + * - a child's `path` is relative to its parent's — `discussions` under `/app` + * is `/app/discussions`; + * - `lazy: () => import('./routes/x')` renders the module's default export; + * - `path: paths.app.root.path` reads the constant object where it is + * declared — here through a monorepo app's own tsconfig `@/*` alias, which + * now resolves from the tsconfig nearest the importing file; + * - `element: ()` renders AppRoot. + */ +import { describe, it, expect, afterAll, beforeAll } 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; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-nested-')); + const app = (rel: string) => `apps/web/${rel}`; + const files: Record = { + 'package.json': JSON.stringify({ name: 'mono', private: true }), + [app('package.json')]: JSON.stringify({ name: 'web', dependencies: { react: '^18', 'react-router': '^7' } }), + [app('tsconfig.json')]: JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { '@/*': ['./src/*'] } } }), + [app('src/config/paths.ts')]: `export const paths = { + home: { path: '/', getHref: () => '/' }, + app: { + root: { path: '/app', getHref: () => '/app' }, + discussions: { path: 'discussions', getHref: () => '/app/discussions' }, + }, +} as const; +`, + [app('src/app/router.tsx')]: `import { createBrowserRouter } from 'react-router'; +import { paths } from '@/config/paths'; +import { ProtectedRoute } from '@/lib/auth'; +import AppRoot from './routes/app/root'; + +export const router = createBrowserRouter([ + { path: paths.home.path, lazy: () => import('./routes/landing') }, + { + path: paths.app.root.path, + element: ( + + + + ), + children: [ + { path: paths.app.discussions.path, lazy: () => import('./routes/app/discussions') }, + { path: 'settings', element: }, + ], + }, +]); +`, + [app('src/lib/auth.tsx')]: `export function ProtectedRoute({ children }: { children: unknown }) { return children; } +`, + [app('src/app/routes/landing.tsx')]: `const LandingRoute = () => null; +export default LandingRoute; +`, + [app('src/app/routes/app/root.tsx')]: `export default function AppRoot() { return null; } +`, + [app('src/app/routes/app/discussions.tsx')]: `export default function DiscussionsRoute() { return null; } +`, + }; + 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); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +const renders = (routeName: string): string[] => { + const route = cg.getNodesByKind('route').find((r) => r.name === routeName); + if (!route) return []; + return cg.getOutgoingEdges(route.id).filter((e) => e.kind === 'references').map((e) => cg.getNode(e.target)!.name); +}; + +describe('nested, lazy and constant React Router routes', () => { + it('compose their paths and render their modules', () => { + const names = cg.getNodesByKind('route').map((r) => r.name).sort(); + expect(names).toEqual(['/', '/app', '/app/discussions', '/app/settings']); + expect(renders('/')).toEqual(['LandingRoute']); + expect(renders('/app')).toEqual(['AppRoot']); + expect(renders('/app/discussions')).toEqual(['DiscussionsRoute']); + }); + + it('resolves an app’s own tsconfig alias', () => { + const router = cg.getNodesInFile('apps/web/src/app/router.tsx').map((n) => n.id); + const imported = cg.getOutgoingEdgesFrom(router).filter((e) => e.kind === 'imports').map((e) => cg.getNode(e.target)!.filePath); + expect(imported).toContain('apps/web/src/config/paths.ts'); + expect(imported).toContain('apps/web/src/lib/auth.tsx'); + }); +}); diff --git a/__tests__/react-router.test.ts b/__tests__/react-router.test.ts index fd4c9d7e90..fa5cd56e16 100644 --- a/__tests__/react-router.test.ts +++ b/__tests__/react-router.test.ts @@ -548,8 +548,9 @@ describe('react-router: route declaration boundaries (#1348)', () => { } `, extension); expect(result).toEqual({ - paths: ['/dashboard', '/empty', '/legacy', '/long', '/no-element', '/sibling', 'settings'], - bindings: ['/legacy->Settings', '/long->Shell', '/sibling->Settings', 'settings->Settings'], + // A nested route's path is relative to its parent's (`settings` under `/dashboard`). + paths: ['/dashboard', '/dashboard/settings', '/empty', '/legacy', '/long', '/no-element', '/sibling'], + bindings: ['/dashboard/settings->Settings', '/legacy->Settings', '/long->Shell', '/sibling->Settings'], }); }); @@ -573,8 +574,8 @@ describe('react-router: route declaration boundaries (#1348)', () => { ]); `, extension); expect(result).toEqual({ - paths: ['/', '/long', '/quoted', '/sibling', 'prefs'], - bindings: ['/->DataSettings', '/long->DataSettings', '/quoted->DataSettings', '/sibling->DataSettings', 'prefs->DataSettings'], + paths: ['/', '/data/prefs', '/long', '/quoted', '/sibling'], + bindings: ['/->DataSettings', '/data/prefs->DataSettings', '/long->DataSettings', '/quoted->DataSettings', '/sibling->DataSettings'], }); }); diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index 6e123c2f51..514ae1d4fe 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -38,7 +38,7 @@ guessed. |---|---|---|---|---| | Expo Router | `frameworks/expo-router.ts` | `expo-router-synthesizer.ts` | `expo-router.test.ts` | — | | Next.js | `frameworks/nextjs.ts` | `next-router-synthesizer.ts` | `nextjs.test.ts` | next-saas-starter | -| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges), proshop-v2 (28), react-redux-realworld (22), react-boilerplate (`styled(Link)`), takenote (v5 ``); bulletproof-react's `paths.x.path` constants and `lazy` routes are not read yet | +| React Router | `frameworks/react-router.ts` | `react-router-synthesizer.ts` | `react-router.test.ts` | proshop (44 edges), proshop-v2 (28), react-redux-realworld (22), react-boilerplate (`styled(Link)`), takenote (v5 ``), bulletproof-react (nested `children`, `lazy` routes, `paths.x.path` constants through an app's own tsconfig alias: 0 → 9 named, linked routes); its `paths.x.getHref()` links are not read yet | | TanStack Router | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts` | TanStack examples, fastapi-template frontend | | Vue Router / Nuxt | `frameworks/vue-router.ts` (Nuxt file routes: `nuxtResolver` in `frameworks/vue.ts`) | `vue-router-synthesizer.ts` | `vue-router.test.ts` | vue-realworld (23 edges); vue-element-admin (62 routes), vue-admin-template (14), vben (192), halo console (34) — named tables, module files, `children` + layouts; Nuxt: mealie, elk, nuxt/movies | | SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts`, `sveltekit-route-names.test.ts` | sveltekit-realworld (31 edges); shadcn-svelte and skeleton (`(group)` layouts: 13 and 23 edges), svelte.dev (74), kit's test apps (47) | diff --git a/src/resolution/frameworks/react-router.ts b/src/resolution/frameworks/react-router.ts index 85db31eee4..c8cd885618 100644 --- a/src/resolution/frameworks/react-router.ts +++ b/src/resolution/frameworks/react-router.ts @@ -28,11 +28,12 @@ * in JavaScript, so the receiver has to name a router — a bare `push` is an * array's, and is never claimed. * - * Known limits, both deliberate: a nested route's path is relative to its - * parent (`` inside ``), and the - * markup scan does not compose that tree, so only an absolute path is a - * destination an href can name; and a splat (`/admin/*`) matches anything, so - * it is never the answer to a concrete href. + * A nested route's path is relative to its parent's (`` + * inside ``, a data router's `children`); the markup + * scan composes that tree, a constant path (`paths.app.root.path`) included, + * and a `lazy: () => import('./routes/x')` route renders that module's default + * export (`frameworks/react.ts`). Known limit, deliberate: a splat + * (`/admin/*`) matches anything, so it is never the answer to a concrete href. */ import type { Language, Node } from '../../types'; @@ -70,13 +71,16 @@ export const reactRouterRoot = appRootFor; * * Its id is a verbatim reconstruction of the node's own fields, which no * other framework's route id is: a server route carries its METHOD - * (`route:file:12:POST:/login`), a file-based page carries no line. + * (`route:file:12:POST:/login`), a file-based page carries no line. A path + * built from a constant keeps the id it was extracted with. */ function isReactRouterRoute(node: Node): boolean { - return ( - (node.language === 'tsx' || node.language === 'jsx') && - node.id === `route:${node.filePath}:${node.startLine}:${node.name}` - ); + const prefix = `route:${node.filePath}:${node.startLine}:`; + if ((node.language !== 'tsx' && node.language !== 'jsx') || !node.id.startsWith(prefix)) return false; + // Its name, or — for a path built from a constant, renamed after extraction + // — the path as the file wrote it; never a server route's `METHOD:`. + const rest = node.id.slice(prefix.length); + return rest === node.name || (!/^[A-Z]+:/.test(rest) && Boolean(node.signature?.startsWith('route-parts:'))); } /** `:id?` — a parameter React Router serves the route with or without. */ diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index fe60ee17f8..4ffd422e10 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -8,6 +8,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; import { dependsOn } from './package-deps'; +import { resolveImportPath } from '../import-resolver'; export const reactResolver: FrameworkResolver = { name: 'react', @@ -27,7 +28,16 @@ export const reactResolver: FrameworkResolver = { return allFiles.some((f) => f.endsWith('.jsx') || f.endsWith('.tsx')); }, + // A data-router `lazy: () => import('./routes/x')` route names a module, not a symbol. + claimsReference(name: string): boolean { + return name.startsWith(LAZY_ROUTE_PREFIX); + }, + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + if (ref.referenceName.startsWith(LAZY_ROUTE_PREFIX)) { + const target = lazyRouteComponent(ref.referenceName.slice(LAZY_ROUTE_PREFIX.length), ref.filePath, context); + return target ? { original: ref, targetNodeId: target, confidence: 0.9, resolvedBy: 'framework' } : null; + } // A component, hook or context the file IMPORTS is the import's: the // package's (`useQuery` from `@tanstack/react-query`, `