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 @@ -38,6 +38,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- 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.
- In Go, a function or method passed as a value, like `sync.Pool{New: wm.new}`, now shows the function that passes it among its callers and impact even when it is named `new`, `nil`, `None`, `self` or a handful of similar words, or when a line break or comment sits beside the dot. Before, such a value was skipped, so a method used only that way looked unused. 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)
- Indexing a project that ships a large minified JavaScript file, such as the GraphiQL bundle inside go-ethereum, no longer stalls at "Resolving refs" when CodeGraph runs on Node 22, and linking such a file is faster on any Node version. The graph it builds is unchanged.
- The time `codegraph init` and `codegraph index` print beside the node and edge counts now covers the whole run, resolving references and linking included, and `codegraph sync` reports its whole run the same way. Before, it counted only reading and parsing the files, which can be a small part of an index, so a slow index looked fast. Thanks @bompus for the report. (#2334)
- In Rust, code that uses an enum only through its variants, like `mode::Mode::A` in an expression, a `Mode::B =>` match arm or `Self::A` inside the enum's own `impl`, now shows up in that enum's callers and impact, linked to the enum that actually declares the variant rather than to a same-named type elsewhere. Standard-library variants like `Ordering::Less` or `Option::Some` and associated items like `Foo::new()` or `Foo::MAX` don't count as using a project enum. Re-index Rust projects after upgrading. Thanks @mg-mg-mg for the report and @danusha2345. (#2328)
- Axum routes now link to their handler when rustfmt wraps the `.route(` call so the handler sits on a later line, and when the handler's name also appears elsewhere on the line, as in `delete(handlers::user::delete)` or `.route("/login", post(handlers::user::login))`; Actix's `web::resource(…)` and `.route(…)` registrations get the same fix. A call inside a closure handler is no longer mistaken for a route, and an Actix resource no longer takes the next route's handler as its own. Thanks @mg-mg-mg for the report and @danusha2345. (#2326)
Expand Down
73 changes: 73 additions & 0 deletions __tests__/js-local-binding-backtracking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Whether a JS function binds a name itself (jsFunctionLocalScope, #2226) is
* read off the function's comment-stripped code with a parameter-list regex.
* Its `(?<!\b(?:if|while|for|switch|with)\s*)` lookbehind is cheap only while
* V8 optimizes the regex, and Node 22's V8 stops optimizing new regexes once a
* process has generated about a megabyte of regex code — which a resolver pool
* worker does early, compiling patterns per receiver and per name. Unoptimized,
* the lookbehind runs at every position and reads back through each whitespace
* run before it, so a long blanked comment cost time in its length squared.
*
* go-ethereum's bundled `graphiql.min.js` is one 980 KB line, and the
* stripper, which doesn't know regex literals, read the `//` that ends
* `/Trident\//` as a comment and blanked the remaining 962 KB of it. Two pool
* workers sat on one such check each until the run was killed (`codegraph
* init` never finished resolving).
*
* The cost lives inside one regex execution, where there is nothing to count,
* so this is timed — with V8's unoptimized mode forced, on any Node version,
* and a bound far above the fix's cost and far below the quadratic one.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as v8 from 'v8';
import { CodeGraph } from '../src';

describe('JS local-binding check on a long comment (go-ethereum graphiql.min.js)', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;

afterEach(() => {
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5 });
tmpDir = undefined;
});

const callees = (graph: CodeGraph, name: string): string[] => {
const from = graph.getNodesByName(name).find((n) => n.kind === 'function')!;
return graph.getOutgoingEdges(from.id).filter((e) => e.kind === 'calls')
.map((e) => graph.getNode(e.target)!.name);
};

it('stays linear when V8 stops optimizing regexes', async () => {
// Blanked by the comment stripper into one 60,000-character run of spaces.
const comment = `/* ${'word '.repeat(12_000)} */`;
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-js-lookbehind-'));
fs.writeFileSync(path.join(tmpDir, 'paint.js'), [
'function paintWidget() {\n return 1;\n}',
// Calls the file's own paintWidget: the check scans all of host's code and finds no binding.
`function host() {\n ${comment}\n return paintWidget();\n}`,
// Calls its parameter, which shadows the file's paintWidget.
`function shadow(paintWidget) {\n ${comment}\n return paintWidget();\n}`,
'',
].join('\n'));

v8.setFlagsFromString('--no-regexp-optimization');
const started = Date.now();
try {
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
} finally {
v8.setFlagsFromString('--regexp-optimization');
}
const elapsed = Date.now() - started;

expect(callees(cg, 'host')).toEqual(['paintWidget']);
expect(callees(cg, 'shadow')).toEqual([]);
// About a second with the fix; minutes when the lookbehind reads back through the run at every position.
expect(elapsed).toBeLessThan(20_000);
}, 300_000);
});
84 changes: 84 additions & 0 deletions __tests__/jsx-render-work.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* The jsx-render pass reads each function's own lines for the component tags
* it renders. Every function of a minified bundle spans the bundle's one line,
* so it split, sliced and scanned the whole file once per function: 2,234
* times over go-ethereum's 980 KB `graphiql.min.js`, 25 of the 28 seconds its
* linking passes took on Node 22 (9 of 13 on Node 24). Functions that span
* the same lines render the same tags, so they share one scan. Counted, never
* timed.
*/
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';

/** The source of the pass's tag pattern, to count its scans by. */
const JSX_TAG_SOURCE = String.raw`<([A-Z][A-Za-z0-9_]*)[\s/>]`;

