fix(frameworks): a framework resolves only references written in its own languages (#2415)

Strategy 1 of resolveOneInner ran every detected framework's resolve() on
every reference, whatever its language: `languages` gated only extraction,
and gateFrameworkLanguage lets every `calls` result through. Express,
detected in etcd by its file-content scan, linked Go `*zap.Logger` result
types to a method named `Logger`, and in harbor and bitwarden it linked
Python and Rust `validate()` / `authenticate()` calls to an unrelated
method or to the calling function itself.

getResolvingFrameworks() narrows the loop to the frameworks whose
`resolveLanguages` (else `languages`) list the reference's language; one
declaring neither stays universal. `resolveLanguages` lets a resolver read
a language it extracts nothing from without running extract() there:
Svelte reads TS/JS ($lib imports, runes in .svelte.ts), ASP.NET reads
Razor (a page's `@model` is the PageModel beside it).

claimsReference() stays universal: protobuf's leading-`::` C++ calls get
past the name pre-filter only on the Swift/ObjC bridge's claim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-07 09:03:00 +00:00
committed by GitHub
parent f261fda5c2
commit 19f91e2791
10 changed files with 270 additions and 11 deletions
+1
View File
@@ -74,6 +74,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.
- 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.
- 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.
@@ -0,0 +1,190 @@
/**
* A framework resolves only the references written in its own languages.
* Express is detected in etcd by a file-content scan, and its `logger`
* middleware rule (a same-file declaration named `logger`) took a Go method's
* `*zap.Logger` result type for the method itself. A resolver that reads more
* languages than it extracts from lists them: a SvelteKit `+page.server.ts`
* still resolves its `$lib/…` imports, and a Razor page's `@model` its
* PageModel.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { getResolvingFrameworks } from '../src/resolution/frameworks';
import type { FrameworkResolver } from '../src/resolution/types';
function writeProject(prefix: string, files: Record<string, string>): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
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 root;
}
describe('getResolvingFrameworks', () => {
const go: FrameworkResolver = { name: 'go', languages: ['go'], detect: () => true, resolve: () => null };
const svelte: FrameworkResolver = {
name: 'svelte', languages: ['svelte'], resolveLanguages: ['svelte', 'typescript'], detect: () => true, resolve: () => null,
};
const any: FrameworkResolver = { name: 'any', detect: () => true, resolve: () => null };
it('keeps the frameworks that list the language, and those that list none', () => {
expect(getResolvingFrameworks([go, svelte, any], 'go').map((f) => f.name)).toEqual(['go', 'any']);
expect(getResolvingFrameworks([go, svelte, any], 'python').map((f) => f.name)).toEqual(['any']);
});
it('reads resolveLanguages before languages', () => {
expect(getResolvingFrameworks([go, svelte, any], 'typescript').map((f) => f.name)).toEqual(['svelte', 'any']);
expect(getResolvingFrameworks([go, svelte, any], 'svelte').map((f) => f.name)).toEqual(['svelte', 'any']);
});
});
describe('Express beside Go code (etcd)', () => {
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = writeProject('cg-fw-lang-go-', {
'package.json': JSON.stringify({ name: 'dashboard', dependencies: { express: '^4.19.0' } }),
'web/server.js': `const express = require('express');
const app = express();
function logger(req, res, next) {
next();
}
app.use(logger);
app.get('/health', (req, res) => res.json({ ok: true }));
`,
'go.mod': 'module example.com/kv\n\ngo 1.22\n',
'server/read/read_test.go': `package read
import "go.uber.org/zap"
type mockServer struct {
lg *zap.Logger
}
func (s *mockServer) Logger() *zap.Logger { return s.lg }
`,
});
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
it('resolves no Go reference', () => {
const ids = cg.getNodesInFile('server/read/read_test.go').map((n) => n.id);
const byExpress = cg.getOutgoingEdgesFrom(ids).filter((e) => (e.metadata as { framework?: string } | undefined)?.framework === 'express');
expect(byExpress).toEqual([]);
});
it('does not take a method’s `*zap.Logger` result for the method itself', () => {
const logger = cg.getNodesInFile('server/read/read_test.go').find((n) => n.kind === 'method' && n.name === 'Logger')!;
expect(logger).toBeDefined();
expect(cg.getOutgoingEdges(logger.id).map((e) => e.target)).not.toContain(logger.id);
});
});
describe('Svelte in a SvelteKit app', () => {
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = writeProject('cg-fw-lang-svelte-', {
'package.json': JSON.stringify({ name: 'web', devDependencies: { '@sveltejs/kit': '^2.0.0', svelte: '^5.0.0' } }),
'src/lib/api.ts': `export async function get(path: string) {
return fetch(path);
}
`,
'src/routes/+page.server.ts': `import * as api from '$lib/api';
export async function load() {
return { articles: await api.get('/articles') };
}
`,
'src/routes/+page.svelte': `<script>
let { data } = $props();
</script>
<p>{data.articles}</p>
`,
});
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
it('still resolves a `$lib/…` import written in a .ts module', () => {
const ids = cg.getNodesInFile('src/routes/+page.server.ts').map((n) => n.id);
const imported = cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind === 'imports').map((e) => cg.getNode(e.target)!);
expect(imported.map((n) => n.filePath)).toContain('src/lib/api.ts');
});
});
describe('ASP.NET in a Razor Pages app (eShopOnWeb)', () => {
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = writeProject('cg-fw-lang-razor-', {
'Program.cs': `var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
var app = builder.Build();
app.MapRazorPages();
app.Run();
`,
'Pages/_ViewImports.cshtml': `@namespace Shop.Web.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
`,
'Pages/Index.cshtml': `@page
@model IndexModel
<h1>Catalog</h1>
`,
'Pages/Index.cshtml.cs': `using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Shop.Web.Pages;
public class IndexModel : PageModel
{
}
`,
'Pages/Basket/Index.cshtml': `@page
@model IndexModel
<h1>Basket</h1>
`,
'Pages/Basket/Index.cshtml.cs': `using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Shop.Web.Pages.Basket;
public class IndexModel : PageModel
{
}
`,
});
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
it('still links each page’s `@model` to the PageModel beside it', () => {
const modelOf = (page: string) => {
const ids = cg.getNodesInFile(page).map((n) => n.id);
return cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind === 'references').map((e) => cg.getNode(e.target)!.filePath);
};
expect(modelOf('Pages/Index.cshtml')).toEqual(['Pages/Index.cshtml.cs']);
expect(modelOf('Pages/Basket/Index.cshtml')).toEqual(['Pages/Basket/Index.cshtml.cs']);
});
});
+9
View File
@@ -306,6 +306,15 @@ Each of these cost real debugging time; they are not hypothetical.
mapped inside a `<Route element>` gets no layout edge: the layout's name
would be looked up in the table's file, and the cross-file pass compares
only route names, so a changed layout would never reach the table's routes.
15. **`resolve()` sees only references written in the framework's own
languages** (`getResolvingFrameworks`): Express's `logger` middleware rule
once took etcd's Go `*zap.Logger` result types for a method. A navigation
written in markup (`.svelte`, `.vue`) needs that language in `languages`.
A resolver that reads a language it extracts nothing from lists it in
`resolveLanguages` instead — Svelte's `$lib/…` imports in `.ts` route
modules, ASP.NET's Razor `@model` — because widening `languages` also runs
`extract()` on those files. `claimsReference()` is still asked of every
detected framework.
---
+3
View File
@@ -12,6 +12,9 @@ import { pickByNameAndKind } from './name-heuristic';
export const aspnetResolver: FrameworkResolver = {
name: 'aspnet',
languages: ['csharp'],
// A Razor page's `@model CheckoutModel` names the PageModel in the
// code-behind beside it (`Checkout.cshtml.cs`); these rules look there first.
resolveLanguages: ['csharp', 'razor'],
detect(context: ResolutionContext): boolean {
// Check for .csproj files with ASP.NET references
+21
View File
@@ -142,6 +142,27 @@ export function getApplicableFrameworks(
);
}
/**
* Filter a list of detected frameworks down to ones whose `resolve()` reads a
* reference written in `language`: its `resolveLanguages`, else its
* `languages`. Frameworks that declare neither are treated as universal.
*
* A framework's conventions are its languages': Express's `logger` middleware
* rule took a Go method's `*zap.Logger` result type for the method itself.
* Bridges list the language their references are written in — the React
* Native bridge JS, the Swift ↔ Objective-C bridge both, Drupal the
* `routing.yml` it reads controllers from.
*/
export function getResolvingFrameworks(
detected: FrameworkResolver[],
language: Language
): FrameworkResolver[] {
return detected.filter((fw) => {
const languages = fw.resolveLanguages ?? fw.languages;
return !languages || languages.includes(language);
});
}
/**
* Register a custom framework resolver
*/
-2
View File
@@ -22,8 +22,6 @@ export const reactResolver: FrameworkResolver = {
// Includes 'tsx'/'jsx' so route extraction runs on JSX files (where
// `<Route element={<X/>}>` routes live) — without them the .tsx/.jsx grammars
// were filtered out of the extract pass and those routes were never indexed.
// (resolve() is unaffected — it runs for every detected framework regardless
// of language; only the extract pass filters on `languages`.)
languages: ['javascript', 'typescript', 'tsx', 'jsx'],
detect(context: ResolutionContext): boolean {
+3
View File
@@ -45,6 +45,9 @@ const SVELTEKIT_MODULE_PREFIXES = [
export const svelteResolver: FrameworkResolver = {
name: 'svelte',
languages: ['svelte'],
// Runes are written in `.svelte.ts` / `.svelte.js` modules too, and a
// SvelteKit `+page.ts` or `hooks.server.ts` imports `$lib/…` and `$app/…`.
resolveLanguages: ['svelte', 'typescript', 'javascript'],
detect(context: ResolutionContext): boolean {
// Check for svelte or @sveltejs/kit in package.json
+3 -3
View File
@@ -41,7 +41,7 @@ export const swiftUIResolver: FrameworkResolver = {
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Swift's conventions, for Swift's refs: an Objective-C `@interface SDDiskCache
// : NSObject <SDDiskCache>` is no SwiftUI view or model (languages gates only extraction).
// : NSObject <SDDiskCache>` is no SwiftUI view or model.
if (ref.language !== 'swift') return null;
// Pattern 1: View references (SwiftUI views are PascalCase ending in View)
if (ref.referenceName.endsWith('View') && /^[A-Z]/.test(ref.referenceName)) {
@@ -110,7 +110,7 @@ export const uikitResolver: FrameworkResolver = {
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Swift's conventions, for Swift's refs: an Objective-C `@interface SDDiskCache
// : NSObject <SDDiskCache>` is no SwiftUI view or model (languages gates only extraction).
// : NSObject <SDDiskCache>` is no SwiftUI view or model.
if (ref.language !== 'swift') return null;
// Pattern 1: ViewController references
if (ref.referenceName.endsWith('ViewController')) {
@@ -201,7 +201,7 @@ export const vaporResolver: FrameworkResolver = {
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
// Swift's conventions, for Swift's refs: an Objective-C `@interface SDDiskCache
// : NSObject <SDDiskCache>` is no SwiftUI view or model (languages gates only extraction).
// : NSObject <SDDiskCache>` is no SwiftUI view or model.
if (ref.language !== 'swift') return null;
// Pattern 0: a route's handler — `use: SearchController.show` arrives as
// `SearchController@show`, `use: self.index` / `use: index` as `@index`.
+26 -5
View File
@@ -36,7 +36,7 @@ import { matchShopifyThemeFile } from './shopify-themes';
import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isDartImportRef, isLuaRequireRef, isJsPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos, resolveImportPath, isExternalImport } from './import-resolver';
import { ResolverPool, minRefsForPool, shouldEngageAdaptively } from './resolver-pool';
import { resolveAliasBinding } from './alias-binding';
import { detectFrameworks } from './frameworks';
import { detectFrameworks, getResolvingFrameworks } from './frameworks';
import { synthesizeCallbackEdges } from './callback-synthesizer';
import { createYielder, type MaybeYield } from './cooperative-yield';
import { MAX_SOURCE_FILE_SIZE_BYTES } from '../file-limits';
@@ -236,6 +236,8 @@ export class ReferenceResolver {
private queries: QueryBuilder;
private context: ResolutionContext;
private frameworks: FrameworkResolver[] = [];
/** `frameworks` narrowed to those that resolve each reference language (see getResolvingFrameworks). */
private frameworksByLanguage = new Map<Language, FrameworkResolver[]>();
// Chained static-factory/fluent call refs the first pass couldn't resolve,
// collected in-memory and left pending in the DB until the post-pass
// finishes, so a restart can recover the queue (#1577). Drained by
@@ -360,9 +362,20 @@ export class ReferenceResolver {
*/
initialize(): void {
this.frameworks = detectFrameworks(this.context);
this.frameworksByLanguage.clear();
this.clearCaches();
}
/** The detected frameworks whose `resolve()` reads a reference written in `language`. */
private frameworksFor(language: Language): FrameworkResolver[] {
let frameworks = this.frameworksByLanguage.get(language);
if (!frameworks) {
frameworks = getResolvingFrameworks(this.frameworks, language);
this.frameworksByLanguage.set(language, frameworks);
}
return frameworks;
}
/**
* Run each framework resolver's cross-file finalization pass and persist
* the returned node updates. Idempotent — safe to call after every indexAll
@@ -1283,6 +1296,10 @@ export class ReferenceResolver {
// calls `FormatPrice`, which the exact-name set never lists.
(CASE_INSENSITIVE_LANGUAGES.has(ref.language) && this.hasAnyPossibleMatchIgnoringCase(existenceName)) ||
this.matchesAnyImport(ref) ||
// Every detected framework's claim, not only those that resolve this
// language: the check above reads no leading `::`, and protobuf's C++
// `::_pbi::…` calls get past it only on the Swift ↔ Objective-C
// bridge's claim of any name with a `:` in it.
this.frameworks.some((f) => f.claimsReference?.(ref.referenceName));
if (this.profileStages) this.stageAdd('preFilter', ref, preFilterPass, tPre);
if (!preFilterPass) {
@@ -1358,12 +1375,16 @@ export class ReferenceResolver {
const candidates: ResolvedRef[] = [];
// Strategy 1: Try framework-specific resolution. Cross-language bridges
// are deliberately preserved (Drupal `routing.yml` → PHP controller, RN
// JS → native `calls`); other code references obey the shared family gate.
// Strategy 1: Try framework-specific resolution, by the frameworks that
// read this reference's language: Express's `logger` middleware rule took
// a Go method's `*zap.Logger` result for the method itself (etcd).
// Cross-language bridges are deliberately preserved (Drupal `routing.yml`
// → PHP controller, RN JS → native `calls`); each lists the language its
// references are written in. Other code references obey the shared
// family gate.
const tFw = this.profileStages ? process.hrtime.bigint() : 0n;
let fwEarly: ResolvedRef | null = null;
for (const framework of this.frameworks) {
for (const framework of this.frameworksFor(ref.language)) {
const resolved = this.gateFrameworkLanguage(framework.resolve(ref, this.context), ref);
// Name the resolver on the edge (`metadata.framework`): a Swift→ObjC or
// React Native bridge hop says how it got into the graph, as a
+14 -1
View File
@@ -264,8 +264,21 @@ export interface CrossFileNodes extends FrameworkExtractionResult {
export interface FrameworkResolver {
/** Framework name */
name: string;
/** Languages this framework applies to. If omitted, applies to all languages. */
/**
* Languages this framework applies to: `extract()` runs only on files in
* them, and `resolve()` sees only references written in them (unless
* `resolveLanguages` says otherwise). If omitted, applies to all languages.
*/
languages?: Language[];
/**
* The languages whose references `resolve()` sees, when they are not
* `languages`. For a resolver that reads references in languages it
* extracts nothing from: SvelteKit's `$lib/…` imports and Svelte 5 runes
* are written in `.ts` / `.js` modules too, but only a `.svelte` file holds
* a route. Widening `languages` instead would run `extract()` on those
* files.
*/
resolveLanguages?: readonly Language[];
/**
* Packages an app declares when it is built on this framework. When set,
* `extract()` runs only on files of an app whose package.json — the file's