From 9a515c3e6492aeee96d7cdf068b3d6267842ad28 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 02:27:27 +0000 Subject: [PATCH 01/26] fix(dead-code): a method a framework calls by decorator or by name is not dead (#2405) The viewer's Dead code screen listed two kinds of framework-called methods. A method whose decorator resolves outside the project (`@HostListener`, `@Cron`, `@OnEvent`, `@Process`, `@BeforeInsert`, `@InitBinder`) has no `decorates` edge and no `node.decorators`, only the resolver's unresolved `decorates` row. That row now counts as a decorator, the same evidence the opaque-ancestor rule reads for an `extends`/`implements` row, under the existing `decorated` count. A lifecycle hook on a class that skips the `implements` clause (`ngOnInit` on a `@Component` without `implements OnInit`) had no evidence at all. A new `hooks` rule drops a TypeScript method when its class carries a decorator and an interface outside the index carries the name. What the interface carries is read off the classes that write it out: every one has the method, declared or inherited, at least two and more than half declare it, and more than half of them carry a decorator. TypeScript only, because `implements` is erased there and a framework can only be finding the hook by name. The ancestor walk moves out of overrideCandidates into ancestorsOf, shared by both rules. Declarations are read from the candidate list rather than searched by name: a hook is never called from inside the index, and a name some declaration of which is called is the ambiguous-name rule's anyway. Co-authored-by: Claude Opus 5.5 --- __tests__/dead-code.test.ts | 231 ++++++++++++++++++ docs/design/codegraph-ui-design-spec.md | 6 +- docs/viewer-launch-changelog.md | 2 + src/db/queries.ts | 60 ++++- src/graph/dead-code.ts | 304 ++++++++++++++++++++---- src/index.ts | 18 ++ src/ui-server/api/deadcode.ts | 1 + 7 files changed, 564 insertions(+), 58 deletions(-) diff --git a/__tests__/dead-code.test.ts b/__tests__/dead-code.test.ts index c0fd98f817..7b446caa8f 100644 --- a/__tests__/dead-code.test.ts +++ b/__tests__/dead-code.test.ts @@ -532,3 +532,234 @@ function reallyUnused() {} expect(names(report)).toContain('neverCalledMember'); }); }); + +describe('a framework that calls members by name', () => { + let root: string; + let graph: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-deadcode-by-name-')); + // Three components that write the interface out: together they are the + // index's own record of what Angular's `OnInit` carries. The third inherits + // `ngOnInit` rather than declaring it, and lacks the `refresh` the other + // two happen to share. + write( + root, + 'src/app/first.component.ts', + `import { Component, OnInit } from '@angular/core'; + +@Component({ selector: 'app-first', template: '' }) +export class FirstComponent implements OnInit { + ngOnInit(): void {} + refresh(): void {} +} +` + ); + write( + root, + 'src/app/second.component.ts', + `import { AfterViewInit, Component, OnInit } from '@angular/core'; + +@Component({ selector: 'app-second', template: '' }) +export class SecondComponent implements OnInit, AfterViewInit { + ngOnInit(): void {} + ngAfterViewInit(): void {} + reload(): void {} + refresh(): void {} +} +` + ); + write( + root, + 'src/app/base-page.ts', + `import { Directive } from '@angular/core'; + +@Directive() +export abstract class BasePage { + ngOnInit(): void { + void 0; + } +} +` + ); + write( + root, + 'src/app/third.component.ts', + `import { Component, OnInit } from '@angular/core'; +import { BasePage } from './base-page'; + +@Component({ selector: 'app-third', template: '' }) +export class ThirdComponent extends BasePage implements OnInit {} +` + ); + // Writes no `implements` clause. Angular calls `ngOnInit` all the same. + write( + root, + 'src/app/demo.component.ts', + `import { Component, HostListener } from '@angular/core'; + +@Component({ selector: 'app-demo', template: '' }) +export class DemoComponent { + ngOnInit(): void { + void 0; + } + + @HostListener('window:keyup', ['$event']) + keyEvent(event: KeyboardEvent): void { + void event; + } + + reload(): void { + void 0; + } + + refresh(): void { + void 0; + } + + unusedHelper(): number { + return 1; + } +} +` + ); + // No decorator, so no framework registers it: the name alone is no evidence. + write(root, 'src/app/plain.ts', `export class Plain {\n ngOnInit(): void {\n void 0;\n }\n}\n`); + write( + root, + 'src/api/cron.service.ts', + `import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Cron } from '@nestjs/schedule'; + +@Injectable() +export class CronService { + @Cron('0 * * * *') + runEveryHour(): void { + void 0; + } + + @OnEvent('portfolio.changed') + handlePortfolioChanged(): void { + void 0; + } + + hasCurrencyPair(): boolean { + return false; + } +} +` + ); + write( + root, + 'src/main.ts', + `import { FirstComponent } from './app/first.component'; +import { SecondComponent } from './app/second.component'; +import { ThirdComponent } from './app/third.component'; +import { DemoComponent } from './app/demo.component'; +import { Plain } from './app/plain'; +import { CronService } from './api/cron.service'; + +export const declarations = [FirstComponent, SecondComponent, ThirdComponent, DemoComponent, Plain, CronService]; +` + ); + // Java checks the interface itself: Spring calls `afterPropertiesSet` on a + // bean that implements InitializingBean, never on one that only has the name. + for (const bean of ['FirstBean', 'SecondBean']) { + write( + root, + `src/main/java/demo/${bean}.java`, + `package demo; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.stereotype.Component; + +@Component +public class ${bean} implements InitializingBean { + public void afterPropertiesSet() {} +} +` + ); + } + write( + root, + 'src/main/java/demo/LooseBean.java', + `package demo; + +import org.springframework.stereotype.Component; + +@Component +public class LooseBean { + public void afterPropertiesSet() {} +} +` + ); + write( + root, + 'src/main/java/demo/App.java', + `package demo; + +public class App { + public static void main(String[] args) { + new FirstBean(); + new SecondBean(); + new LooseBean(); + } +} +` + ); + graph = CodeGraph.initSync(root, { + config: { include: ['src/**/*.ts', 'src/**/*.java'], exclude: [] }, + }); + await graph.indexAll(); + }, 60_000); + + afterAll(() => { + graph?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + const listed = (report: ReturnType, qualifiedName: string): boolean => + report.entries.some((entry) => entry.node.qualifiedName === qualifiedName); + + it('does not list a method whose decorator resolves outside the index', () => { + for (const readSource of [undefined, null] as const) { + const report = buildDeadCodeReport(graph, readSource === null ? { readSource } : {}); + expect(listed(report, 'DemoComponent::keyEvent')).toBe(false); + expect(listed(report, 'CronService::runEveryHour')).toBe(false); + expect(listed(report, 'CronService::handlePortfolioChanged')).toBe(false); + expect(report.excluded.decorated).toBeGreaterThanOrEqual(3); + } + }); + + it('does not list a hook its class fills by name, without writing the interface', () => { + for (const readSource of [undefined, null] as const) { + const report = buildDeadCodeReport(graph, readSource === null ? { readSource } : {}); + expect(listed(report, 'DemoComponent::ngOnInit')).toBe(false); + // The base a component inherits the hook from is reached the same way. + expect(listed(report, 'BasePage::ngOnInit')).toBe(false); + expect(report.excluded.hooks).toBe(2); + } + }); + + it('still lists what nothing reaches in a class a framework registers', () => { + const report = buildDeadCodeReport(graph); + // A decorator on the class is not evidence for each of its members. + expect(listed(report, 'DemoComponent::unusedHelper')).toBe(true); + expect(listed(report, 'CronService::hasCurrencyPair')).toBe(true); + // One implementer is not a contract: `reload` is only SecondComponent's. + expect(listed(report, 'DemoComponent::reload')).toBe(true); + // Two implementers sharing a method is no contract while a third lacks it. + expect(listed(report, 'DemoComponent::refresh')).toBe(true); + // Nothing registers an undecorated class. + expect(listed(report, 'Plain::ngOnInit')).toBe(true); + }); + + it('infers a contract only where the interface has no runtime effect', () => { + // Java has no export marker in the index, so ask for the exported list, + // where every other rule still runs. + const report = buildDeadCodeReport(graph, { includeExported: true }); + expect(listed(report, 'demo::LooseBean::afterPropertiesSet')).toBe(true); + expect(listed(report, 'DemoComponent::ngOnInit')).toBe(false); + }); +}); diff --git a/docs/design/codegraph-ui-design-spec.md b/docs/design/codegraph-ui-design-spec.md index e07b72ae27..ac8ec2d9b2 100644 --- a/docs/design/codegraph-ui-design-spec.md +++ b/docs/design/codegraph-ui-design-spec.md @@ -378,8 +378,10 @@ modules are not dimmed — a module with one `.pb.go` in it is still one somebod **What the list refuses to claim** is the whole design. Behind it, `src/graph/dead-code.ts` starts from "no incoming edge other than `contains`" and subtracts every candidate there is any reason to believe something reaches: exported symbols and -header declarations, test and generated files, abstract and interface members, anything carrying a `decorates` edge, overrides -of an ancestor's member, names the language calls by itself, vendored directories, files nothing in the index reaches (those are +header declarations, test and generated files, abstract and interface members, anything carrying a decorator (a `decorates` +edge, or a decorator from outside the index the resolver could not follow), overrides of an ancestor's member, a TypeScript +hook its framework calls by name on a decorated class that skips the `implements` clause (which names are hooks is read off the +classes that do write the interface out), names the language calls by itself, vendored directories, files nothing in the index reaches (those are islands — the Map's job, not this list's), names the resolver failed to resolve somewhere, names shared with a symbol that IS referenced, and — the only rule that reads a file — names written more than once in a file that can reach them. diff --git a/docs/viewer-launch-changelog.md b/docs/viewer-launch-changelog.md index f7a4174137..3ef374bcb1 100644 --- a/docs/viewer-launch-changelog.md +++ b/docs/viewer-launch-changelog.md @@ -175,6 +175,8 @@ These describe `codegraph ui` and its screens. They were taken out of `## [Unrel ## Fixes — Symbols, tests and the viewer +- **Dead code no longer lists methods a framework calls for you.** A method with a framework's decorator on it — Angular's `@HostListener` or `@HostBinding`, NestJS's `@Cron`, `@OnEvent` or `@Process`, TypeORM's `@BeforeInsert`, Spring's `@InitBinder` — was listed as unused whenever the decorator came from a package outside your project. So was a lifecycle hook like Angular's `ngOnInit` or NestJS's `onModuleInit` on a class that skips the `implements` clause, though the framework calls it by name all the same. Both are now left off and counted under the list. What counts as a hook is learned from your own classes that do write the interface out, not from a built-in list, so an unused helper on the same component is still listed. Nothing needs a re-index. + - **One slow file can no longer freeze the viewer.** Code is highlighted with the same grammars the index uses. The COBOL grammar never finishes a line like ` .`, which free-format COBOL paragraphs end with, so opening a Flow through cobolcraft froze `codegraph ui`, and every page after it waited forever. Highlighting now runs where it can be stopped: a piece of code that takes too long is shown unhighlighted, and everything else keeps working. - **The Map opens on a picture, not a single box.** A project whose code sits in one flat folder, like Express's `lib/`, an R package's `R/`, an Erlang app's `src/` or fmt's `include/fmt/`, opened as one box with nothing to say. It now opens on the whole repository when that draws more. Files at the repository's top level also count when the Map picks where the program lives: git's hundreds of top-level `.c` files had made `builtin/` look like most of the code. diff --git a/src/db/queries.ts b/src/db/queries.ts index b51034dfdc..77fef9bba8 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -2426,19 +2426,69 @@ export class QueryBuilder { * Chunked probe over `idx_unresolved_from_node`. */ getUnresolvedSupertypeSourcesAmong(nodeIds: Iterable): Set { + return new Set(this.getUnresolvedReferenceNamesFrom(nodeIds, ['extends', 'implements']).keys()); + } + + /** + * The names each of `nodeIds` holds an unresolved reference of `kinds` to. + * + * A reference into code outside the index leaves no edge, only this row: a + * decorator imported from a framework (`@HostListener`, `@Cron`) or an + * interface named in an `implements` clause. Pending rows count with failed + * ones, as in {@link getUnresolvedSupertypeSourcesAmong}. Ids with no such row + * are absent from the map. Chunked probe over `idx_unresolved_from_node`. + */ + getUnresolvedReferenceNamesFrom(nodeIds: Iterable, kinds: readonly string[]): Map { const unique = [...new Set(nodeIds)]; - const found = new Set(); + const found = new Map(); + if (unique.length === 0 || kinds.length === 0) return found; + const kindPlaceholders = kinds.map(() => '?').join(','); for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) { const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); const placeholders = chunk.map(() => '?').join(','); const rows = this.db .prepare( - `SELECT DISTINCT from_node_id AS id FROM unresolved_refs + `SELECT DISTINCT from_node_id AS id, reference_name AS name FROM unresolved_refs WHERE from_node_id IN (${placeholders}) - AND reference_kind IN ('extends', 'implements')` + AND reference_kind IN (${kindPlaceholders})` + ) + .all(...chunk, ...kinds) as Array<{ id: string; name: string }>; + for (const row of rows) { + const names = found.get(row.id); + if (names) names.push(row.name); + else found.set(row.id, [row.name]); + } + } + return found; + } + + /** + * Every node holding an unresolved reference of `kinds` to each of `names` — + * the mirror of {@link getUnresolvedReferenceNamesFrom}, keyed by the name: + * for `implements`, every class that names an interface outside the index. + * Names nothing refers to are absent from the map. Chunked probe over + * `idx_unresolved_name`. + */ + getUnresolvedReferenceSourcesNamed(names: Iterable, kinds: readonly string[]): Map> { + const unique = [...new Set(names)].filter((name) => name.length > 0); + const found = new Map>(); + if (unique.length === 0 || kinds.length === 0) return found; + const kindPlaceholders = kinds.map(() => '?').join(','); + for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + const rows = this.db + .prepare( + `SELECT DISTINCT reference_name AS name, from_node_id AS id FROM unresolved_refs + WHERE reference_name IN (${placeholders}) + AND reference_kind IN (${kindPlaceholders})` ) - .all(...chunk) as Array<{ id: string }>; - for (const row of rows) found.add(row.id); + .all(...chunk, ...kinds) as Array<{ name: string; id: string }>; + for (const row of rows) { + const ids = found.get(row.name); + if (ids) ids.add(row.id); + else found.set(row.name, new Set([row.id])); + } } return found; } diff --git a/src/graph/dead-code.ts b/src/graph/dead-code.ts index 5dce8f6db1..c3a2fd1b4a 100644 --- a/src/graph/dead-code.ts +++ b/src/graph/dead-code.ts @@ -25,11 +25,18 @@ * - it is **decorated** (`@app.route`, `@Component`, `@EventHandler`) — a * decorator is a registration, and the framework that reads it is not in the * graph. Seen as the symbol's own outgoing `decorates` edge, which is where - * the engine records it; `node.decorators` is only populated by a couple of - * languages and is checked as well rather than instead; + * the engine records it, or, for a decorator imported from outside the index + * (`@HostListener`, `@Cron`), as the resolver's unresolved `decorates` row — + * the same record the ancestor rule below reads for a supertype it could not + * follow. `node.decorators` is only populated by a couple of languages and is + * checked as well rather than instead; * - it **overrides** a member an ancestor declares (calls land on the ancestor; * see {@link overrideCandidates} for why an ancestor we cannot read counts * the same way); + * - it is a **hook its framework calls by name**: `ngOnInit` on a component + * that never writes `implements OnInit`. Which names are hooks is not kept in + * a table here — the index already says it, in what the classes that DO + * write the interface out declare (see {@link byNameHookCandidates}); * - it has a name the language calls by itself (`constructor`, `__enter__`, * `main`); * - it sits in a **vendored** directory (`vendor/`, `third_party/`, @@ -90,7 +97,7 @@ import fs from 'fs'; import path from 'path'; import type CodeGraph from '../index'; -import type { Node, NodeKind } from '../types'; +import type { EdgeKind, Node, NodeKind } from '../types'; import { isTestFile } from '../search/query-utils'; // ============================================================================= @@ -184,6 +191,18 @@ const OVERRIDABLE_KINDS: ReadonlySet = new Set([ 'field', ]); +/** + * Languages whose `implements` clause is erased before the program runs, so a + * framework that calls an interface's member can only be finding it by name — + * the premise of {@link byNameHookCandidates}. Java, C#, Kotlin and the rest + * check the interface itself: Spring never calls `afterPropertiesSet` on a + * bean that merely has a method of that name. + */ +const BY_NAME_DISPATCH_LANGUAGES: ReadonlySet = new Set(['typescript', 'tsx']); + +/** Member kinds a framework calls as a hook. */ +const HOOK_KINDS: readonly NodeKind[] = ['method', 'function']; + /** * Names a language or a runtime calls without anything in the source naming * them. @@ -193,7 +212,8 @@ const OVERRIDABLE_KINDS: ReadonlySet = new Set([ * stale, and it would hide real dead code behind a name coincidence. The * entries below are the ones where the *language itself* does the calling, so * no source file could name them even in principle. Framework hooks are caught - * by the decorator and override rules instead, which are structural. + * by the decorator, override and by-name hook rules instead, which are + * structural. */ const IMPLICIT_ENTRY_NAMES: ReadonlySet = new Set([ 'constructor', @@ -346,6 +366,12 @@ export interface DeadCodeExclusions { decorated: number; /** Overrides a member an ancestor declares, or an ancestor we cannot read. */ overriding: number; + /** + * A hook its framework calls by name, on a class that skips the interface: + * the class carries a decorator, and the name is one an interface outside + * the index carries, going by the classes that write it out. + */ + hooks: number; /** Named something the language calls by itself. */ implicit: number; /** In a vendored directory — carried code, reached by something outside the index. */ @@ -437,6 +463,7 @@ export function buildDeadCodeReport(cg: CodeGraph, query: DeadCodeQuery = {}): D declarations: 0, decorated: 0, overriding: 0, + hooks: 0, implicit: 0, vendored: 0, testScope: 0, @@ -508,14 +535,7 @@ export function buildDeadCodeReport(cg: CodeGraph, query: DeadCodeQuery = {}): D // A `decorates` edge runs FROM the decorated symbol to the decorator, so // this is an outgoing-edge question, not something the candidate query could // have answered. - const decorated = new Set( - cg - .getOutgoingEdgesFrom( - surviving.map((row) => row.node.id), - ['decorates'] - ) - .map((edge) => edge.source) - ); + const decorated = carriesDecorator(cg, surviving.map((row) => row.node)); const containers = containersOf(cg, surviving.map((row) => row.node)); const overriding = overrideCandidates(cg, surviving.map((row) => row.node), containers); // One batched count for every file still in play. A file nothing reaches is @@ -532,10 +552,19 @@ export function buildDeadCodeReport(cg: CodeGraph, query: DeadCodeQuery = {}): D const names = surviving.map((row) => row.node.name); const unresolved = cg.getUnresolvedNamesAmong(names); const ambiguousNames = cg.getAmbiguousReferencedNames(names); + // Asked only about the members no earlier rule in the loop below drops. + const hooks = byNameHookCandidates( + cg, + surviving + .map((row) => row.node) + .filter((node) => !decorated.has(node.id) && !overriding.has(node.id)), + containers, + candidates.map((row) => row.node) + ); const kept: Array<{ node: Node; generated: boolean }> = []; for (const row of surviving) { - if (decorated.has(row.node.id) || (row.node.decorators?.length ?? 0) > 0) { + if (decorated.has(row.node.id)) { excluded.decorated += 1; continue; } @@ -548,6 +577,10 @@ export function buildDeadCodeReport(cg: CodeGraph, query: DeadCodeQuery = {}): D excluded.overriding += 1; continue; } + if (hooks.has(row.node.id)) { + excluded.hooks += 1; + continue; + } if (unresolved.has(row.node.name)) { excluded.unresolvedName += 1; continue; @@ -814,44 +847,8 @@ function overrideCandidates( const containerIds = [...new Set([...containers.values()].map((node) => node.id))]; if (containerIds.length === 0) return dropped; - // Level-by-level upward walk over EVERY container at once: one query per - // level rather than one per container. `reach` maps an ancestor back to the - // containers it is an ancestor of. - const reach = new Map>(); - const seen = new Map(containerIds.map((id) => [id, new Set([id])])); - let frontier = containerIds.map((id) => ({ id, roots: new Set([id]) })); - - for (let depth = 0; depth < MAX_OVERRIDE_ANCESTOR_DEPTH && frontier.length > 0; depth++) { - const rootsOf = new Map(frontier.map((item) => [item.id, item.roots])); - const edges = cg.getOutgoingEdgesFrom( - frontier.map((item) => item.id), - ['extends', 'implements'] - ); - const next = new Map>(); - for (const edge of edges) { - if (edge.target === edge.source) continue; - const roots = rootsOf.get(edge.source); - if (!roots) continue; - const merged = next.get(edge.target) ?? new Set(); - const visited = seen.get(edge.target) ?? new Set(); - const known = reach.get(edge.target) ?? new Set(); - // An ancestor may already be a candidate root. Only skip pairs we have - // propagated, so later descendants still reach all of its ancestors. - for (const root of roots) { - if (visited.has(root)) continue; - visited.add(root); - merged.add(root); - known.add(root); - } - seen.set(edge.target, visited); - if (merged.size > 0) next.set(edge.target, merged); - if (known.size > 0) reach.set(edge.target, known); - } - frontier = []; - for (const [id, roots] of next) { - frontier.push({ id, roots }); - } - } + // `reach` maps an ancestor back to the containers it is an ancestor of. + const reach = ancestorsOf(cg, containerIds, ['extends', 'implements']); const opaqueContainers = new Set(); const externalBased = cg.getUnresolvedSupertypeSourcesAmong([...containerIds, ...reach.keys()]); @@ -901,3 +898,208 @@ function overrideCandidates( } return dropped; } + +/** + * Every ancestor of `ids` along `kinds` edges, mapped back to the ids it is an + * ancestor of. A level-by-level upward walk over EVERY id at once — one query + * per level rather than one per id — up to {@link MAX_OVERRIDE_ANCESTOR_DEPTH}. + */ +function ancestorsOf( + cg: CodeGraph, + ids: readonly string[], + kinds: EdgeKind[] +): Map> { + const reach = new Map>(); + const seen = new Map(ids.map((id) => [id, new Set([id])])); + let frontier = ids.map((id) => ({ id, roots: new Set([id]) })); + + for (let depth = 0; depth < MAX_OVERRIDE_ANCESTOR_DEPTH && frontier.length > 0; depth++) { + const rootsOf = new Map(frontier.map((item) => [item.id, item.roots])); + const edges = cg.getOutgoingEdgesFrom( + frontier.map((item) => item.id), + kinds + ); + const next = new Map>(); + for (const edge of edges) { + if (edge.target === edge.source) continue; + const roots = rootsOf.get(edge.source); + if (!roots) continue; + const merged = next.get(edge.target) ?? new Set(); + const visited = seen.get(edge.target) ?? new Set(); + const known = reach.get(edge.target) ?? new Set(); + // An ancestor may already be a root. Only skip pairs we have propagated, + // so later descendants still reach all of its ancestors. + for (const root of roots) { + if (visited.has(root)) continue; + visited.add(root); + merged.add(root); + known.add(root); + } + seen.set(edge.target, visited); + if (merged.size > 0) next.set(edge.target, merged); + if (known.size > 0) reach.set(edge.target, known); + } + frontier = []; + for (const [id, roots] of next) { + frontier.push({ id, roots }); + } + } + return reach; +} + +/** + * Which of `nodes` carry a decorator: their own outgoing `decorates` edge, an + * unresolved `decorates` row (the only trace a decorator imported from outside + * the index, like `@HostListener` or `@Cron`, leaves), or the `decorators` + * list a couple of languages record on the node itself. + */ +function carriesDecorator(cg: CodeGraph, nodes: readonly Node[]): Set { + const ids = nodes.map((node) => node.id); + const found = new Set(cg.getOutgoingEdgesFrom(ids, ['decorates']).map((edge) => edge.source)); + for (const id of cg.getUnresolvedReferenceNamesFrom(ids, ['decorates']).keys()) found.add(id); + for (const node of nodes) if ((node.decorators?.length ?? 0) > 0) found.add(node.id); + return found; +} + +/** + * Which members are hooks their framework calls by name — the ids to drop. + * + * Angular calls `ngOnInit` on every component it creates, and NestJS calls + * `onModuleInit` on every provider, whether or not the class writes + * `implements OnInit`: TypeScript erases the clause, so the framework can only + * be finding the method by its name. A class that writes the clause is already + * off the list, because its ancestor is outside the index + * ({@link overrideCandidates}). One that skips it is tied to the framework by + * nothing but the name. + * + * Which names are hooks is not a table kept here; a table would go stale and + * hide real dead code behind a name coincidence. The index says it already: + * what `OnInit` carries is what the classes that DO write `implements OnInit` + * have. A method is dropped when all of this holds: + * + * - **its class carries a decorator**, so a framework creates it + * (`@Component`, `@Injectable`). Nothing creates an undecorated class, so + * nothing calls its hooks. And the class's decorator says nothing about its + * other methods: an unused helper on a component stays on the list; + * - **an interface outside the index carries the name**. Every TypeScript class + * that writes the interface into an `implements` clause has a method of that + * name, declared or inherited from an ancestor that declares it, which is + * what TypeScript itself checks; a class that inherits from outside the index + * may have it from there, so it is no evidence either way. At least two of + * them, and more than half, declare it themselves: one class's methods are + * that class, not a contract, and a name that only a few declare while the + * rest are unreadable is not evidence of one either; + * - **more than half of those classes carry a decorator** as well: the + * interface is one that framework-created classes fill, not a library type + * whose method happens to share the name. + * + * TypeScript only — see {@link BY_NAME_DISPATCH_LANGUAGES}. + */ +function byNameHookCandidates( + cg: CodeGraph, + nodes: readonly Node[], + containers: ReadonlyMap, + unreferenced: readonly Node[] +): Set { + const dropped = new Set(); + const isHookShaped = (node: Node): boolean => + HOOK_KINDS.includes(node.kind) && BY_NAME_DISPATCH_LANGUAGES.has(node.language); + const methods = nodes.filter( + (node) => isHookShaped(node) && containers.get(node.id)?.kind === 'class' + ); + if (methods.length === 0) return dropped; + + const classes = new Map(); + for (const node of methods) { + const container = containers.get(node.id); + if (container) classes.set(container.id, container); + } + const registered = carriesDecorator(cg, [...classes.values()]); + const eligible = methods.filter((node) => registered.has(containers.get(node.id)?.id ?? '')); + if (eligible.length === 0) return dropped; + + // Every class declaring a method of each name. A hook is called by its + // framework and never by the code in the index, so each class's own + // declaration of it is unreferenced too: it is in `unreferenced` already, and + // the index need not be searched by name. (A name with a declaration that + // something does call is the ambiguous-name rule's, which drops every + // candidate of that name.) + const names = new Set(eligible.map((node) => node.name)); + const declarations = unreferenced.filter((node) => names.has(node.name) && isHookShaped(node)); + const declaredIn = containersOf( + cg, + declarations.filter((node) => !containers.has(node.id)) + ); + const declaring = new Map>(); + for (const node of declarations) { + const container = containers.get(node.id) ?? declaredIn.get(node.id); + if (container?.kind !== 'class') continue; + const ids = declaring.get(node.name); + if (ids) ids.add(container.id); + else declaring.set(node.name, new Set([container.id])); + } + + // The interfaces outside the index each of those classes writes out. + const declarers = new Set([...declaring.values()].flatMap((ids) => [...ids])); + const interfacesOf = cg.getUnresolvedReferenceNamesFrom(declarers, ['implements']); + const interfaces = new Set([...interfacesOf.values()].flat()); + if (interfaces.size === 0) return dropped; + + // Every TypeScript class writing each of those interfaces out, kept where + // more than half of them are framework-created. + const writers = cg.getUnresolvedReferenceSourcesNamed(interfaces, ['implements']); + const writerIds = [...new Set([...writers.values()].flatMap((ids) => [...ids]))]; + const typescript = [...cg.getNodesByIds(writerIds).values()].filter((node) => + BY_NAME_DISPATCH_LANGUAGES.has(node.language) + ); + const typescriptIds = new Set(typescript.map((node) => node.id)); + const registeredWriters = carriesDecorator(cg, typescript); + const implementers = new Map(); + for (const [name, ids] of writers) { + const own = [...ids].filter((id) => typescriptIds.has(id)); + const registeredCount = own.filter((id) => registeredWriters.has(id)).length; + if (registeredCount > own.length / 2) implementers.set(name, own); + } + if (implementers.size === 0) return dropped; + + // What each implementer inherits from, and which inherit from outside the + // index somewhere up the chain. + const implementerIds = [...new Set([...implementers.values()].flat())]; + const ancestors = ancestorsOf(cg, implementerIds, ['extends']); + const inherits = new Map(); + for (const [ancestor, roots] of ancestors) { + for (const root of roots) { + const bucket = inherits.get(root); + if (bucket) bucket.push(ancestor); + else inherits.set(root, [ancestor]); + } + } + const outside = cg.getUnresolvedReferenceNamesFrom([...implementerIds, ...ancestors.keys()], ['extends']); + const opaque = new Set(implementerIds.filter((id) => outside.has(id))); + for (const [ancestor, roots] of ancestors) { + if (outside.has(ancestor)) for (const root of roots) opaque.add(root); + } + + const hookNames = new Set(); + for (const name of names) { + const declared = declaring.get(name); + if (!declared) continue; + const has = (id: string): boolean => + declared.has(id) || + opaque.has(id) || + (inherits.get(id) ?? []).some((ancestor) => declared.has(ancestor)); + const contracts = new Set([...declared].flatMap((id) => interfacesOf.get(id) ?? [])); + for (const contract of contracts) { + const own = implementers.get(contract); + if (!own) continue; + const declaredCount = own.filter((id) => declared.has(id)).length; + if (declaredCount >= 2 && declaredCount > own.length / 2 && own.every(has)) { + hookNames.add(name); + break; + } + } + } + + for (const node of eligible) if (hookNames.has(node.name)) dropped.add(node.id); + return dropped; +} diff --git a/src/index.ts b/src/index.ts index 170498ba3d..c3b8d4f7ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1651,6 +1651,24 @@ export class CodeGraph { return this.queries.getUnresolvedSupertypeSourcesAmong(nodeIds); } + /** + * The names each of the given symbols refers to, by reference kind, where + * the resolver could not follow the reference — a decorator or an interface + * from outside the index, which leaves no edge. Ids with none are absent. + */ + getUnresolvedReferenceNamesFrom(nodeIds: Iterable, kinds: readonly Edge['kind'][]): Map { + return this.queries.getUnresolvedReferenceNamesFrom(nodeIds, kinds); + } + + /** + * Every symbol that refers to each of the given names, by reference kind, + * where the resolver could not follow it: for `implements`, every class that + * names a given interface from outside the index. + */ + getUnresolvedReferenceSourcesNamed(names: Iterable, kinds: readonly Edge['kind'][]): Map> { + return this.queries.getUnresolvedReferenceSourcesNamed(names, kinds); + } + /** * The symbols with the most distinct dependents, most first — the index's * hubs. Distinct dependents, not edges: a helper called forty times from one diff --git a/src/ui-server/api/deadcode.ts b/src/ui-server/api/deadcode.ts index b099debaf2..67b687f066 100644 --- a/src/ui-server/api/deadcode.ts +++ b/src/ui-server/api/deadcode.ts @@ -100,6 +100,7 @@ const EXCLUSION_LABELS: Record = { declarations: 'abstract, or declared on an interface', decorated: 'carrying a decorator, so a framework registers them', overriding: 'overriding a member declared further up', + hooks: 'framework hooks, called by name on classes that skip the interface', implicit: 'named something the language calls by itself', vendored: 'in vendored directories', testScope: 'inside a test module', From 787f75fbc9da104d9abf041978e704e0db875c1b Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 02:27:30 +0000 Subject: [PATCH 02/26] fix(go): a type written bare links its own package's, not a namesake struct elsewhere (#2408) * fix(go): a type written bare links its own package's, not a namesake struct elsewhere The Go framework resolver's name heuristics (Patterns 1-4) picked a same-named declaration by folder convention and fell back to the first candidate anywhere, outranking name matching on a tie. Go reads a bare name only from the reference's own package, so every cross-package pick was wrong: promql/parser's `Node` parameters went to discovery/kubernetes's struct, `apiv1.Node` to the same file's struct, and method calls through an expression (`err[i].Error()`, a chained `.String()`) became instantiations of structs named like the method. - The patterns guess only for a name written bare, and only in its own package; Pattern 4 takes any type there, not only structs. - isGoBareName is the one check for "Go reads this name from its own package"; a Go call through an expression never lands on a type. - Go's if/switch/for/range join the bare-call keywords, so `if T(x)` conversions stay bare. - goRefQualifier reads a variadic `...pkg.T` as written through `pkg`. Co-Authored-By: Claude Opus 5.5 * docs(changelog): the Go type fix covers exported types; name the before-state precisely Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/go-framework-own-package.test.ts | 203 +++++++++++++++++++++ src/resolution/frameworks/go.ts | 67 +++---- src/resolution/index.ts | 8 +- src/resolution/name-matcher.ts | 46 ++++- 5 files changed, 287 insertions(+), 38 deletions(-) create mode 100644 __tests__/go-framework-own-package.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fb52ae2892..4169a4227a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Dart, a type is now linked from more of the places it is written, not only from parameters and return types: the type an `extension … on` targets, a field's type, a top-level variable's type, generic arguments such as `Future.value(null)` or Riverpod's `final reportProvider = Family()`, and a local variable's type, a cast or a type check inside a function. So `callers` and impact for a model class now reach the extensions, models and providers that use it. Thanks @mg-mg-mg for the report. (#2327) - In Go, calls through a method receiver or parameter of an unexported type, the usual shape of gRPC and HTTP handlers (`s.service.AddItem()` inside `func (s *server) Create()`), now resolve, and always within that type's own package: another package's `server` with a same-named method no longer takes the call, and a method the type gets from a struct or interface it embeds is found too. Re-index Go projects after upgrading. Thanks @GoDiao for the report and the fix. (#2323) - In Go, calls into a module whose `go.mod` is not at the project root now resolve, whether it is a `server/` backend next to a `web/` frontend or one of several modules side by side as in etcd, so calls like `store.New()` and `s.db.CreateItem()` find their targets instead of being treated as calls into a third-party package. A name written through a package, like a `job.OPCommand` result type or a field of type `artifact.Manager`, now links to that package's symbol rather than a same-named one elsewhere, which also corrects links in projects with a single `go.mod`. Re-index Go projects after upgrading. Thanks @GoDiao for the report and @danusha2345 for the fix. (#2322) +- In Go, an exported type written without a package, like `Node` in `func Walk(v Visitor, node Node)`, now links to the type of that name in its own package, whether it is a struct, an interface or another kind of type. Before, it could link to a struct of the same name in an unrelated package, so prometheus's PromQL parser functions pointed at the Kubernetes discovery `Node` struct instead of the parser's own `Node` interface, or, for a result type like `Appender` in `func (f *fanout) Appender(…) Appender`, to the method itself. A type from a package outside your project, like `apiv1.Node` or `http.Handler`, no longer links to a struct of the same name in the file that uses it; a variadic parameter like `...storage.Filter` now links to that package's type; a route handler written as a method value, like `h.Follow` in an Echo or Gin app, links to that method rather than a model struct named `Follow`; and a method called on another call's result or on an indexed value, like `err[i].Error()` or a chained `.String()`, is no longer recorded as creating a struct of the same name. Re-index Go projects after upgrading. - Indexing a project that includes large bundled JavaScript files, such as a copy of pdf.js or d3, is fast again: since 1.6.2, resolving the calls in a JavaScript or TypeScript file re-read the file's text above each call, so a single bundled library could add many seconds to an index. The graph it builds is unchanged. Thanks @bompus for the report. (#2334) - The time `codegraph init` and `codegraph index` print beside the node and edge counts now covers the whole run, resolving references and linking included, and `codegraph sync` reports its whole run the same way. Before, it counted only reading and parsing the files, which can be a small part of an index, so a slow index looked fast. Thanks @bompus for the report. (#2334) - In Rust, code that uses an enum only through its variants, like `mode::Mode::A` in an expression, a `Mode::B =>` match arm or `Self::A` inside the enum's own `impl`, now shows up in that enum's callers and impact, linked to the enum that actually declares the variant rather than to a same-named type elsewhere. Standard-library variants like `Ordering::Less` or `Option::Some` and associated items like `Foo::new()` or `Foo::MAX` don't count as using a project enum. Re-index Rust projects after upgrading. Thanks @mg-mg-mg for the report and @danusha2345. (#2328) diff --git a/__tests__/go-framework-own-package.test.ts b/__tests__/go-framework-own-package.test.ts new file mode 100644 index 0000000000..137fa58804 --- /dev/null +++ b/__tests__/go-framework-own-package.test.ts @@ -0,0 +1,203 @@ +/** + * The Go framework resolver's name heuristics ("a `…Handler` is a handler + * function", "a PascalCase name is a model struct") outrank name matching on a + * tie, so where they guess wrong nothing corrects them. Go reads a name from + * one package only — one written bare from the reference's own, `pkg.Name` + * from that import's, `x[i].Name()` from whatever type `x[i]` has — so they + * guess only for a bare name, and only in its own package. Before, they took + * a struct of that name from any package: prometheus's promql/parser `Node` + * parameters went to discovery/kubernetes's struct `Node` instead of the + * package's own `Node` interface, and every `.String()` called through an + * expression became an instantiation of the struct `promql.String`. + */ +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-go-own-pkg-')); + const files: Record = { + 'go.mod': 'module example.com/app\n\ngo 1.22\n', + 'promql/parser/ast.go': `package parser + +type Node interface { + String() string +} + +type Visitor interface { + Visit(node Node, path []Node) (Visitor, error) +} + +func Walk(v Visitor, node Node, path []Node) error { + return nil +} +`, + 'discovery/kubernetes/node.go': `package kubernetes + +import apiv1 "k8s.io/api/core/v1" + +type Node struct { + name string +} + +func nodeSource(n *apiv1.Node) string { + return n.Name +} +`, + 'storage/interface.go': `package storage + +type Appender interface { + Append(ref uint64) error +} + +type Filter interface { + Accept(value string) bool +} +`, + 'storage/fanout.go': `package storage + +type fanout struct{} + +func (f *fanout) Appender() Appender { + return nil +} +`, + 'discovery/aws/aws.go': `package aws + +type Filter struct { + Name string +} +`, + 'web/api/v1/search.go': `package v1 + +import "example.com/app/storage" + +type ChainFilter struct{} + +func NewChainFilter(filters ...storage.Filter) *ChainFilter { + return &ChainFilter{} +} +`, + 'errors.go': `package gin + +type Error struct { + Err error +} + +func (msg *Error) Error() string { + return msg.Err.Error() +} + +var _ error = (*Error)(nil) + +func newError(err error) *Error { + return &Error{Err: err} +} +`, + 'binding/validator.go': `package binding + +import "strings" + +type SliceValidationError []error + +func (err SliceValidationError) Error() string { + var b strings.Builder + for i := range err { + b.WriteString(err[i].Error()) + } + return b.String() +} +`, + 'handler/handler.go': `package handler + +type Handler struct{} + +func (h *Handler) Follow(c Context) error { + return nil +} + +type Context interface{} +`, + 'handler/routes.go': `package handler + +type Group interface { + POST(path string, h func(Context) error) +} + +func (h *Handler) Register(g Group) { + g.POST("/:username/follow", h.Follow) +} +`, + 'model/user.go': `package model + +type Follow struct { + FollowerID uint +} +`, + }; + 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 }); +}); + +/** + * `kind file:qualifiedName` of each edge out of `file`'s nodes (of one kind, + * or named `from`) whose target is named `name`, deduplicated and sorted. + */ +const linksFrom = (file: string, name: string, from?: { kind?: string; name?: string }) => { + const sources = cg.getNodesInFile(file).filter((n) => (!from?.kind || n.kind === from.kind) && (!from?.name || n.name === from.name)); + const links = cg.getOutgoingEdgesFrom(sources.map((n) => n.id)) + .filter((e) => e.kind !== 'contains') + .map((e) => ({ kind: e.kind, target: cg.getNode(e.target)! })) + .filter(({ target }) => target.name === name) + .map(({ kind, target }) => `${kind} ${target.filePath}:${target.qualifiedName}`); + return [...new Set(links)].sort(); +}; + +describe('Go framework heuristics pick only in the package Go reads the name from', () => { + it('a bare type is its own package’s interface, not another package’s struct', () => { + expect(linksFrom('promql/parser/ast.go', 'Node')).toEqual(['references promql/parser/ast.go:Node']); + }); + + it('a type written through an outside import is not the same-named struct of its own file', () => { + expect(linksFrom('discovery/kubernetes/node.go', 'Node')).toEqual([]); + }); + + it('a method called through an expression is not an instantiation of a same-named struct', () => { + const links = linksFrom('binding/validator.go', 'Error'); + expect(links.filter((l) => l.startsWith('instantiates'))).toEqual([]); + expect(links).not.toContain('calls errors.go:Error'); + }); + + it('a result type is its own package’s type, not the method the line declares', () => { + expect(linksFrom('storage/fanout.go', 'Appender')).toEqual(['references storage/interface.go:Appender']); + }); + + it('a variadic `...pkg.T` is that import’s type', () => { + expect(linksFrom('web/api/v1/search.go', 'Filter')).toEqual(['references storage/interface.go:Filter']); + }); + + it('a conversion and a composite literal still reach their own package’s struct', () => { + expect(linksFrom('errors.go', 'Error', { name: '_' })).toEqual(['instantiates errors.go:Error']); + expect(linksFrom('errors.go', 'Error', { name: 'newError' })).toEqual([ + 'instantiates errors.go:Error', + 'references errors.go:Error', + ]); + }); + + it('a route’s handler written through a value is that value’s method, not a model struct', () => { + expect(linksFrom('handler/routes.go', 'Follow', { kind: 'route' })).toEqual(['references handler/handler.go:Handler::Follow']); + }); +}); diff --git a/src/resolution/frameworks/go.ts b/src/resolution/frameworks/go.ts index b90d20deaa..58453a9ee3 100644 --- a/src/resolution/frameworks/go.ts +++ b/src/resolution/frameworks/go.ts @@ -7,6 +7,7 @@ import { Node } from '../../types'; import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; import { stripCommentsForRegex } from '../strip-comments'; +import { GO_TYPE_KINDS, isGoBareName } from '../name-matcher'; import { pickByNameAndKind } from './name-heuristic'; export const goResolver: FrameworkResolver = { @@ -26,18 +27,24 @@ export const goResolver: FrameworkResolver = { }, resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { - // An embedded type is the one its package declares under that name: the - // same package's for `Node`, the import's for `pkg.Node`, which name - // matching and the import resolver work out. The patterns below pick among - // every same-named node by folder convention, and Pattern 4 sees structs - // only: `Node` embedded beside promql/parser's `type Node interface` went - // to discovery/kubernetes's struct `Node`, etcd's `Client` embedding its - // own `Lease` interface to the server's `Lease` struct. + // The patterns below guess a declaration from the shape of a name, but Go + // reads a name from one package only: one written bare from the + // reference's own, `pkg.Node` from that import's (the import resolver's + // job), `x[i].Error()` from whatever type `x[i]` has. So they guess only + // for a bare name, only in its own package, and never for an embedded + // type, which name matching and the import resolver work out. Past that, + // promql/parser's `Node` parameters and embeddings went to + // discovery/kubernetes's struct `Node` beside the package's own `Node` + // interface, `apiv1.Node` to the struct in the same file, etcd's `Client` + // embedding its own `Lease` interface to the server's `Lease` struct, and + // every `.String()` called through an expression to the struct + // `promql.String`, as an instantiation. if (ref.referenceKind === 'extends' || ref.referenceKind === 'implements') return null; + if (!isGoBareName(ref, context)) return null; // Pattern 1: Handler references if (ref.referenceName.endsWith('Handler') || ref.referenceName.startsWith('Handle')) { - const result = resolveByNameAndKind(ref, 'function', HANDLER_DIRS, context); + const result = resolveInOwnPackage(ref, FUNCTION_KINDS, context); if (result) { return { original: ref, @@ -50,7 +57,7 @@ export const goResolver: FrameworkResolver = { // Pattern 2: Service/Repository references if (ref.referenceName.endsWith('Service') || ref.referenceName.endsWith('Repository') || ref.referenceName.endsWith('Store')) { - const result = resolveByNameAndKind(ref, null, SERVICE_DIRS, context, SERVICE_KINDS); + const result = resolveInOwnPackage(ref, SERVICE_KINDS, context); if (result) { return { original: ref, @@ -63,7 +70,7 @@ export const goResolver: FrameworkResolver = { // Pattern 3: Middleware references if (ref.referenceName.endsWith('Middleware') || ref.referenceName.startsWith('Auth') || ref.referenceName.startsWith('Log')) { - const result = resolveByNameAndKind(ref, 'function', MIDDLEWARE_DIRS, context); + const result = resolveInOwnPackage(ref, FUNCTION_KINDS, context); if (result) { return { original: ref, @@ -74,9 +81,12 @@ export const goResolver: FrameworkResolver = { } } - // Pattern 4: Model/Entity references (typically PascalCase structs) + // Pattern 4: Model/Entity references — a PascalCase type of any kind: the + // package's `Node` interface or `Entry` type, not only its structs. Left + // to name matching, the result type of `func (f *fanout) Appender(…) + // Appender` linked to the method the line declares. if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) { - const result = resolveByNameAndKind(ref, 'struct', MODEL_DIRS, context); + const result = resolveInOwnPackage(ref, GO_TYPE_KINDS, context); if (result) { return { original: ref, @@ -175,24 +185,17 @@ function extractGoTailIdent(expr: string): string | null { return m ? m[1]! : null; } -// Directory patterns for framework resolution -const HANDLER_DIRS = ['handler', 'handlers', 'api', 'routes', 'controller', 'controllers']; -const SERVICE_DIRS = ['service', 'services', 'repository', 'store', 'pkg']; -const MIDDLEWARE_DIRS = ['middleware', 'middlewares']; -const MODEL_DIRS = ['model', 'models', 'entity', 'entities', 'domain', 'pkg']; -const SERVICE_KINDS = new Set(['struct', 'interface']); - -/** A framework name heuristic's pick (see name-heuristic.ts), preferring these folders. */ -function resolveByNameAndKind( - ref: UnresolvedRef, - kind: string | null, - preferredDirs: string[], - context: ResolutionContext, - kinds?: Set -): string | null { - const allowed: ReadonlySet = kinds ?? (kind ? new Set([kind]) : GO_NAMED_KINDS); - return pickByNameAndKind(ref, allowed, (f) => preferredDirs.some((d) => f.includes(`/${d}/`)), context); -} +const FUNCTION_KINDS: ReadonlySet = new Set(['function']); +const SERVICE_KINDS: ReadonlySet = new Set(['struct', 'interface']); -/** Any declaration a name can be (the heuristic with no kind). */ -const GO_NAMED_KINDS: ReadonlySet = new Set(['function', 'method', 'struct', 'interface', 'type_alias', 'variable', 'constant']); +/** + * A framework name heuristic's pick (see name-heuristic.ts) among the + * declarations of the reference's own package — its directory — its own + * file's first. No folder convention applies: Go never reads a bare name from + * another package, a dot import aside. + */ +function resolveInOwnPackage(ref: UnresolvedRef, kinds: ReadonlySet, context: ResolutionContext): string | null { + const dir = ref.filePath.slice(0, ref.filePath.lastIndexOf('/') + 1); + const inPackage = (n: Node) => n.filePath.startsWith(dir) && !n.filePath.slice(dir.length).includes('/'); + return pickByNameAndKind(ref, kinds, () => false, context, { accept: inPackage }); +} diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 7e6d2034a4..6716adf5d4 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -21,7 +21,7 @@ import { isImportableKind, CPP_DEFINE_SIGNATURE, } from './types'; -import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified } from './name-matcher'; +import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoBareName, GO_TYPE_KINDS } from './name-matcher'; import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { gateSwiftTypeTarget, clearSwiftTypeVisibility, swiftExtendedConformances } from './swift-type-visibility'; @@ -3036,6 +3036,12 @@ export class ReferenceResolver { if (ref.referenceKind === 'calls') { const target = this.nodeById(result.targetNodeId); if (target?.kind === 'constant' && CPP_DEFINE_SIGNATURE.test(target.signature ?? '')) return null; + // A Go call made through an expression — `h.Validate().Error()`, a + // `.Metrics()` chained onto the line above — calls a method of whatever + // the expression is. A type is called only bare (`T(x)`, `(*T)(x)`) or + // through its package; as a struct here it read as an instantiation. + if (target && ref.language === 'go' && GO_TYPE_KINDS.has(target.kind) && + !ref.referenceName.includes('.') && !isGoBareName(ref, this.context)) return null; } // An `imports` reference names something importable — never a member that diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 0238266417..045fe03fc3 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1905,9 +1905,10 @@ function goRefQualification(ref: UnresolvedRef, context: ResolutionContext): GoQ const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split(/\r?\n/)[ref.line - 1] ?? ''; const at = Math.max(0, ref.column); // The qualifier right before the name at the reference's column, or the - // line's only spelling of the name. - const written = line.startsWith(name, at) ? /(?:^|[^\w.])([A-Za-z_]\w*)\.$/.exec(line.slice(0, at))?.[1] - : !new RegExp(`(? m.localName === written) : undefined; const found = { written, imported }; memo.set(key, found); @@ -2396,6 +2397,37 @@ function isBareGoCall(ref: UnresolvedRef, context: ResolutionContext): boolean { return ref.language === 'go' && isReceiverLessCall(ref, context); } +/** + * Whether a Go reference's name is written bare, so that Go reads it from the + * reference's own package: a type `Node` (variadic `...Node` too), a composite + * literal `&Event{}`, a route's handler `Index`, a call `Walk(v, n)` or a + * conversion `(*Block)(pb)`. Not `parser.Node` or `...chunks.Meta`, written + * through an import, nor a name reached through a value: `err[i].Error()`, a + * `.String()` chained onto the line above, a route's handler `h.Follow`. + */ +export function isGoBareName(ref: UnresolvedRef, context: ResolutionContext): boolean { + const name = ref.referenceName; + if (ref.language !== 'go' || !/^[A-Za-z_]\w*$/.test(name)) return false; + if (ref.referenceKind === 'calls' && isReceiverLessCall(ref, context)) return true; + const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] + ?? context.readFile(ref.filePath)?.split(/\r?\n/)[ref.line - 1]; + if (line === undefined) return false; + // A call's column is its expression's: only a conversion's parenthesized + // type is still bare there. + if (ref.referenceKind === 'calls') { + return line[ref.column] === '(' && new RegExp(`^\\(\\s*\\*?\\s*${name}\\s*\\)\\s*\\(`).test(line.slice(ref.column)); + } + if (line.startsWith(name, ref.column)) { + let end = ref.column; + while (end > 0 && WHITESPACE.test(line[end - 1]!)) end--; + return line[end - 1] !== '.' || (end >= 3 && line.slice(end - 3, end) === '...'); + } + // A route's handler is recorded at the start of its line: its spelling + // there, outside the path string. + const code = line.replace(/"(?:[^"\\]|\\.)*"|`[^`]*`/g, (s) => ' '.repeat(s.length)); + return new RegExp(`(? = new Set([ 'return', 'await', 'yield', 'typeof', 'void', 'new', 'else', 'case', 'throw', 'in', 'of', 'instanceof', 'go', 'defer', + 'if', 'switch', 'for', 'range', ]); /** @@ -10611,7 +10647,7 @@ function goDeclaredTypePackage(raw: string | undefined, filePath: string, contex } /** The node kinds a Go `type` declaration produces. */ -const GO_TYPE_KINDS: ReadonlySet = new Set(['struct', 'interface', 'type_alias']); +export const GO_TYPE_KINDS: ReadonlySet = new Set(['struct', 'interface', 'type_alias']); /** * `methodName` on the Go type `typeName` that the package in directory From dd7064698c1b0c0e2801c8f4da076ff6810818d9 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 02:27:44 +0000 Subject: [PATCH 03/26] fix(react-router): read route tables another file hands the router (#2400) A route table kept in a file of its own produced no routes: the ASP.NET Core React template's AppRoutes array, which App.js maps into , a routes array imported into useRoutes(routes) or createBrowserRouter(routes), and codedthemes' one-object-per-file [MainRoutes, LoginRoutes]. Route objects were read only from a file that itself names a data router, so these apps had no routes and their navigate()/ navigation reached nothing. The table's own file cannot tell it from a menu's { path, element } list, so the evidence is the file that hands it over: a useRoutes / create*Router argument, or a .map whose callback renders a from the item's own fields ({...route}, {...rest}, path={route.path}). A new FrameworkResolver.crossFileNodes hook keeps those routes, reconciled in runPostExtract on every index and sync (removal-only syncs included): new ones are inserted with pending references, ones no longer wanted are removed with their incoming resolution edges resurrected, unchanged ones keep their edges. The React pass starts from the files that import React Router, follows the import to the table and the tables it names (...ApiAuthorizationRoutes, children: x), each under its mount, and owns its routes by id (route:::table:). Route objects (tables and data routers alike): an index: true route is the page at its parent's address; a route object around others is their layout (a references edge marked layout: true, as Angular's), and is not a page of its own where a child claims its address. A route renders past a *Wrapper or *Route guard, and through a same-file lazy(() => import(...)) to the module it loads, never a same-named component in another app. useRoutes joins the file-level gate. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/react-router-nested-lazy.test.ts | 12 +- __tests__/react-router.test.ts | 573 ++++++++++++++++++++- docs/design/framework-coverage.md | 24 +- docs/viewer-launch-changelog.md | 2 + src/extraction/index.ts | 34 +- src/index.ts | 20 +- src/resolution/frameworks/react-router.ts | 17 +- src/resolution/frameworks/react.ts | 565 ++++++++++++++++++-- src/resolution/index.ts | 56 +- src/resolution/resurrect-ref.ts | 38 ++ src/resolution/types.ts | 25 + 12 files changed, 1270 insertions(+), 97 deletions(-) create mode 100644 src/resolution/resurrect-ref.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4169a4227a..71dd32e4da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In Go, an interface that embeds another, like gin's `IRouter` embedding `IRoutes`, now links to it, so `codegraph_explore` and impact show the embedded interface as its base. Before, every embedded interface was dropped. An embedded field written through another package of your project, like `*refresh.Discovery` or `storage.Appender`, or with type arguments, like `List[int]`, now links too. One from the standard library or another module, like `io.Closer` or `sync.Mutex`, links to nothing rather than to a project type that shares its name, and a type-set constraint like `~int | ~float64` is not an embedding. An embedded type written without a package now always links to its own package's type: before, etcd's `Client`, which embeds its own `Lease` interface, was linked to a `Lease` struct in another package. Re-index Go projects after upgrading. - In Angular templates, a property binding, an interpolation, a structural directive or a control-flow block that calls one of the component's own members, like `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading()) {`, now links the component to that member, and so does reading a getter, like `[disabled]="!canSave"`, or handing a method to a child component, like `[displayWith]="displayFn"` or `trackBy: trackById`. Before, only event bindings such as `(click)="save()"` were read, so a signal, a `computed` value, a getter or a method used only from a template had no callers and looked unused. A pipe, a template variable, a call on another object such as `form.reset()`, and a plain field read like `[value]="title"` still link nothing. Re-index Angular projects after upgrading. +- React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into ``, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/react-router-nested-lazy.test.ts b/__tests__/react-router-nested-lazy.test.ts index e47dccdfc3..da5d594143 100644 --- a/__tests__/react-router-nested-lazy.test.ts +++ b/__tests__/react-router-nested-lazy.test.ts @@ -75,11 +75,14 @@ afterAll(() => { if (root) fs.rmSync(root, { recursive: true, force: true }); }); -const renders = (routeName: string): string[] => { +const bindings = (routeName: string, layout: boolean): 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); + return cg.getOutgoingEdges(route.id) + .filter((e) => e.kind === 'references' && Boolean((e.metadata as Record | undefined)?.layout) === layout) + .map((e) => cg.getNode(e.target)!.name); }; +const renders = (routeName: string): string[] => bindings(routeName, false); describe('nested, lazy and constant React Router routes', () => { it('compose their paths and render their modules', () => { @@ -90,6 +93,11 @@ describe('nested, lazy and constant React Router routes', () => { expect(renders('/app/discussions')).toEqual(['DiscussionsRoute']); }); + it('a child route renders inside its parent’s component, the layout around it', () => { + expect(bindings('/app/discussions', true)).toEqual(['AppRoot']); + expect(bindings('/', true)).toEqual([]); + }); + 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); diff --git a/__tests__/react-router.test.ts b/__tests__/react-router.test.ts index fa5cd56e16..1ae76ff784 100644 --- a/__tests__/react-router.test.ts +++ b/__tests__/react-router.test.ts @@ -574,8 +574,9 @@ describe('react-router: route declaration boundaries (#1348)', () => { ]); `, extension); expect(result).toEqual({ - paths: ['/', '/data/prefs', '/long', '/quoted', '/sibling'], - bindings: ['/->DataSettings', '/data/prefs->DataSettings', '/long->DataSettings', '/quoted->DataSettings', '/sibling->DataSettings'], + // `{ index: true, Component: DataIndex }` is the page at its parent's address. + paths: ['/', '/data', '/data/prefs', '/long', '/quoted', '/sibling'], + bindings: ['/->DataSettings', '/data->DataIndex', '/data/prefs->DataSettings', '/long->DataSettings', '/quoted->DataSettings', '/sibling->DataSettings'], }); }); @@ -692,3 +693,571 @@ export default function FeaturePage() { return

Features

; } expect(navsFrom('PrivateRoute')).toEqual(['redirect /']); }); }); + +// ============================================================================= +// Route tables another file hands the router +// ============================================================================= + +function writeFiles(root: string, files: Record): void { + 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); + } +} + +async function indexProject(files: Record): Promise<{ root: string; cg: CodeGraph }> { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rr-tables-')); + writeFiles(root, files); + return { root, cg: await CodeGraph.init(root, { index: true }) }; +} + +/** `path -> Component` for what each route renders, `path ~> Layout` for each layout around it. */ +function routeBindings(cg: CodeGraph): string[] { + return cg.getNodesByKind('route').flatMap((r) => cg.getOutgoingEdges(r.id) + .filter((e) => e.kind === 'references') + .map((e) => `${r.name} ${(e.metadata as Record | undefined)?.layout ? '~>' : '->'} ${cg.getNode(e.target)?.name}`)) + .sort(); +} + +/** `Source -> /path` for every navigation into a route. */ +function navigations(cg: CodeGraph): string[] { + return cg.getNodesByKind('route').flatMap((r) => cg.getIncomingEdges(r.id) + .filter((e) => e.kind === 'navigates') + .map((e) => `${cg.getNode(e.source)?.name} -> ${r.name}`)) + .sort(); +} + +const routeNames = (cg: CodeGraph): string[] => cg.getNodesByKind('route').map((r) => r.name).sort(); + +const component = (name: string): string => `export function ${name}() {\n return
${name}
;\n}\n`; + +/** jasontaylordev/CleanArchitecture's ClientApp-React and the ASP.NET Core React template it comes from. */ +const ASPNET_TEMPLATE: Record = { + 'src/Web/ClientApp/package.json': JSON.stringify({ name: 'web', dependencies: { react: '^18', 'react-router-dom': '^6' } }), + 'src/Web/ClientApp/src/AppRoutes.js': `import ApiAuthorzationRoutes from './components/api-authorization/ApiAuthorizationRoutes'; +import { Counter } from "./components/Counter"; +import { FetchData } from "./components/FetchData"; +import { Home } from "./components/Home"; +import { ProtectedRoute } from "./components/ProtectedRoute"; +import AdminWrapper from "./components/AdminWrapper"; +import { Users } from "./components/Users"; + +const AppRoutes = [ + { + index: true, + element: + }, + { + path: '/counter', + element: + }, + { + path: '/fetch-data', + requireAuth: true, + element: + }, + { + path: 'admin/users', + element: + }, + ...ApiAuthorzationRoutes +]; + +export default AppRoutes; +`, + 'src/Web/ClientApp/src/components/api-authorization/ApiAuthorizationRoutes.js': `import { Profile } from './Profile'; +import { Login } from './Login'; +import { ApplicationPaths } from './ApiAuthorizationConstants'; + +const ApiAuthorizationRoutes = [ + { + path: '/authentication/profile', + element: + }, + { + path: ApplicationPaths.Login, + element: loginAction('login') + } +]; + +function loginAction(name) { + return ; +} + +export default ApiAuthorizationRoutes; +`, + 'src/Web/ClientApp/src/components/api-authorization/ApiAuthorizationConstants.js': + "const prefix = '/authentication';\nexport const ApplicationPaths = {\n Login: `${prefix}/login`,\n};\n", + 'src/Web/ClientApp/src/App.js': `import React, { Component } from 'react'; +import { Route, Routes } from 'react-router-dom'; +import AppRoutes from './AppRoutes'; +import AuthorizeRoute from './components/api-authorization/AuthorizeRoute'; +import { Layout } from './components/Layout'; + +export default class App extends Component { + static displayName = App.name; + + render() { + return ( + + + {AppRoutes.map((route, index) => { + const { element, requireAuth, ...rest } = route; + return : element} />; + })} + + + ); + } +} +`, + 'src/Web/ClientApp/src/components/NavMenu.js': `import { Link, NavLink, useNavigate } from 'react-router-dom'; +import { menu } from '../menu'; + +export function NavMenu() { + const navigate = useNavigate(); + const showProfile = () => { + navigate('/authentication/profile'); + }; + return ( + + ); +} +`, + // `{ path, element }` lists that are not route tables: a menu rendered as + // links, a list nothing renders, and a table mapped into a path it does not + // hold (`layout + path`). + 'src/Web/ClientApp/src/menu.js': `import { HelpIcon, SettingsIcon } from './icons'; +export const menu = [ + { path: '/help', element: }, + { path: '/settings', element: }, +]; +`, + 'src/Web/ClientApp/src/breadcrumbs.js': `import { Crumb } from './icons'; +export const crumbs = [{ path: '/history', element: }]; +`, + 'src/Web/ClientApp/src/dashboard.js': `import { Route, Routes } from 'react-router-dom'; +import { Stats } from './icons'; +const dashboardRoutes = [{ path: '/stats', layout: '/admin', element: }]; +export function Dashboard() { + return {dashboardRoutes.map((r) => )}; +} +`, + 'src/Web/ClientApp/src/icons.js': component('HelpIcon') + component('SettingsIcon') + component('Crumb') + component('Stats'), + 'src/Web/ClientApp/src/components/Home.js': component('Home'), + 'src/Web/ClientApp/src/components/Counter.js': component('Counter'), + 'src/Web/ClientApp/src/components/FetchData.js': component('FetchData'), + 'src/Web/ClientApp/src/components/ProtectedRoute.js': 'export function ProtectedRoute({ children }) {\n return children;\n}\n', + 'src/Web/ClientApp/src/components/AdminWrapper.js': 'export default function AdminWrapper({ children }) {\n return children;\n}\n', + 'src/Web/ClientApp/src/components/Users.js': component('Users'), + 'src/Web/ClientApp/src/components/Layout.js': + "import { NavMenu } from './NavMenu';\nexport function Layout({ children }) {\n return
{children}
;\n}\n", + 'src/Web/ClientApp/src/components/api-authorization/Profile.js': component('Profile'), + 'src/Web/ClientApp/src/components/api-authorization/Login.js': component('Login'), + 'src/Web/ClientApp/src/components/api-authorization/AuthorizeRoute.js': + 'export default function AuthorizeRoute({ element }) {\n return element;\n}\n', +}; + +describe('react-router: a route table another file maps into (the ASP.NET Core React template)', () => { + let root: string; + let cg: CodeGraph; + beforeAll(async () => { + ({ root, cg } = await indexProject(ASPNET_TEMPLATE)); + }); + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reads the table App.js spreads into , and the table it spreads in turn', () => { + expect(routeBindings(cg)).toEqual([ + // `index: true` at the top of the table is the app's `/`. + '/ -> Home', + // `` and + // `` render what they wrap. + '/admin/users -> Users', + '/authentication/profile -> Profile', + '/counter -> Counter', + '/fetch-data -> FetchData', + ]); + // An entry that renders a call's result names no component, so it is no route. + expect(routeNames(cg)).toEqual(['/', '/admin/users', '/authentication/profile', '/counter', '/fetch-data']); + }); + + it('keeps each route at the line of its own object in the table', () => { + const counter = cg.getNodesByKind('route').find((r) => r.name === '/counter')!; + expect(counter.filePath).toBe('src/Web/ClientApp/src/AppRoutes.js'); + expect(counter.startLine).toBe(15); + }); + + it('makes no route of a menu, a list nothing hands the router, or a path the table does not hold', () => { + for (const p of ['/help', '/settings', '/history', '/stats', '/admin/stats']) expect(routeNames(cg)).not.toContain(p); + }); + + it('gives the navigation that names those routes somewhere to go', () => { + expect(navigations(cg)).toEqual([ + 'NavMenu -> /', + 'NavMenu -> /counter', + 'NavMenu -> /fetch-data', + 'showProfile -> /authentication/profile', + ]); + }); + + it('lands on the Screens tab', async () => { + const screens = await buildScreens(cg, root); + expect(screens.routed).toBe(true); + expect(screens.screens.map((s) => s.path).sort()).toEqual(['/', '/admin/users', '/authentication/profile', '/counter', '/fetch-data']); + }); +}); + +describe('react-router: a table handed to useRoutes from another file, an index route under a layout', () => { + let root: string; + let cg: CodeGraph; + beforeAll(async () => { + ({ root, cg } = await indexProject({ + 'package.json': JSON.stringify({ name: 'kit', dependencies: { react: '^18', 'react-router-dom': '^6' } }), + // A table written as a function of the login state, the way many admin kits write it. + 'src/routes.jsx': `import { Navigate } from 'react-router-dom'; +import DashboardLayout from './layouts/DashboardLayout'; +import MainLayout from './layouts/MainLayout'; +import { Account, Dashboard, Landing, Login } from './pages'; + +const routes = (isLoggedIn) => [ + { + path: 'app', + element: isLoggedIn ? : , + children: [ + { path: 'dashboard', element: }, + { path: 'account', element: }, + ], + }, + { + path: '/', + element: , + children: [ + { index: true, element: }, + { path: 'login', element: }, + ], + }, +]; + +export default routes; +`, + 'src/App.jsx': `import { useRoutes } from 'react-router-dom'; +import routes from './routes'; + +export default function App({ isLoggedIn }) { + return useRoutes(routes(isLoggedIn)); +} +`, + 'src/layouts/MainLayout.jsx': `import { Link, Outlet } from 'react-router-dom'; +export default function MainLayout() { + return
Dashboard
; +} +`, + 'src/layouts/DashboardLayout.jsx': `import { Outlet } from 'react-router-dom'; +export default function DashboardLayout() { + return ; +} +`, + 'src/pages.jsx': `import { useNavigate } from 'react-router-dom'; +export function Dashboard() { return
Dashboard
; } +export function Account() { return
Account
; } +export function Landing() { return
Landing
; } +export function Login() { + const navigate = useNavigate(); + const onSubmit = () => navigate('/app/account'); + return
; +} +`, + })); + }); + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reads the table the function returns, nested paths composed', () => { + expect(routeBindings(cg)).toEqual([ + // The index route is the page at `/`; MainLayout is the layout around it, not a second `/`. + '/ -> Landing', + '/ ~> MainLayout', + // `app`'s element is a choice made at runtime: a path to sit under, no layout. + '/app/account -> Account', + '/app/dashboard -> Dashboard', + '/login -> Login', + '/login ~> MainLayout', + ]); + }); + + it("draws a layout's links on every screen inside it", async () => { + expect(navigations(cg)).toEqual([ + 'MainLayout -> /app/dashboard', + 'onSubmit -> /app/account', + // The table's own guard: `isLoggedIn ? : `. + 'routes -> /login', + ]); + const screens = await buildScreens(cg, root); + expect(screens.routed).toBe(true); + const at = (p: string) => screens.screens.find((s) => s.path === p)!.id; + for (const from of ['/', '/login']) { + expect(screens.links.find((l) => l.from === at(from) && l.to === at('/app/dashboard'))).toBeDefined(); + } + expect(screens.links.find((l) => l.from === at('/login') && l.to === at('/app/account'))).toBeDefined(); + }); +}); + +describe("react-router: route objects in their own files, handed to createBrowserRouter (codedthemes' admin templates)", () => { + let root: string; + let cg: CodeGraph; + beforeAll(async () => { + ({ root, cg } = await indexProject({ + 'package.json': JSON.stringify({ name: 'berry', dependencies: { react: '^18', 'react-router-dom': '^7' } }), + 'src/routes/index.jsx': `import { createBrowserRouter } from 'react-router-dom'; + +// routes +import AuthenticationRoutes from './AuthenticationRoutes'; +import MainRoutes from './MainRoutes'; + +const router = createBrowserRouter([MainRoutes, AuthenticationRoutes], { + basename: import.meta.env.VITE_APP_BASE_NAME +}); + +export default router; +`, + 'src/routes/MainRoutes.jsx': `import { lazy } from 'react'; +import MainLayout from '../layout/MainLayout'; +import Loadable from '../ui-component/Loadable'; + +const DashboardDefault = Loadable(lazy(() => import('../views/DashboardDefault'))); +// An import only the app's own build resolves (\`baseUrl\`). +const SamplePage = Loadable(lazy(() => import('views/sample-page'))); + +const MainRoutes = { + path: '/', + element: , + children: [ + { + path: '/', + element: + }, + { + path: 'dashboard', + children: [ + { + path: 'default', + element: + } + ] + }, + { + path: '/sample-page', + element: + } + ] +}; + +export default MainRoutes; +`, + 'src/routes/AuthenticationRoutes.jsx': `import MinimalLayout from '../layout/MinimalLayout'; +import LoginPage from '../views/LoginPage'; + +const AuthenticationRoutes = { + path: '/', + element: , + children: [ + { + path: '/pages/login', + element: + } + ] +}; + +export default AuthenticationRoutes; +`, + 'src/layout/MainLayout.jsx': 'export default function MainLayout() {\n return
;\n}\n', + 'src/layout/MinimalLayout.jsx': 'export default function MinimalLayout() {\n return
;\n}\n', + 'src/views/DashboardDefault.jsx': 'export default function DashboardDefault() {\n return
;\n}\n', + 'src/views/LoginPage.jsx': 'export default function LoginPage() {\n return
;\n}\n', + 'src/ui-component/Loadable.jsx': 'export default function Loadable(Component) {\n return (props) => ;\n}\n', + // The repository's other app, with pages of the same names. + 'next/package.json': JSON.stringify({ name: 'berry-next', dependencies: { next: '^15', react: '^18' } }), + 'next/src/views/sample-page.jsx': 'export default function SamplePage() {\n return
;\n}\n', + 'next/src/views/default.jsx': 'export default function DashboardDefault() {\n return
;\n}\n', + })); + }); + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('reads each route object the array names, its children under its path', () => { + expect(routeBindings(cg)).toEqual([ + // MainRoutes' `/` child claims its layout's address. + '/ -> DashboardDefault', + '/ ~> MainLayout', + // No child of the login layout claims `/`, so it is a page there of its + // own — the rule Angular and Vue routes follow. + '/ -> MinimalLayout', + '/dashboard/default -> DashboardDefault', + '/dashboard/default ~> MainLayout', + '/pages/login -> LoginPage', + '/pages/login ~> MinimalLayout', + '/sample-page -> SamplePage', + '/sample-page ~> MainLayout', + ].sort()); + }); + + it('binds a page the route file loads lazily to the module it loads, and never to a same-named page of another app', () => { + const rendered = (p: string): string[] => cg.getNodesByKind('route').filter((r) => r.name === p).flatMap((r) => cg.getOutgoingEdges(r.id) + .filter((e) => e.kind === 'references' && !(e.metadata as Record | undefined)?.layout) + .map((e) => { const n = cg.getNode(e.target)!; return `${n.kind} ${n.name} ${n.filePath}`; })); + expect(rendered('/dashboard/default')).toEqual(['function DashboardDefault src/views/DashboardDefault.jsx']); + // Its module is out of reach, so the page is the declaration itself. + expect(rendered('/sample-page')).toEqual(['constant SamplePage src/routes/MainRoutes.jsx']); + }); +}); + +describe('react-router: a table mapped inside in the same file', () => { + let root: string; + let cg: CodeGraph; + beforeAll(async () => { + ({ root, cg } = await indexProject({ + 'ClientApp/package.json': JSON.stringify({ name: 'client', dependencies: { react: '^18', 'react-router-dom': '^6' } }), + 'ClientApp/src/routes.js': `import { Routes, Route } from 'react-router-dom'; +import DashboardLayout from './layouts/dashboard'; +import DashboardApp from './pages/DashboardApp'; +import LandingPage from './pages/LandingPage'; +import TicketDetail from './pages/TicketDetail'; + +const DashboardRoutes = [ + { + path: 'app', + requireAuth: true, + element: + }, + { + path: 'tickets/:id', + requireAuth: true, + element: + } +]; + +export default function Router() { + return ( + + }/> + }> + {DashboardRoutes.map((route, index) => { + const {element, requireAuth, ...rest} = route; + return ; + })} + + + ); +} +`, + 'ClientApp/src/layouts/dashboard.js': 'export default function DashboardLayout() {\n return
;\n}\n', + 'ClientApp/src/pages/DashboardApp.js': 'export default function DashboardApp() {\n return
;\n}\n', + 'ClientApp/src/pages/LandingPage.js': 'export default function LandingPage() {\n return
;\n}\n', + 'ClientApp/src/pages/TicketDetail.js': 'export default function TicketDetail() {\n return
;\n}\n', + })); + }); + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it("composes the table's relative paths onto the it is mapped inside", () => { + expect(routeBindings(cg)).toEqual([ + '/dashboard -> DashboardLayout', + '/dashboard/app -> DashboardApp', + '/dashboard/tickets/:id -> TicketDetail', + '/landing -> LandingPage', + ]); + }); +}); + +describe('react-router: a route table as the files around it change', () => { + let root: string; + let cg: CodeGraph; + const app = (body: string) => `import { Route, Routes } from 'react-router-dom'; +import AppRoutes from './AppRoutes'; +import { Counter } from './Counter'; + +export default function App() { + return ( + + ${body} + + ); +} +`; + const MAPPED = app('{AppRoutes.map(({ element, ...rest }, index) => )}'); + const table = (extra: string) => `import { Counter } from './Counter'; +import { Home } from './Home'; +import { FetchData } from './FetchData'; + +const AppRoutes = [${extra} + { index: true, element: }, + { path: '/counter', element: }, +]; + +export default AppRoutes; +`; + beforeAll(async () => { + ({ root, cg } = await indexProject({ + 'package.json': JSON.stringify({ name: 'app', dependencies: { react: '^18', 'react-router-dom': '^6' } }), + 'src/AppRoutes.js': table(''), + 'src/App.js': MAPPED, + 'src/Home.js': component('Home'), + 'src/Counter.js': component('Counter'), + 'src/FetchData.js': component('FetchData'), + 'src/NavMenu.js': `import { Link, useNavigate } from 'react-router-dom'; +export function NavMenu() { + const navigate = useNavigate(); + const goHome = () => navigate('/'); + return ; +} +`, + })); + }); + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it('starts with the table read', () => { + expect(routeNames(cg)).toEqual(['/', '/counter']); + expect(navigations(cg)).toEqual(['NavMenu -> /counter', 'goHome -> /']); + }); + + it('an edit to the table moves its routes, and what navigated to them still does', async () => { + writeFiles(root, { 'src/AppRoutes.js': table("\n { path: '/fetch-data', element: },") }); + await cg.sync(); + expect(routeBindings(cg)).toEqual(['/ -> Home', '/counter -> Counter', '/fetch-data -> FetchData']); + expect(navigations(cg)).toEqual(['NavMenu -> /counter', 'goHome -> /']); + }); + + it('a file that stops handing the table over takes its routes with it, and putting it back brings them back', async () => { + writeFiles(root, { 'src/App.js': app('} />') }); + await cg.sync(); + expect(routeNames(cg)).toEqual(['/static']); + writeFiles(root, { 'src/App.js': MAPPED }); + await cg.sync(); + expect(routeNames(cg)).toEqual(['/', '/counter', '/fetch-data']); + expect(navigations(cg)).toContain('NavMenu -> /counter'); + }); + + it('deleting that file is a change too', async () => { + fs.rmSync(path.join(root, 'src/App.js')); + await cg.sync(); + expect(routeNames(cg)).toEqual([]); + }); +}); diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index e08bb44262..c4a3765d2d 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -1,6 +1,6 @@ # Framework & language coverage — what is done, what is left -**Last verified: 2026-08-29** (Angular row: 2026-10-06) against the build at that date. Re-verify with the +**Last verified: 2026-08-29** (Angular row: 2026-10-06; React Router row: 2026-10-06) against the build at that date. Re-verify with the queries in [Checking this file is still true](#checking-this-file-is-still-true) before trusting a row; this is a snapshot, not a live view. @@ -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 (nested `children`, `lazy` routes, `paths.x.path` constants through an app's own tsconfig alias: 0 → 9 named, linked routes), and its `paths.x.getHref(id)` links and `navigate(paths.x.getHref())` calls (0 → 11 navigates) | +| 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), and its `paths.x.getHref(id)` links and `navigate(paths.x.getHref())` calls (0 → 11 navigates). Route tables another file hands the router (`tableRoutes` in `frameworks/react.ts`, trap 13): the ASP.NET Core React template's `AppRoutes.map(… )` on jasontaylordev/CleanArchitecture's `ClientApp-React` (0 → 6 routes, 11 navigates), replaysMike/Binner (0 → 46, 87), GavinLonDigital/RankingApp, GarrettHays/PaydirtPickem, charlessolar/eShopOnContainersDDD; `useRoutes(routes)` from another file and `[...sessionRoutes]` on uilibrary/matx-react (0 → 7, 16); codedthemes' `createBrowserRouter([MainRoutes, LoginRoutes])` on berry and mantis (9 and 8 routes, no navigation they can resolve); a table `.map`ped inside `` on RADeveloping/chickadeeinvest; `useRoutes([...])` in place on minimal-ui-kit/material-kit-react (0 → 7, bound through `lazy(() => import(…))` to each page) | | 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) | @@ -263,6 +263,26 @@ Each of these cost real debugging time; they are not hypothetical. template-bound handler there was listed as dead code. A new detector that gates on a dependency should ask `dependsOn`, not read a `package.json` itself, so it sees the same manifests every other detector does. +13. **A route table can be named only by another file.** The ASP.NET Core + React template keeps its routes in `AppRoutes.js`, a file that never names + the router, and renders them from `App.js` with `AppRoutes.map(({ element, + ...rest }) => )`; codedthemes' admin + kits put one route object per file and list them in + `createBrowserRouter([MainRoutes, LoginRoutes])`. Read on its own, such a + file cannot be told from a menu's `{ path, element }` list, and per-file + extraction cannot see the file that hands it over, nor run again when that + file changes. So React keeps those routes in the cross-file pass + (`FrameworkResolver.crossFileNodes`, reconciled in `runPostExtract` on every + index and sync, removals included): it starts from the files that import + React Router, takes only a `useRoutes` / `create*Router` argument or a + `.map` whose callback renders a `` from the item's own fields + (`{...route}`, `{...rest}`, `path={route.path}` — never + `path={r.layout + r.path}`), follows the import to the table and the tables + it names in turn, and owns its routes by id (`…:table:`). Two limits + it shares with every router: a table rendered by a component that is + itself mounted under another file's `` reads as if + at the root, and a navigation that failed while its route was gone is not + retried until a full index. --- diff --git a/docs/viewer-launch-changelog.md b/docs/viewer-launch-changelog.md index 3ef374bcb1..4afa667d68 100644 --- a/docs/viewer-launch-changelog.md +++ b/docs/viewer-launch-changelog.md @@ -141,6 +141,8 @@ These describe `codegraph ui` and its screens. They were taken out of `## [Unrel - **A server action written through a wrapper starts its transition on the right page.** `export const signIn = validatedAction(schema, async (data) => { … redirect('/dashboard') })` — the arrow inside is no symbol of its own — now belongs to `signIn` on the Screens tab, and `signIn` is attributed to the page whose component hands it to `useActionState(signIn, …)`, read from the source when the graph holds no such edge. +- **React Router apps that keep their routes in a table get a Screens picture.** The ASP.NET Core React template's `AppRoutes` array, a `routes` array handed to `useRoutes` or `createBrowserRouter` from another file, and admin kits' `[MainRoutes, LoginRoutes]` route objects drew no screens, because their routes were never read. They are now, with the links and `navigate(…)` calls between them. A parent route object's component is drawn as the layout around the screens inside it: its own links count from each of those screens, and when an `index: true` child claims its address, that child is the screen there. Re-index React projects after upgrading. + ## Fixes — Steps — what a call does, and when - **A Steps picture with nothing past its anchor says why.** Starting Steps from a helper that only computes drew one box and no explanation. The panel now says that nothing it sets in motion is a step the picture draws, explains that calls between plain functions fold into the lines, and links to the symbol's callers and callees. diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 51445af38b..8566e03988 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -17,7 +17,6 @@ import { ExtractionError, Edge, UnresolvedReference, - ReferenceKind, } from '../types'; import { QueryBuilder, NodeIdentity } from '../db/queries'; import { extractFromSource } from './tree-sitter'; @@ -35,6 +34,7 @@ import { detectFrameworks, getFrameworkResolver } from '../resolution/frameworks import { declaredDependencies } from '../resolution/frameworks/package-deps'; import type { ResolutionContext } from '../resolution/types'; import { createYielder, type MaybeYield } from '../resolution/cooperative-yield'; +import { resurrectRefFromDroppedEdge } from '../resolution/resurrect-ref'; import { MAX_SOURCE_FILE_SIZE_BYTES, oversizeStamp, readBoundedSource, readBoundedSourceSync } from '../file-limits'; export { oversizeStamp }; @@ -1771,38 +1771,6 @@ function scanDirectoryWalk( return files; } -/** - * Resurrect a resolution edge that is about to be dropped (its target symbol - * was removed, renamed, or its whole file deleted) as the ORIGINAL unresolved - * reference that created it, read from the refName/refKind stamp - * `createEdges` writes into edge metadata. Inserted as status='pending', the - * ref is consumed by the same sync's resolution sweep: it rebinds to an - * alternative definition if one exists, or parks as status='failed' where the - * #1240 retry finds it if the symbol later reappears. - * - * Returns null — drop silently, the pre-#1240 behavior — for edges without a - * refName stamp (created before the stamp existed, or synthesized): rebuilding - * a ref from the target's plain node name would strip the receiver/qualifier - * context the original text carried (`h.greet` → `greet`) and could rebind - * somewhere a full re-index never would. Silent beats wrong. - */ -function resurrectRefFromDroppedEdge( - e: Edge & { sourceFilePath: string; sourceLanguage: Language } -): UnresolvedReference | null { - const refName = e.metadata?.refName; - if (typeof refName !== 'string' || refName.length === 0) return null; - const refKind = typeof e.metadata?.refKind === 'string' ? (e.metadata.refKind as ReferenceKind) : e.kind; - return { - fromNodeId: e.source, - referenceName: refName, - referenceKind: refKind, - line: e.line ?? 0, - column: e.column ?? 0, - filePath: e.sourceFilePath, - language: e.sourceLanguage, - }; -} - /** * What tells a file's nodes apart across a re-index, coarsest first. (kind, * name) is the #899 key and settles every name a file defines once; a name it diff --git a/src/index.ts b/src/index.ts index c3b8d4f7ba..70b7acfe62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -940,18 +940,16 @@ export class CodeGraph { // Cross-file finalization (e.g. NestJS RouterModule prefixes). Run on // every sync that touched files so edits to `app.module.ts` propagate // to controllers in unchanged files. The pass is idempotent and cheap - // (regex over *.module.ts only). - if (result.filesAdded > 0 || result.filesModified > 0) { + // (regex over *.module.ts only). A removal counts too: deleting the + // file that hands a React Router table to the router leaves the + // table's routes behind unless this pass runs to take them away. + // (A pure-removal sync still resolves refs below — the deletion path + // resurrects the removed file's incoming edges as pending refs, #1240 + // removal case — and runPostExtract starts by dropping the resolver's + // name caches, which a long-lived daemon warmed against the + // pre-removal graph.) + if (result.filesAdded > 0 || result.filesModified > 0 || result.filesRemoved > 0) { this.resolver.runPostExtract(); - } else if (result.filesRemoved > 0) { - // A pure-removal sync still resolves refs below — the deletion path - // resurrects the removed file's incoming edges as pending refs - // (#1240 removal case) and the orphan sweep consumes them. In a - // long-lived process (daemon) the resolver's name caches were - // warmed against the pre-removal graph; drop them so resolution - // sees the post-removal state. (runPostExtract above clears caches - // itself, so the changed-files branch is already covered.) - this.resolver.clearCaches(); } // Resolve references if files were updated diff --git a/src/resolution/frameworks/react-router.ts b/src/resolution/frameworks/react-router.ts index 6993889787..01e8562cdb 100644 --- a/src/resolution/frameworks/react-router.ts +++ b/src/resolution/frameworks/react-router.ts @@ -5,8 +5,11 @@ * `` (v5), * `}/>` (v6) and * `createBrowserRouter([{ path, element }])` (v6.4+) each become a `route` - * node named by its path, bound to the component that renders it. That is - * half of what "how does this app flow" means. This file is the other half. + * node named by its path, bound to the component that renders it — and so + * does a table another file hands the router (`AppRoutes.map(… )`, `useRoutes(routes)` with `routes` imported), read once every + * file is indexed. That is half of what "how does this app flow" means. This + * file is the other half. * * **Navigation is a string.** `history.push('/placeorder')` (v5, and the * `useHistory` hook), `navigate('/placeorder')` (v6's `useNavigate`), @@ -32,7 +35,9 @@ * 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 + * export (`frameworks/react.ts`). An `index: true` route is the page at its + * parent's address, and a route object around others is the layout they + * render inside (a `references` edge marked `layout: true`). Known limit, deliberate: a splat * (`/admin/*`) matches anything, so it is never the answer to a concrete href. */ @@ -54,7 +59,7 @@ import { // two arms). It lives in `nextjs.ts` because that is where it was first // needed; duplicating it here would be a second derivation of the same rule. import { destinationsForHref } from './nextjs'; -import { configHrefExpression } from './react'; +import { configHrefExpression, isTableRoute } from './react'; const ROUTE_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'tsx', 'jsx']; @@ -73,11 +78,13 @@ 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. A path - * built from a constant keeps the id it was extracted with. + * built from a constant keeps the id it was extracted with, and a route read + * from a table another file hands the router says so (`…:12:table:/login`). */ function isReactRouterRoute(node: Node): boolean { const prefix = `route:${node.filePath}:${node.startLine}:`; if ((node.language !== 'tsx' && node.language !== 'jsx') || !node.id.startsWith(prefix)) return false; + if (isTableRoute(node)) return true; // 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); diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index 55bbec5814..72d05c91d8 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -5,10 +5,14 @@ * Next.js pages, route handlers and navigation are `nextjs.ts`'s. */ -import { Node } from '../../types'; -import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; +import { Language, Node } from '../../types'; +import { FrameworkExtractionResult, FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types'; import { dependsOn } from './package-deps'; import { resolveImportPath } from '../import-resolver'; +import { stripCommentsForRegex } from '../strip-comments'; +import { makeLineAt } from '../synth-utils'; +import { isTestPath } from '../../search/query-utils'; +import { matchBracket, skipString } from './object-literal'; /** The languages React components, hooks and contexts are written and used in. */ const REACT_SCRIPT_LANGUAGES: ReadonlySet = new Set(['typescript', 'javascript', 'tsx', 'jsx']); @@ -31,9 +35,10 @@ 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. + // A data-router `lazy: () => import('./routes/x')` route names a module, not + // a symbol; a route's `layout:MainLayout` names the component around it. claimsReference(name: string): boolean { - return name.startsWith(LAZY_ROUTE_PREFIX); + return name.startsWith(LAZY_ROUTE_PREFIX) || name.startsWith(LAYOUT_PREFIX); }, resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { @@ -44,6 +49,12 @@ export const reactResolver: FrameworkResolver = { 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; } + // The layout a route renders inside: what happens in it — its header's + // links, its logout — happens on the route's screen too. + if (ref.referenceName.startsWith(LAYOUT_PREFIX)) { + const target = layoutComponent(ref.referenceName.slice(LAYOUT_PREFIX.length), ref, context); + return target ? { original: ref, targetNodeId: target, confidence: 0.9, resolvedBy: 'framework', metadata: { layout: true } } : null; + } // A component, hook or context the file IMPORTS is the import's: the // package's (`useQuery` from `@tanstack/react-query`, `\n' + + '}\n' + + 'export default CartScreen\n', + }, + route: { + file: 'src/App.js', + before: + "import { BrowserRouter as Router, Route } from 'react-router-dom'\n" + + "import LoginScreen from './screens/LoginScreen'\n" + + "import CartScreen from './screens/CartScreen'\n" + + 'const App = () => (\n' + + ' \n' + + " \n" + + ' \n' + + ')\n' + + 'export default App\n', + }, + from: 'src/screens/CartScreen.js', + expected: '/login?redirect=shipping → /login', + }, + { + name: 'React Router (a path built from a constant another file holds)', + files: { + 'package.json': JSON.stringify({ name: 'web', dependencies: { react: '18', 'react-router': '7' } }), + 'src/config/paths.ts': "export const paths = {\n auth: { login: { path: '/auth/login' } },\n} as const;\n", + 'src/app/router.tsx': + "import { createBrowserRouter } from 'react-router';\n" + + "import { paths } from '../config/paths';\n" + + "import { LoginRoute } from './routes/login';\n" + + 'export const router = createBrowserRouter([\n' + + ' { path: paths.auth.login.path, element: },\n' + + ']);\n', + 'src/app/routes/login.tsx': 'export function LoginRoute() { return null; }\n', + 'src/lib/auth.tsx': + "import { useNavigate } from 'react-router';\n" + + 'export function Logout() {\n' + + ' const navigate = useNavigate();\n' + + " return ;\n" + + '}\n', + }, + // The route's own file never changes: the constant it reads does. + route: { file: 'src/config/paths.ts', before: "export const paths = {\n auth: { login: { path: '/auth/sign-in' } },\n} as const;\n" }, + from: 'src/lib/auth.tsx', + expected: '/auth/login → /auth/login', + }, + { + // The routes live in a file that never changes: they exist only while + // another file hands that table to the router. + name: 'React Router (a table another file hands the router)', + files: { + 'package.json': JSON.stringify({ name: 'app', dependencies: { react: '18', 'react-router-dom': '6' } }), + 'src/AppRoutes.js': + "import { Home } from './Home';\n" + + "import { Counter } from './Counter';\n" + + 'const AppRoutes = [\n' + + ' { index: true, element: },\n' + + " { path: '/counter', element: },\n" + + '];\n' + + 'export default AppRoutes;\n', + 'src/App.js': + "import { Route, Routes } from 'react-router-dom';\n" + + "import AppRoutes from './AppRoutes';\n" + + 'export default function App() {\n' + + ' return {AppRoutes.map(({ element, ...rest }, index) => )};\n' + + '}\n', + 'src/Home.js': 'export function Home() { return null; }\n', + 'src/Counter.js': 'export function Counter() { return null; }\n', + 'src/NavMenu.js': + "import { Link, useNavigate } from 'react-router-dom';\n" + + 'export function NavMenu() {\n' + + ' const navigate = useNavigate();\n' + + " const goHome = () => navigate('/');\n" + + ' return ;\n' + + '}\n', + }, + route: { + file: 'src/App.js', + before: + "import { Route, Routes } from 'react-router-dom';\n" + + "import { Counter } from './Counter';\n" + + 'export default function App() {\n' + + ' return } />;\n' + + '}\n', + }, + from: 'src/NavMenu.js', + expected: '/ → /', + }, + { + name: 'React Router (one arm of a conditional destination)', + files: { + 'package.json': JSON.stringify({ name: 'shop', dependencies: { react: '18', 'react-router-dom': '5' } }), + 'src/App.js': + "import { BrowserRouter as Router, Route } from 'react-router-dom'\n" + + "import LoginScreen from './screens/LoginScreen'\n" + + "import CartScreen from './screens/CartScreen'\n" + + 'const App = () => (\n' + + ' \n' + + " \n" + + " \n" + + ' \n' + + ')\n' + + 'export default App\n', + 'src/screens/LoginScreen.js': 'const LoginScreen = () =>
Login
\nexport default LoginScreen\n', + // While `/login` is missing the call still resolves, through its other + // arm: it is not parked as failed, it lost a destination. + 'src/screens/CartScreen.js': + 'const CartScreen = ({ history, user }) => {\n' + + ' const checkout = () => {\n' + + " history.push(user ? '/cart' : '/login')\n" + + ' }\n' + + ' return \n' + + '}\n' + + 'export default CartScreen\n', + }, + route: { + file: 'src/App.js', + before: + "import { BrowserRouter as Router, Route } from 'react-router-dom'\n" + + "import CartScreen from './screens/CartScreen'\n" + + 'const App = () => (\n' + + ' \n' + + " \n" + + ' \n' + + ')\n' + + 'export default App\n', + }, + from: 'src/screens/CartScreen.js', + expected: '/login → /login', + }, + { + name: 'React Router (a parameter route that took the call meanwhile)', + files: { + 'package.json': JSON.stringify({ name: 'admin', dependencies: { react: '18', 'react-router-dom': '6' } }), + 'src/App.jsx': + "import { Routes, Route } from 'react-router-dom'\n" + + "import { User, NewUser } from './User'\n" + + 'export const App = () => (\n' + + ' \n' + + ' } />\n' + + ' } />\n' + + ' \n' + + ')\n', + 'src/User.jsx': 'export const User = () =>
User
\nexport const NewUser = () =>
New
\n', + 'src/Admin.jsx': + "import { useNavigate } from 'react-router-dom'\n" + + 'export const Admin = () => {\n' + + ' const navigate = useNavigate()\n' + + " const add = () => navigate('/users/new')\n" + + ' return \n' + + '}\n', + }, + route: { + file: 'src/App.jsx', + before: + "import { Routes, Route } from 'react-router-dom'\n" + + "import { User } from './User'\n" + + 'export const App = () => (\n' + + ' \n' + + ' } />\n' + + ' \n' + + ')\n', + }, + from: 'src/Admin.jsx', + expected: '/users/new → /users/new', + }, + { + name: 'Next.js', + files: { + 'package.json': JSON.stringify({ name: 'site', dependencies: { next: '15', react: '19' } }), + 'app/page.tsx': 'export default function Home() {\n return null\n}\n', + 'app/login/page.tsx': 'export default function LoginPage() {\n return null\n}\n', + 'components/logout-button.tsx': + "'use client'\n" + + "import { useRouter } from 'next/navigation'\n" + + 'export function LogoutButton() {\n' + + ' const router = useRouter()\n' + + " const out = () => router.push('/login')\n" + + ' return \n' + + '}\n', + }, + route: { file: 'app/login/page.tsx', before: null }, + from: 'components/logout-button.tsx', + expected: '/login → /login', + }, + { + name: 'Expo Router', + files: { + 'package.json': JSON.stringify({ name: 'app', dependencies: { expo: '52', 'expo-router': '4', react: '18' } }), + 'src/app/_layout.tsx': 'export default function Layout() { return null }\n', + 'src/app/index.tsx': 'export default function Home() { return null }\n', + 'src/app/settings.tsx': 'export default function Settings() { return null }\n', + 'src/services/nav.ts': "import { router } from 'expo-router'\nexport function openSettings() {\n router.push('/settings')\n}\n", + }, + route: { file: 'src/app/settings.tsx', before: null }, + from: 'src/services/nav.ts', + expected: '/settings → /settings', + }, + { + name: 'Expo Router (a catch-all screen that took the call meanwhile)', + files: { + 'package.json': JSON.stringify({ name: 'app', dependencies: { expo: '52', 'expo-router': '4', react: '18' } }), + 'src/app/_layout.tsx': 'export default function Layout() { return null }\n', + 'src/app/index.tsx': 'export default function Home() { return null }\n', + 'src/app/[...missing].tsx': 'export default function NotFound() { return null }\n', + 'src/app/login.tsx': 'export default function Login() { return null }\n', + 'src/services/session.ts': "import { router } from 'expo-router'\nexport function signOut() {\n router.replace('/login')\n}\n", + }, + route: { file: 'src/app/login.tsx', before: null }, + from: 'src/services/session.ts', + expected: '/login → /login', + }, + { + name: 'Vue Router', + files: { + 'package.json': JSON.stringify({ name: 'conduit', dependencies: { vue: '3', 'vue-router': '4' } }), + 'src/router/index.js': + 'import { createRouter, createWebHistory } from "vue-router"\n' + + 'const router = createRouter({\n' + + ' history: createWebHistory(),\n' + + ' routes: [\n' + + ' { name: "home", path: "/", component: () => import("@/views/Home") },\n' + + ' { name: "login", path: "/login", component: () => import("@/views/Login") },\n' + + ' ]\n' + + '})\n' + + 'export default router\n', + 'src/views/Home.vue': + '\n' + + '\n', + 'src/views/Login.vue': '\n\n', + }, + route: { + file: 'src/router/index.js', + before: + 'import { createRouter, createWebHistory } from "vue-router"\n' + + 'const router = createRouter({\n' + + ' history: createWebHistory(),\n' + + ' routes: [\n' + + ' { name: "home", path: "/", component: () => import("@/views/Home") },\n' + + ' ]\n' + + '})\n' + + 'export default router\n', + }, + from: 'src/views/Home.vue', + expected: '/login → /login', + }, + { + name: 'Angular', + files: { + 'package.json': JSON.stringify({ dependencies: { '@angular/core': '19', '@angular/router': '19' } }), + 'src/app/app.routes.ts': + "import { Routes } from '@angular/router';\n" + + "import { HomeComponent } from './home.component';\n" + + "import { LoginComponent } from './login.component';\n" + + 'export const routes: Routes = [\n' + + " { path: 'home', component: HomeComponent },\n" + + " { path: 'login', component: LoginComponent },\n" + + '];\n', + 'src/app/home.component.ts': angularComponent('HomeComponent', " signIn() { this.router.navigate(['/login']); }"), + 'src/app/login.component.ts': angularComponent('LoginComponent'), + }, + route: { + file: 'src/app/app.routes.ts', + before: + "import { Routes } from '@angular/router';\n" + + "import { HomeComponent } from './home.component';\n" + + 'export const routes: Routes = [\n' + + " { path: 'home', component: HomeComponent },\n" + + '];\n', + }, + from: 'src/app/home.component.ts', + expected: '/login → /login', + }, + { + // A link in a template is a synthesizer's edge, and a routes file holds + // nothing that sends a sync to the synthesizers on its own. + name: 'Angular (a routerLink, the routes file added later)', + files: { + 'package.json': JSON.stringify({ dependencies: { '@angular/core': '19', '@angular/router': '19' } }), + 'src/app/app.routes.ts': + "import { Routes } from '@angular/router';\n" + + "import { HomeComponent } from './home.component';\n" + + "import { LoginComponent } from './login.component';\n" + + 'export const routes: Routes = [\n' + + " { path: 'home', component: HomeComponent },\n" + + " { path: 'login', component: LoginComponent },\n" + + '];\n', + 'src/app/home.component.ts': angularComponent('HomeComponent', '', 'Sign in'), + 'src/app/login.component.ts': angularComponent('LoginComponent'), + }, + route: { file: 'src/app/app.routes.ts', before: null }, + from: 'src/app/home.component.ts', + expected: '/login → /login', + }, + { + name: 'TanStack Router', + files: { + 'package.json': JSON.stringify({ name: 'app', dependencies: { react: '19', '@tanstack/react-router': '1' } }), + 'src/routes/index.tsx': + "import { createFileRoute, useNavigate } from '@tanstack/react-router'\n" + + "export const Route = createFileRoute('/')({ component: IndexComponent })\n" + + 'function IndexComponent() {\n' + + ' const navigate = useNavigate()\n' + + " const signIn = () => navigate({ to: '/login' })\n" + + ' return \n' + + '}\n', + 'src/routes/login.tsx': + "import { createFileRoute } from '@tanstack/react-router'\n" + + "export const Route = createFileRoute('/login')({ component: LoginComponent })\n" + + 'function LoginComponent() {\n return
Login
\n}\n', + }, + route: { file: 'src/routes/login.tsx', before: null }, + from: 'src/routes/index.tsx', + expected: '/login → /login', + }, + { + name: 'SvelteKit', + files: { + 'package.json': JSON.stringify({ name: 'conduit', devDependencies: { '@sveltejs/kit': '2', svelte: '5' } }), + 'src/routes/+page.svelte': '