describe('jsx-render pass work', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
const originalExec = RegExp.prototype.exec;

afterEach(() => {
RegExp.prototype.exec = originalExec;
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5 });
tmpDir = undefined;
});

async function index(files: Record<string, string>): Promise<CodeGraph> {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-jsx-work-'));
fs.writeFileSync(path.join(tmpDir, 'package.json'), '{"dependencies":{"react":"^18.0.0"}}');
for (const [file, content] of Object.entries(files)) fs.writeFileSync(path.join(tmpDir, file), content);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
return cg;
}

/** What each named function renders, by jsx-render edge. */
const renders = (graph: CodeGraph, name: string): string[] => {
const from = graph.getNodesByName(name).find((n) => n.kind === 'function' || n.kind === 'component')!;
return graph.getOutgoingEdges(from.id)
.filter((e) => (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy === 'jsx-render')
.map((e) => graph.getNode(e.target)!.name);
};

it('scans a line that many components share once, not once per component', async () => {
const COMPONENTS = 60;
const bundle = Array.from({ length: COMPONENTS }, (_, i) => `function Card${i}(){return <Badge/>}`).join('');
// A scan of the bundle's line: the tag pattern run over it from the start.
let scans = 0;
RegExp.prototype.exec = function (this: RegExp, input: string) {
if (this.source === JSX_TAG_SOURCE && this.lastIndex === 0 && typeof input === 'string' &&
input.includes(`Card${COMPONENTS - 1}(`)) scans++;
return originalExec.call(this, input);
};
const graph = await index({
'bundle.jsx': `${bundle}\n`,
'badge.jsx': 'export function Badge(){return null}\n',
});
RegExp.prototype.exec = originalExec;

for (const i of [0, 31, COMPONENTS - 1]) expect(renders(graph, `Card${i}`)).toEqual(['Badge']);
expect(scans).toBeGreaterThan(0);
expect(scans).toBeLessThan(COMPONENTS / 4);
});

it('keeps the tags of functions on different lines apart', async () => {
const graph = await index({
'page.jsx': [
'export function Page() { return <Header/>; }',
'export function Footer() {',
' return <Links/>;',
'}',
'function Header(){return null} function Links(){return null}',
'',
].join('\n'),
});
expect(renders(graph, 'Page')).toEqual(['Header']);
expect(renders(graph, 'Footer')).toEqual(['Links']);
});
});
27 changes: 21 additions & 6 deletions src/resolution/callback-synthesizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1432,13 +1432,28 @@ async function reactJsxChildEdges(ctx: ResolutionContext, onYield: MaybeYield):
let imports: Map<string, string> | null = null;
const importsOf = () =>
(imports ??= importedFrom(ctx, file, parents[0]!.language));
// A function renders the tags its own lines hold, so functions that span
// the same lines share them: every function of a minified bundle spans
// its one line, and reading that line again per function took 9 to 25 s
// over go-ethereum's graphiql.min.js (2,234 functions on 980 KB). The
// file is split once, not once per function.
let lines: string[] | null = null;
const tagsBySpan = new Map<string, Set<string>>();
for (const parent of parents) {
const src = sliceLines(content, parent.startLine, parent.endLine);
if (!src || (!src.includes('</') && !src.includes('/>'))) continue;
const names = new Set<string>();
JSX_TAG_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = JSX_TAG_RE.exec(src))) names.add(m[1]!);
if (!parent.startLine || !parent.endLine) continue;
const span = `${parent.startLine}:${parent.endLine}`;
let names = tagsBySpan.get(span);
if (!names) {
names = new Set<string>();
lines ??= content.split('\n');
const src = lines.slice(parent.startLine - 1, parent.endLine).join('\n');
if (src.includes('</') || src.includes('/>')) {
JSX_TAG_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = JSX_TAG_RE.exec(src))) names.add(m[1]!);
}
tagsBySpan.set(span, names);
}
let added = 0;
for (const name of names) {
if (added >= MAX_JSX_CHILDREN) break;
Expand Down
10 changes: 9 additions & 1 deletion src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6846,7 +6846,15 @@ function jsCodeBindsName(name: string, fn: Node, ref: UnresolvedRef, context: Re
const declared = new RegExp(`\\b(?:const|let|var)\\s+${n}\\b(?!\\s*[,\\]}])`).test(text);
// A parameter list — never a control-flow head (`if (openMarkerClose) {`).
// A return type stays on its line, never a ternary's `: data.slice()` below `filter(canRowExpand)`.
const parameter = new RegExp(`(?<!\\b(?:if|while|for|switch|with)\\s*)${param.source.replace('(?::[^=;{]*)?', '(?::[^=;{}()\\n]*)?')}`);
// The leading `(?=\()` (every match opens a list) keeps the lookbehind to
// where one opens. Node 22's V8 stops optimizing the regexes a process
// compiles once it has generated about a megabyte of regex code, which a
// resolver pool worker soon has, and then tried the lookbehind at every
// position, each time reading back through the run of blanks before it:
// go-ethereum's graphiql.min.js, a 980 KB line whose last 962 KB the
// stripper blanks (it reads the `//` closing `/Trident\//` as a comment),
// never resolved.
const parameter = new RegExp(`(?=\\()(?<!\\b(?:if|while|for|switch|with)\\s*)${param.source.replace('(?::[^=;{]*)?', '(?::[^=;{}()\\n]*)?')}`);
binds = declared || parameter.test(text);
memo.set(key, binds);
}
Expand Down