Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
190 changes: 190 additions & 0 deletions __tests__/framework-resolve-language.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 9 additions & 0 deletions docs/design/framework-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
3 changes: 3 additions & 0 deletions src/resolution/frameworks/csharp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions src/resolution/frameworks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
2 changes: 0 additions & 2 deletions src/resolution/frameworks/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions src/resolution/frameworks/svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/resolution/frameworks/swift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -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`.
Expand Down
Loading