From 8c6bad5270e2db43837f4ef689e32576a99f6669 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 06:04:22 -0500 Subject: [PATCH] =?UTF-8?q?perf(resolution):=20read=20a=20bare-recorded=20?= =?UTF-8?q?call's=20receiver=20on=20its=20own=20line=20first=20=E2=80=94?= =?UTF-8?q?=20graph=20byte-identical?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- __tests__/js-resolution-work.test.ts | 87 ++++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 17 +++++- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/__tests__/js-resolution-work.test.ts b/__tests__/js-resolution-work.test.ts index 37a976d73..1849f91a4 100644 --- a/__tests__/js-resolution-work.test.ts +++ b/__tests__/js-resolution-work.test.ts @@ -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']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 36365398d..4455d5409 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -2411,9 +2411,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(`(?()]*>|\\[(?:[^\\[\\]]|\\[[^\\[\\]]*\\])*\\])?\\s*[({]`).exec(text); + const call = new RegExp(`(?()]*>|\\[(?:[^\\[\\]]|\\[[^\\[\\]]*\\])*\\])?\\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;