Home

\n', + 'src/routes/settings/+page.svelte': '

Settings

\n', + 'src/routes/settings/+page.server.js': + "import { redirect } from '@sveltejs/kit'\n" + + 'export function load({ locals }) {\n' + + " if (!locals.user) redirect(302, '/login')\n" + + '}\n', + 'src/routes/login/+page.svelte': '

Sign in

\n', + }, + route: { file: 'src/routes/login/+page.svelte', before: null }, + from: 'src/routes/settings/+page.server.js', + expected: '/login → /login', + }, +]; + +const roots: string[] = []; +const graphs: CodeGraph[] = []; + +afterEach(() => { + for (const graph of graphs.splice(0)) graph.close(); + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +function tempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-sync-nav-')); + roots.push(root); + return root; +} + +function write(root: string, files: Record): void { + 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); + } +} + +/** Set a file's content, or remove the file for null. */ +function put(root: string, rel: string, content: string | null): void { + if (content === null) fs.rmSync(path.join(root, rel)); + else write(root, { [rel]: content }); +} + +async function index(root: string): Promise { + const graph = await CodeGraph.init(root, { index: true }); + graphs.push(graph); + return graph; +} + +/** ` → ` for every navigation out of `file`. */ +function navigations(graph: CodeGraph, file: string): string[] { + const ids = graph.getNodesInFile(file).map((n) => n.id); + return graph + .getOutgoingEdgesFrom(ids, ['navigates']) + .map((edge) => `${String(edge.metadata?.href)} → ${graph.getNode(edge.target)?.name}`) + .sort(); +} + +/** The same files, indexed from scratch in a folder of their own. */ +async function freshNavigations(files: Record, file: string): Promise { + const root = tempRoot(); + write(root, files); + return navigations(await index(root), file); +} + +function internals(graph: CodeGraph): { queries: QueryBuilder; resolver: ReferenceResolver } { + return graph as unknown as { queries: QueryBuilder; resolver: ReferenceResolver }; +} + +describe('sync binds a navigation call to a route that appears later', () => { + it.each(CASES)('$name', async (c) => { + const root = tempRoot(); + const without = { ...c.files }; + if (c.route.before === null) delete without[c.route.file]; + else without[c.route.file] = c.route.before; + write(root, without); + const graph = await index(root); + const missing = navigations(graph, c.from); + expect(missing).not.toContain(c.expected); + const fresh = await freshNavigations(c.files, c.from); + expect(fresh).toContain(c.expected); + + // The route appears; the file with the call does not change. + put(root, c.route.file, c.files[c.route.file]!); + await graph.sync(); + expect(navigations(graph, c.from)).toEqual(fresh); + expect(graph.getPendingReferenceCount()).toBe(0); + + // Gone again, and back: the call parks while the route is missing and + // binds again when it returns, as a full index of each state has it. + put(root, c.route.file, c.route.before); + await graph.sync(); + expect(navigations(graph, c.from)).toEqual(missing); + put(root, c.route.file, c.files[c.route.file]!); + await graph.sync(); + expect(navigations(graph, c.from)).toEqual(fresh); + expect(graph.getPendingReferenceCount()).toBe(0); + }, 120_000); +}); + +describe('a route that goes away', () => { + it('lets a call two routes tied for bind to the one left', async () => { + const files = { + 'package.json': JSON.stringify({ name: 'admin', dependencies: { react: '18', 'react-router-dom': '6' } }), + 'src/App.jsx': + "import { Routes, Route } from 'react-router-dom'\n" + + "import { User } from './User'\n" + + 'export const App = () => (\n' + + ' \n' + + ' } />\n' + + ' \n' + + ')\n', + 'src/User.jsx': 'export const User = () =>
User
\n', + 'src/Admin.jsx': + "import { useNavigate } from 'react-router-dom'\n" + + 'export const Admin = () => {\n' + + ' const navigate = useNavigate()\n' + + " const open = () => navigate('/users/5')\n" + + ' return \n' + + '}\n', + }; + const root = tempRoot(); + write(root, files); + // A second route of the same shape: `/users/5` matches both, and a tie names nothing. + put(root, 'src/App.jsx', files['src/App.jsx'].replace(' ', ' } />\n ')); + const graph = await index(root); + expect(navigations(graph, 'src/Admin.jsx')).toEqual([]); + + put(root, 'src/App.jsx', files['src/App.jsx']); + await graph.sync(); + expect(navigations(graph, 'src/Admin.jsx')).toEqual(['/users/5 → /users/:id']); + expect(navigations(graph, 'src/Admin.jsx')).toEqual(await freshNavigations(files, 'src/Admin.jsx')); + }, 60_000); +}); + +describe('reopenNavigationsFor', () => { + const app = (name: string) => ({ + [`apps/${name}/package.json`]: JSON.stringify({ name, dependencies: { react: '18', 'react-router-dom': '6' } }), + [`apps/${name}/src/App.jsx`]: + "import { Routes, Route } from 'react-router-dom'\n" + + "import { Home } from './Home'\n" + + 'export const App = () => (\n \n } />\n \n)\n', + // Two calls to routes the app does not have, and one to its `/`. + [`apps/${name}/src/Home.jsx`]: + "import { useNavigate } from 'react-router-dom'\n" + + 'export const Home = () => {\n' + + ' const navigate = useNavigate()\n' + + " const go = () => navigate('/login')\n" + + " const back = () => navigate('/back')\n" + + " const home = () => navigate('/')\n" + + ' return \n' + + '}\n', + }); + + async function monorepo(): Promise { + const root = tempRoot(); + write(root, { 'package.json': JSON.stringify({ name: 'mono', private: true, workspaces: ['apps/*'] }), ...app('web'), ...app('admin') }); + return index(root); + } + + /** ` failed` per failed `navigate` call, ` → ` per navigation. */ + const navigationState = (graph: CodeGraph): string[] => [ + ...internals(graph).queries.getFailedCallsByTail(['navigate']).map((ref) => `${ref.filePath} failed`), + ...internals(graph).queries.getResolvedNavigations().map((e) => `${e.sourceFilePath} → ${graph.getNode(e.target)?.name}`), + ].sort(); + + /** The app's `/` route, moved to `path` — a route of that app the sync added. */ + const routeAt = (route: Node, path: string): Node => ({ ...route, id: route.id.replace(/:\/$/, `:${path}`), name: path }); + + it('re-opens the calls in the app the route belongs to, failed and resolved, and no others', async () => { + const graph = await monorepo(); + const { queries, resolver } = internals(graph); + const admin = ['apps/admin/src/Home.jsx failed', 'apps/admin/src/Home.jsx failed', 'apps/admin/src/Home.jsx → /']; + expect(navigationState(graph)).toEqual([...admin, 'apps/web/src/Home.jsx failed', 'apps/web/src/Home.jsx failed', 'apps/web/src/Home.jsx → /']); + const web = queries.getNodesByKind('route').find((r) => r.filePath === 'apps/web/src/App.jsx')!; + + expect(resolver.reopenNavigationsFor([routeAt(web, '/login')])).toBe(3); + expect(navigationState(graph)).toEqual(admin); + expect(graph.getPendingReferenceCount()).toBe(3); + }, 60_000); + + it('leaves alone the calls in a file the sync just resolved', async () => { + const graph = await monorepo(); + const { queries, resolver } = internals(graph); + const web = queries.getNodesByKind('route').find((r) => r.filePath === 'apps/web/src/App.jsx')!; + expect(resolver.reopenNavigationsFor([routeAt(web, '/login')], ['apps/web/src/Home.jsx'])).toBe(0); + expect(graph.getPendingReferenceCount()).toBe(0); + }, 60_000); + + it('skips a name more calls share than the ceiling', async () => { + const graph = await monorepo(); + const { queries, resolver } = internals(graph); + // Both apps gain a route: six `navigate` calls between them. + const added = queries.getNodesByKind('route').map((r) => routeAt(r, '/login')); + expect(resolver.reopenNavigationsFor(added, [], 5)).toBe(0); + expect(resolver.reopenNavigationsFor(added, [], 6)).toBe(6); + }, 60_000); + + it('passes over a route no router navigates to', async () => { + const graph = await monorepo(); + const { queries, resolver } = internals(graph); + // A nested route's relative path is not a destination of its own. + const added = queries.getNodesByKind('route').map((r) => routeAt(r, 'login')); + const lookup = vi.spyOn(QueryBuilder.prototype, 'getFailedCallsByTail'); + expect(resolver.reopenNavigationsFor(added)).toBe(0); + expect(lookup).not.toHaveBeenCalled(); + }, 60_000); +}); + +describe('changedRoutes', () => { + const route = (filePath: string, name: string, id = `route:${filePath}:1:${name}`): Node => ({ + id, kind: 'route', name, qualifiedName: `${filePath}::route:${name}`, filePath, language: 'jsx', + startLine: 1, endLine: 1, startColumn: 0, endColumn: 0, updatedAt: 0, + }); + + it('is the routes on one side only, by file and path', () => { + const kept = route('src/App.jsx', '/'); + const moved = route('src/App.jsx', '/cart'); + const gone = route('src/App.jsx', '/login'); + const added = route('src/Admin.jsx', '/admin'); + // A body edit moves a route's line, and with it its id: still the same route. + const after = [{ ...kept, id: 'route:src/App.jsx:2:/', startLine: 2 }, moved, added]; + expect(changedRoutes([kept, moved, gone], after)).toEqual([gone, added]); + }); + + it('counts a route renamed in place as its old path and its new one', () => { + const old = route('src/router.tsx', '/auth/sign-in', 'route:src/router.tsx:5:paths.auth.login.path'); + const renamed = { ...old, name: '/auth/login' }; + expect(changedRoutes([old], [renamed])).toEqual([old, renamed]); + }); +}); + +describe('a sync that changes no route', () => { + it('looks up no failed navigation', async () => { + const c = CASES[0]!; + const root = tempRoot(); + write(root, c.files); + const graph = await index(root); + const lookup = vi.spyOn(QueryBuilder.prototype, 'getFailedCallsByTail'); + // A body edit to the file the routes are declared in, and to an unrelated one. + put(root, c.route.file, c.files[c.route.file]!.replace('export default App', 'export default App // routes')); + put(root, 'src/screens/LoginScreen.js', 'const LoginScreen = () =>
Sign in
\nexport default LoginScreen\n'); + const result = await graph.sync(); + expect(result.filesModified).toBe(2); + expect(lookup).not.toHaveBeenCalled(); + }, 60_000); + + it('takes no route snapshot without a router that navigates', async () => { + const root = tempRoot(); + write(root, { 'package.json': JSON.stringify({ name: 'lib' }), 'src/a.ts': 'export function a() { return 1; }\n' }); + const graph = await index(root); + const reopen = vi.spyOn(internals(graph).resolver, 'reopenNavigationsFor'); + put(root, 'src/a.ts', 'export function a() { return 2; }\n'); + expect((await graph.sync()).filesModified).toBe(1); + expect(reopen).not.toHaveBeenCalled(); + }, 60_000); +}); + +describe('FrameworkResolver.navigation', () => { + // Navigation calls each router's own tests resolve. + const CALLS: Record = { + 'react-router': ['history.push', 'history.replace', 'navigate', 'navigate.push', 'router.navigate', 'redirect'], + nextjs: ['router.push', 'router.replace', 'router.prefetch', 'redirect', 'permanentRedirect', 'NextResponse.redirect'], + 'expo-router': ['router.push', 'router.replace', 'router.navigate', 'router.dismissTo', 'nav.push'], + 'vue-router': ['router.push', 'this.$router.push', '$router.replace', 'navigateTo'], + 'angular-router': ['this.router.navigate', 'router.navigateByUrl', 'this._router.createUrlTree', 'Router.parseUrl'], + 'tanstack-router': ['navigate', 'redirect', 'router.navigate', 'Route.navigate'], + 'sveltekit-router': ['goto', 'redirect'], + }; + + it('is set by every router that binds navigation calls, and finds each by its tail', () => { + const routers = getAllFrameworkResolvers().filter((r) => r.navigation); + expect(routers.map((r) => r.name).sort()).toEqual(Object.keys(CALLS).sort()); + for (const router of routers) { + for (const call of CALLS[router.name]!) { + expect(router.claimsReference?.(call), `${router.name} claims ${call}`).toBe(true); + expect(router.navigation!.tails, `${router.name} ${call}`).toContain(referenceNameTail(call, 'calls')); + } + } + }); +}); diff --git a/docs/benchmarks/index-drift-cg33.md b/docs/benchmarks/index-drift-cg33.md index 58fdce46a3..2778beaf08 100644 --- a/docs/benchmarks/index-drift-cg33.md +++ b/docs/benchmarks/index-drift-cg33.md @@ -125,6 +125,69 @@ named `push`, or a Rust method named `join`. **The full rebuild is the wrong one here** — converging would mean teaching sync to manufacture thousands of wrong edges. Left as is, deliberately. +### Navigation waits on a route, not a name (2026-10) + +A router's navigation call — `history.push('/login')`, `navigate('/login')`, +`router.push('/x')`, `goto('/x')` — names its route by path and its reference +by the router's method, so a route that changes in another file is invisible +to every pass above: + +- **A call parked while its route was missing.** The #1240 retry looks failed + refs up by the names the synced files define, and a call's name tail is + `push`, never a path. On proshop_mern, deleting and restoring + `` left six `history.push('/login')` calls failed and + their six `navigates` edges missing. +- **A call that bound elsewhere meanwhile.** It resolved, so nothing revisits + it: to a catch-all screen (Obytes' Expo starter: `router.replace('/login')` + stayed on `[...messing].tsx` after `login.tsx` came back), to a parameter + route (`/users/new` on `/users/:id`), or to the other arm of a conditional + (sveltekit-realworld's `redirect` lost its `/login` arm for good). +- **A route renamed in place.** `runPostExtract` renames a route whose path is + built from a constant another file holds, an Angular route under its mount, + or a React Router table route, and keeps its node, so the calls bound to it + keep pointing at a path it no longer has. `definitionDelta` is read before + that pass. +- **A link in markup.** ``, `routerLink` and the like are a + synthesizer's edges, and a routes file that matches none of the synthesis + triggers (an Angular `Routes` array, Ghostfolio's `home-page.routes.ts`) + does not send a sync to the synthesizers. + +Sync now reads every route node at its first file change, only when a router +that resolves navigation calls is detected, and again after `runPostExtract`. +When a route is on one side only (`changedRoutes`), the calls that routers' +`navigation` hooks say it can answer go back to the pending set for the +orphan sweep: the failed ones, found by their method tails through +`idx_unresolved_failed_tail`, and the resolved ones, whose `navigates` edges +are turned back into the references that made them, as the rebind does. A +call counts when the router's `claimsReference` accepts its whole name and its +file is in an app whose table holds the route, and a name more such calls +share than the same 500 ceiling is skipped. The sync also refreshes synthesis. +A sync that changes no route reads the routes twice and stops there. + +Measured by deleting a route (or renaming it), syncing, restoring it and +syncing again, with both states diffed against a fresh index of the same tree. +On `main` each of ten scenarios drifted in one of the two states; with the fix +all twenty match: proshop_mern (``), next-saas-starter +(the `/sign-in` and `/pricing` pages), create-t3-turbo (`/`), vue-realworld +(`/login` in the router config), sveltekit-realworld (`login/+page.svelte`), +angular-realworld (`login` in `app.routes.ts`), Obytes' Expo starter +(`app/login.tsx`), Ghostfolio (`home-page.routes.ts`), and bulletproof-react +with its `/auth/login` path constant renamed and back — the one that drifts +while the route is renamed rather than after it returns. CleanArchitecture's +React client, whose routes are a table `App.jsx` maps into ``, drifted +when `App.jsx` stopped mapping it and started again: its two +`navigate('/login')` calls stayed failed, though `AppRoutes.jsx` itself never +changed. With the fix every step of that replay matches. A sync that changes +no route spends 0–3 ms on the two reads (proshop's 49 routes, Ghostfolio's +203); a route change on Ghostfolio spends 12–17 ms putting 40–50 calls back. + +Known limits, deliberate: a call bound by name to a method while its route +was missing (a `redirect` that matched a project method called `redirect`) is +not revisited, since only `navigates` edges are looked at; a project's own +wrapper (Expo's `safePush`) is not looked up, since its tail is its own name +and the failed-tail index cannot find a suffix; and a Vue route whose `name:` +alone changes changes no route node. + ### `codegraph status` — decided: no drift metric The issue asked whether `status` should surface divergence. Decision: **no**. diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index b6f5e5648d..1ac0eb989e 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -278,11 +278,11 @@ Each of these cost real debugging time; they are not hypothetical. `.map` whose callback renders a `` from the item's own fields (`{...route}`, `{...rest}`, `path={route.path}` — never `path={r.layout + r.path}`), follows the import to the table and the tables - it names in turn, and owns its routes by id (`…:table:`). Two limits + it names in turn, and owns its routes by id (`…:table:`). One limit it shares with every router: a table rendered by a component that is itself mounted under another file's `` reads as if - at the root, and a navigation that failed while its route was gone is not - retried until a full index. + at the root. A sync that changes such a route resolves the calls it can + answer again (16), as for any other route. 14. **An index route is at its parent's address, so it needs that address written down.** `} />` inside `}>` is the page at `/`, and Layout is the @@ -315,6 +315,18 @@ Each of these cost real debugging time; they are not hypothetical. modules, ASP.NET's Razor `@model` — because widening `languages` also runs `extract()` on those files. `claimsReference()` is still asked of every detected framework. +16. **A navigation call waits for a route, not for a name.** A call indexed + before its route existed is parked as failed (or bound to a catch-all, a + parameter route, the other arm of a conditional), and sync revisits a + failed reference only by matching its last name segment against the names + the synced files define — never `push` or `navigate`. Set the resolver's + `navigation`: `tails`, the method names its calls end in, and `scope`, the + apps whose calls one of its routes can answer (its own app's table, or every + file for a table the whole project shares). A sync that adds, removes or + renames a route — a cross-file table route included — then resolves those + calls again, failed or not; without it the synced index keeps an answer a + full index does not have. `sync-navigation-retry.test.ts` runs all seven + routers through it, and asserts each router's calls end in a declared tail. --- diff --git a/src/db/queries.ts b/src/db/queries.ts index 2e18bace98..f1a49cb36e 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -3939,6 +3939,70 @@ export class QueryBuilder { })); } + /** + * Failed `calls` refs whose name tail is one of `tails` — the navigation + * calls (`history.push`, `navigate`) a sync looks through after a route + * appeared or went away. Read through the failed-tail index; the caller + * decides on each whole name. + */ + getFailedCallsByTail(tails: string[]): Array<{ rowId: number; referenceName: string; filePath: string }> { + const out: Array<{ rowId: number; referenceName: string; filePath: string }> = []; + const unique = [...new Set(tails)]; + for (let i = 0; i < unique.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = unique.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + const rows = this.db + .prepare( + `SELECT id, reference_name, file_path FROM unresolved_refs + WHERE status = 'failed' AND reference_kind = 'calls' AND name_tail IN (${placeholders})` + ) + .all(...chunk) as Array<{ id: number; reference_name: string; file_path: string }>; + for (const row of rows) out.push({ rowId: row.id, referenceName: row.reference_name, filePath: row.file_path }); + } + return out; + } + + /** + * The `navigates` edges a router's resolver made (not a synthesizer's), with + * the source file and language a resurrection needs — the navigation calls + * a sync re-resolves after a route appeared or went away. + */ + getResolvedNavigations(): Array { + const rows = this.db + .prepare( + `SELECT e.*, src.file_path AS source_file_path, src.language AS source_language + FROM edges e + JOIN nodes src ON src.id = e.source + WHERE e.kind = 'navigates' AND (e.provenance IS NULL OR e.provenance != 'heuristic')` + ) + .all() as Array; + return rows.map((row) => ({ + ...rowToEdge(row), + edgeId: row.id, + sourceFilePath: row.source_file_path, + sourceLanguage: row.source_language, + })); + } + + /** + * Put failed refs back in the pending set, for the next resolution pass — + * the sync's orphan sweep — to try again. Returns the number re-opened. + */ + reopenFailedReferences(rowIds: number[]): number { + if (rowIds.length === 0) return 0; + let changed = 0; + this.db.transaction(() => { + for (let i = 0; i < rowIds.length; i += SQLITE_PARAM_CHUNK_SIZE) { + const chunk = rowIds.slice(i, i + SQLITE_PARAM_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(','); + changed += this.db + .prepare(`UPDATE unresolved_refs SET status = 'pending' WHERE status = 'failed' AND id IN (${placeholders})`) + .run(...chunk).changes; + } + })(); + return changed; + } + /** * Resolution edges whose TARGET symbol is named one of `names` — the edges a * sync must re-resolve after `names` gained or lost a definition (CG-33). diff --git a/src/index.ts b/src/index.ts index a789730ee4..127d9cf8da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,7 @@ import { import { detectLanguage, hasGrammarLoadFailure, isFileLevelOnlyLanguage } from './extraction/grammars'; import { ReferenceResolver, + changedRoutes, createResolver, ResolutionResult, } from './resolution'; @@ -922,8 +923,15 @@ export class CodeGraph { let refreshSynthesis = this.queries.getMetadata('synthesis_pending') === '1' || this.queries.getUnresolvedReferencesCount() > 0; if (refreshSynthesis) this.queries.setMetadata('synthesis_pending', '1'); + // The route nodes as they were before this sync replaced or removed a + // file — read at the first change, so a sync that changes nothing + // reads nothing, and only where a router binds navigation calls to + // routes. Compared with the routes after runPostExtract below. + const watchRoutes = this.resolver.hasNavigationRouters(); + let routesBefore = null as Node[] | null; const result = await this.orchestrator.sync(options.onProgress, options.paths, backpressure, (filePath, content) => { + if (watchRoutes && routesBefore === null) routesBefore = this.queries.getNodesByKind('route'); if (!refreshSynthesis && (this.queries.hasSynthesizedEdgesTouchingFile(filePath) || this.queries.wasSynthesisInput(filePath) || (content !== undefined && hasSynthesisPattern(filePath, content)))) { @@ -952,6 +960,29 @@ export class CodeGraph { this.resolver.runPostExtract(); } + // A route that appeared, went away or was renamed changes what a + // navigation call in an UNCHANGED file resolves to: `history.push( + // '/login')` binds again once `/login` is back. While the route was + // missing the call was parked as failed, or bound to a catch-all, and + // nothing below revisits it: the retry keys on the names this sync's + // files define, which a call named for the router's method (`push`) + // never matches. Put those calls back in the pending set for the sweep + // below, and let the synthesizers redraw the links markup makes to + // routes. The route table is final here: renames and table routes are + // runPostExtract's. + if (routesBefore) { + const tNav = Date.now(); + const routes = changedRoutes(routesBefore, this.queries.getNodesByKind('route')); + const reopened = this.resolver.reopenNavigationsFor(routes, result.changedFilePaths ?? []); + if (routes.length > 0 && !refreshSynthesis) { + refreshSynthesis = true; + this.queries.setMetadata('synthesis_pending', '1'); + } + if (process.env.CODEGRAPH_SYNTH_TIMINGS) { + console.error(`[phase-timing] sync-navigation-retry: ${Date.now() - tNav}ms (${routes.length} routes changed, ${reopened} refs re-opened)`); + } + } + // Resolve references if files were updated const filesChanged = result.filesAdded > 0 || result.filesModified > 0; if (filesChanged) { diff --git a/src/resolution/frameworks/angular-router.ts b/src/resolution/frameworks/angular-router.ts index 0980c4a5c5..5d37951a99 100644 --- a/src/resolution/frameworks/angular-router.ts +++ b/src/resolution/frameworks/angular-router.ts @@ -693,6 +693,11 @@ function angularAppRoot(filePath: string, context: ResolutionContext): string { export type AngularRouteTable = RootedRouteTable; +/** True for a route the table holds: this resolver's, at an absolute path. */ +function inAngularTable(node: Node): boolean { + return isAngularRoute(node) && node.name.startsWith('/'); +} + const tables = new WeakMap(); export function angularRouteTable(context: ResolutionContext): AngularRouteTable { @@ -702,7 +707,7 @@ export function angularRouteTable(context: ResolutionContext): AngularRouteTable const byRoot = new Map(); const byFile = new Map(); for (const node of all) { - if (!isAngularRoute(node) || !node.name.startsWith('/')) continue; + if (!inAngularTable(node)) continue; const root = angularAppRoot(node.filePath, context); let t = byRoot.get(root); if (!t) byRoot.set(root, (t = { source: all, exact: new Map(), dynamic: [] })); @@ -760,6 +765,23 @@ export function angularRouteTable(context: ResolutionContext): AngularRouteTable return table; } +const appCounts = new WeakMap(); + +/** + * How many apps the table splits into — `byRoot.size` of + * {@link angularRouteTable}, counted from the routes alone: building the table + * reads every file that mounts or redirects. + */ +function angularAppCount(context: ResolutionContext): number { + const all = context.getNodesByKind('route'); + const cached = appCounts.get(context); + if (cached && cached.source === all) return cached.count; + const roots = new Set(); + for (const node of all) if (inAngularTable(node)) roots.add(angularAppRoot(node.filePath, context)); + appCounts.set(context, { source: all, count: roots.size }); + return roots.size; +} + /** * The routes a file navigates among: its app's, or — for a shared library * outside any app (an Nx `libs/ui`) — the one app's, when there is only one. @@ -970,6 +992,17 @@ export const angularRouterResolver: FrameworkResolver = { return NAV_CALL.test(name) || LAZY_COMPONENT_REF.test(name) || LAYOUT_REF.test(name); }, + navigation: { + tails: ['navigate', 'navigateByUrl', 'createUrlTree', 'parseUrl'], + // A call matches against its own app's table — or, in a workspace of one + // app, against that app's from anywhere, a shared library included + // (`angularRoutesFor`). + scope: (route, context) => { + if (!inAngularTable(route)) return null; + return angularAppCount(context) === 1 ? [''] : [angularAppRoot(route.filePath, context)]; + }, + }, + extract(filePath: string, content: string): FrameworkExtractionResult { if (!/\.[cm]?ts$/.test(filePath) || filePath.endsWith('.d.ts')) return { nodes: [], references: [] }; const { routes } = parseAngularRoutes(content); diff --git a/src/resolution/frameworks/expo-router.ts b/src/resolution/frameworks/expo-router.ts index ef6f44d81c..e6641a4608 100644 --- a/src/resolution/frameworks/expo-router.ts +++ b/src/resolution/frameworks/expo-router.ts @@ -610,6 +610,15 @@ export interface RouteTable { const tables = new Map(); +/** + * True for this framework's own route nodes: the ones whose name IS the path + * derived from their file. Express/SvelteKit routes in the same project name + * themselves differently and never match. + */ +function isScreenRoute(node: Node): boolean { + return routePathForFile(node.filePath) === node.name; +} + export function routeTable(context: ResolutionContext): RouteTable { const all = context.getNodesByKind('route'); const key = context.getProjectRoot(); @@ -618,10 +627,7 @@ export function routeTable(context: ResolutionContext): RouteTable { const exact = new Map(); const dynamic: RouteEntry[] = []; for (const node of all) { - // Only this framework's own route nodes: the ones whose name IS the path - // derived from their file. Express/SvelteKit routes in the same project - // name themselves differently and never match. - if (routePathForFile(node.filePath) !== node.name) continue; + if (!isScreenRoute(node)) continue; exact.set(node.name, node); if (node.name.includes('[')) dynamic.push({ node, segs: node.name.split('/').slice(1) }); } @@ -749,6 +755,14 @@ export const expoRouterResolver: FrameworkResolver = { return NAV_METHOD.test(name); }, + navigation: { + // A project's own wrapper (`safePush`) is left out: its tail is its own + // name, which the failed-tail index cannot find by suffix. + tails: ['push', 'replace', 'navigate', 'dismissTo'], + // One table for the whole project, and any file's call matches against it. + scope: (route) => (isScreenRoute(route) ? [''] : null), + }, + extract(filePath: string, content: string) { const apiPath = apiRoutePathForFile(filePath); if (apiPath !== null) { diff --git a/src/resolution/frameworks/nextjs.ts b/src/resolution/frameworks/nextjs.ts index 22b85f7466..a2aba3d5dd 100644 --- a/src/resolution/frameworks/nextjs.ts +++ b/src/resolution/frameworks/nextjs.ts @@ -125,6 +125,12 @@ interface NextTable extends RouteTable { const tables = new WeakMap(); +/** The page a route node is, or null for a node the table leaves out (a handler, another framework's route). */ +function tablePage(node: Node): NextRouteFile | null { + const file = nextRouteForFile(node.filePath); + return file && file.kind === 'page' && file.path === node.name ? file : null; +} + export function nextRouteTable(context: ResolutionContext): NextTable { const all = context.getNodesByKind('route'); const cached = tables.get(context); @@ -133,8 +139,8 @@ export function nextRouteTable(context: ResolutionContext): NextTable { const dynamic: RouteTable['dynamic'] = []; const roots = new Set(); for (const node of all) { - const file = nextRouteForFile(node.filePath); - if (!file || file.kind !== 'page' || file.path !== node.name) continue; + const file = tablePage(node); + if (!file) continue; exact.set(node.name, node); if (node.name.includes(':')) dynamic.push({ node, segs: node.name.split('/').slice(1) }); roots.add(file.root); @@ -235,6 +241,13 @@ export const nextjsResolver: FrameworkResolver = { return NAV_CALL.test(name); }, + navigation: { + tails: ['push', 'replace', 'prefetch', 'redirect', 'permanentRedirect'], + // One table holds every Next app's pages, and a call under any of them + // matches against all of it. + scope: (route, context) => (tablePage(route) ? nextRouteTable(context).roots : null), + }, + extract(filePath: string, content: string) { const file = nextRouteForFile(filePath); if (!file) return { nodes: [], references: [] }; diff --git a/src/resolution/frameworks/react-router.ts b/src/resolution/frameworks/react-router.ts index 59404a6431..b13292272b 100644 --- a/src/resolution/frameworks/react-router.ts +++ b/src/resolution/frameworks/react-router.ts @@ -95,6 +95,15 @@ function isReactRouterRoute(node: Node): boolean { return rest === node.name || (!/^[A-Z]+:/.test(rest) && Boolean(node.signature?.startsWith('route-parts:'))); } +/** + * True for a route the table holds. A nested route's path is relative to its + * parent; without the tree it is not a destination. A splat matches + * everything, so it answers nothing. + */ +function isDestination(node: Node): boolean { + return isReactRouterRoute(node) && node.name.startsWith('/') && !node.name.endsWith('*'); +} + /** `:id?` — a parameter React Router serves the route with or without. */ function isOptionalParam(seg: string): boolean { return seg.startsWith(':') && seg.endsWith('?'); @@ -114,10 +123,7 @@ export function reactRouterTable(context: ResolutionContext): ReactRouterTable { return t; }; for (const node of all) { - if (!isReactRouterRoute(node)) continue; - // A nested route's path is relative to its parent; without the tree it is - // not a destination. A splat matches everything, so it answers nothing. - if (!node.name.startsWith('/') || node.name.endsWith('*')) continue; + if (!isDestination(node)) continue; const root = reactRouterRoot(node.filePath); const path = node.name.length > 1 && node.name.endsWith('/') ? node.name.slice(0, -1) : node.name; addRouteTo(tableAt(root), path, node); @@ -179,6 +185,12 @@ export const reactRouterResolver: FrameworkResolver = { return NAV_CALL.test(name); }, + navigation: { + tails: ['push', 'replace', 'navigate', 'redirect'], + // A call matches against the table of the app its file is in. + scope: (route) => (isDestination(route) ? [reactRouterRoot(route.filePath)] : null), + }, + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { if (ref.referenceKind !== 'calls') return null; const verb = reactRouterNavVerb(ref.referenceName); diff --git a/src/resolution/frameworks/sveltekit-router.ts b/src/resolution/frameworks/sveltekit-router.ts index bf105e9b89..95dcb09959 100644 --- a/src/resolution/frameworks/sveltekit-router.ts +++ b/src/resolution/frameworks/sveltekit-router.ts @@ -59,6 +59,14 @@ function isSvelteKitPage(node: Node): boolean { ); } +/** + * True for a page the table holds. `[...rest]` becomes `*rest`, which matches + * anything — never an answer. + */ +function inSvelteKitTable(node: Node): boolean { + return isSvelteKitPage(node) && node.name.startsWith('/') && !node.name.includes('*'); +} + /** `:id?` — a parameter SvelteKit serves the route with or without. */ function isOptionalParam(seg: string): boolean { return seg.startsWith(':') && seg.endsWith('?'); @@ -78,9 +86,7 @@ export function svelteKitTable(context: ResolutionContext): SvelteKitTable { return t; }; for (const node of all) { - if (!isSvelteKitPage(node)) continue; - // `[...rest]` becomes `*rest`, which matches anything — never an answer. - if (!node.name.startsWith('/') || node.name.includes('*')) continue; + if (!inSvelteKitTable(node)) continue; const root = appRootFor(node.filePath); addRouteTo(tableAt(root), node.name, node); // `[[optional]]` is `:x?`: the route serves the path with and without it. @@ -129,6 +135,12 @@ export const svelteKitRouterResolver: FrameworkResolver = { return NAV_CALL.test(name); }, + navigation: { + tails: ['goto', 'redirect'], + // A call matches against the table of the app its file is in. + scope: (route) => (inSvelteKitTable(route) ? [appRootFor(route.filePath)] : null), + }, + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { // `import { redirect } from '@sveltejs/kit'` is not a navigation. if (ref.referenceKind !== 'calls') return null; diff --git a/src/resolution/frameworks/tanstack-router.ts b/src/resolution/frameworks/tanstack-router.ts index 844eefeffd..935389fabc 100644 --- a/src/resolution/frameworks/tanstack-router.ts +++ b/src/resolution/frameworks/tanstack-router.ts @@ -379,6 +379,12 @@ export const tanstackRouterResolver: FrameworkResolver = { return NAV_CALL.test(name); }, + navigation: { + tails: ['navigate', 'redirect'], + // A call matches against the table of the app its file is in. + scope: (route) => (isTanstackRoute(route) ? [appRootFor(route.filePath)] : null), + }, + extract(filePath: string, content: string): FrameworkExtractionResult { // A file-based route file describes ONE route, so the file's own shape says // whether that route is a page. A file holding a code-based route TREE diff --git a/src/resolution/frameworks/vue-router.ts b/src/resolution/frameworks/vue-router.ts index 84a1a9beb5..f84dad897b 100644 --- a/src/resolution/frameworks/vue-router.ts +++ b/src/resolution/frameworks/vue-router.ts @@ -243,6 +243,11 @@ function isNuxtPage(node: Node): boolean { ); } +/** True for a route the table holds: a config route or a Nuxt page, at an absolute path. */ +function inVueTable(node: Node): boolean { + return (isVueConfigRoute(node) || isNuxtPage(node)) && node.name.startsWith('/'); +} + const tables = new WeakMap(); export function vueRouteTable(context: ResolutionContext): VueRouteTable { @@ -257,12 +262,10 @@ export function vueRouteTable(context: ResolutionContext): VueRouteTable { return t; }; for (const node of all) { - const config = isVueConfigRoute(node); - if (!config && !isNuxtPage(node)) continue; - if (!node.name.startsWith('/')) continue; + if (!inVueTable(node)) continue; const root = appRootFor(node.filePath); addRouteTo(tableAt(root), node.name, node); - if (config) { + if (isVueConfigRoute(node)) { const group = configFiles.get(node.filePath); if (group) group.nodes.push(node); else configFiles.set(node.filePath, { root, nodes: [node] }); @@ -412,6 +415,12 @@ export const vueRouterResolver: FrameworkResolver = { return NAV_CALL.test(name) || name.startsWith('import:') || name.startsWith('layout:'); }, + navigation: { + tails: ['push', 'replace', 'navigateTo'], + // A call matches against the table of the app its file is in. + scope: (route) => (inVueTable(route) ? [appRootFor(route.filePath)] : null), + }, + extract(filePath: string, content: string): FrameworkExtractionResult { const entries = parseVueRoutes(content, filePath); if (entries.length === 0) return { nodes: [], references: [] }; diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 751ad9ccba..425a83f7a7 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -2574,6 +2574,91 @@ export class ReferenceResolver { return this.frameworks.map((f) => f.name); } + /** True when a detected router binds navigation calls to its routes (see {@link reopenNavigationsFor}). */ + hasNavigationRouters(): boolean { + return this.frameworks.some((f) => f.navigation !== undefined); + } + + /** + * Put back in the pending set the navigation calls `routes` — route nodes a + * sync added, removed or renamed ({@link changedRoutes}) — may now resolve + * differently, for the sync's resolution sweep: the calls parked as failed, + * and the calls whose `navigates` edges a router's resolver made. Calls in + * `changedFilePaths` are left alone; the sync resolved them against the new + * routes already. Returns the number of references put back. + * + * A navigation call names its route by path (`navigate('/login')`) and its + * reference by the router's method (`navigate`, `history.push`), so a route + * table that changed in another file is invisible to the rest of sync. A + * call parked as failed while its route was missing is keyed on `push`, + * which no synced file defines, and a resolved call keeps the route it bound + * to while a better one was missing (a catch-all, a parameter route, one arm + * of a conditional) or a route renamed under it. A full index resolves every + * call against the final table. Each router says which calls a route of its + * own can answer (`FrameworkResolver.navigation`): the method tails, and the + * apps whose files match against that route's table. A name more of those + * calls share than `perNameCeiling` is skipped, as the symbol retry skips a + * common tail. + */ + reopenNavigationsFor(routes: readonly Node[], changedFilePaths: readonly string[] = [], perNameCeiling: number = 500): number { + if (routes.length === 0) return 0; + const plans: Array<{ router: FrameworkResolver; scopes: string[] }> = []; + const tails = new Set(); + for (const router of this.frameworks) { + if (!router.navigation) continue; + const scopes = new Set(); + for (const route of routes) { + for (const scope of router.navigation.scope(route, this.context) ?? []) scopes.add(scope); + } + if (scopes.size === 0) continue; + plans.push({ router, scopes: [...scopes] }); + for (const tail of router.navigation.tails) tails.add(tail); + } + if (plans.length === 0) return 0; + const claimed = (name: string, filePath: string): boolean => + plans.some(({ router, scopes }) => scopes.some((scope) => filePath.startsWith(scope)) && router.claimsReference?.(name) === true); + + const fresh = new Set(changedFilePaths); + const failed = new Map(); + for (const ref of this.queries.getFailedCallsByTail([...tails])) { + if (fresh.has(ref.filePath) || !claimed(ref.referenceName, ref.filePath)) continue; + const rowIds = failed.get(ref.referenceName); + if (rowIds) rowIds.push(ref.rowId); + else failed.set(ref.referenceName, [ref.rowId]); + } + const resolved = new Map>(); + for (const edge of this.queries.getResolvedNavigations()) { + const name = edge.metadata?.refName; + if (typeof name !== 'string' || fresh.has(edge.sourceFilePath) || !claimed(name, edge.sourceFilePath)) continue; + const edges = resolved.get(name); + if (edges) edges.push(edge); + else resolved.set(name, [edge]); + } + + const rowIds: number[] = []; + const edgeIds: number[] = []; + const refs: UnresolvedReference[] = []; + // A conditional's arms are edges of one call: it goes back as one reference. + const sites = new Set(); + for (const name of new Set([...failed.keys(), ...resolved.keys()])) { + const rows = failed.get(name) ?? []; + const edges = resolved.get(name) ?? []; + if (rows.length + edges.length > perNameCeiling) continue; + for (const rowId of rows) rowIds.push(rowId); + for (const edge of edges) { + const ref = resurrectRefFromDroppedEdge(edge); + if (!ref) continue; + edgeIds.push(edge.edgeId); + const site = `${ref.fromNodeId}\0${ref.referenceName}\0${ref.line}\0${ref.column}`; + if (sites.has(site)) continue; + sites.add(site); + refs.push(ref); + } + } + if (edgeIds.length > 0) this.queries.replaceResolutionEdgesWithUnresolvedRefs(edgeIds, refs); + return this.queries.reopenFailedReferences(rowIds) + refs.length; + } + /** * True when `receiver` is a local name bound by an import that resolves to a * file IN THIS PROJECT — the only case where letting a python @@ -3306,6 +3391,20 @@ function goDirOf(p: string): string { return dir === '.' || dir === '/' ? '' : dir; } +/** + * The routes a sync added, removed or renamed: `before` is every route node + * before the sync changed a file, `after` every one after its post-extract + * pass, and a route whose file and path are on one side only changed. A route + * renamed in place — the same node under a new path — is in the result twice, + * as its old self and its new one. + */ +export function changedRoutes(before: readonly Node[], after: readonly Node[]): Node[] { + const key = (route: Node): string => `${route.filePath}\0${route.name}`; + const beforeKeys = new Set(before.map(key)); + const afterKeys = new Set(after.map(key)); + return [...before.filter((route) => !afterKeys.has(key(route))), ...after.filter((route) => !beforeKeys.has(key(route)))]; +} + /** * Create a reference resolver instance */ diff --git a/src/resolution/types.ts b/src/resolution/types.ts index b3ed71a5a4..f394d52893 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -258,6 +258,26 @@ export interface CrossFileNodes extends FrameworkExtractionResult { owns(node: Node): boolean; } +/** + * The navigation calls a router binds to its routes — `navigate('/login')`, + * `router.push('/x')` — described so a sync can find the ones a changed route + * may answer (see `FrameworkResolver.navigation`). + */ +export interface NavigationCalls { + /** + * The name tails of the calls `claimsReference` accepts as navigation — + * `push` for `history.push` — which find them through the failed-tail + * index; `claimsReference` then decides on the whole name. + */ + tails: readonly string[]; + /** + * The files whose navigation calls can name `route`: the path prefixes of + * the apps whose table it is in (`''` for every file), or null when it is + * not one of this router's routes. + */ + scope(route: Node, context: ResolutionContext): readonly string[] | null; +} + /** * Framework-specific resolver */ @@ -337,6 +357,16 @@ export interface FrameworkResolver { * next run puts it back. */ crossFileNodes?(context: ResolutionContext): CrossFileNodes; + /** + * Set by a router whose `resolve()` binds a navigation call to one of its + * route nodes. A call whose route did not exist yet was parked as failed, or + * bound to whatever answered it then (a catch-all, a parameter route), and + * the retry that matches a failed ref's name tail against the names a sync + * adds (#1240) never finds it: its name is the router's method, never the + * route's path. So a sync that adds, removes or renames a route puts the + * calls this describes back for its resolution sweep. + */ + navigation?: NavigationCalls; } /** From 2f2afea88a0cb8ee5db5130ecafb061aec6744a9 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 09:32:39 +0000 Subject: [PATCH 21/26] fix(go): a defined type implements the interfaces its methods satisfy (#2419) goImplementsEdges (#584) offered only structs as implementers. A Go defined type over a slice, map, function or basic type declares methods the same way (gin's `formSource map[string][]string` has TrySet, prometheus's `staticDiscoverer []*targetgroup.Group` has Run, an adapter `HandlerFunc func(...)` has ServeHTTP) and is extracted as a `type_alias` that owns them through go-method-contains edges. So it never satisfied an interface, and interfaceOverrideEdges, which walks only class, struct and union, could not link a call through the interface to its methods. Structs and defined types are now candidates together, in one canonical (file, line) order. A defined type declares every method it has, so under the per-interface cap it ranks with the structs that declare theirs, ahead of promoted-only ones. interfaceOverrideEdges also walks Go `type_alias` nodes. Every Go type's embeddings come from the batched prefetch (the per-type lazy lookup for defined types is gone), and a type without a method is dropped before the per-interface loop. A true alias (`type A = B`) is not extracted, so it never implements anything itself. iterateNodesByKindIn takes several kinds, interleaved in its one order. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/go-implements-defined-types.test.ts | 315 ++++++++++++++++++ src/db/queries.ts | 14 +- src/resolution/callback-synthesizer.ts | 96 +++--- 4 files changed, 376 insertions(+), 50 deletions(-) create mode 100644 __tests__/go-implements-defined-types.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 23a188536c..071dd2931c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Go, an interface that embeds another, like gin's `IRouter` embedding `IRoutes`, now links to it, so `codegraph_explore` and impact show the embedded interface as its base. Before, every embedded interface was dropped. An embedded field written through another package of your project, like `*refresh.Discovery` or `storage.Appender`, or with type arguments, like `List[int]`, now links too. One from the standard library or another module, like `io.Closer` or `sync.Mutex`, links to nothing rather than to a project type that shares its name, and a type-set constraint like `~int | ~float64` is not an embedding. An embedded type written without a package now always links to its own package's type: before, etcd's `Client`, which embeds its own `Lease` interface, was linked to a `Lease` struct in another package. Re-index Go projects after upgrading. - In Go, working out which structs implement an interface now counts the methods of the interfaces it embeds, and the methods a struct gets from the types it embeds. Before, only each type's own methods counted: etcd's `AuthReadTx`, which embeds `UnsafeAuthReader` and adds `RLock` and `RUnlock`, was listed as implemented by every read-write lock in the project, an interface that only embeds others had no implementations at all, and a struct that gets its methods from an embedded base, like prometheus's service discoveries embedding `refresh.Discovery` or gin's `Engine` embedding `RouterGroup`, was missing. `codegraph_explore`, impact and the type hierarchy now list the right implementations, and a call through the interface reaches a method the embedding struct overrides, like gin's `Engine.Use`. Re-index Go projects after upgrading. - In Go, a call through an interface now reaches a method that the implementing struct gets from a type it embeds: prometheus's `targetScraper` gets `Report` from the `*Target` it embeds, so a call through the `scraper` interface now reaches `Target.Report`, and etcd's `Hasher.MemberID` reaches `EtcdServer.MemberID` through `hasherAdapter`. Before, the call reached only structs that declare the method themselves, often just a test double, so `codegraph_explore`'s call path stopped at the interface. The method is picked the way Go picks it, from the nearest embedded type, and none is linked when two embedded types at the same depth both have it or it comes from an embedded interface. Re-index Go projects after upgrading. +- In Go, a type defined over a slice, map, function or basic type now counts as implementing the interfaces its methods satisfy, just as a struct does: prometheus's `type staticDiscoverer []*targetgroup.Group` is a `Discoverer`, gin's `type formSource map[string][]string` a `setter`, etcd's `type Int64Comparable int64` a `Comparable`, and an adapter like `type HandlerFunc func(…)` a `Handler`. Before, only structs were considered, so these types were missing from an interface's implementations in `codegraph_explore`, impact and the type hierarchy, and a call through the interface, like `Discoverer.Run` or `setter.TrySet`, stopped there instead of reaching their methods. Re-index Go projects after upgrading. - In Angular templates, a property binding, an interpolation, a structural directive or a control-flow block that calls one of the component's own members, like `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading()) {`, now links the component to that member, and so does reading a getter, like `[disabled]="!canSave"`, or handing a method to a child component, like `[displayWith]="displayFn"` or `trackBy: trackById`. Before, only event bindings such as `(click)="save()"` were read, so a signal, a `computed` value, a getter or a method used only from a template had no callers and looked unused. A pipe, a template variable, a call on another object such as `form.reset()`, and a plain field read like `[value]="title"` still link nothing. Re-index Angular projects after upgrading. - React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into ``, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading. - In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374) diff --git a/__tests__/go-implements-defined-types.test.ts b/__tests__/go-implements-defined-types.test.ts new file mode 100644 index 0000000000..66a2f30b28 --- /dev/null +++ b/__tests__/go-implements-defined-types.test.ts @@ -0,0 +1,315 @@ +/** + * Go's implicit interface satisfaction (#584) is not only for structs. Any + * defined type can declare methods, and plenty of real implementers are not + * structs at all: + * + * type staticDiscoverer []*targetgroup.Group // prometheus discovery + * func (c staticDiscoverer) Run(ctx context.Context, up chan<- []*targetgroup.Group) + * + * type formSource map[string][]string // gin binding + * func (form formSource) TrySet(…) (bool, error) + * + * type Int64Comparable int64 // etcd pkg/adt + * func (v Int64Comparable) Compare(c Comparable) int + * + * Such a type is extracted as a `type_alias` that owns its methods through + * `contains` edges, but goImplementsEdges only ever offered structs as + * implementers, and the interface-dispatch bridge only walked class, struct and + * union. So `Discoverer` never listed the static discoverer, and a call + * through `Discoverer.Run` could not reach its `Run`. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; +import { buildTypeHierarchy } from '../src/graph/type-hierarchy'; +import type { Edge, Node } from '../src/types'; + +const RING_STRUCTS = 40; + +const FILES: Record = { + 'go.mod': 'module example.com/app\n\ngo 1.22\n', + // prometheus's discovery package. + 'discovery/discovery.go': `package discovery + +import "context" + +type Group struct{ Source string } + +type Discoverer interface { + Run(ctx context.Context, up chan<- []*Group) +} + +type Config interface { + Name() string + NewDiscoverer() (Discoverer, error) +} + +// A list of groups that is a Config, and hands out a Discoverer over itself. +type StaticConfig []*Group + +func (StaticConfig) Name() string { return "static" } + +func (c StaticConfig) NewDiscoverer() (Discoverer, error) { return staticDiscoverer(c), nil } + +type staticDiscoverer []*Group + +func (c staticDiscoverer) Run(ctx context.Context, up chan<- []*Group) { + select { + case <-ctx.Done(): + case up <- c: + } +} + +// A true alias: the same type as staticDiscoverer, not a second one. +type StaticDiscoverer = staticDiscoverer + +// A name but no NewDiscoverer: no Config. +type namedGroups []*Group + +func (namedGroups) Name() string { return "named" } +`, + // gin's binding package: two setters over a map and over a struct type, the + // second with its method in another file. + 'binding/form_mapping.go': `package binding + +type setOptions struct{ isDefaultExists bool } + +type setter interface { + TrySet(key string, opt setOptions) (bool, error) +} + +type formSource map[string][]string + +func (form formSource) TrySet(key string, opt setOptions) (bool, error) { return false, nil } + +// No method at all. +type keys []string +`, + 'binding/multipart_form_mapping.go': `package binding + +import "net/http" + +type multipartRequest http.Request +`, + 'binding/multipart_set.go': `package binding + +func (r *multipartRequest) TrySet(key string, opt setOptions) (bool, error) { return false, nil } +`, + // etcd's pkg/adt. + 'adt/interval_tree.go': `package adt + +type Comparable interface { + Compare(c Comparable) int +} + +type StringComparable string + +func (s StringComparable) Compare(c Comparable) int { return 0 } + +type Int64Comparable int64 + +func (v Int64Comparable) Compare(c Comparable) int { return 0 } +`, + // An adapter over a function type, a generic defined type, and a defined + // type whose underlying type is an interface. + 'web/handler.go': `package web + +type Request struct{} + +type ResponseWriter interface { + Write(b []byte) (int, error) +} + +type Handler interface { + ServeHTTP(w ResponseWriter, r *Request) +} + +type HandlerFunc func(ResponseWriter, *Request) + +func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) { f(w, r) } + +type Lener interface { + Len() int +} + +type Set[T comparable] map[T]struct{} + +func (s Set[T]) Len() int { return len(s) } + +// An interface type by another name: never an implementer itself. +type Serving Handler +`, + // More implementers that declare Tick than the cap: a defined type in the + // file listed first, then the structs. + 'ring/a.go': 'package ring\n\ntype Ticker interface{ Tick() }\n\ntype alarm int\n\nfunc (alarm) Tick() {}\n', + 'ring/b.go': [ + 'package ring', + '', + ...Array.from({ length: RING_STRUCTS }, (_, i) => { + const name = `tick${String(i).padStart(2, '0')}`; + return `type ${name} struct{}\n\nfunc (${name}) Tick() {}\n`; + }), + ].join('\n'), +}; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-defined-types-')); + 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 }); +}, 120_000); + +afterAll(() => { + cg?.destroy(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +const meta = (e: Edge): Record => (e.metadata ?? {}) as Record; +const pkg = (n: Node | null): string => `${path.posix.dirname(n?.filePath ?? '?')}.${n?.name}`; + +/** The one non-import node with this name in this file. */ +function one(name: string, file: string): Node { + const found = cg.getNodesByName(name).filter((n) => n.filePath === file && n.kind !== 'import'); + expect(found, `${name} in ${file}`).toHaveLength(1); + return found[0]!; +} + +/** Every Go type in the graph that could implement an interface. */ +function goTypes(g: CodeGraph): Node[] { + return [...g.getNodesByKind('struct'), ...g.getNodesByKind('type_alias')].filter((n) => n.language === 'go'); +} + +/** Every synthesized type → interface edge, as `pkg.Type -> pkg.Interface`. */ +function goImplements(): string[] { + return cg + .getOutgoingEdgesFrom(goTypes(cg).map((n) => n.id), ['implements']) + .filter((e) => meta(e).synthesizedBy === 'go-implements') + .map((e) => `${pkg(cg.getNode(e.source))} -> ${pkg(cg.getNode(e.target))}`) + .filter((e) => !e.startsWith('ring.')) + .sort(); +} + +/** Where a call through the interface's method is bridged to. */ +function dispatchTargets(iface: string, file: string, method: string): string[] { + const m = cg + .getOutgoingEdgesFrom([one(iface, file).id], ['contains']) + .map((e) => cg.getNode(e.target)) + .find((n) => n?.name === method)!; + return cg + .getOutgoingEdgesFrom([m.id], ['calls']) + .filter((e) => meta(e).synthesizedBy === 'interface-impl') + .map((e) => cg.getNode(e.target)?.qualifiedName ?? '?') + .sort(); +} + +describe('Go implicit satisfaction counts defined types as implementers', () => { + it('links every defined type whose methods cover the interface', () => { + expect(goImplements()).toEqual([ + 'adt.Int64Comparable -> adt.Comparable', + 'adt.StringComparable -> adt.Comparable', + 'binding.formSource -> binding.setter', + 'binding.multipartRequest -> binding.setter', + 'discovery.StaticConfig -> discovery.Config', + 'discovery.staticDiscoverer -> discovery.Discoverer', + 'web.HandlerFunc -> web.Handler', + 'web.Set -> web.Lener', + ]); + }); + + it('bridges a call through the interface to the defined type\'s method', () => { + expect(dispatchTargets('Discoverer', 'discovery/discovery.go', 'Run')).toEqual(['staticDiscoverer::Run']); + expect(dispatchTargets('Config', 'discovery/discovery.go', 'NewDiscoverer')).toEqual([ + 'StaticConfig::NewDiscoverer', + ]); + expect(dispatchTargets('setter', 'binding/form_mapping.go', 'TrySet')).toEqual([ + 'formSource::TrySet', + 'multipartRequest::TrySet', + ]); + expect(dispatchTargets('Comparable', 'adt/interval_tree.go', 'Compare')).toEqual([ + 'Int64Comparable::Compare', + 'StringComparable::Compare', + ]); + expect(dispatchTargets('Handler', 'web/handler.go', 'ServeHTTP')).toEqual(['HandlerFunc::ServeHTTP']); + }); + + it('shows them in the type hierarchy, both ways', () => { + const below = buildTypeHierarchy(cg, one('Comparable', 'adt/interval_tree.go'))?.descendants ?? []; + expect(below.map((d) => `${d.relation} ${pkg(d.node)}${d.synthesized ? ' (synthesized)' : ''}`).sort()).toEqual([ + 'implements adt.Int64Comparable (synthesized)', + 'implements adt.StringComparable (synthesized)', + ]); + const above = buildTypeHierarchy(cg, one('staticDiscoverer', 'discovery/discovery.go'))?.ancestors ?? []; + expect(above.map((a) => `${a.relation} ${pkg(a.node)}`)).toEqual(['implements discovery.Discoverer']); + }); + + it('keeps a true alias and an interface by another name from implementing anything', () => { + const named = (name: string) => + cg + .getNodesByName(name) + .filter((n) => n.language === 'go' && n.kind !== 'import') + .flatMap((n) => cg.getOutgoingEdgesFrom([n.id], ['implements'])); + expect(named('StaticDiscoverer')).toEqual([]); + expect(named('Serving')).toEqual([]); + // The type the alias names is linked once. + expect(goImplements().filter((e) => e.includes('staticDiscoverer'))).toHaveLength(1); + }); + + it('caps an interface at 40 implementers, taking defined types in file order with the declaring structs', () => { + const linked = cg + .getIncomingEdgesTo([one('Ticker', 'ring/a.go').id], ['implements']) + .filter((e) => meta(e).synthesizedBy === 'go-implements') + .map((e) => cg.getNode(e.source)!.name); + // alarm and 40 structs declare Tick. alarm comes first in file order, so + // the last struct is the one the cap leaves out. + expect(linked).toHaveLength(40); + expect(linked).toContain('alarm'); + expect(linked).not.toContain(`tick${RING_STRUCTS - 1}`); + expect(dispatchTargets('Ticker', 'ring/a.go', 'Tick')).toContain('alarm::Tick'); + }); +}); + +describe('a sync that edits a defined type\'s method', () => { + let dir = ''; + let graph: CodeGraph | undefined; + + afterEach(() => { + graph?.destroy(); + graph = undefined; + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + dir = ''; + }); + + it('re-derives what the defined type satisfies', async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-defined-types-sync-')); + const write = (rel: string, content: string) => { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), content); + }; + const int64 = 'package adt\n\ntype Int64Comparable int64\n\nfunc (v Int64Comparable) Compare(c Comparable) int { return 0 }\n'; + write('go.mod', 'module example.com/app\n\ngo 1.22\n'); + write('adt/comparable.go', 'package adt\n\ntype Comparable interface {\n\tCompare(c Comparable) int\n}\n'); + write('adt/int64.go', int64); + graph = await CodeGraph.init(dir, { index: true }); + const g = graph; + const linked = () => + g + .getOutgoingEdgesFrom(goTypes(g).map((n) => n.id), ['implements']) + .filter((e) => meta(e).synthesizedBy === 'go-implements') + .map((e) => `${g.getNode(e.source)?.name} -> ${g.getNode(e.target)?.name}`); + + expect(linked()).toEqual(['Int64Comparable -> Comparable']); + write('adt/int64.go', int64.replace('Compare(', 'Cmp(')); + await g.sync({ paths: ['adt/int64.go'] }); + expect(linked()).toEqual([]); + write('adt/int64.go', int64); + await g.sync({ paths: ['adt/int64.go'] }); + expect(linked()).toEqual(['Int64Comparable -> Comparable']); + }, 60_000); +}); diff --git a/src/db/queries.ts b/src/db/queries.ts index f1a49cb36e..a063beff2b 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -1334,14 +1334,18 @@ export class QueryBuilder { * order — the ORDER BY is total (`id` is unique), so this yields exactly the * nodes a caller filtering iterateNodesByKind by language would keep, in the * same sequence. A Go pass on a TypeScript monorepo otherwise materialized - * every method in the project to find a couple of Go ones. + * every method in the project to find a couple of Go ones. Several kinds + * come interleaved in that one order (the sort then holds only the rows + * the language filter keeps). */ - *iterateNodesByKindIn(kind: NodeKind, languages: readonly string[]): IterableIterator { - if (languages.length === 0) return; + *iterateNodesByKindIn(kind: NodeKind | readonly NodeKind[], languages: readonly string[]): IterableIterator { + const kinds: readonly NodeKind[] = typeof kind === 'string' ? [kind] : kind; + if (kinds.length === 0 || languages.length === 0) return; + const kindTest = kinds.length === 1 ? 'kind = ?' : `kind IN (${kinds.map(() => '?').join(', ')})`; const stmt = this.db.prepare( - `SELECT * FROM nodes WHERE kind = ? AND language IN (${languages.map(() => '?').join(', ')}) ORDER BY file_path, start_line, id` + `SELECT * FROM nodes WHERE ${kindTest} AND language IN (${languages.map(() => '?').join(', ')}) ORDER BY file_path, start_line, id` ); - for (const row of stmt.iterate(kind, ...languages)) { + for (const row of stmt.iterate(...kinds, ...languages)) { yield rowToNode(row as NodeRow); } } diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 7d146453ae..d848617287 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -815,12 +815,21 @@ function isGoEmbedding(e: Edge, kindOf: (id: string) => NodeKind | null): boolea } /** * Go implicit interface satisfaction (#584). Go has no `implements` keyword — a - * struct satisfies an interface structurally when its method set covers the - * interface's. Synthesize the missing `implements` edge (struct → interface) by + * type satisfies an interface structurally when its method set covers the + * interface's. Synthesize the missing `implements` edge (type → interface) by * matching method-NAME sets, so impl-navigation works and the interface-dispatch * bridge ({@link interfaceOverrideEdges}, now 'go'-enabled) can link an interface * method call to the concrete overrides, and to the methods embedding promotes. * + * The implementers are structs and defined types. A defined type declares + * methods as a struct does — gin's `type formSource map[string][]string` has + * `TrySet`, prometheus's `type staticDiscoverer []*targetgroup.Group` has `Run`, + * an adapter `type HandlerFunc func(…)` has `ServeHTTP` — and is extracted as a + * `type_alias` that owns them through `contains` edges. Go gives it none of + * the methods declared on the type it is written over, so it is matched by + * the methods it declares. A true alias (`type A = B`) is B, not a type of its + * own, and is not extracted as one. + * * Both method sets include what embedding brings in, read off the declared * `extends`/`implements` edge each embedded type is. An interface has the * methods of the interfaces it embeds: etcd's `AuthReadTx` is `RLock` and @@ -835,7 +844,7 @@ function isGoEmbedding(e: Edge, kindOf: (id: string) => NodeKind | null): boolea * where Go finds it ambiguous or hidden by a field) — over-approximation * accepted, in line with the other dispatch synthesizers; capped per * interface. Empty interfaces (`any`) are skipped so they don't match every - * struct, and a struct that embeds the interface keeps the edge its embedding + * type, and a struct that embeds the interface keeps the edge its embedding * already is. */ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Promise { @@ -843,13 +852,15 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr const edges: Edge[] = []; const seen = new Set(); - // Materializes GO structs and interfaces only (the pass is language-gated by - // the caller), never the whole struct kind — that array is O(nodes) on - // struct-heavy repos like the Linux kernel (#1212). - const goStructs: Node[] = []; - for (const s of queries.iterateNodesByKindIn('struct', ['go'])) { + // Materializes GO types only (the pass is language-gated by the caller), + // never the whole struct kind — that array is O(nodes) on struct-heavy repos + // like the Linux kernel (#1212). Structs and defined types arrive + // interleaved in one canonical order, so the cap below takes implementers + // as the files declare them, whatever their kind. + const goImplementers: Node[] = []; + for (const n of queries.iterateNodesByKindIn(['struct', 'type_alias'], ['go'])) { if ((++scanned255 & 63) === 0) await onYield(); - goStructs.push(s); + goImplementers.push(n); } const goInterfaces: Node[] = []; for (const i of queries.iterateNodesByKindIn('interface', ['go'])) { @@ -857,20 +868,12 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr goInterfaces.push(i); } - // The kinds of type whose methods embedding passes on. A defined type - // (`type HandlersChain []HandlerFunc`) is looked up when an embedding names one. - const typeKind = new Map(); - for (const s of goStructs) typeKind.set(s.id, 'struct'); + // The kinds of type whose methods embedding passes on: a struct can embed a + // defined type (`type HandlersChain []HandlerFunc`) as well. + const typeKind = new Map(); + for (const n of goImplementers) typeKind.set(n.id, n.kind); for (const i of goInterfaces) typeKind.set(i.id, 'interface'); - const kindOf = (id: string): NodeKind | null => { - let kind = typeKind.get(id); - if (kind === undefined) { - const n = queries.getNodeById(id); - kind = n?.language === 'go' && n.kind === 'type_alias' ? 'type_alias' : null; - typeKind.set(id, kind); - } - return kind; - }; + const kindOf = (id: string): NodeKind | null => typeKind.get(id) ?? null; // Memoized: an embedded base is read once, however many types embed it. const ownMemo = new Map>(); @@ -890,8 +893,8 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr }; // The types one embeds: an interface embeds interfaces, a struct any of the - // three. Every struct's and interface's are read up front in a few batched - // queries rather than one query per type; a defined type's, when reached. + // three. Every type's are read up front in a few batched queries rather than + // one query per type. const isEmbedding = (e: Edge): boolean => isGoEmbedding(e, kindOf); const NO_EMBEDS: string[] = []; const embedMemo = new Map(); @@ -905,15 +908,7 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr } await onYield(); } - const embeds = (id: string): string[] => { - let targets = embedMemo.get(id); - if (targets) return targets; - const kind = typeKind.get(id); - if (kind === 'struct' || kind === 'interface') return NO_EMBEDS; - targets = queries.getOutgoingEdges(id, ['extends', 'implements']).filter(isEmbedding).map((e) => e.target); - embedMemo.set(id, targets); - return targets; - }; + const embeds = (id: string): string[] => embedMemo.get(id) ?? NO_EMBEDS; // Own methods plus every embedded type's, down to the last level. Go allows // a struct to embed a pointer to itself, or to one embedding it back. @@ -942,10 +937,15 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr return true; }; - const structMethods = new Map>(); - for (const s of goStructs) { + // A type without a method satisfies no interface this pass looks at. + const candidates: Node[] = []; + const candidateMethods = new Map>(); + for (const s of goImplementers) { if ((++scanned255 & 63) === 0) await onYield(); - structMethods.set(s.id, methodSet(s.id)); + const have = methodSet(s.id); + if (have.size === 0) continue; + candidates.push(s); + candidateMethods.set(s.id, have); } for (const iface of goInterfaces) { @@ -967,14 +967,14 @@ async function goImplementsEdges(queries: QueryBuilder, onYield: MaybeYield): Pr }); added++; }; - // A struct that needs promoted methods waits for those declaring all of - // them: under the cap, those are what the interface-dispatch bridge links - // a call through the interface to. + // A struct that needs promoted methods waits for the types declaring all + // of them, every defined type among them: under the cap, those are what + // the interface-dispatch bridge links a call through the interface to. const throughEmbedding: Node[] = []; - for (const s of goStructs) { + for (const s of candidates) { if (added >= MAX_CALLBACKS_PER_CHANNEL) break; - const have = structMethods.get(s.id); - if (!have || !covers(have, want)) continue; + const have = candidateMethods.get(s.id)!; + if (!covers(have, want)) continue; if (embeds(s.id).includes(iface.id)) continue; // declared by embedding it const own = ownMethods(s.id); if (own !== have && !covers(own, want)) throughEmbedding.push(s); @@ -1289,10 +1289,16 @@ async function interfaceOverrideEdges(queries: QueryBuilder, onYield: MaybeYield const promoted: { cls: Node; methods: Node[]; left: number }[] = []; // Concrete-side kinds vary by language: `class` covers Java / Kotlin / // C# / TS / Swift-classes / Scala-classes; `struct` covers Swift value - // types that conform to protocols. Iterate both. + // types that conform to protocols. Iterate both. A Go defined type + // (`type HandlerFunc func(…)`) holds its methods as a struct does, as a + // `type_alias`; that kind is walked for Go alone. const concreteKinds = ['class', 'struct', 'union'] as const; - for (const kind of concreteKinds) { - for (const cls of queries.iterateNodesByKind(kind)) { + const concrete = [ + ...concreteKinds.map((kind) => () => queries.iterateNodesByKind(kind)), + () => queries.iterateNodesByKindIn('type_alias', ['go']), + ]; + for (const nodesOfKind of concrete) { + for (const cls of nodesOfKind()) { if ((++scanned255 & 63) === 0) await onYield(); // A class can only emit here if it HAS a supertype edge — check that // (one edge query) before materializing its methods: most classes in a From 61f8b09cb83ef5c3ea10c7903153f2cf79bbc24e Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 09:43:10 +0000 Subject: [PATCH 22/26] fix(cpp): a receiver declared as a std or other outside type with a lowercase name calls that type's own member (#2413) A C++ call on a variable, parameter or member the calling function or its class declares as a namespace-qualified type the project does not declare, with a lowercase name (std::string, std::vector, absl::flat_hash_set, ...), no longer falls through to Strategy 3's guess by the method's name. leveldb's `std::string saved_key_; saved_key_.clear()` reached Slice::clear, protobuf's `std::string proto; proto.append(...)` LeftoverBuffer::append. The gate applies to `.` on a value and `->` through a raw pointer; `->` on a smart pointer, iterator or optional still reaches the element type. It skips declarations it cannot trust: read from outside the caller's function and class (including a class nested in the caller's), contradicted by the call (`.` on a pointer), shadowed by an unreadable re-declaration (`auto`, range-for, structured binding), unqualified, with unbalanced `<>` (the tail of a multi-line declaration), or a `_t` name (scalars, tags, traits like std::conditional_t). A `std::` name counts as the project's only when the project declares it in `std` (vendored googletest's testing::internal::string). Receiver inference now blanks comments before matching declarations, so `// ... non-null imm_` no longer types leveldb's imm_ as `null`. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/cpp-library-type-receiver.test.ts | 522 ++++++++++++++++++++ src/resolution/name-matcher.ts | 181 ++++++- 3 files changed, 680 insertions(+), 24 deletions(-) create mode 100644 __tests__/cpp-library-type-receiver.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 071dd2931c..26d23d097f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a base class like `Message` in `class DynamicMessage final : public Message` now links to the class C++ itself would pick: the one declared in the namespaces around the class, or brought in by the file's `using` declarations and namespace aliases, and the class a `typedef` or `using` alias names. Before, a base class was matched by its name alone, so it could land on a class of the same name in another namespace or another language: in protocolbuffers/protobuf, `DynamicMessage` derived from the JSON parser's internal `Message` class, and in google/leveldb and RocksDB, iterators derived from an unrelated nested `Iterator` class such as the skip list's. The wrong base showed up in `codegraph_explore`'s type hierarchy and in impact, and tied the base's methods to the wrong overrides. A template parameter used as a base no longer links to a class that shares its name, and a base written from the global scope, like `public ::testing::Test`, now links to its class. Re-index C++ projects after upgrading. - In C and C++, a header or source file named like a test, such as protocolbuffers/protobuf's `conformance_test.h` and `test_runner.h`, now counts as part of the code that `#include`s it, directly or through other headers. Before, calls from that code into it were dropped, so protobuf's conformance suites' `suite_.ReportFailure(…)` and `RunValidInputTest(…)` calls, and its unit tests' calls to the shared `TestUtil` helpers, linked to nothing. A test file that only tests include is still kept out of reach of the rest of your code. Re-index C and C++ projects after upgrading. - In C++, a class, struct or method now keeps the namespaces and the class it is declared in when the parser misreads something earlier in the file, such as an unknown macro in front of a member or in a class header. Before, everything after the misread code could lose them, or land inside a class it isn't in: in protocolbuffers/protobuf, `FieldDescriptor` was indexed without `google::protobuf::`, and in RocksDB, the `Opts` struct declared inside a cache table class was indexed outside it, so finding a class's base classes and the methods called on its objects fell back to guessing by name. Methods that had come loose from their class are its members again, and classes the misread code had hidden, like leveldb's POSIX file and environment classes, are now indexed. Re-index C++ projects after upgrading. +- In C++, a call on a variable, parameter or member declared as a standard-library or other outside type with a lowercase name, like `std::string`, `std::vector`, `std::set` or `absl::flat_hash_set`, no longer links to a project method that only shares the method's name. In google/leveldb, `saved_key_.clear()` on a `std::string saved_key_;` reached `Slice::clear`, and in protocolbuffers/protobuf, `proto.append(…)` on a `std::string` reached an unrelated `LeftoverBuffer::append`. The same goes for `->` through a raw pointer like `std::string* out`, while `->` through a smart pointer, iterator or optional still links to the type it holds. Comments also no longer pass for a variable's declaration, so more calls now link to the method of the type the variable is really declared with. Re-index C++ projects after upgrading. - Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading. - In a repository that mixes languages, a web framework's naming conventions now link only code written in that framework's own languages. Before, Express's also linked Go and Python code: in etcd a Go method's `*zap.Logger` result type linked to the method itself, and in Harbor a Python `self.jaeger.validate()` call linked to another class's `validate`. Links that cross languages on purpose, like React Native and Expo native modules, Swift and Objective-C, Drupal's `routing.yml`, SvelteKit's `$lib/…` imports in `.ts` files and a Razor page's `@model`, are unchanged. - In Go, an interface that embeds another, like gin's `IRouter` embedding `IRoutes`, now links to it, so `codegraph_explore` and impact show the embedded interface as its base. Before, every embedded interface was dropped. An embedded field written through another package of your project, like `*refresh.Discovery` or `storage.Appender`, or with type arguments, like `List[int]`, now links too. One from the standard library or another module, like `io.Closer` or `sync.Mutex`, links to nothing rather than to a project type that shares its name, and a type-set constraint like `~int | ~float64` is not an embedding. An embedded type written without a package now always links to its own package's type: before, etcd's `Client`, which embeds its own `Lease` interface, was linked to a `Lease` struct in another package. Re-index Go projects after upgrading. diff --git a/__tests__/cpp-library-type-receiver.test.ts b/__tests__/cpp-library-type-receiver.test.ts new file mode 100644 index 0000000000..70d368dcd1 --- /dev/null +++ b/__tests__/cpp-library-type-receiver.test.ts @@ -0,0 +1,522 @@ +/** + * A C++ receiver declared as a type from outside the project with a lowercase + * name — `std::string`, `std::vector<…>`, `absl::flat_hash_set<…>` — calls + * that type's own member, not a project method. + * + * google/leveldb's `DBIter` declares `std::string saved_key_;` and calls + * `saved_key_.clear()`. Receiver inference read the type as `string`, which + * names no project class, and the check for a type from outside the project + * only caught capitalized names (`List`, `String`), so the call fell through + * to a guess by the method's name: `Slice::clear`, the one project `clear`. + * protocolbuffers/protobuf had about 900 such edges (`std::string proto; + * proto.append(…)` reached `LeftoverBuffer::append`). + * + * When the calling function or its class declares the receiver that way, a + * `.` call — or a `->` through a raw pointer, `std::string* out` — gets no + * project edge. A `->` on a smart pointer, iterator or optional still goes to + * the element type, and a call that contradicts its declaration (`.` on a + * pointer) keeps its guess, since that declaration is another variable's. + * Comments no longer pass for declarations in the scan: `// … non-null imm_` + * read as `imm_`'s declaration, with the type `null`. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import type { Node } from '../src/types'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-library-')); + roots.push(root); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + return CodeGraph.init(root, { index: true }); +} + +function callable(cg: CodeGraph, qualifiedName: string): Node { + const node = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName); + if (!node) throw new Error(`no function or method ${qualifiedName}`); + return node; +} + +/** `calls` callees of a function or method, as `qualifiedName`. */ +function calls(cg: CodeGraph, caller: string): string[] { + return cg + .getCallees(callable(cg, caller).id) + .filter((r) => r.edge.kind === 'calls') + .map((r) => r.node.qualifiedName) + .sort(); +} + +const lines = (...l: string[]): string => [...l, ''].join('\n'); + +/** leveldb's Slice and its namesakes elsewhere: every method a library type's call could be mistaken for. */ +const PROJECT = { + 'include/leveldb/slice.h': lines( + 'namespace leveldb {', + 'class Slice {', + ' public:', + ' const char* data() const { return data_; }', + ' unsigned size() const { return size_; }', + ' void clear() { size_ = 0; }', + ' const char* begin() const { return data_; }', + ' void remove_prefix(unsigned n) { size_ -= n; }', + ' private:', + ' const char* data_;', + ' unsigned size_;', + '};', + '} // namespace leveldb', + ), + 'util/buffers.h': lines( + 'namespace leveldb {', + 'class LeftoverBuffer {', + ' public:', + ' void append(const char* s, unsigned n) {}', + '};', + 'class Arena {', + ' public:', + ' void insert(int block) {}', + '};', + 'class Cache {', + ' public:', + ' void reset() {}', + '};', + 'class Iterator {', + ' public:', + ' void SeekToFirst() {}', + '};', + '} // namespace leveldb', + ), +}; + +describe('a C++ receiver declared as a library type with a lowercase name', () => { + it('reproduction: `std::string saved_key_; saved_key_.clear()` is not Slice::clear', async () => { + const cg = await indexed({ + ...PROJECT, + 'db/db_iter.cc': lines( + '#include ', + '#include "include/leveldb/slice.h"', + 'namespace leveldb {', + 'class DBIter {', + ' public:', + ' void Next();', + ' private:', + ' std::string saved_key_; // == current key when direction_==kReverse', + '};', + 'void DBIter::Next() {', + ' saved_key_.clear();', + '}', + '} // namespace leveldb', + ), + }); + try { + expect(calls(cg, 'leveldb::DBIter::Next')).toEqual([]); + expect(cg.getCallers(callable(cg, 'leveldb::Slice::clear').id).filter((r) => r.edge.kind === 'calls')).toEqual([]); + } finally { + cg.close(); + } + }); + + it('a local, a parameter, a raw pointer, nested template arguments and an abseil container', async () => { + const cg = await indexed({ + ...PROJECT, + 'db/encode.cc': lines( + '#include ', + '#include ', + '#include "absl/container/flat_hash_set.h"', + '#include "include/leveldb/slice.h"', + '#include "util/buffers.h"', + 'namespace leveldb {', + 'void Encode(std::string* dst, const Slice& value) {', + ' dst->clear();', + ' dst->append(value.data(), value.size());', + '}', + 'std::string Render(const std::vector>& files) {', + ' std::string proto;', + ' proto.append("x", 1);', + ' absl::flat_hash_set seen;', + ' seen.insert(1);', + ' const char* first = proto.data();', + ' return files.begin() == files.end() ? proto : std::string(first);', + '}', + '} // namespace leveldb', + ), + }); + try { + // `value` is a Slice; `dst`, `proto`, `seen` and `files` are not. + expect(calls(cg, 'leveldb::Encode')).toEqual(['leveldb::Slice::data', 'leveldb::Slice::size']); + expect(calls(cg, 'leveldb::Render')).toEqual([]); + } finally { + cg.close(); + } + }); + + it('a `std::` name beside a project alias of the same name elsewhere', async () => { + // facebook/rocksdb vendors googletest, whose `typedef ::std::string + // string;` in `testing::internal` is a project type named `string`. + const cg = await indexed({ + ...PROJECT, + 'third_party/gtest.h': lines( + '#include ', + 'namespace testing {', + 'namespace internal {', + 'typedef ::std::string string;', + '} // namespace internal', + '} // namespace testing', + ), + 'tools/trim.cc': lines( + '#include ', + '#include "include/leveldb/slice.h"', + 'void Trim(std::string& text) {', + ' text.clear();', + '}', + ), + }); + try { + expect(calls(cg, 'Trim')).toEqual([]); + } finally { + cg.close(); + } + }); + + it('a smart pointer: `.` is its own member, `->` reaches the element type', async () => { + const cg = await indexed({ + ...PROJECT, + 'db/scan.cc': lines( + '#include ', + '#include "util/buffers.h"', + 'namespace leveldb {', + 'void Scan(Iterator* raw) {', + ' std::unique_ptr iter(raw);', + ' iter->SeekToFirst();', + ' iter.reset();', + '}', + '} // namespace leveldb', + ), + }); + try { + // `iter.reset()` is unique_ptr's, not Cache::reset. + expect(calls(cg, 'leveldb::Scan')).toEqual(['leveldb::Iterator::SeekToFirst']); + } finally { + cg.close(); + } + }); + + it('a std trait or member type names another type, maybe the project\'s', async () => { + // facebook/rocksdb's compression.cc: `std::conditional_t dict` is a Slice when the flag is off. + const cg = await indexed({ + ...PROJECT, + 'util/compression.cc': lines( + '#include ', + '#include ', + '#include "include/leveldb/slice.h"', + 'namespace leveldb {', + 'template ', + 'unsigned DictSize(std::conditional_t dict) {', + ' return dict.size();', + '}', + 'void Drop(const std::vector& slices) {', + ' std::vector::value_type first = slices[0];', + ' first.remove_prefix(1);', + '}', + '} // namespace leveldb', + ), + }); + try { + expect(calls(cg, 'leveldb::DictSize')).toEqual(['leveldb::Slice::size']); + expect(calls(cg, 'leveldb::Drop')).toEqual(['leveldb::Slice::remove_prefix']); + } finally { + cg.close(); + } + }); + + it('a project type with a lowercase name is still the project\'s', async () => { + const cg = await indexed({ + ...PROJECT, + 'util/arena.h': lines( + 'namespace util {', + 'class arena {', + ' public:', + ' void* allocate(unsigned n) { return 0; }', + '};', + '} // namespace util', + ), + 'util/use.cc': lines( + '#include "util/arena.h"', + 'void Use(util::arena& a) {', + ' a.allocate(8);', + '}', + ), + }); + try { + expect(calls(cg, 'Use')).toEqual(['util::arena::allocate']); + } finally { + cg.close(); + } + }); +}); + +describe('a declaration that is not the receiver\'s does not rule a guess out', () => { + it('a range-for variable, or an `auto` one, hides an earlier library-typed one of the same name', async () => { + const cg = await indexed({ + ...PROJECT, + 'db/shadow.cc': lines( + '#include ', + '#include ', + '#include "include/leveldb/slice.h"', + '#include "util/buffers.h"', + 'namespace leveldb {', + 'void Trim(const std::vector& slices) {', + ' std::string key;', + ' for (const Slice& key : slices) {', + ' key.remove_prefix(1);', + ' }', + '}', + 'void Flush(Cache* caches) {', + ' { std::string buf; buf.clear(); }', + ' auto buf = caches[0];', + ' buf.reset();', + '}', + '} // namespace leveldb', + ), + }); + try { + expect(calls(cg, 'leveldb::Trim')).toEqual(['leveldb::Slice::remove_prefix']); + // The block's `buf.clear()` is the string's; the `auto` one's `reset` is a guess left alone. + expect(calls(cg, 'leveldb::Flush')).toEqual(['leveldb::Cache::reset']); + } finally { + cg.close(); + } + }); + + it('a call that contradicts the declaration it found: `.` on what reads as a pointer', async () => { + // As in fmtlib/fmt's bigint, where `carry = (upper * bigits_[i] …` reads + // as a declaration `upper* bigits_[…]`: the product here reads as + // `absl::kWeightScale* key[…]`, but `key.size()` is a `.` call. + const cg = await indexed({ + ...PROJECT, + 'util/weight.cc': lines( + '#include "include/leveldb/slice.h"', + 'namespace leveldb {', + 'unsigned Weight(const Slice& key) {', + ' unsigned w = absl::kWeightScale * key[0];', + ' return w + key.size();', + '}', + '} // namespace leveldb', + ), + }); + try { + expect(calls(cg, 'leveldb::Weight')).toEqual(['leveldb::Slice::size']); + } finally { + cg.close(); + } + }); + + it('a constant of a project class read as a type, and a scalar', async () => { + const cg = await indexed({ + ...PROJECT, + 'util/mask.cc': lines( + '#include ', + '#include "include/leveldb/slice.h"', + '#define SLICE_AT(var, list, i) const Slice& var = list[i]', + 'namespace leveldb {', + 'unsigned Mask(const Slice& key) {', + // reads like `Slice::kMask& key[…]` + ' unsigned m = Slice::kMask & key[0];', + ' return m + key.size();', + '}', + 'void Visit(const Slice* slices) {', + ' std::size_t key = 0;', + ' {', + // the macro declares the `key` the call is made on + ' SLICE_AT(key, slices, 0);', + ' key.remove_prefix(1);', + ' }', + '}', + '} // namespace leveldb', + ), + }); + try { + expect(calls(cg, 'leveldb::Mask')).toEqual(['leveldb::Slice::size']); + expect(calls(cg, 'leveldb::Visit')).toEqual(['leveldb::Slice::remove_prefix']); + } finally { + cg.close(); + } + }); + + it('a member of another class in the file', async () => { + // google/leveldb's db/log_test.cc: StringDest's `std::string contents_` + // sits above StringSource, whose own `Slice contents_` comes after its methods. + const cg = await indexed({ + ...PROJECT, + 'db/log_test.cc': lines( + '#include ', + '#include "include/leveldb/slice.h"', + 'namespace leveldb {', + 'class StringDest {', + ' public:', + ' std::string contents_;', + '};', + 'class StringSource {', + ' public:', + ' void Skip(unsigned n) { contents_.remove_prefix(n); }', + ' private:', + ' Slice contents_;', + '};', + '} // namespace leveldb', + ), + }); + try { + expect(calls(cg, 'leveldb::StringSource::Skip')).toEqual(['leveldb::Slice::remove_prefix']); + } finally { + cg.close(); + } + }); + + it('the last line of a declaration that starts on an earlier one', async () => { + // facebook/rocksdb's BlobFileReader: `autovector>>& blob_reqs` starts the next. + const cg = await indexed({ + 'util/autovector.h': lines( + 'namespace rocksdb {', + 'template ', + 'class autovector {', + ' public:', + ' unsigned size() const { return 0; }', + '};', + '} // namespace rocksdb', + ), + 'db/blob/blob_file_reader.cc': lines( + '#include ', + '#include "util/autovector.h"', + 'namespace rocksdb {', + 'class BlobFileReader {', + ' public:', + ' unsigned MultiGetBlob(autovector>>& blob_reqs);', + '};', + 'unsigned BlobFileReader::MultiGetBlob(autovector>>& blob_reqs) {', + ' return blob_reqs.size();', + '}', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::BlobFileReader::MultiGetBlob')).toEqual(['rocksdb::autovector::size']); + } finally { + cg.close(); + } + }); + + it('a member of a class nested in the caller\'s', async () => { + // facebook/rocksdb's MultiScan: its nested MultiScanIterator declares a + // `scan_opts_` of its own, above MultiScan's. + const cg = await indexed({ + 'include/rocksdb/multi_scan.h': lines( + '#include ', + 'namespace rocksdb {', + 'struct ScanOptions {};', + 'class MultiScanArgs {', + ' public:', + ' int GetScanRanges() const { return 0; }', + '};', + 'class MultiScan {', + ' public:', + ' class MultiScanIterator {', + ' private:', + ' const std::vector& scan_opts_;', + ' };', + ' int begin() { return scan_opts_.GetScanRanges(); }', + ' private:', + ' const MultiScanArgs scan_opts_;', + '};', + '} // namespace rocksdb', + ), + }); + try { + expect(calls(cg, 'rocksdb::MultiScan::begin')).toEqual(['rocksdb::MultiScanArgs::GetScanRanges']); + } finally { + cg.close(); + } + }); +}); + +describe('comments in the declaration scan', () => { + it('a comment that mentions the receiver is not its declaration', async () => { + // google/leveldb's db_impl.cc: `// … since there is a non-null imm_` + // above `imm_->…` read as `imm_`'s declaration, with the type `null`. + const cg = await indexed({ + 'db/memtable.h': lines( + 'namespace leveldb {', + 'class MemTable {', + ' public:', + ' unsigned ApproximateMemoryUsage() { return 0; }', + '};', + 'class Arena {', + ' public:', + ' unsigned ApproximateMemoryUsage() { return 0; }', + '};', + '} // namespace leveldb', + ), + 'db/db_impl.h': lines( + '#include "db/memtable.h"', + 'namespace leveldb {', + 'class DBImpl {', + ' public:', + ' unsigned MemoryUsage();', + ' private:', + ' MemTable* imm_;', + '};', + '} // namespace leveldb', + ), + 'db/db_impl.cc': lines( + '#include "db/db_impl.h"', + 'namespace leveldb {', + 'unsigned DBImpl::MemoryUsage() {', + ' // Count the memtable being compacted, since there is a non-null imm_', + ' return imm_->ApproximateMemoryUsage();', + '}', + '} // namespace leveldb', + ), + }); + try { + expect(calls(cg, 'leveldb::DBImpl::MemoryUsage')).toEqual(['leveldb::MemTable::ApproximateMemoryUsage']); + } finally { + cg.close(); + } + }); + + it('a parameter after a leading `/* static */` is the caller\'s own', async () => { + const cg = await indexed({ + ...PROJECT, + 'util/status.cc': lines( + '#include ', + '#include "include/leveldb/slice.h"', + 'namespace leveldb {', + 'class Status {', + ' public:', + ' static bool Check(const std::string& msg);', + '};', + '/* static */ bool Status::Check(const std::string& msg) {', + ' return msg.begin() != nullptr;', + '}', + '} // namespace leveldb', + ), + }); + try { + expect(calls(cg, 'leveldb::Status::Check')).toEqual([]); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 6528db81bb..1c46a5d0b4 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -14,7 +14,7 @@ import { SWIFT_TYPE_PATH_CALL, resolveSwiftTypePathCall } from './swift-type-vis import { dartImportPrefixes, dartLibrarySees, dartPrefixSees, inSameDartLibrary } from './dart-libraries'; import { isDartLocallyBound } from './dart-local-scope'; import { breakVbTie, isVbMemberInScope, isVbNestedTypeInScope, isVbTypeQualifiedBy, matchVbTypedCall, preferVbProject, sameVbProject } from './vbnet-receivers'; -import { cppAliasedTypeName, isCppPointerType, resolveCppAliasedType } from './cpp-type-aliases'; +import { cppAliasedTypeName, cppTypeSegments, isCppPointerType, resolveCppAliasedType } from './cpp-type-aliases'; import { cppIncludedFile, cppIncluders } from './cpp-includers'; import { isTestPath } from '../search/query-utils'; import { isMinifiedContent } from '../extraction/generated-detection'; @@ -8388,6 +8388,16 @@ interface CppReceiverDeclaration { pointer?: boolean; /** The type is one of the project's class templates, whose specializations may add members. */ classTemplate?: boolean; + /** The declared type as written (`std::vector*`). */ + raw?: string; + /** The declaration is the calling function's own, or a member of its class (isCppCallersDeclaration). */ + callers?: boolean; + /** + * On its way back to the declaration the scan passed one of the receiver + * it could not read a type from — `auto x = Make();`, `for (Foo& x : xs)`, + * `auto [x, y] = …` — so the declaration it found is an earlier variable's. + */ + shadowed?: boolean; } /** @@ -8421,37 +8431,136 @@ function cppDeclaredType( return aliased ? cppAliasedTypeName(aliased) : null; } +const CPP_CLASSES_IN_FILE = new WeakMap>(); + +/** The classes, structs and unions a C or C++ file declares. */ +function cppClassesIn(file: string, context: ResolutionContext): Node[] { + let memo = CPP_CLASSES_IN_FILE.get(context); + if (!memo) CPP_CLASSES_IN_FILE.set(context, (memo = new Map())); + let classes = memo.get(file); + if (!classes) { + classes = context.getNodesInFile(file).filter((n) => n.kind === 'class' || n.kind === 'struct' || n.kind === 'union'); + memo.set(file, classes); + } + return classes; +} + /** - * Is line `line` of `file` — where `text` declares a C++ receiver at column - * `at` — the calling function's own code, or a member declaration of the - * caller's class? Receiver inference reads back to the top of the file and - * through the header, so a declaration it finds elsewhere (an earlier - * function, another class in the header, a comment) may be another variable - * of the same name. + * Is line `line` of `file`, which declares a C++ receiver, the calling + * function's own code, or a member declaration of the caller's class? + * Receiver inference reads back to the top of the file and through the + * header, so a declaration it finds elsewhere — an earlier function, another + * class in the header, a class nested in the caller's (rocksdb's + * `MultiScan::MultiScanIterator` has a `scan_opts_` of its own) — may be + * another variable of the same name. (The scan has blanked comments.) */ -function isCppCallersDeclaration(file: string, line: number, text: string, at: number, ref: UnresolvedRef, context: ResolutionContext): boolean { - if (/^\s*(?:\/\/|\/\*|\*)/.test(text)) return false; - const comment = text.indexOf('//'); - if (comment >= 0 && comment < at) return false; +function isCppCallersDeclaration(file: string, line: number, ref: UnresolvedRef, context: ResolutionContext): boolean { const caller = context.getNodeById?.(ref.fromNodeId); if (!caller || (caller.kind !== 'method' && caller.kind !== 'function')) return false; if (file === ref.filePath && line >= caller.startLine && line <= ref.line) return true; const cut = caller.qualifiedName.lastIndexOf('::'); if (cut < 0) return false; - return context.getNodesByQualifiedName(caller.qualifiedName.slice(0, cut)).some((owner) => - (owner.kind === 'class' || owner.kind === 'struct' || owner.kind === 'union') && owner.filePath === file && - line >= owner.startLine && line <= (owner.endLine ?? owner.startLine)); + let innermost: Node | undefined; + for (const cls of cppClassesIn(file, context)) { + if (line < cls.startLine || line > (cls.endLine ?? cls.startLine)) continue; + if (!innermost || cls.startLine >= innermost.startLine) innermost = cls; + } + return innermost?.qualifiedName === caller.qualifiedName.slice(0, cut); } -/** Is the C++ member call at `ref` written `receiver.method(…)`, not through `->`? */ -function isCppDotCall(receiver: string, ref: UnresolvedRef, context: ResolutionContext): boolean { +/** + * The operator the C++ member call at `ref` is written with: `.` for + * `receiver.method(…)`, `->` through a pointer, iterator or smart pointer, or + * null when the source doesn't show it. + */ +function cppMemberOperator(receiver: string, ref: UnresolvedRef, context: ResolutionContext): '.' | '->' | null { const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/); const at = lines?.[ref.line - 1]?.slice(ref.column); - if (!lines || !at?.startsWith(receiver)) return false; + if (!lines || !at?.startsWith(receiver)) return null; let rest = at.slice(receiver.length); // `symbols_by_parent_` ending its line, `.insert(…)` starting the next. for (let next = ref.line; !rest.trim() && next < Math.min(lines.length, ref.line + 3); next++) rest = lines[next]!; - return /^\s*\./.test(rest); + const op = /^\s*(\.|->)/.exec(rest)?.[1]; + return op === '.' || op === '->' ? op : null; +} + +/** Is the C++ member call at `ref` written `receiver.method(…)`, not through `->`? */ +function isCppDotCall(receiver: string, ref: UnresolvedRef, context: ResolutionContext): boolean { + return cppMemberOperator(receiver, ref, context) === '.'; +} + +/** Kinds that declare a C or C++ type name. */ +const CPP_TYPE_KINDS: ReadonlySet = new Set(['class', 'struct', 'union', 'enum', 'interface', 'type_alias']); + +/** The C and C++ types the project declares under `name`. */ +function cppTypesNamed(name: string, context: ResolutionContext): Node[] { + return context.getNodesByName(name).filter((n) => (n.language === 'cpp' || n.language === 'c') && CPP_TYPE_KINDS.has(n.kind)); +} + +/** + * Is a C++ declared type, as written (`std::vector*`, `const + * absl::flat_hash_set&`), one from outside the project with a lowercase + * name, as the standard library's and abseil's are? (isUndeclaredTypeName + * takes the capitalized ones.) The name is qualified, by namespaces that are + * no project type's (`Foo::kMask & key` reads like a declaration of `key`), + * and the project declares no type of that name: in `std` itself for a + * `std::` name (googletest's `testing::internal::string` is no + * `std::string`), anywhere for any other, since a namespace a macro opens + * (fmt's `FMT_BEGIN_NAMESPACE`) is in no qualified name. An unqualified name + * is not known to be std's: fmt's `using namespace std;` is inside `namespace + * adl { … }`, and its `basic_string_view` is fmt's own. Nor is a type whose + * `>`s outnumber its `<`s: the end of a declaration begun on an earlier line + * (`autovector>>& reqs`). A + * `_t` name has no members of its own: it is a scalar (`std::size_t`, so + * the declaration is another variable's), a tag, or a trait that names + * another type (rocksdb's `std::conditional_t + * dict`). + */ +function isCppLibraryType(raw: string, context: ResolutionContext): boolean { + if ((raw.match(//g)?.length ?? 0)) return false; + const segments = cppTypeSegments(raw); + if (!segments || segments.length < 2) return false; + const name = segments[segments.length - 1]!; + if (!/^[a-z]/.test(name) || /_t$/.test(name)) return false; + if (segments.slice(0, -1).some((s) => cppTypesNamed(s, context).length > 0)) return false; + const declared = cppTypesNamed(name, context); + if (segments[0] !== 'std') return declared.length === 0; + const spelled = segments.join('::'); + return !declared.some((n) => n.qualifiedName === spelled || n.qualifiedName.endsWith(`::${spelled}`)); +} + +/** + * A C++ source line as code, for reading declarations: null for a line of a + * comment (`// …`, ` * …`), else the line with its comments blanked, columns + * kept. Receiver inference read leveldb's `// … non-null imm_` as a + * declaration of `imm_` with the type `null`. + */ +function cppCodeOf(line: string): string | null { + if (/^\s*(?:\/\/|\*)/.test(line)) return null; + let code = line.replace(/\/\*.*?\*\//g, (c) => ' '.repeat(c.length)); + // A block comment opening here runs past the line. + const open = code.indexOf('/*'); + if (open >= 0) code = code.slice(0, open); + const comment = code.indexOf('//'); + return comment >= 0 ? code.slice(0, comment) : code; +} + +/** + * Does `line` declare the receiver in a form the declarator pattern doesn't + * read: a range-`for` variable (`for (const Foo& x : xs)`) or a structured + * binding (`auto [x, y] = …`)? + */ +function cppRebindsReceiver(line: string, escapedReceiver: string): boolean { + return new RegExp(String.raw`\bfor\s*\(.*\b${escapedReceiver}\s*:(?!:)`).test(line) || + new RegExp(String.raw`\bauto\s*&{0,2}\s*\[[^\]]*\b${escapedReceiver}\b[^\]]*\]`).test(line); +} + +/** Record the C++ declaration receiver inference settles on. */ +function noteCppDeclaration(found: CppReceiverDeclaration | undefined, raw: string, callers: boolean): void { + if (!found) return; + found.raw = raw; + found.callers = callers; + found.pointer = isCppPointerType(raw); } /** @@ -8484,7 +8593,9 @@ function inferCppReceiverType( const declaratorRegex = buildDeclaratorRegex(escapedReceiver); for (let i = callLineIndex; i >= 0; i--) { - const line = lines[i]; + const text = lines[i]; + if (!text || !receiverPattern.test(text)) continue; + const line = cppCodeOf(text); if (!line || !receiverPattern.test(line)) continue; const declaratorMatch = line.match(declaratorRegex); @@ -8495,11 +8606,16 @@ function inferCppReceiverType( // from the initializer (call return type / construction) (#645). const initType = inferCppAutoInitializerType(line, receiverName, ref, context, depth); if (initType) return initType; - // No usable initializer on this line — keep scanning earlier ones. + // No usable initializer on this line — keep scanning earlier ones, + // where what is declared is an earlier variable. + if (found) found.shadowed = true; } else if (normalized) { - const inCallerScope = isCppCallersDeclaration(ref.filePath, i + 1, line, declaratorMatch.index ?? 0, ref, context); + const inCallerScope = isCppCallersDeclaration(ref.filePath, i + 1, ref, context); + noteCppDeclaration(found, declaratorMatch[1]!, inCallerScope); return cppDeclaredType(declaratorMatch[1]!, normalized, inCallerScope, ref, context, found); } + } else if (found && cppRebindsReceiver(line, escapedReceiver)) { + found.shadowed = true; } } @@ -8517,13 +8633,16 @@ function inferCppReceiverType( if (!headerLines) continue; for (let i = 0; i < headerLines.length; i++) { - const line = headerLines[i]!; - if (!receiverPattern.test(line)) continue; + const text = headerLines[i]!; + if (!receiverPattern.test(text)) continue; + const line = cppCodeOf(text); + if (!line) continue; const declaratorMatch = line.match(declaratorRegex); if (!declaratorMatch) continue; const normalized = normalizeCppTypeName(declaratorMatch[1] ?? ''); if (normalized && normalized !== 'auto') { - const inCallerScope = isCppCallersDeclaration(headerPath, i + 1, line, declaratorMatch.index ?? 0, ref, context); + const inCallerScope = isCppCallersDeclaration(headerPath, i + 1, ref, context); + noteCppDeclaration(found, declaratorMatch[1]!, inCallerScope); return cppDeclaredType(declaratorMatch[1]!, normalized, inCallerScope, ref, context, found); } } @@ -9038,6 +9157,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { CPP_NS_MACROS.delete(context); CPP_NS_FRAMES.delete(context); CPP_NS_ALIASES.delete(context); + CPP_CLASSES_IN_FILE.delete(context); SOLIDITY_SUPERS.delete(context); DECLARED_SUPERS.delete(context); INHERITED_METHODS.delete(context); @@ -10274,6 +10394,19 @@ export function matchMethodCall( // followed. if (isUndeclaredTypeName(cppDecl.written!, ref, context)) return null; } + // A C++ receiver the calling function or its class declares as a type + // from outside the project with a lowercase name calls that type's own + // member, through `.` or a raw pointer's `->`: leveldb's `std::string + // data_; data_.data()` went to `Slice::data`, protobuf's `std::string + // proto; proto.append(…)` to `LeftoverBuffer::append`. Not so for `->` + // on such a value — an iterator, smart pointer or optional hands the call + // to its element type — nor for a call that doesn't fit the declaration + // (`.` on a pointer), which then is another variable's. + if (ref.language === 'cpp' && cppDecl.callers && !cppDecl.aliased && !cppDecl.shadowed && + cppMemberOperator(objectOrClass!, ref, context) === (cppDecl.pointer ? '->' : '.') && + isCppLibraryType(cppDecl.raw!, context)) { + return null; + } } // Go 2-hop field chain `base.field.Method` (#1276): the base's type comes From ed199e60d8f1aed5baf73836366ef0a6f7838d11 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 10:16:45 +0000 Subject: [PATCH 23/26] fix(go): a type alias `type A = B` is indexed and links to the type it names (#2417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tree-sitter-go parses `type Event = mvccpb.Event` as a `type_alias`, beside the `type_spec` of a defined type, and both extractors read `type_spec` only. etcd's client/v3 `Event` had no node; a use through its package (`clientv3.Event`, prometheus's `storage.AOptions{…}`) linked to nothing, and a use inside it linked by name to any type so named, another package's included. - Extraction (TS and kernel): an alias is a `type_alias` node referencing each type its right-hand side names, at the name so resolution reads the package back; an alias of a struct or interface literal is a struct or interface. The alias's own type parameters and Go's predeclared types are skipped. A generic alias, which tree-sitter-go 0.23 parses as a `type_spec` around an error, reads as an alias on wasm; the kernel defers its file. - A method called on an alias-typed value is the aliased type's: an alias of a project type is followed in its own package, through pointers and further aliases; one of an outside type resolves by name as before. - An alias's target written through a package that is none of the file's imports as indexed stays unresolved, as an embedded type's does, so the alias never links to itself or a namesake. - With Go defined types as implementers (#2419), an alias that owns methods written with it as the receiver implements what they satisfy, in place of the type it names. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/fixtures/kernel-parity/torture.go | 26 ++ __tests__/go-type-alias.test.ts | 394 ++++++++++++++++++++ codegraph-kernel/src/go.rs | 60 ++- src/extraction/languages/go.ts | 39 +- src/extraction/tree-sitter.ts | 18 +- src/resolution/callback-synthesizer.ts | 4 +- src/resolution/index.ts | 6 + src/resolution/name-matcher.ts | 55 ++- 9 files changed, 591 insertions(+), 12 deletions(-) create mode 100644 __tests__/go-type-alias.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d23d097f..08226636c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Go, working out which structs implement an interface now counts the methods of the interfaces it embeds, and the methods a struct gets from the types it embeds. Before, only each type's own methods counted: etcd's `AuthReadTx`, which embeds `UnsafeAuthReader` and adds `RLock` and `RUnlock`, was listed as implemented by every read-write lock in the project, an interface that only embeds others had no implementations at all, and a struct that gets its methods from an embedded base, like prometheus's service discoveries embedding `refresh.Discovery` or gin's `Engine` embedding `RouterGroup`, was missing. `codegraph_explore`, impact and the type hierarchy now list the right implementations, and a call through the interface reaches a method the embedding struct overrides, like gin's `Engine.Use`. Re-index Go projects after upgrading. - In Go, a call through an interface now reaches a method that the implementing struct gets from a type it embeds: prometheus's `targetScraper` gets `Report` from the `*Target` it embeds, so a call through the `scraper` interface now reaches `Target.Report`, and etcd's `Hasher.MemberID` reaches `EtcdServer.MemberID` through `hasherAdapter`. Before, the call reached only structs that declare the method themselves, often just a test double, so `codegraph_explore`'s call path stopped at the interface. The method is picked the way Go picks it, from the nearest embedded type, and none is linked when two embedded types at the same depth both have it or it comes from an embedded interface. Re-index Go projects after upgrading. - In Go, a type defined over a slice, map, function or basic type now counts as implementing the interfaces its methods satisfy, just as a struct does: prometheus's `type staticDiscoverer []*targetgroup.Group` is a `Discoverer`, gin's `type formSource map[string][]string` a `setter`, etcd's `type Int64Comparable int64` a `Comparable`, and an adapter like `type HandlerFunc func(…)` a `Handler`. Before, only structs were considered, so these types were missing from an interface's implementations in `codegraph_explore`, impact and the type hierarchy, and a call through the interface, like `Discoverer.Run` or `setter.TrySet`, stopped there instead of reaching their methods. Re-index Go projects after upgrading. +- In Go, a type alias like `type Event = mvccpb.Event`, alone or inside a `type ( … )` group, is now indexed and links to the type it names, so impact on that type reaches the code that uses the alias. Before, aliases were missing: a use written through the alias's package, like `clientv3.Event` or prometheus's `storage.AOptions{…}`, linked to nothing, and one inside the package, like `&PrometheusVersion{}` in prometheus's `web` package, linked to whichever type of that name the project had, there a test helper's struct. A method called on a value of an alias's type now reaches the aliased type's method rather than a namesake's, and a generic alias like `type Items[T any] = List[T]` links to `List`, so `xs.Len()` on one reaches `List.Len` instead of nothing. Re-index Go projects after upgrading. - In Angular templates, a property binding, an interpolation, a structural directive or a control-flow block that calls one of the component's own members, like `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading()) {`, now links the component to that member, and so does reading a getter, like `[disabled]="!canSave"`, or handing a method to a child component, like `[displayWith]="displayFn"` or `trackBy: trackById`. Before, only event bindings such as `(click)="save()"` were read, so a signal, a `computed` value, a getter or a method used only from a template had no callers and looked unused. A pipe, a template variable, a call on another object such as `form.reset()`, and a plain field read like `[value]="title"` still link nothing. Re-index Angular projects after upgrading. - React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into ``, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading. - In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374) diff --git a/__tests__/fixtures/kernel-parity/torture.go b/__tests__/fixtures/kernel-parity/torture.go index 3aada94afc..9e10704565 100644 --- a/__tests__/fixtures/kernel-parity/torture.go +++ b/__tests__/fixtures/kernel-parity/torture.go @@ -45,6 +45,32 @@ type Exact interface{ int64 } type Dur int +// Aliases (`=`): the types they name are references; a literal makes a struct or interface. +type Alias = pkga.Widget + +type ( + LocalAlias = Widget + PtrAlias = *Stack[int] + FnAlias = func(w Widget) error + MapAlias = map[string][]pkga.Item + Defined Widget +) + +type AnonAlias = struct { + *Base + n int +} + +type IfaceAlias = interface { + Render() string +} + +type WordAlias = uint + +func useAlias(a *Alias) LocalAlias { + return LocalAlias{} +} + func NewRegistry() *Registry { w := Widget{name: "w"} q := pkga.Widget{} diff --git a/__tests__/go-type-alias.test.ts b/__tests__/go-type-alias.test.ts new file mode 100644 index 0000000000..38f7e44437 --- /dev/null +++ b/__tests__/go-type-alias.test.ts @@ -0,0 +1,394 @@ +/** + * A Go alias declaration (`type Event = mvccpb.Event`, `type ( A = B )`) was + * in neither extractor's graph. tree-sitter-go parses it as a `type_alias` + * beside the `type_spec` of a defined type (`type WatchChan <-chan + * WatchResponse`), and both extractors only read `type_spec`, so etcd's + * client/v3 `Event` had no node while the line below it did. + * + * An alias is now a node of the kind a declaration of its type gets: a + * `type_alias` that references the types it names, a `struct` or an + * `interface` when it names a struct or interface literal. Each reference sits + * on the type's name, where resolution reads a package qualifier back. A + * generic alias (`type Set[T any] = …`, Go 1.24) has no rule in this grammar: + * it parses as a `type_spec` around an error, so the kernel hands the file to + * the wasm extractor, which reads it as an alias too, without its type + * parameters. + * + * Resolution treats the alias as the type it names. A bare `Event{}` or + * `*Event` in its package links to the alias, a method called on an `*Event` + * is the aliased type's method, methods written with the alias as the + * receiver make it implement what they satisfy, and an alias whose target is + * written through a package the index doesn't know (`clientv3` under an + * unaliased `go.etcd.io/etcd/client/v3`, known as `v3` or `client`) never + * links to itself or to a namesake. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel'; +import type { ExtractionResult } from '../src/types'; + +const KERNEL_PATH = path.join( + __dirname, + '..', + 'codegraph-kernel', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'codegraph-kernel.node' +); +const kernelAvailable = fs.existsSync(KERNEL_PATH) || process.env.CODEGRAPH_KERNEL_EXPECT === '1'; + +/** Every alias shape the grammar has a rule for, beside defined types. */ +const ALIAS_SOURCE = `package clientv3 + +import ( + "context" + + "go.etcd.io/etcd/api/v3/mvccpb" +) + +// Event is the event a watcher reports. +type Event = mvccpb.Event + +type WatchChan <-chan WatchResponse + +type WatchResponse struct { + Events []*Event +} + +type ( + // Local names a type of this package. + Local = WatchResponse + Ptr = *WatchResponse + Slice = []WatchResponse + Handler = func(ctx context.Context, r WatchResponse) error + Index = map[string]*mvccpb.KeyValue + Defined WatchResponse +) + +type Plain = List[WatchResponse] + +type Anon = struct { + Key string +} + +type Closer = interface { + Close() error +} + +type Type = string + +type Word = uint +`; + +/** Generic aliases: no grammar rule, so a parse error the kernel defers. */ +const GENERIC_SOURCE = `package clientv3 + +type List[T any] struct { + items []T +} + +// Set is a set of comparable values. +type Set[T comparable] = map[T]struct{} + +type Items[T any] = List[T] + +type Pairs[K comparable, V any] = map[K]List[V] +`; + +const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const; + +/** ` ` of every node but the file and its imports, in source order. */ +function declarations(result: ExtractionResult): string[] { + return result.nodes.filter((n) => n.kind !== 'file' && n.kind !== 'import').map((n) => `${n.kind} ${n.name}`); +} + +/** ` ` for every `references` ref out of a type declaration, sorted. */ +function typeRefs(result: ExtractionResult): string[] { + const byId = new Map(result.nodes.map((n) => [n.id, n])); + return result.unresolvedReferences + .filter((r) => r.referenceKind === 'references' && ['type_alias', 'struct', 'interface'].includes(byId.get(r.fromNodeId)?.kind ?? '')) + .map((r) => `${byId.get(r.fromNodeId)!.name} ${r.referenceName}`) + .sort(); +} + +describe('Go alias declarations are extracted', () => { + let savedEnv: Record = {}; + + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['go']); + }); + + beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + resetKernelForTests(); + }); + + afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + resetKernelForTests(); + }); + + function extract(backend: 'kernel' | 'wasm', file: string, source: string): ExtractionResult { + if (backend === 'wasm') { + process.env.CODEGRAPH_KERNEL = '0'; + return extractFromSource(file, source, 'go'); + } + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + const result = tryKernelExtract(file, source, 'go'); + expect(result, `kernel extraction of ${file}`).not.toBeNull(); + return result!; + } + + /** Each type ref sits on its name, where resolution reads a package qualifier back. */ + function expectRefsOnTheirNames(result: ExtractionResult, source: string): void { + const lines = source.split(/\r?\n/); + const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references'); + expect(refs.length).toBeGreaterThan(0); + for (const r of refs) { + expect(lines[r.line - 1]!.startsWith(r.referenceName, r.column), `${r.referenceName} at ${r.line}:${r.column}`).toBe(true); + } + } + + const backends = kernelAvailable ? (['kernel', 'wasm'] as const) : (['wasm'] as const); + + for (const crlf of [false, true]) { + const eol = (s: string) => (crlf ? s.replace(/\n/g, '\r\n') : s); + const label = crlf ? ' (CRLF)' : ''; + + it.each(backends)(`an alias is a node of its type's kind: %s${label}`, (backend) => { + const result = extract(backend, 'client/v3/watch.go', eol(ALIAS_SOURCE)); + expect(declarations(result)).toEqual([ + 'type_alias Event', + 'type_alias WatchChan', + 'struct WatchResponse', + 'type_alias Local', + 'type_alias Ptr', + 'type_alias Slice', + 'type_alias Handler', + 'type_alias Index', + 'type_alias Defined', + 'type_alias Plain', + 'struct Anon', + 'interface Closer', + 'method Close', + 'type_alias Type', + 'type_alias Word', + ]); + const event = result.nodes.find((n) => n.name === 'Event')!; + expect(event.isExported).toBe(true); + expect(event.startLine).toBe(10); + // A doc comment is read as for any type declaration: inside a group. + expect(result.nodes.find((n) => n.name === 'Local')!.docstring).toBe('Local names a type of this package.'); + // Nested under the file, as a defined type is. + const file = result.nodes.find((n) => n.kind === 'file')!; + expect(result.edges.some((e) => e.kind === 'contains' && e.source === file.id && e.target === event.id)).toBe(true); + }); + + it.each(backends)(`an alias references the types it names: %s${label}`, (backend) => { + const source = eol(ALIAS_SOURCE); + const result = extract(backend, 'client/v3/watch.go', source); + // The package of mvccpb.Event / context.Context stays in the source; + // predeclared types (string, uint, error) are no references. + expect(typeRefs(result)).toEqual([ + 'Event Event', + 'Handler Context', + 'Handler WatchResponse', + 'Index KeyValue', + 'Local WatchResponse', + 'Plain List', + 'Plain WatchResponse', + 'Ptr WatchResponse', + 'Slice WatchResponse', + ]); + expectRefsOnTheirNames(result, source); + }); + } + + it.each([false, true])('a generic alias is an alias without its type parameters (wasm; CRLF: %s)', (crlf) => { + const source = crlf ? GENERIC_SOURCE.replace(/\n/g, '\r\n') : GENERIC_SOURCE; + if (kernelAvailable) { + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + // No grammar rule: a parse error, which the kernel leaves to wasm. + expect(tryKernelExtract('client/v3/set.go', source, 'go')).toBeNull(); + } + const result = extract('wasm', 'client/v3/set.go', source); + expect(declarations(result)).toEqual(['struct List', 'type_alias Set', 'type_alias Items', 'type_alias Pairs']); + expect(result.nodes.find((n) => n.name === 'Set')!.isExported).toBe(true); + expect(typeRefs(result)).toEqual(['Items List', 'Pairs List']); + expectRefsOnTheirNames(result, source); + }); +}); + +describe('an indexed Go module resolves through its aliases', () => { + let root = ''; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-alias-')); + const files: Record = { + 'go.mod': 'module go.etcd.io/etcd\n\ngo 1.24\n', + 'api/v3/mvccpb/kv.go': [ + 'package mvccpb', + '', + 'type Event struct {', + '\tType int', + '}', + '', + 'func (e *Event) IsCreate() bool { return e.Type == 0 }', + '', + ].join('\n'), + // A namesake package that sorts first, so a lookup by name meets its + // `Event` and `Event::IsCreate` before mvccpb's. + 'alpha/event.go': [ + 'package alpha', + '', + 'type Event struct{}', + '', + 'func (e *Event) IsCreate() bool { return false }', + '', + ].join('\n'), + 'client/v3/watch.go': [ + 'package clientv3', + '', + 'import "go.etcd.io/etcd/api/v3/mvccpb"', + '', + 'type Event = mvccpb.Event', + '', + 'type WatchResponse struct {', + '\tEvents []*Event', + '}', + '', + 'func (wr *WatchResponse) Err() error { return nil }', + '', + 'type (', + '\tLocal = WatchResponse', + '\tPtr = *Local', + ')', + '', + 'func newEvent() Event { return Event{} }', + '', + 'func isCreate(e *Event) bool { return e.IsCreate() }', + '', + 'func check(p Ptr) error { return p.Err() }', + '', + ].join('\n'), + // A generic alias: the file parses with an error and goes to wasm. + 'client/v3/list.go': [ + 'package clientv3', + '', + 'type List[T any] struct{ items []T }', + '', + 'func (l *List[T]) Len() int { return len(l.items) }', + '', + 'type Items[T any] = List[T]', + '', + 'func count(xs *Items[int]) int { return xs.Len() }', + '', + ].join('\n'), + // prometheus's discovery/xds: methods written on the alias are SDConfig's. + 'discovery/kuma.go': [ + 'package discovery', + '', + 'type Config interface {', + '\tName() string', + '}', + '', + 'type SDConfig struct{}', + '', + 'type KumaSDConfig = SDConfig', + '', + 'func (*KumaSDConfig) Name() string { return "kuma" }', + '', + ].join('\n'), + 'watcher/watcher.go': [ + 'package watcher', + '', + 'import "go.etcd.io/etcd/client/v3"', + '', + '// The package is clientv3, though the index knows the import as v3 or client.', + 'type Event = clientv3.Event', + '', + ].join('\n'), + }; + 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 }); + }, 60_000); + + afterAll(() => { + cg?.destroy(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + /** `kind file:qualifiedName` of each non-contains edge out of `name` in `file`, sorted. */ + const linksFrom = (file: string, name: string) => { + const sources = cg.getNodesInFile(file).filter((n) => n.name === name && n.kind !== 'import'); + expect(sources, `${name} in ${file}`).toHaveLength(1); + const links = cg.getOutgoingEdgesFrom(sources.map((n) => n.id)) + .filter((e) => e.kind !== 'contains') + .map((e) => ({ kind: e.kind, target: cg.getNode(e.target)! })) + .map(({ kind, target }) => `${kind} ${target.filePath}:${target.qualifiedName}`); + return [...new Set(links)].sort(); + }; + + it('an alias references the type it names, in the package it is written through', () => { + expect(linksFrom('client/v3/watch.go', 'Event')).toEqual(['references api/v3/mvccpb/kv.go:Event']); + expect(linksFrom('client/v3/watch.go', 'Local')).toEqual(['references client/v3/watch.go:WatchResponse']); + expect(linksFrom('client/v3/watch.go', 'Ptr')).toEqual(['references client/v3/watch.go:Local']); + expect(linksFrom('client/v3/list.go', 'Items')).toEqual(['references client/v3/list.go:List']); + }); + + it('a bare name in the alias package is the alias', () => { + expect(linksFrom('client/v3/watch.go', 'newEvent')).toEqual([ + 'instantiates client/v3/watch.go:Event', + 'references client/v3/watch.go:Event', + ]); + }); + + it('a method called on an alias is the aliased type’s', () => { + expect(linksFrom('client/v3/watch.go', 'isCreate')).toEqual([ + 'calls api/v3/mvccpb/kv.go:Event::IsCreate', + 'references client/v3/watch.go:Event', + ]); + // Through two aliases, one of a pointer. + expect(linksFrom('client/v3/watch.go', 'check')).toEqual([ + 'calls client/v3/watch.go:WatchResponse::Err', + 'references client/v3/watch.go:Ptr', + ]); + expect(linksFrom('client/v3/list.go', 'count')).toEqual([ + 'calls client/v3/list.go:List::Len', + 'references client/v3/list.go:Items', + ]); + }); + + it('an alias owning methods written on it implements what they satisfy', () => { + expect(linksFrom('discovery/kuma.go', 'KumaSDConfig')).toEqual([ + 'implements discovery/kuma.go:Config', + 'references discovery/kuma.go:SDConfig', + ]); + // A call through Config.Name reaches the method written on the alias. + const config = cg.getNodesInFile('discovery/kuma.go').find((n) => n.name === 'Config')!; + const name = cg.getOutgoingEdgesFrom([config.id], ['contains']).map((e) => cg.getNode(e.target)!).find((n) => n.name === 'Name')!; + expect(cg.getOutgoingEdgesFrom([name.id], ['calls']).map((e) => cg.getNode(e.target)?.qualifiedName)).toEqual(['KumaSDConfig::Name']); + }); + + it('an alias written through a package the index does not know links to nothing', () => { + // Not to itself, nor to client/v3's, mvccpb's or alpha's `Event`. + expect(linksFrom('watcher/watcher.go', 'Event')).toEqual([]); + const event = cg.getNodesInFile('watcher/watcher.go').find((n) => n.name === 'Event')!; + expect(cg.getUnresolvedReferencesFrom(event.id).map((r) => `${r.referenceKind} ${r.referenceName}`)).toEqual(['references Event']); + }); +}); diff --git a/codegraph-kernel/src/go.rs b/codegraph-kernel/src/go.rs index 3a93bacf19..f0ccbab764 100644 --- a/codegraph-kernel/src/go.rs +++ b/codegraph-kernel/src/go.rs @@ -395,7 +395,7 @@ impl<'t> Walker<'t> { } else if kind == "method_declaration" { self.extract_method(node); skip_children = true; - } else if kind == "type_spec" { + } else if kind == "type_spec" || kind == "type_alias" { skip_children = self.extract_type_alias(node); } else if matches!(kind, "var_declaration" | "short_var_declaration" | "const_declaration") && !self.inside_class_like() @@ -529,7 +529,8 @@ impl<'t> Walker<'t> { self.stack.pop(); } - /// extractTypeAlias for Go: type_spec → struct / interface / plain alias. + /// extractTypeAlias for Go: type_spec (`type A B`) and type_alias + /// (`type A = B`) → struct / interface / plain alias. fn extract_type_alias(&mut self, node: Node<'t>) -> bool { stack_guard!(); let name = self.extract_name(node); @@ -582,14 +583,22 @@ impl<'t> Walker<'t> { return true; } - self.create_node( + let row = self.create_node( "type_alias", &name, node, Extra { docstring, is_exported, ..Extra::default() }, ); - // (go type_spec has no `value` field — no type-ref walk; TS/tsx member - // extraction is TS-family-only) + // (go has no `value` field — no TS-style type-ref walk or member + // extraction.) An alias references what its `type` field names; a + // defined type (`type_spec`) declares a type of its own. + if let Some(row) = row { + let references = edge_kind_index("references").unwrap(); + for ty in self.alias_type_names(node) { + let text = self.text(ty).to_string(); + self.push_ref_at(row, &text, references, ty); + } + } false } @@ -863,6 +872,47 @@ impl<'t> Walker<'t> { } } + /// goAliasTypeNames (languages/go.ts): the name nodes of the types an + /// alias's `type` field names, in source order, but its own type + /// parameters and the predeclared types. Only a `type_alias` is one here: + /// a generic alias, which tree-sitter-go 0.23 parses as a `type_spec` + /// around an error, never reaches the kernel (its file defers to wasm). + fn alias_type_names(&self, node: Node<'t>) -> Vec> { + let mut names = Vec::new(); + if node.kind() != "type_alias" { + return names; + } + let Some(ty) = node.child_by_field_name("type") else { return names }; + let mut params: HashSet<&str> = HashSet::new(); + if let Some(list) = node.child_by_field_name("type_parameters") { + for decl in (0..list.named_child_count()).filter_map(|i| list.named_child(i)) { + for c in (0..decl.named_child_count()).filter_map(|j| decl.named_child(j)) { + if c.kind() == "identifier" { + params.insert(self.text(c)); + } + } + } + } + self.collect_alias_type_names(ty, ¶ms, &mut names); + names + } + + fn collect_alias_type_names(&self, node: Node<'t>, params: &HashSet<&str>, out: &mut Vec>) { + stack_guard!(); + if node.kind() == "type_identifier" { + let text = self.text(node); + if !params.contains(text) && !is_go_predeclared_type(text) { + out.push(node); + } + return; + } + for i in 0..node.named_child_count() { + if let Some(c) = node.named_child(i) { + self.collect_alias_type_names(c, params, out); + } + } + } + /// extractInheritance — the Go branches: interface embedding (a type_elem /// holding one named type; a union, `~T` or basic type is a constraint) /// and struct embedding (field_declaration without a field_identifier), diff --git a/src/extraction/languages/go.ts b/src/extraction/languages/go.ts index 1bad7ff225..1ecbf8612d 100644 --- a/src/extraction/languages/go.ts +++ b/src/extraction/languages/go.ts @@ -64,6 +64,40 @@ export function goEmbeddedTypeName(type: SyntaxNode | null | undefined, source: return GO_PREDECLARED_TYPES.has(getNodeText(type, source)) ? undefined : type; } +/** + * The name nodes of the types a Go alias declaration names, in source order: + * `Event` in `type Event = mvccpb.Event`, `List` and `Event` in `type Page = + * List[Event]`, each where it is written, so resolution reads a package + * qualifier back. The alias's own type parameters (`T` in `type Items[T any] + * = List[T]`) and Go's predeclared types are not types it names. A generic + * alias has no rule in tree-sitter-go 0.23: it parses as a `type_spec` whose + * `=` is an error, and reads as an alias here too. Undefined for a defined + * type (`type WatchChan <-chan Event`), which declares a type of its own. + */ +export function goAliasTypeNames(node: SyntaxNode, source: string): SyntaxNode[] | undefined { + const isAlias = node.type === 'type_alias' || + (node.type === 'type_spec' && node.children.some((c: SyntaxNode) => c.type === 'ERROR' && getNodeText(c, source).trim() === '=')); + const type = isAlias ? getChildByField(node, 'type') : null; + if (!type) return undefined; + const params = new Set(); + for (const decl of getChildByField(node, 'type_parameters')?.namedChildren ?? []) { + for (const c of decl.namedChildren) { + if (c.type === 'identifier') params.add(getNodeText(c, source)); + } + } + const names: SyntaxNode[] = []; + const walk = (n: SyntaxNode): void => { + if (n.type === 'type_identifier') { + const text = getNodeText(n, source); + if (!params.has(text) && !GO_PREDECLARED_TYPES.has(text)) names.push(n); + return; + } + for (const c of n.namedChildren) walk(c); + }; + walk(type); + return names; +} + export const goExtractor: LanguageExtractor = { functionTypes: ['function_declaration'], classTypes: [], // Go doesn't have classes @@ -71,7 +105,7 @@ export const goExtractor: LanguageExtractor = { interfaceTypes: [], // Handled via type_spec → resolveTypeAliasKind structTypes: [], // Handled via type_spec → resolveTypeAliasKind enumTypes: [], - typeAliasTypes: ['type_spec'], // Go type declarations + typeAliasTypes: ['type_spec', 'type_alias'], // `type A B` and `type A = B` importTypes: ['import_declaration'], callTypes: ['call_expression'], variableTypes: ['var_declaration', 'short_var_declaration', 'const_declaration'], @@ -92,7 +126,8 @@ export const goExtractor: LanguageExtractor = { return sig; }, resolveTypeAliasKind: (node, _source) => { - // Go type_spec: `type Foo struct { ... }` or `type Bar interface { ... }` + // Go type_spec: `type Foo struct { ... }` or `type Bar interface { ... }`, + // and an alias of a literal (`type Foo = struct { ... }`) alike. // The inner type is in the 'type' field of the type_spec node const typeChild = getChildByField(node, 'type'); if (!typeChild) return undefined; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 580cccce55..8aa00c32e3 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -24,7 +24,7 @@ import { EXTRACTORS } from './languages'; import { stripCppTemplateArgs, isCppConstructorDeclaration } from './languages/c-cpp'; import { NestedIntervals, scanCppBraceScopes, type CppBraceScopes } from './languages/cpp-brace-scopes'; import { rustImplTypeName } from './languages/rust'; -import { goEmbeddedTypeName } from './languages/go'; +import { goAliasTypeNames, goEmbeddedTypeName } from './languages/go'; import { dartMisparsedGenericCall, dartReceiverOf, isDartTypeName, pushDartTypeRefs } from './languages/dart'; import { LiquidExtractor } from './liquid-extractor'; import { RazorExtractor } from './razor-extractor'; @@ -1430,8 +1430,9 @@ export class TreeSitterExtractor { skipChildren = true; // extractEnum visits body children } // Check for type alias declarations (e.g. `type X = ...` in TypeScript) - // For Go, type_spec wraps struct/interface definitions — resolveTypeAliasKind - // detects these and extractTypeAlias creates the correct node kind. + // For Go, type_spec (and type_alias, `type A = B`) wraps struct/interface + // definitions — resolveTypeAliasKind detects these and extractTypeAlias + // creates the correct node kind. else if (this.extractor.typeAliasTypes.includes(nodeType)) { skipChildren = this.extractTypeAlias(node); } @@ -3871,6 +3872,17 @@ export class TreeSitterExtractor { // entry's string-literal name as a searchable member (issue #634). this.extractTsTupleContractNames(value, typeAliasNode); } + } else if (this.language === 'go') { + // Go's `type Event = mvccpb.Event` names its type in the `type` field. + for (const type of goAliasTypeNames(node, this.source) ?? []) { + this.unresolvedReferences.push({ + fromNodeId: typeAliasNode.id, + referenceName: getNodeText(type, this.source), + referenceKind: 'references', + line: type.startPosition.row + 1, + column: type.startPosition.column, + }); + } } } return false; diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index d848617287..87229132ae 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -828,7 +828,9 @@ function isGoEmbedding(e: Edge, kindOf: (id: string) => NodeKind | null): boolea * `type_alias` that owns them through `contains` edges. Go gives it none of * the methods declared on the type it is written over, so it is matched by * the methods it declares. A true alias (`type A = B`) is B, not a type of its - * own, and is not extracted as one. + * own: its `type_alias` owns only methods written with it as the receiver + * (`func (c *KumaSDConfig) Name()`), which are B's, and stands in for B with + * those. One without any is no implementer. * * Both method sets include what embedding brings in, read off the declared * `extends`/`implements` edge each embedded type is. An interface has the diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 425a83f7a7..68665c1270 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -3231,6 +3231,12 @@ export class ReferenceResolver { return target && !isImportableKind(target.kind) ? null : result; } + // A Go alias names its type as an embedding does (below): written through + // a package that is none of the file's imports as indexed, the type found + // by its bare name is another package's namesake, or the alias itself. + if (ref.language === 'go' && ref.referenceKind === 'references' && + this.nodeById(ref.fromNodeId)?.kind === 'type_alias' && isGoUnknownQualified(ref, this.context)) return null; + // A Go type position — a parameter or result type, a composite literal's // type — names a type of the package Go reads it from (route handlers are // `references` too, but values). diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 1c46a5d0b4..36365398d0 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -9184,6 +9184,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { SCALA_OBJECT_PACKAGES.delete(context); GO_QUALIFIERS.delete(context); GO_EMBEDS.delete(context); + GO_ALIAS_TARGETS.delete(context); JAVA_FILE_SCOPES.delete(context); JAVA_ANCESTORS.delete(context); SCALA_SUPERS.delete(context); @@ -10899,7 +10900,18 @@ function resolveGoMethodInPackage( const types = goPackageTypes(typeName, pkgDir, context); if (types.length === 0) return undefined; if (depth >= 4) return null; + let unplaced = 0; for (const t of types) { + // An alias is the type it names, and has that type's methods. + const aliased = goAliasTarget(t, context); + if (aliased !== undefined) { + const via = aliased && resolveMethodOnType( + aliased.name, methodName, ref, context, confidence, resolvedBy, aliased.pkgDir, depth + 1, + ); + if (via) return via; + if (!aliased) unplaced++; + continue; + } for (const embedded of goEmbeddedTypes(t, context)) { const via = resolveMethodOnType( embedded.name, methodName, ref, context, confidence, resolvedBy, embedded.pkgDir, depth + 1, @@ -10907,7 +10919,48 @@ function resolveGoMethodInPackage( if (via) return via; } } - return null; + // An alias of a type from outside the project (`type Ctx = context.Context`) + // or of no named type declares nothing here: the method is looked up by name, + // as it was before aliases had nodes. + return unplaced === types.length ? undefined : null; +} + +const GO_ALIAS_TARGETS = new WeakMap>(); + +/** + * The type a Go alias names, with the directory of the package that declares + * it — `mvccpb.Event` for `type Event = mvccpb.Event`, `Local` for `type Ptr + * = *Local`, `List` for `type Items[T any] = List[T]` — read from the + * declaration, as an embedding is. Null for an alias of anything else: a type + * from outside the project's packages, a predeclared one, a `func(…)` or + * `map[…]…`. Undefined when the node is no alias: a struct, an interface, or a + * defined type (`type Dur int`), which declares a type of its own. + */ +function goAliasTarget(typeNode: Node, context: ResolutionContext): { name: string; pkgDir: string } | null | undefined { + if (typeNode.kind !== 'type_alias') return undefined; + let memo = GO_ALIAS_TARGETS.get(context); + if (!memo) GO_ALIAS_TARGETS.set(context, (memo = new Map())); + if (memo.has(typeNode.id)) return memo.get(typeNode.id); + const lines = context.getFileLines?.(typeNode.filePath) ?? context.readFile(typeNode.filePath)?.split(/\r?\n/) ?? []; + // From the alias's name, where the node starts, to the end of its type. + const decl = lines + .slice(Math.max(0, typeNode.startLine - 1), typeNode.endLine ?? typeNode.startLine) + .map((l, i) => (i === 0 ? l.slice(typeNode.startColumn ?? 0) : l).replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, '')) + .join(' '); + // `Name =` or `Name[T any] =`; a defined type has no `=` there. + const head = /^\s*[A-Za-z_]\w*\s*(?:\[[^\]]*\])?\s*=/.exec(decl); + // `T`, `*T`, `pkg.T`, each perhaps with type arguments. + const m = head && /^\s*(?:\*\s*)?([A-Za-z_]\w*)(?:\s*\.\s*([A-Za-z_]\w*))?\s*(?:\[.*\])?\s*;?\s*$/.exec(decl.slice(head[0].length)); + let target: { name: string; pkgDir: string } | null | undefined; + if (m) { + const pkgDir = m[2] ? goImportPackageDir(m[1]!, typeNode.filePath, context) : goPackageDir(typeNode.filePath); + const name = m[2] ?? m[1]!; + target = pkgDir == null || (!m[2] && GO_BUILTIN_FIELD_TYPES.has(name)) ? null : { name, pkgDir }; + } else { + target = head ? null : undefined; + } + memo.set(typeNode.id, target); + return target; } /** The declarations of Go type `typeName` in the package at directory `pkgDir`. */ From e8defd67a2ed4294783a5ac083a4763ee6dd516e Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 7 Oct 2026 09:31:16 -0600 Subject: [PATCH 24/26] fix: address reconciliation import and receiver findings --- CHANGELOG.md | 4 ++++ README.md | 2 +- __tests__/cpp-library-type-receiver.test.ts | 16 ++++++++++++++ __tests__/framework-resolve-language.test.ts | 14 ++++++++++++ __tests__/go-import-package-names.test.ts | 15 +++++++++++++ __tests__/go-type-position-kinds.test.ts | 15 +++++++++++++ __tests__/sync-navigation-retry.test.ts | 12 ++++++++++ codegraph-kernel/src/go/mod.rs | 22 ++++++++++++------- codegraph-kernel/src/resolve/cpp_aliases.rs | 5 ++--- codegraph-kernel/src/resolve/cpp_receivers.rs | 2 ++ codegraph-kernel/src/resolve/mod.rs | 7 ++++++ docs/design/ccpp-kernel-port-checklist.md | 2 +- site/src/content/docs/reference/languages.md | 2 +- src/db/queries.ts | 7 +++--- src/resolution/frameworks/react.ts | 1 + src/resolution/go-type-visibility.ts | 3 ++- src/resolution/index.ts | 2 +- 17 files changed, 112 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 284e8f760f..61725daf0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Go conventional import names are available before reading chained calls and keep source order; imported types also resolve from a module root. +- C++ receiver lookup reuses comment-stripped source and refuses outer receiver types hidden by unproven function declarations. +- React layout links stay with React routes, and navigation in an imported route-table file survives removing and restoring its destination. + - Go chained calls with an unproven receiver no longer link an unrelated method by its name. Longer fluent chains follow each declared return type; typed receivers and package factory chains retain their links. - Native Go import-name collection avoids recursively scanning function bodies, so deep expressions retain the existing per-file stack protection. - C++ base lookup ignores using-directives in closed sibling scopes. React imported tables bind the exported module declaration rather than an unused local namesake. diff --git a/README.md b/README.md index f2e1542f47..43eaf0b5ed 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ C# property accessors and expression-bodied properties contribute calls and refe C# field and property initializers retain their calls and references under the member that owns them. Target-typed `new()` resolves relative declared types through enclosing namespaces and honors `global::` qualification without requiring a redundant `using`. VB.NET resolves typed receivers, enclosing and inherited members, and field or property reads through values and Shared types without choosing unrelated project declarations. Names beginning with keywords, such as `SharedCache`, `Dimension` and `NewItem`, retain their declarations. -The native resolver includes upstream's Go import-name assumptions, type-position checks, alias method forwarding and embedded interface method sets. Chained Go calls follow declared return types at each fluent step; an unproven receiver stays unresolved instead of borrowing an unrelated method by name. C++ bases use enclosing-scope directives, parent-scope lookup and translation-unit visibility; included test-named headers remain eligible, while declared external library receivers avoid unrelated project methods. Incremental sync retries newly available Liquid targets, lazy route modules and navigation destinations. +The native resolver includes upstream's Go import-name assumptions, type-position checks, alias method forwarding and embedded interface method sets. Chained Go calls follow declared return types at each fluent step; an unproven receiver stays unresolved instead of borrowing an unrelated method by name. C++ bases use enclosing-scope directives, parent-scope lookup and translation-unit visibility; included test-named headers remain eligible, while declared external library receivers avoid unrelated project methods. Incremental sync retries newly available Liquid targets, lazy route modules and navigation destinations. Navigation in an imported route-table file also survives removing and restoring its destination. React layout links exclude Vue and Angular routes. Go interfaces link named embedded interfaces and aliases that resolve to interfaces. Named scalar or struct terms, unions, underlying-type constraints and basic types are not supertypes. Package-qualified embeddings stay in their imported package. Go imports follow the nearest indexed module and the longest matching module path, including module changes during incremental sync. Unexported receivers and embedded methods stay in their declaring package. Dart imports, exports and part directives follow their library URIs and visibility rules. Library directive reads stay inside the indexed project, including resolved symlink targets. Calls through import prefixes, annotations and member chains follow the visible declaration and written receiver types, including explicit generic lookup types. Parameters and locals shadow bare calls. Top-level and field initializers contribute calls; const constructors, redirecting factories and annotated members retain their declarations and dartdoc. Getter reads become calls only when the receiver type reaches that getter and no nearer field in the visible class hierarchy overrides it; enum extensions and type-position references participate in resolution. Rust enum-variant values retain their enum references. diff --git a/__tests__/cpp-library-type-receiver.test.ts b/__tests__/cpp-library-type-receiver.test.ts index c99c8f384c..19d82f6341 100644 --- a/__tests__/cpp-library-type-receiver.test.ts +++ b/__tests__/cpp-library-type-receiver.test.ts @@ -551,3 +551,19 @@ describe('proved receiver types retain their written pointer operation', () => { } }); }); + +it('an unproven parenthesized declaration shadows the outer receiver', async () => { + const cg = await indexed({ ...PROJECT, 'shadow.cc': lines( + '#include "include/leveldb/slice.h"', + 'struct Foo {};', + 'void Bad() {', + ' leveldb::Slice key;', + ' { std::unique_ptr key(Unknown); key.size(); }', + '}', + 'void Good() { leveldb::Slice key; key.size(); }', + ) }); + try { + expect(calls(cg, 'Bad')).toEqual([]); + expect(calls(cg, 'Good')).toEqual(['leveldb::Slice::size']); + } finally { cg.close(); } +}); diff --git a/__tests__/framework-resolve-language.test.ts b/__tests__/framework-resolve-language.test.ts index 5751ce1f2d..7d5b6f168c 100644 --- a/__tests__/framework-resolve-language.test.ts +++ b/__tests__/framework-resolve-language.test.ts @@ -13,6 +13,8 @@ import * as os from 'os'; import * as path from 'path'; import { CodeGraph } from '../src'; import { getResolvingFrameworks } from '../src/resolution/frameworks'; +import { reactResolver } from '../src/resolution/frameworks/react'; +import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types'; import type { FrameworkResolver } from '../src/resolution/types'; function writeProject(prefix: string, files: Record): string { @@ -188,3 +190,15 @@ public class IndexModel : PageModel expect(modelOf('Pages/Basket/Index.cshtml')).toEqual(['Pages/Basket/Index.cshtml.cs']); }); }); + +it('React layout resolution leaves Vue and Angular route ownership intact', () => { + const context = { + getImportMappings: () => [{ localName: 'Shell', source: './Shell', exportedName: 'Shell' }], + resolveImport: () => ({ targetNodeId: 'shell' }), + } as unknown as ResolutionContext; + const ref: UnresolvedRef = { fromNodeId: 'route:app:/', referenceName: 'layout:Shell', referenceKind: 'calls', filePath: 'app.tsx', language: 'tsx', line: 1, column: 0 }; + expect(reactResolver.resolve(ref, context)?.targetNodeId).toBe('shell'); + for (const id of ['route:app:/:vue', 'route:app:/:angular', 'app:function']) { + expect(reactResolver.resolve({ ...ref, fromNodeId: id }, context)).toBeNull(); + } +}); diff --git a/__tests__/go-import-package-names.test.ts b/__tests__/go-import-package-names.test.ts index edf80651e6..2617cd8726 100644 --- a/__tests__/go-import-package-names.test.ts +++ b/__tests__/go-import-package-names.test.ts @@ -248,3 +248,18 @@ describe('Go references through a versioned or go- import', () => { ]); }); }); + +it('emits assumed imports in source order and encodes their factory chains', () => { + const result = tryKernelExtract('x.go', `package p +import ( + "go.yaml.in/yaml/v3" + "github.com/mattn/go-sqlite3" + "k8s.io/api/core/v1" +) +func run(r any, v any) { yaml.NewDecoder(r).Decode(v) } +`, 'go')!; + const ordered = (importMappingsFromBindings(result.bindings ?? []) ?? []) + .map((m) => m.localName).filter((n) => ['yaml', 'sqlite3', 'core'].includes(n)); + expect(ordered).toEqual(['yaml', 'sqlite3', 'core']); + expect(result.unresolvedReferences.map((ref) => ref.referenceName)).toContain('yaml.NewDecoder().Decode'); +}); diff --git a/__tests__/go-type-position-kinds.test.ts b/__tests__/go-type-position-kinds.test.ts index e7a0386ae1..9e788d1da5 100644 --- a/__tests__/go-type-position-kinds.test.ts +++ b/__tests__/go-type-position-kinds.test.ts @@ -17,6 +17,8 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { CodeGraph } from '../src'; +import { goTypePositionTarget } from '../src/resolution/go-type-visibility'; +import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types'; let root = ''; let cg: CodeGraph; @@ -243,3 +245,16 @@ describe('A Go type position resolves to a type', () => { expect(typeLinksFrom('storage/remote/client_test.go', 'URL')).toEqual([]); }); }); + +it('an imported module-root type replaces a namesake function', () => { + const wanted = { id: 'root-type', name: 'Widget', kind: 'struct', language: 'go', filePath: 'widget.go' }; + const functionNode = { ...wanted, id: 'function', kind: 'function', filePath: 'decoy/func.go' }; + const context = { + getNodeById: () => functionNode, + getNodesByName: () => [functionNode, wanted], + getImportMappings: () => [{ localName: 'kit', source: 'example.com/kit' }], + getGoPackageDir: () => '', + } as unknown as ResolutionContext; + const ref: UnresolvedRef = { fromNodeId: 'caller', referenceName: 'kit.Widget', referenceKind: 'references', filePath: 'app/main.go', language: 'go', line: 1, column: 0 }; + expect(goTypePositionTarget({ original: ref, targetNodeId: 'function', confidence: 0.7, resolvedBy: 'framework' }, ref, context)?.targetNodeId).toBe('root-type'); +}); diff --git a/__tests__/sync-navigation-retry.test.ts b/__tests__/sync-navigation-retry.test.ts index 0863559bbd..359089140d 100644 --- a/__tests__/sync-navigation-retry.test.ts +++ b/__tests__/sync-navigation-retry.test.ts @@ -457,6 +457,18 @@ function internals(graph: CodeGraph): { queries: QueryBuilder; resolver: Referen return graph as unknown as { queries: QueryBuilder; resolver: ReferenceResolver }; } +const sharedTable = CASES.find((c) => c.name === 'React Router (a table another file hands the router)')!; +CASES.push({ + ...sharedTable, + name: 'React Router (navigation and an imported table share an unchanged file)', + from: 'src/AppRoutes.js', + files: { + ...sharedTable.files, + 'src/AppRoutes.js': sharedTable.files['src/AppRoutes.js']! + + "\nimport { useNavigate } from 'react-router-dom';\nexport function LocalNav() { const navigate = useNavigate(); return ; }\n", + }, +}); + describe('sync binds a navigation call to a route that appears later', () => { it.each(CASES)('$name', async (c) => { const root = tempRoot(); diff --git a/codegraph-kernel/src/go/mod.rs b/codegraph-kernel/src/go/mod.rs index be00c7bdf8..196c240a58 100644 --- a/codegraph-kernel/src/go/mod.rs +++ b/codegraph-kernel/src/go/mod.rs @@ -107,8 +107,10 @@ pub fn extract(file_path: &str, source: &str) -> Result { w.node_ids.push(ids::file_node_id(file_path)); w.stack.push(Scope { row: 0, kind: "file", name: base_name.to_string() }); + let assumed_imports = w.assumed_imports(tree.root_node()); + w.imported_names.extend(assumed_imports.iter().map(|(name, _, _)| name.clone())); w.visit_node(tree.root_node()); - w.emit_assumed_import_bindings(tree.root_node()); + for (name, path, spec) in assumed_imports { w.emit_import_binding(&name, &path, spec); } w.flush_fn_ref_candidates(); w.flush_value_refs(tree.root_node()); w.stack.pop(); @@ -616,7 +618,7 @@ impl<'t> Walker<'t> { /// Keep the written/path name and add the conventional package name only /// when no other import in the file binds or assumes that name. - fn emit_assumed_import_bindings(&mut self, root: Node<'t>) { + fn assumed_imports(&self, root: Node<'t>) -> Vec<(String, String, Node<'t>)> { let mut imports = Vec::new(); // Imports are top-level declarations. Do not revisit unrelated deep // function bodies after the guarded walker has deferred the file. @@ -625,7 +627,9 @@ impl<'t> Walker<'t> { if node.kind() == "import_spec" { imports.push(node); } else { pending.extend(named_kids(node)); } } + imports.sort_by_key(|node| node.start_byte()); let mut bound = HashSet::new(); + let mut order = Vec::new(); let mut assumed: HashMap)>> = HashMap::new(); for spec in imports { let Some(lit) = spec.child_by_field_name("path") @@ -643,15 +647,17 @@ impl<'t> Walker<'t> { let base = base.strip_prefix("go-").unwrap_or(base); let name: String = base.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect(); if name.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_') { - assumed.entry(name).or_default().push((path, spec)); + let candidates = assumed.entry(name.clone()).or_default(); + if candidates.is_empty() { order.push(name); } + candidates.push((path, spec)); } } - for (name, candidates) in assumed { - if bound.contains(&name) || candidates.len() != 1 { continue; } + order.into_iter().filter_map(|name| { + let candidates = &assumed[&name]; + if bound.contains(&name) || candidates.len() != 1 { return None; } let (path, spec) = &candidates[0]; - self.emit_import_binding(&name, path, *spec); - self.imported_names.insert(name); - } + Some((name, path.clone(), *spec)) + }).collect() } // --- bindings (resolution-binding-model-plan.md, Phase 3: Go) -------------------- diff --git a/codegraph-kernel/src/resolve/cpp_aliases.rs b/codegraph-kernel/src/resolve/cpp_aliases.rs index 4885eb597c..76ce08b47d 100644 --- a/codegraph-kernel/src/resolve/cpp_aliases.rs +++ b/codegraph-kernel/src/resolve/cpp_aliases.rs @@ -214,12 +214,11 @@ impl KernelResolver { let escaped_receiver = regex::escape(receiver); for file in files { let Some(source) = self.read_file(&file) else { continue; }; - let code = super::awaited::strip_ts_comments(source.text()); - let lines: Vec<_> = code.lines().collect(); + let lines = source.cpp_code_lines(); let indexes: Vec<_> = if file == r.file_path { (0..(r.line.max(0) as usize).min(lines.len())).rev().collect() } else { (0..lines.len()).collect() }; let classes = self.nodes_in_file(&file)?; for i in indexes { - let line = lines[i]; + let line = lines[i].as_str(); if !has_word(line, receiver) { continue; } let Some(raw) = self.cpp_declarator_match(line, &escaped_receiver)? else { let range = shared_regex(&format!(r"\bfor\s*\(.*\b{}\s*:", escaped_receiver))?; diff --git a/codegraph-kernel/src/resolve/cpp_receivers.rs b/codegraph-kernel/src/resolve/cpp_receivers.rs index 63b0edeb0f..33233b0fa8 100644 --- a/codegraph-kernel/src/resolve/cpp_receivers.rs +++ b/codegraph-kernel/src/resolve/cpp_receivers.rs @@ -295,6 +295,8 @@ fn cpp_binding_type(parent: tree_sitter::Node, at: tree_sitter::Node, name: &str } } } + // This declaration shadows the receiver even when its type is unproven. + return Some(String::new()); } } let mut cursor = declaration.walk(); diff --git a/codegraph-kernel/src/resolve/mod.rs b/codegraph-kernel/src/resolve/mod.rs index e5572e17f5..9ab5a5efc6 100644 --- a/codegraph-kernel/src/resolve/mod.rs +++ b/codegraph-kernel/src/resolve/mod.rs @@ -342,6 +342,7 @@ pub(super) struct SourceFile { java_static_imports: OnceCell, php_file_scope: OnceCell, rust_code_lines: OnceCell>, + cpp_code_lines: OnceCell>, python_file: OnceCell, python_field_file: OnceCell, cpp_field_tree: OnceCell>>, @@ -360,6 +361,7 @@ impl SourceFile { java_static_imports: OnceCell::new(), php_file_scope: OnceCell::new(), rust_code_lines: OnceCell::new(), + cpp_code_lines: OnceCell::new(), python_file: OnceCell::new(), python_field_file: OnceCell::new(), cpp_field_tree: OnceCell::new(), @@ -414,6 +416,11 @@ impl SourceFile { .get_or_init(|| fields::mask_rust_code(self.text()).split('\n').map(str::to_string).collect()) } + /// C++ comment-stripped lines, shared by receiver lookups in this file. + pub(super) fn cpp_code_lines(&self) -> &[String] { + self.cpp_code_lines.get_or_init(|| awaited::strip_ts_comments(self.text()).split('\n').map(str::to_string).collect()) + } + /// The file's Python statements and the scopes they run in /// (bound::PyFile). pub(super) fn python_file(&self) -> &bound::PyFile { diff --git a/docs/design/ccpp-kernel-port-checklist.md b/docs/design/ccpp-kernel-port-checklist.md index 5e7f770d64..a9c3648286 100644 --- a/docs/design/ccpp-kernel-port-checklist.md +++ b/docs/design/ccpp-kernel-port-checklist.md @@ -187,7 +187,7 @@ function_declarator → null, 12-hop guard). | Line | Mechanism | Must-mirror details | |---|---|---| | 962 | cpp namespace prefix stack (#1291) | named `namespace_definition` pushes its name (C++17 `a::b` as written) onto the QN prefix while walking children; anonymous falls through bare | -| — | cpp brace scopes (erroring trees only) | when the tree has errors, every visitNode takes its namespace prefix from the source's braces, and at declaration level (only class scopes above the file node) its enclosing classes: `languages/cpp-brace-scopes.ts` ↔ `ccpp/brace_scopes.rs`. A class-like node's end is its body's `}`; a class-like `type` of a namespace-level `declaration` is walked as a class. Reachable in the kernel only under `CODEGRAPH_KERNEL_CCPP_ERROR_EXTRACT=1` (every other erroring file defers) — kept parity-true there | +| — | cpp brace scopes (erroring trees only) | when the tree has errors, every visitNode takes its namespace prefix from the source's braces, and at declaration level (only class scopes above the file node) its enclosing classes: `languages/cpp-brace-scopes.ts` ↔ `ccpp/brace_scopes.rs`. A class-like node's end is its body's `}`; a class-like `type` of a namespace-level `declaration` is walked as a class. Native brace-scope recovery is canonical for erroring C++ trees and has no opt-in gate | | 2795 | C file-scope variables | only when NO function ancestor; iterate declarators; accept ONLY init_declarator / pointer_declarator / array_declarator — a BARE identifier declarator is a macro-prototype misparse, skip (loses uninit scalars by design); name via cDeclaratorIdentifier; signature `= `; kind constant/variable via isConst | | 4313 | explicit operator calls (#1247) | callee = `function` field + ERROR-wrapped `operator_name` sibling; compact symbolic spacing (`operator *`→`operator*`, word forms keep space); receiver `->`→`.`; DROP unless receiver is `this` (bare name) or simple identifier/member chain (silent miss over wrong edge) | | ~4340 | field_expression method calls | `recv.method`/`ptr->method` → `recv.method`; SKIP_RECEIVERS {self,this,cls,super} → bare name; LITERAL receiver → emit nothing (#1230) | diff --git a/site/src/content/docs/reference/languages.md b/site/src/content/docs/reference/languages.md index 3b8559fd85..6d3d6407be 100644 --- a/site/src/content/docs/reference/languages.md +++ b/site/src/content/docs/reference/languages.md @@ -85,6 +85,6 @@ A Python method passed as a value through a module-level variable, such as `pool C++ receivers declared through typedef or using aliases follow the alias declaration scope and nested owners. Pointer, array and function members are not supertypes. Shopify section and snippet references stay inside their nearest theme; JSON under `templates/` or `sections/` is Liquid only beside a theme marker. Adding or removing a marker reconciles JSON membership and re-resolves unchanged Liquid links, including after reopening the index. -Go unaliased imports retain their path name and add an unambiguous conventional package name for versioned and `go-` paths. Type positions prefer types in the written package. Aliases forward methods to their target; defined types keep their own methods. Fluent chains follow each declared return type. Chained calls with an unproven receiver stay unresolved instead of borrowing an unrelated method by name. +Go unaliased imports retain their path name and add an unambiguous conventional package name for versioned and `go-` paths. Conventional names are available when reading package factory chains and preserve import order. Imported module-root types use the same package scope as root source files. Type positions prefer types in the written package. Aliases forward methods to their target; defined types keep their own methods. Fluent chains follow each declared return type. Chained calls with an unproven receiver stay unresolved instead of borrowing an unrelated method by name. C++ base classes use the enclosing declaration scope, with source-file classes limited to their translation unit. Dependent template bases and ambiguous candidates remain unresolved. Test-named headers included by production files remain visible, including same-stem implementations. Calls on declared lowercase external library types do not link to unrelated project methods when the member operator agrees with the declaration. diff --git a/src/db/queries.ts b/src/db/queries.ts index 9490d8c040..6a3382ddaa 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -3238,7 +3238,8 @@ export class QueryBuilder { * `contains`. */ getCrossFileIncomingEdgesWithTarget( - filePath: string + filePath: string, + includeSameFile = false ): Array { const sql = `SELECT e.*, tgt.name AS target_name, tgt.kind AS target_kind, src.file_path AS source_file_path, src.language AS source_language @@ -3247,8 +3248,8 @@ export class QueryBuilder { JOIN nodes src ON src.id = e.source WHERE tgt.file_path = ? AND e.kind != 'contains' - AND src.file_path != ?`; - const rows = this.db.prepare(sql).all(filePath, filePath) as Array< + AND (? = 1 OR src.file_path != ?)`; + const rows = this.db.prepare(sql).all(filePath, includeSameFile ? 1 : 0, filePath) as Array< EdgeRow & { target_name: string; target_kind: NodeKind; source_file_path: string; source_language: Language } >; return rows.map(row => ({ diff --git a/src/resolution/frameworks/react.ts b/src/resolution/frameworks/react.ts index 034a8344be..bf0e7a03f6 100644 --- a/src/resolution/frameworks/react.ts +++ b/src/resolution/frameworks/react.ts @@ -53,6 +53,7 @@ export const reactResolver: FrameworkResolver = { // The layout a route renders inside: what happens in it — its header's // links, its logout — happens on the route's screen too. if (ref.referenceName.startsWith(LAYOUT_PREFIX)) { + if (!ref.fromNodeId.startsWith('route:') || ref.fromNodeId.endsWith(':vue') || ref.fromNodeId.endsWith(':angular')) return null; const target = layoutComponent(ref.referenceName.slice(LAYOUT_PREFIX.length), ref, context); return target ? { original: ref, targetNodeId: target, confidence: 0.9, resolvedBy: 'framework', metadata: { layout: true } } : null; } diff --git a/src/resolution/go-type-visibility.ts b/src/resolution/go-type-visibility.ts index 5c70178e14..df26bc7b6d 100644 --- a/src/resolution/go-type-visibility.ts +++ b/src/resolution/go-type-visibility.ts @@ -100,8 +100,9 @@ export function goTypePositionTarget(result: ResolvedRef, ref: UnresolvedRef, co if (bare) pkgDir = goPackageDir(ref.filePath); else if (imported) pkgDir = context.getGoPackageDir?.(imported.source, ref.filePath) ?? null; } + if (pkgDir === '') pkgDir = '.'; if (isType && (!bare || goPackageDir(target.filePath) === pkgDir)) return result; - const types = pkgDir ? goPackageTypes(name, pkgDir, context) : []; + const types = pkgDir != null ? goPackageTypes(name, pkgDir, context) : []; if (types.length > 0) return { ...result, targetNodeId: preferCallSiteFile(types, ref.filePath)[0]!.id }; return isType ? result : null; } diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 88abb6d032..b6218593f8 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -288,7 +288,7 @@ export class ReferenceResolver { let changed = 0; for (const [id, old] of existing) { if (wanted.has(id)) continue; - const refs = this.queries.getCrossFileIncomingEdgesWithTarget(old.filePath) + const refs = this.queries.getCrossFileIncomingEdgesWithTarget(old.filePath, true) .filter((e) => e.target === id) .map((e) => resurrectRefFromDroppedEdge(e)) .filter((r): r is UnresolvedReference => r !== null); From f7071402de72eb85131c334be896ddf5ccac2b49 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 7 Oct 2026 09:52:50 -0600 Subject: [PATCH 25/26] fix(resolve): mask raw strings before C++ receiver scans --- CHANGELOG.md | 1 + README.md | 2 +- codegraph-kernel/src/resolve/mod.rs | 19 +++++++++++++++++-- site/src/content/docs/reference/languages.md | 2 +- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61725daf0e..d2466f07de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- C++ receiver scans ignore multiline raw-string contents before reading declarations. - Go conventional import names are available before reading chained calls and keep source order; imported types also resolve from a module root. - C++ receiver lookup reuses comment-stripped source and refuses outer receiver types hidden by unproven function declarations. - React layout links stay with React routes, and navigation in an imported route-table file survives removing and restoring its destination. diff --git a/README.md b/README.md index 43eaf0b5ed..9659cec9f9 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ C# property accessors and expression-bodied properties contribute calls and refe C# field and property initializers retain their calls and references under the member that owns them. Target-typed `new()` resolves relative declared types through enclosing namespaces and honors `global::` qualification without requiring a redundant `using`. VB.NET resolves typed receivers, enclosing and inherited members, and field or property reads through values and Shared types without choosing unrelated project declarations. Names beginning with keywords, such as `SharedCache`, `Dimension` and `NewItem`, retain their declarations. -The native resolver includes upstream's Go import-name assumptions, type-position checks, alias method forwarding and embedded interface method sets. Chained Go calls follow declared return types at each fluent step; an unproven receiver stays unresolved instead of borrowing an unrelated method by name. C++ bases use enclosing-scope directives, parent-scope lookup and translation-unit visibility; included test-named headers remain eligible, while declared external library receivers avoid unrelated project methods. Incremental sync retries newly available Liquid targets, lazy route modules and navigation destinations. Navigation in an imported route-table file also survives removing and restoring its destination. React layout links exclude Vue and Angular routes. +The native resolver includes upstream's Go import-name assumptions, type-position checks, alias method forwarding and embedded interface method sets. Chained Go calls follow declared return types at each fluent step; an unproven receiver stays unresolved instead of borrowing an unrelated method by name. C++ bases use enclosing-scope directives, parent-scope lookup and translation-unit visibility; included test-named headers remain eligible, while declared external library receivers avoid unrelated project methods. Incremental sync retries newly available Liquid targets, lazy route modules and navigation destinations. Navigation in an imported route-table file also survives removing and restoring its destination. React layout links exclude Vue and Angular routes. C++ receiver declaration scans exclude multiline raw-string contents. Go interfaces link named embedded interfaces and aliases that resolve to interfaces. Named scalar or struct terms, unions, underlying-type constraints and basic types are not supertypes. Package-qualified embeddings stay in their imported package. Go imports follow the nearest indexed module and the longest matching module path, including module changes during incremental sync. Unexported receivers and embedded methods stay in their declaring package. Dart imports, exports and part directives follow their library URIs and visibility rules. Library directive reads stay inside the indexed project, including resolved symlink targets. Calls through import prefixes, annotations and member chains follow the visible declaration and written receiver types, including explicit generic lookup types. Parameters and locals shadow bare calls. Top-level and field initializers contribute calls; const constructors, redirecting factories and annotated members retain their declarations and dartdoc. Getter reads become calls only when the receiver type reaches that getter and no nearer field in the visible class hierarchy overrides it; enum extensions and type-position references participate in resolution. Rust enum-variant values retain their enum references. diff --git a/codegraph-kernel/src/resolve/mod.rs b/codegraph-kernel/src/resolve/mod.rs index 9ab5a5efc6..cf01d24e81 100644 --- a/codegraph-kernel/src/resolve/mod.rs +++ b/codegraph-kernel/src/resolve/mod.rs @@ -416,9 +416,9 @@ impl SourceFile { .get_or_init(|| fields::mask_rust_code(self.text()).split('\n').map(str::to_string).collect()) } - /// C++ comment-stripped lines, shared by receiver lookups in this file. + /// C++ lines with raw literals and comments masked, shared by receiver lookups. pub(super) fn cpp_code_lines(&self) -> &[String] { - self.cpp_code_lines.get_or_init(|| awaited::strip_ts_comments(self.text()).split('\n').map(str::to_string).collect()) + self.cpp_code_lines.get_or_init(|| awaited::strip_ts_comments(&cpp::mask_cpp_raw_strings(self.text())).split('\n').map(str::to_string).collect()) } /// The file's Python statements and the scopes they run in @@ -1255,6 +1255,21 @@ impl Drop for KernelResolver { mod tests { use super::*; + #[test] + fn cpp_receiver_lines_exclude_raw_string_declarations() { + for prefix in ["R", "u8R", "uR", "UR", "LR"] { + for delimiter in ["", "payload"] { + let text = format!("Slice *ptr;\nauto text = {prefix}\"{delimiter}(\nWidget *ptr;\n){delimiter}\";\nptr->size();"); + let source = SourceFile::new(text.lines().map(str::to_string).collect()); + let code = source.cpp_code_lines(); + assert_eq!(code.len(), 5); + assert_eq!(code[0], "Slice *ptr;"); + assert!(!code[2].contains("Widget"), "raw literal leaked into declarations: {prefix}/{delimiter}"); + assert_eq!(code[4], "ptr->size();"); + } + } + } + #[test] fn utf16_len_counts_units_from_bytes() { for s in ["", "abc", "é", "€", "😀", "a😀é€x"] { diff --git a/site/src/content/docs/reference/languages.md b/site/src/content/docs/reference/languages.md index 6d3d6407be..909ee28fc3 100644 --- a/site/src/content/docs/reference/languages.md +++ b/site/src/content/docs/reference/languages.md @@ -87,4 +87,4 @@ C++ receivers declared through typedef or using aliases follow the alias declara Go unaliased imports retain their path name and add an unambiguous conventional package name for versioned and `go-` paths. Conventional names are available when reading package factory chains and preserve import order. Imported module-root types use the same package scope as root source files. Type positions prefer types in the written package. Aliases forward methods to their target; defined types keep their own methods. Fluent chains follow each declared return type. Chained calls with an unproven receiver stay unresolved instead of borrowing an unrelated method by name. -C++ base classes use the enclosing declaration scope, with source-file classes limited to their translation unit. Dependent template bases and ambiguous candidates remain unresolved. Test-named headers included by production files remain visible, including same-stem implementations. Calls on declared lowercase external library types do not link to unrelated project methods when the member operator agrees with the declaration. +C++ base classes use the enclosing declaration scope, with source-file classes limited to their translation unit. Dependent template bases and ambiguous candidates remain unresolved. Test-named headers included by production files remain visible, including same-stem implementations. Calls on declared lowercase external library types do not link to unrelated project methods when the member operator agrees with the declaration. Multiline raw-string contents are excluded from receiver declaration scans. From a23a6bb5f36c93fa1dd6d319498e4338bdc130fe Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 7 Oct 2026 10:08:22 -0600 Subject: [PATCH 26/26] fix(resolve): persist route reconciliation atomically --- CHANGELOG.md | 2 + README.md | 2 +- __tests__/react-router.test.ts | 39 ++++++++++++++++++- .../content/docs/guides/framework-routes.md | 2 + src/resolution/index.ts | 3 +- 5 files changed, 45 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2466f07de..04363a2aa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Cross-file route-table reconciliation commits nodes and references together, allowing retry after a failed reference write. + - C++ receiver scans ignore multiline raw-string contents before reading declarations. - Go conventional import names are available before reading chained calls and keep source order; imported types also resolve from a module root. - C++ receiver lookup reuses comment-stripped source and refuses outer receiver types hidden by unproven function declarations. diff --git a/README.md b/README.md index 9659cec9f9..f550373c3b 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ C# property accessors and expression-bodied properties contribute calls and refe C# field and property initializers retain their calls and references under the member that owns them. Target-typed `new()` resolves relative declared types through enclosing namespaces and honors `global::` qualification without requiring a redundant `using`. VB.NET resolves typed receivers, enclosing and inherited members, and field or property reads through values and Shared types without choosing unrelated project declarations. Names beginning with keywords, such as `SharedCache`, `Dimension` and `NewItem`, retain their declarations. -The native resolver includes upstream's Go import-name assumptions, type-position checks, alias method forwarding and embedded interface method sets. Chained Go calls follow declared return types at each fluent step; an unproven receiver stays unresolved instead of borrowing an unrelated method by name. C++ bases use enclosing-scope directives, parent-scope lookup and translation-unit visibility; included test-named headers remain eligible, while declared external library receivers avoid unrelated project methods. Incremental sync retries newly available Liquid targets, lazy route modules and navigation destinations. Navigation in an imported route-table file also survives removing and restoring its destination. React layout links exclude Vue and Angular routes. C++ receiver declaration scans exclude multiline raw-string contents. +The native resolver includes upstream's Go import-name assumptions, type-position checks, alias method forwarding and embedded interface method sets. Chained Go calls follow declared return types at each fluent step; an unproven receiver stays unresolved instead of borrowing an unrelated method by name. C++ bases use enclosing-scope directives, parent-scope lookup and translation-unit visibility; included test-named headers remain eligible, while declared external library receivers avoid unrelated project methods. Incremental sync retries newly available Liquid targets, lazy route modules and navigation destinations. Navigation in an imported route-table file also survives removing and restoring its destination. Route-table nodes and their references commit together, so a failed reference write can be retried without leaving an incomplete route. React layout links exclude Vue and Angular routes. C++ receiver declaration scans exclude multiline raw-string contents. Go interfaces link named embedded interfaces and aliases that resolve to interfaces. Named scalar or struct terms, unions, underlying-type constraints and basic types are not supertypes. Package-qualified embeddings stay in their imported package. Go imports follow the nearest indexed module and the longest matching module path, including module changes during incremental sync. Unexported receivers and embedded methods stay in their declaring package. Dart imports, exports and part directives follow their library URIs and visibility rules. Library directive reads stay inside the indexed project, including resolved symlink targets. Calls through import prefixes, annotations and member chains follow the visible declaration and written receiver types, including explicit generic lookup types. Parameters and locals shadow bare calls. Top-level and field initializers contribute calls; const constructors, redirecting factories and annotated members retain their declarations and dartdoc. Getter reads become calls only when the receiver type reaches that getter and no nearer field in the visible class hierarchy overrides it; enum extensions and type-position references participate in resolution. Rust enum-variant values retain their enum references. diff --git a/__tests__/react-router.test.ts b/__tests__/react-router.test.ts index d8bd23e0d6..be3a4a5801 100644 --- a/__tests__/react-router.test.ts +++ b/__tests__/react-router.test.ts @@ -10,7 +10,7 @@ * is what the app-root gate has to get right. Mirrors `nextjs.test.ts`. */ -import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -20,6 +20,7 @@ import { buildScreens } from '../src/ui-server/api/screens'; import { buildSteps } from '../src/ui-server/api/steps'; import { reactRouterRoot, reactRouterNavVerb } from '../src/resolution/frameworks/react-router'; import type { Node } from '../src/types'; +import { ReferenceResolver } from '../src/resolution'; // ============================================================================= // The app root a route file owns @@ -1780,3 +1781,39 @@ describe('react-router: route tables re-exported through a barrel', () => { } }); }); + + +describe('react-router: route-table reference persistence', () => { + it('rolls back a failed reference write so the next pass restores the complete route', async () => { + const { root, cg } = await indexProject({ + 'package.json': JSON.stringify({ name: 'web', dependencies: { react: '^18', 'react-router-dom': '^6' } }), + 'src/Home.tsx': component('Home'), + 'src/table.tsx': "import { Home } from './Home';\nexport const routes = [{ path: '/home', element: }];\n", + 'src/App.tsx': "import { Routes, Route } from 'react-router-dom';\nimport { routes } from './table';\nexport function App() { return {routes.map(route => )}; }\n", + }); + try { + expect(routeBindings(cg)).toEqual(['/home -> Home']); + const route = cg.getNodesByKind('route').find(n => n.name === '/home')!; + cg.queries.deleteNode(route.id); + const resolver = new ReferenceResolver(root, cg.queries); + resolver.initialize(); + const insert = vi.spyOn(cg.queries, 'insertUnresolvedRefsBatch').mockImplementationOnce(() => { + throw new Error('injected reference write failure'); + }); + try { + resolver.runPostExtract(); + } finally { + insert.mockRestore(); + } + expect(cg.getNode(route.id)).toBeNull(); + resolver.runPostExtract(); + expect(cg.getNode(route.id)?.name).toBe('/home'); + expect(cg.queries.getUnresolvedReferencesFrom(route.id)).toEqual(expect.arrayContaining([ + expect.objectContaining({ referenceName: 'Home', referenceKind: 'references' }), + ])); + } finally { + cg.close(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/site/src/content/docs/guides/framework-routes.md b/site/src/content/docs/guides/framework-routes.md index 90027eedb0..73df605b86 100644 --- a/site/src/content/docs/guides/framework-routes.md +++ b/site/src/content/docs/guides/framework-routes.md @@ -93,3 +93,5 @@ Axum and Actix handler links use the handler argument position, including multil Angular template calls in property bindings, interpolations, structural directives and control-flow blocks link the component member they invoke. A method passed to a child, such as `[displayWith]="displayFn"`, links as a function reference. Template locals hide same-named component members; inherited component members remain unsupported. Framework detection reads the root manifest and the first two directory levels. Remaining slots cover directories above JS/TS code, shallowest first, within the manifest limit. React Router also reads imported route tables from their module-level export bindings, and JSX index and layout routes. Incremental sync retries lazy module links and navigation destinations when their files or routes appear. Angular shared-library navigation is reconsidered when another app changes the sole-app fallback. Framework resolvers apply only to their supported languages, including TSX/JSX for SolidStart route targets. + +Cross-file route-table nodes and their references are persisted in one transaction per framework. A failed write rolls back that reconciliation, allowing the next pass to retry it. diff --git a/src/resolution/index.ts b/src/resolution/index.ts index b6218593f8..0774cd016b 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -251,7 +251,8 @@ export class ReferenceResolver { for (const fw of this.frameworks) { if (!fw.crossFileNodes) continue; try { - updated += this.reconcileCrossFileNodes(fw.crossFileNodes(this.context)); + const result = fw.crossFileNodes(this.context); + updated += this.queries.runInTransaction(() => this.reconcileCrossFileNodes(result)); } catch (err) { logDebug(`Framework '${fw.name}' crossFileNodes failed`, { error: err instanceof Error ? err.message : String(err),