perf(resolution): read a bare-recorded call's receiver on its own line first — graph byte-identical (#2431)

bareCallReceiver joined the ref's line with the seven after it for every
bare-recorded call, then cut the text at the ref's column. On a minified
bundle that line is the whole file, so every call copied it: about 1 MB
per call on go-ethereum's graphiql.min.js, 9 GB over that repo's call
refs.

It now searches the ref's own line from the column first. Every match of
the call pattern starts with the name, so a match there with no earlier
occurrence of the name on the line is where the joined lines match first
too. With no match on the line (a call that continues on the next one),
or with the name earlier on it (an earlier `name[…` or `name<…` can run
past the line break and enclose the match), the joined lines decide as
before. The result is the same for every input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-07 15:17:00 +00:00
committed by GitHub
parent 2c807f5c13
commit ca90841fb8
2 changed files with 102 additions and 2 deletions
+87
View File
@@ -3,7 +3,9 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { matchCollapsedObjectCall } from '../src/resolution/name-matcher';
import { stripCommentsForRegex } from '../src/resolution/strip-comments';
import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
// Pass-through, so a test can count how often text is stripped.
vi.mock('../src/resolution/strip-comments', async (importOriginal) => {
@@ -82,3 +84,88 @@ describe('JS resolution work (#2334)', () => {
expect(strips('javascript', host)).toBeLessThan(LINES / 4);
});
});
/**
* A call the extractor records by its bare name has its receiver read back
* from the source, from the ref's column on. A minified bundle is one long
* line, and joining it with the lines after it copied all of it for every
* call on it: about 1 MB per call on go-ethereum's graphiql.min.js. Counted,
* never timed.
*/
describe('a receiver read back from a minified line', () => {
/** How many characters arrays were joined into while `run` ran. */
function joinedDuring(run: () => void): number {
const join = Array.prototype.join;
let chars = 0;
Array.prototype.join = function (this: unknown[], separator?: string): string {
const text = join.call(this, separator);
chars += text.length;
return text;
};
try {
run();
} finally {
Array.prototype.join = join;
}
return chars;
}
/**
* Runs `refs` through the check for a call written on `window.App…`, which
* looks up the object the receiver it read back names.
*/
function readBack(source: string, refs: UnresolvedRef[]): { holders: string[]; joined: number } {
const lines = source.split('\n');
const holders: string[] = [];
const context: ResolutionContext = {
getNodesInFile: () => [],
getNodesByName: (name) => {
holders.push(name);
return [];
},
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
getNodesByLowerName: () => [],
fileExists: () => false,
readFile: () => source,
getFileLines: () => lines,
getProjectRoot: () => '',
getAllFiles: () => [],
getImportMappings: () => [],
};
const joined = joinedDuring(() => {
for (const ref of refs) matchCollapsedObjectCall(ref, context);
});
return { holders, joined };
}
const call = (name: string, line: number, column: number): UnresolvedRef => ({
fromNodeId: 'bundle', referenceName: name, referenceKind: 'calls', line, column,
filePath: 'bundle.min.js', language: 'javascript',
});
it('reads every receiver on the line without copying the line once per call', () => {
const CALLS = 300;
const refs: UnresolvedRef[] = [];
let line = '!function(){';
for (let i = 0; i < CALLS; i++) {
refs.push(call(`m${i}`, 2, line.length));
line += `window.App${i}.m${i}(${i});`;
}
line += '}();';
const { holders, joined } = readBack(`/*! bundle */\n${line}\n//# sourceMappingURL=bundle.min.js.map`, refs);
// Each call's receiver was read back...
expect(holders).toEqual(refs.map((_, i) => `App${i}`));
// ...and all of them together copied less than the line once.
expect(joined).toBeLessThan(line.length);
});
it('reads on past the line when the line alone does not settle the call', () => {
// The call continues on the next lines.
expect(readBack('window.App\n .go\n (1);', [call('go', 1, 0)]).holders).toEqual(['App']);
// The line holds `foo(`, but the call is `foo[…](3)`, whose subscript runs onto the next line.
expect(readBack('window.App.foo[foo(1),\n2](3);', [call('foo', 1, 0)]).holders).toEqual(['App']);
// A column past the end of its line reads on from the next one.
expect(readBack('x\nwindow.App.go(1);', [call('go', 1, 2)]).holders).toEqual(['App']);
});
});
+15 -2
View File
@@ -2856,9 +2856,22 @@ function bareCallReceiver(ref: UnresolvedRef, context: ResolutionContext): { rec
if (ref.referenceKind !== 'calls' || !/^[A-Za-z_$][\w$]*$/.test(ref.referenceName)) return null;
const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/);
if (!lines) return null;
const text = lines.slice(ref.line - 1, ref.line + 7).join('\n').slice(Math.max(0, ref.column));
const name = ref.referenceName.replace(/\$/g, '\\$');
const at = new RegExp(`(?<![\\w$])${name}\\s*(?:<[^<>()]*>|\\[(?:[^\\[\\]]|\\[[^\\[\\]]*\\])*\\])?\\s*[({]`).exec(text);
const call = new RegExp(`(?<![\\w$])${name}\\s*(?:<[^<>()]*>|\\[(?:[^\\[\\]]|\\[[^\\[\\]]*\\])*\\])?\\s*[({]`);
// Search the ref's own line first: a minified bundle is one line, and
// joining it to the lines after it copied all of it for every call on it
// (about 1 MB a call on go-ethereum's graphiql.min.js). Every match starts
// with the name, so a match with no `name` before it on the line is where
// the joined lines match first too. Otherwise (no match, as for a call that
// continues on the next line, or the name earlier) the joined lines decide.
const span = lines.slice(ref.line - 1, ref.line + 7);
const from = Math.max(0, ref.column);
let text = span[0]?.slice(from) ?? '';
let at = call.exec(text);
if (!at || text.indexOf(ref.referenceName) < at.index) {
text = span.join('\n').slice(from);
at = call.exec(text);
}
if (!at) return null;
const before = text.slice(0, at.index).replace(/\s+$/, '');
if (!/\??\.$/.test(before)) return null;