mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 11:24:49 +08:00
fix(ts): a call to a parameter of any type links nothing elsewhere (#2732)
A JS/TS name the calling function binds as a parameter is that parameter, never a same-named function in another file, as the file's own were made in #2226. The parameter reader took a type or default to be anything up to a comma or a parenthesis, so a parameter typed as a function, defaulted to one, or after one was never seen: bitwarden's isAvailable(f) on a callback typed (f: string) => boolean linked to the napi isAvailable, and vscode's merge editor's compare(a, b) on a comparator parameter to strings.ts's. A type or default may now hold balanced parentheses, braces, brackets and generics, a parameter property's modifiers are read, and an earlier destructured parameter is passed over; each alternative still has one parse. A name ends where no identifier character follows, so a const syncState$ no longer declares syncState. The parameter also shadows the file's own import of the name, which the import resolver answered before the check ran, but only where it is in scope: in the body of the function, arrow or catch clause that binds it, or the block or for statement of a const, let, var, function or class, read from the function's text up to the reference. A lone arrow parameter counts, and an object type a function returns is no body. A Promise executor's resolve is the calls inside it, never a resolve(file) after it, and a const X = require('./x').default is the import itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5994edb34e
commit
73f68e188c
@@ -259,6 +259,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- In TypeScript and JavaScript, a type, class, constant or other declaration that a `.d.ts` file declares inside `declare module '…'` now links only from files that use that module: files that import it, the module's other declarations, and files that import it through a file of yours that re-exports it. Before, any file using the same name linked to it: Node's `import { readFileSync } from 'fs'` linked to the `fs` that VS Code's `vscode.d.ts` declares, and a `ProcessEnv`, or an LSP `TextDocument` imported through a barrel, to the `vscode` module's types of those names. Re-index TypeScript projects after upgrading.
|
||||
- In C++, a call written through a class the index doesn't hold, like protocolbuffers/protobuf's `FeatureSet::descriptor()` or `OneofOptions::default_instance()` (both classes are declared in `descriptor.pb.h`, a file too large to index), no longer links to another class's method of that name whose class only shares a word with it, like `JavaFeatures_NestInFileClassFeature::descriptor` or `CppFileOptions::default_instance`. A class named like the call's would have been found first, so one that only shares a word is another class. A call through a template parameter, through an alias, including one the calling function or a macro it expands declares, or through a namespace the file opens, aliases or uses links as before, and so does a call of a method only one class in the project declares. Re-index C++ projects after upgrading.
|
||||
- In C++, a call written through a namespace, like protocolbuffers/protobuf's `absl::StripPrefix(…)` or fmtlib/fmt's `fmt::ptr(p)`, no longer links to a class's method that only shares the function's name, like protobuf's `MultiCasePrefixStripper::StripPrefix` or a test scanner's `ptr()`: a namespace holds functions and types, not methods. A call through a namespace a macro opens now links to what that namespace declares, so fmt's `fmt::ptr(p)` reaches fmt's own `ptr` function and `fmt::day(…)` its `day` type, while a call into a library outside your project, like abseil, links to nothing. A call through a lowercase class, or an alias or typedef of one, links as before. Re-index C++ projects after upgrading.
|
||||
- In TypeScript and JavaScript, a call to a function's own parameter, like `isAvailable(f)` on a parameter typed `(f: string) => boolean` or `compare(a, b)` on a comparator parameter, no longer links to a function of the same name in another file, or to the file's own import of that name. Before, a parameter whose type or default value contained parentheses, as a function type does, wasn't recognized as one. A parameter or local stands in for an import of its name only where it is in scope, so a Promise executor's `resolve(…)` no longer links to an imported `path.resolve`, while a `resolve(file)` after the executor still does. Re-index TypeScript and JavaScript projects after upgrading.
|
||||
- In TypeScript and JavaScript, a name ending in `$`, like `syncState$`, is no longer read as a declaration of `syncState`, so a call to an imported `syncState(…)` in the same function keeps its link, and a file's own `function $()` or `const $ = …` now counts as declaring `$`.
|
||||
|
||||
## [1.6.2] - 2026-10-03
|
||||
|
||||
|
||||
@@ -79,3 +79,228 @@ describe('JS names the calling function binds', () => {
|
||||
expect(targetsFrom('runInContext::run::walk')).toContain('runInContext::run::walk');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The same holds for a TypeScript parameter whose type or default holds
|
||||
* parentheses, generics or braces, and across files: bitwarden's
|
||||
* `isAvailable(f)` on a callback parameter typed `(f: string) => boolean`
|
||||
* reached the napi `isAvailable` another file exports, and vscode's merge
|
||||
* editor's `compare(a, b)` on a comparator parameter a `strings.ts`
|
||||
* `compare`. A parameter shadows the file's own import of the name too, but
|
||||
* only where it is in scope: inside its function's or arrow's body, never
|
||||
* after it. Neither does `const syncState$` declare `syncState`, nor `const
|
||||
* X = require('./x').default` hide the import it is.
|
||||
*/
|
||||
describe('TS parameters of any type are the calling function’s, in any file', () => {
|
||||
let tsRoot = '';
|
||||
let ts: CodeGraph;
|
||||
|
||||
beforeAll(async () => {
|
||||
tsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-js-local-ts-'));
|
||||
const files: Record<string, string> = {
|
||||
'src/sdk.ts': 'export function isAvailable(format: string): boolean { return format.length > 0; }\n',
|
||||
'src/strings.ts': 'export function compare(a: number, b: number): number { return a - b; }\n',
|
||||
'src/use.ts': `import { isAvailable } from './sdk';
|
||||
export function check(format: string): boolean {
|
||||
return isAvailable(format);
|
||||
}
|
||||
export function pick(formats: string[], isAvailable: (f: string) => boolean): string[] {
|
||||
return formats.filter((f) => isAvailable(f));
|
||||
}
|
||||
export function pickDefault(formats: string[], isAvailable = (f: string) => f.length > 0): string[] {
|
||||
return formats.filter((f) => isAvailable(f));
|
||||
}
|
||||
`,
|
||||
'src/sort.ts': `export const compareAll = (items: number[], compare: (a: number, b: number) => number) =>
|
||||
items.sort((a, b) => compare(a, b));
|
||||
export function later(cb: () => void, options: Map<string, number>, compare: unknown): void {
|
||||
cb();
|
||||
(compare as (a: number, b: number) => number)(options.size, 0);
|
||||
}
|
||||
export class Sorter {
|
||||
constructor(private readonly compare: (a: number, b: number) => number) {
|
||||
compare(1, 2);
|
||||
}
|
||||
}
|
||||
`,
|
||||
'src/other.ts': `export function sortAll(items: number[]): number[] {
|
||||
return items.sort((a, b) => compare(a, b));
|
||||
}
|
||||
`,
|
||||
'src/state.ts': 'export function syncState(obs: object): object { return obs; }\n',
|
||||
'src/sync.ts': `import { syncState } from './state';
|
||||
export function syncObservable(obs$: object): object {
|
||||
const syncState$ = syncState(obs$);
|
||||
return syncState$;
|
||||
}
|
||||
export function syncLater(items: object[]): object {
|
||||
items.forEach((syncState) => console.log(syncState));
|
||||
return syncState(items);
|
||||
}
|
||||
export function syncInside(items: object[]): void {
|
||||
items.forEach((syncState: (o: object) => object) => {
|
||||
syncState(items);
|
||||
});
|
||||
}
|
||||
`,
|
||||
'src/paths.ts': 'export function resolve(...parts: string[]): string { return parts.join("/"); }\n',
|
||||
// No semicolons after the arrow in fmtAll, as a project formatted without them writes it.
|
||||
'src/run.ts': `import { resolve } from './paths';
|
||||
export function run(file: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
resolve(file);
|
||||
});
|
||||
}
|
||||
export function runThen(file: string): string {
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
return resolve(file, 'out');
|
||||
}
|
||||
export function fmtAll(items: string[]): string {
|
||||
const fmt = (resolve: string) => resolve.trim()
|
||||
return resolve(fmt(items[0]))
|
||||
}
|
||||
export function eachInLoop(files: string[]): void {
|
||||
for (const resolve of [String]) resolve(files[0]);
|
||||
}
|
||||
export function eachAfterLoop(files: string[]): string {
|
||||
for (const resolve of [String]) String(resolve);
|
||||
return resolve(files[1]);
|
||||
}
|
||||
export function bare(): Promise<void> {
|
||||
return new Promise<void>(resolve => {
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
export function bareThen(file: string): string {
|
||||
new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
return resolve(file);
|
||||
}
|
||||
export function nested(file: string): string {
|
||||
function resolve(p: string): string { return p; }
|
||||
return resolve(file);
|
||||
}
|
||||
`,
|
||||
'src/widget.ts': 'export class Widget {}\nexport type Size = { width: number };\n',
|
||||
// A return type's name is no lone arrow parameter, and a value never hides a type.
|
||||
'src/build.ts': `import { Widget, type Size } from './widget';
|
||||
export const buildWidget = (name: string): Widget => {
|
||||
return new Widget();
|
||||
};
|
||||
export function measure(Size: number): number {
|
||||
const size: Size = { width: Size };
|
||||
return size.width;
|
||||
}
|
||||
`,
|
||||
// The object type a function returns is no body: withShape's own one still holds its parameter.
|
||||
'src/shape.ts': `import { compare } from './strings';
|
||||
export function withShape(compare: (a: number, b: number) => number): { sorted: number[]; size: number } {
|
||||
return { sorted: [compare(1, 2)], size: 1 };
|
||||
}
|
||||
export function plain(a: number, b: number): number {
|
||||
return compare(a, b);
|
||||
}
|
||||
`,
|
||||
'src/biometrics.ts': `export default class OsBiometrics {
|
||||
constructor(readonly name: string) {}
|
||||
}
|
||||
`,
|
||||
// describeBiometrics binds the name too, so the file's bindings alone don't settle init's.
|
||||
'src/service.ts': `export class BiometricsService {
|
||||
private service: object | undefined;
|
||||
init(platform: string): void {
|
||||
if (platform === 'darwin') {
|
||||
const OsBiometrics = require('./biometrics').default;
|
||||
this.service = new OsBiometrics('mac');
|
||||
}
|
||||
}
|
||||
}
|
||||
export function describeBiometrics(OsBiometrics: unknown): string {
|
||||
return String(OsBiometrics);
|
||||
}
|
||||
`,
|
||||
// An anonymous IIFE's calls are the file's own: only the file's view of
|
||||
// its bindings sees the parameter, after a function-typed one.
|
||||
'src/iife.ts': `((done: () => void, compare: (a: number, b: number) => number) => {
|
||||
compare(1, 2);
|
||||
done();
|
||||
})(() => {}, (a, b) => a - b);
|
||||
`,
|
||||
};
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(tsRoot, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(tsRoot, rel), content);
|
||||
}
|
||||
ts = await CodeGraph.init(tsRoot, { index: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
ts?.close();
|
||||
if (tsRoot) fs.rmSync(tsRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** `<file>:<qualified name>` of every call (or edge of `kind`) the named symbol in `file` makes. */
|
||||
const callsFrom = (file: string, name: string, kind = 'calls'): string[] => {
|
||||
const node = ts.getNodesInFile(file).find((n) => n.name === name && n.kind !== 'file')!;
|
||||
expect(node, `${file} ${name}`).toBeDefined();
|
||||
return ts.getOutgoingEdges(node.id).filter((e) => e.kind === kind).map((e) => {
|
||||
const target = ts.getNode(e.target)!;
|
||||
return `${target.filePath}:${target.qualifiedName}`;
|
||||
});
|
||||
};
|
||||
|
||||
it('a parameter typed as a function, or defaulted to one, calls what it is given', () => {
|
||||
expect(callsFrom('src/use.ts', 'pick')).toEqual([]);
|
||||
expect(callsFrom('src/use.ts', 'pickDefault')).toEqual([]);
|
||||
expect(callsFrom('src/sort.ts', 'compareAll')).toEqual([]);
|
||||
});
|
||||
|
||||
it('after earlier parameters with function and generic types, and as a parameter property', () => {
|
||||
expect(callsFrom('src/sort.ts', 'later')).toEqual([]);
|
||||
expect(callsFrom('src/sort.ts', 'constructor')).toEqual([]);
|
||||
});
|
||||
|
||||
it('shadows the file’s import of the name, which a function binding nothing still calls', () => {
|
||||
expect(callsFrom('src/use.ts', 'check')).toEqual(['src/sdk.ts:isAvailable']);
|
||||
});
|
||||
|
||||
it('in code no function node holds, the parameter list the call sits in', () => {
|
||||
const file = ts.getNodesInFile('src/iife.ts').find((n) => n.kind === 'file')!;
|
||||
const calls = ts.getOutgoingEdges(file.id).filter((e) => e.kind === 'calls').map((e) => ts.getNode(e.target)!.filePath);
|
||||
expect(calls).not.toContain('src/strings.ts');
|
||||
});
|
||||
|
||||
it('leaves a name no function binds to the matcher', () => {
|
||||
expect(callsFrom('src/other.ts', 'sortAll')).toEqual(['src/strings.ts:compare']);
|
||||
});
|
||||
|
||||
it('shadows an import only inside the body that binds the name', () => {
|
||||
expect(callsFrom('src/sync.ts', 'syncInside')).toEqual([]);
|
||||
expect(callsFrom('src/run.ts', 'run')).toEqual([]);
|
||||
expect(callsFrom('src/run.ts', 'eachInLoop')).toEqual([]);
|
||||
expect(callsFrom('src/sync.ts', 'syncLater')).toEqual(['src/state.ts:syncState']);
|
||||
expect(callsFrom('src/run.ts', 'runThen')).toEqual(['src/paths.ts:resolve']);
|
||||
expect(callsFrom('src/run.ts', 'eachAfterLoop')).toEqual(['src/paths.ts:resolve']);
|
||||
});
|
||||
|
||||
it('ends an arrow’s expression body at a line that starts the next statement', () => {
|
||||
expect(callsFrom('src/run.ts', 'fmtAll')).toContain('src/paths.ts:resolve');
|
||||
});
|
||||
|
||||
it('reads a lone arrow parameter, a nested function and a body after an object return type', () => {
|
||||
expect(callsFrom('src/run.ts', 'bare')).toEqual([]);
|
||||
expect(callsFrom('src/run.ts', 'bareThen')).toEqual(['src/paths.ts:resolve']);
|
||||
expect(callsFrom('src/run.ts', 'nested')).not.toContain('src/paths.ts:resolve');
|
||||
expect(callsFrom('src/shape.ts', 'withShape')).toEqual([]);
|
||||
expect(callsFrom('src/shape.ts', 'plain')).toEqual(['src/strings.ts:compare']);
|
||||
});
|
||||
|
||||
it('takes no return type for a parameter, and lets no value hide a type', () => {
|
||||
expect(callsFrom('src/build.ts', 'buildWidget', 'instantiates')).toEqual(['src/widget.ts:Widget']);
|
||||
expect(callsFrom('src/build.ts', 'measure', 'references')).toContain('src/widget.ts:Size');
|
||||
});
|
||||
|
||||
it('reads `syncState$` as no declaration of `syncState`, and a required local as the import', () => {
|
||||
expect(callsFrom('src/sync.ts', 'syncObservable')).toEqual(['src/state.ts:syncState']);
|
||||
expect(callsFrom('src/service.ts', 'init', 'instantiates')).toEqual(['src/biometrics.ts:OsBiometrics']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
isImportableKind,
|
||||
CPP_DEFINE_SIGNATURE,
|
||||
} from './types';
|
||||
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves } from './name-matcher';
|
||||
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, isImportShadowedByJsBinding } from './name-matcher';
|
||||
import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility';
|
||||
import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor';
|
||||
import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes';
|
||||
@@ -1356,6 +1356,10 @@ export class ReferenceResolver {
|
||||
const module = this.nodeById(resolved.targetNodeId);
|
||||
if (module && module.kind !== 'file' && module.kind !== 'module') return null;
|
||||
}
|
||||
// A name the calling JS/TS function binds itself (a parameter, a local) in
|
||||
// scope at the reference is that binding even when the file imports the
|
||||
// name, which the import resolver answers before the matcher's own check.
|
||||
if (resolved && resolved.resolvedBy === 'import' && isImportShadowedByJsBinding(resolved, ref, this.context)) return null;
|
||||
// A declaration of a declaration file's `declare module 'x'` is reached
|
||||
// through an import of `x`, never by its bare name from a file that
|
||||
// reaches no `x` (see reachesAmbientModule).
|
||||
|
||||
+212
-29
@@ -11136,29 +11136,171 @@ export function jsCodeBindsName(name: string, fn: Node, filePath: string, line:
|
||||
let binds = memo.get(key);
|
||||
if (binds === undefined) {
|
||||
const text = jsFunctionCodeThrough(fn, filePath, line, context);
|
||||
const { param } = localBindingPatterns(name, 'g');
|
||||
const n = name.replace(/\$/g, '\\$');
|
||||
// A plain declaration. Destructuring re-binds what a call returns under
|
||||
// the same name — `const { t } = useI18n()`, `const { getLabel } =
|
||||
// useProps(props)` — which is the same-named function more often than not.
|
||||
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)`.
|
||||
// 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);
|
||||
const declared = new RegExp(`\\b(?:const|let|var)\\s+${n}(?![\\w$])(?!\\s*[,\\]}])`).test(text);
|
||||
binds = declared || jsParameterListPattern(name, '').test(text);
|
||||
memo.set(key, binds);
|
||||
}
|
||||
return binds;
|
||||
}
|
||||
|
||||
/**
|
||||
* A parameter list that names `name` (localBindingPatterns' `param`), never
|
||||
* a control-flow head (`if (openMarkerClose) {`). A return type stays on its
|
||||
* line, never a ternary's `: data.slice()` below `filter(canRowExpand)`, and
|
||||
* may hold an object type, so the match ends at the body's `{`, not at the
|
||||
* type's: `delta(…): { removed: T[]; added: T[] } {`.
|
||||
* 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.
|
||||
*/
|
||||
function jsParameterListPattern(name: string, flags: string): RegExp {
|
||||
const { param } = localBindingPatterns(name, flags);
|
||||
const returnType = `(?::(?:[^=;{}()\\n]|${PARAM_BRACE})*)?`;
|
||||
return new RegExp(`(?=\\()(?<!\\b(?:if|while|for|switch|with)\\s*)${param.source.replace('(?::[^=;{]*)?', () => returnType)}`, flags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the JS/TS function a reference sits in binds its name in a scope
|
||||
* that holds at the reference, not just somewhere in its text: a parameter
|
||||
* list or a lone arrow parameter whose body is still open there (a
|
||||
* function's or a `catch` clause's block, an arrow's block or expression),
|
||||
* or a `var`/`let`/`const`, `function` or `class` whose block or `for`
|
||||
* statement is. A `new Promise((resolve) => …)` executor's `resolve` is the
|
||||
* calls inside it, not a `resolve(file)` after it, and a `const X =
|
||||
* require('./x')` binds the import itself. A function too long to read
|
||||
* again keeps the import.
|
||||
*/
|
||||
function jsBindingEnclosesReference(name: string, fn: Node, ref: UnresolvedRef, context: ResolutionContext): boolean {
|
||||
const through = jsFunctionCodeThrough(fn, ref.filePath, ref.line, context);
|
||||
const cut = through.slice(0, through.lastIndexOf('\n') + 1 + ref.column);
|
||||
if (cut.length > 100_000) return false;
|
||||
// Brackets in strings, template literals and regex literals open no scope.
|
||||
const regexes: number[] = [];
|
||||
const chars = blankStringContents(cut, regexes).split('');
|
||||
for (let r = 0; r + 1 < regexes.length; r += 2) chars.fill(' ', regexes[r]! + 1, regexes[r + 1]!);
|
||||
const code = chars.join('');
|
||||
const n = name.replace(/\$/g, '\\$');
|
||||
const declaration = new RegExp(
|
||||
`\\b(?:(?:const|let|var)\\s+${n}(?![\\w$])(?!\\s*[,\\]}])(\\s*=\\s*(?:await\\s+)?(?:require|import)\\s*\\()?|(?:function|class)\\s+${n}(?![\\w$]))`,
|
||||
'g'
|
||||
);
|
||||
for (const m of code.matchAll(declaration)) {
|
||||
if (m[1]) continue;
|
||||
const inForHead = /\bfor\s*(?:await\s*)?\(\s*$/.test(code.slice(Math.max(0, m.index! - 16), m.index));
|
||||
if (inForHead ? jsForStatementOpenAtEnd(code, m.index!) : jsBlockOpenAtEnd(code, m.index!)) return true;
|
||||
}
|
||||
for (const m of code.matchAll(jsParameterListPattern(name, 'g'))) {
|
||||
const end = m.index! + m[0].length;
|
||||
if (code[end - 1] === '{' ? jsBlockOpenAtEnd(code, end) : jsBodyOpenAtEnd(code, end)) return true;
|
||||
}
|
||||
// `new Promise<void>(resolve => { … })`: a lone parameter where an
|
||||
// expression starts, never the name a return type ends with (`(): Bounds =>`).
|
||||
const loneParameter = new RegExp(`(^|[(,=\\[{?:]|=>|\\b(?:return|async|default|yield|await))\\s*${n}\\s*=>`, 'g');
|
||||
for (const m of code.matchAll(loneParameter)) {
|
||||
if (m[1] === ':' && /\)\s*$/.test(code.slice(Math.max(0, m.index! - 200), m.index))) continue;
|
||||
if (jsBodyOpenAtEnd(code, m.index! + m[0].length)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the block that holds `at` (just inside a body's `{`, or where a
|
||||
* declaration starts) is still open at the end of `code`.
|
||||
*/
|
||||
function jsBlockOpenAtEnd(code: string, at: number): boolean {
|
||||
let depth = 0;
|
||||
for (let i = at; i < code.length; i++) {
|
||||
if (code[i] === '{') depth++;
|
||||
else if (code[i] === '}' && --depth < 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const JS_OPERAND_END = /[\w$)\]}'"`]/;
|
||||
const JS_OPERAND_START = /[\w$'"`]/;
|
||||
|
||||
/**
|
||||
* Whether the body that starts at `at` (an arrow's, after its `=>`, or a
|
||||
* `for` statement's, after its head) is still open at the end of `code`: a
|
||||
* `{…}` block, or an expression or statement, which a comma or a semicolon
|
||||
* ends, or the bracket closing around it, or a line that starts another
|
||||
* statement where no semicolon ends the last.
|
||||
*/
|
||||
function jsBodyOpenAtEnd(code: string, at: number): boolean {
|
||||
let i = at;
|
||||
while (i < code.length && WHITESPACE.test(code[i]!)) i++;
|
||||
if (code[i] === '{') return jsBlockOpenAtEnd(code, i + 1);
|
||||
let depth = 0;
|
||||
for (; i < code.length; i++) {
|
||||
const c = code[i]!;
|
||||
if (c === '(' || c === '[' || c === '{') {
|
||||
depth++;
|
||||
} else if (c === ')' || c === ']' || c === '}') {
|
||||
if (--depth < 0) return false;
|
||||
} else if (depth === 0 && (c === ',' || c === ';')) {
|
||||
return false;
|
||||
} else if (depth === 0 && c === '\n') {
|
||||
// `const f = (x) => x + 1` with no semicolon, above the reference: the
|
||||
// line before ends an operand (a name, a closing bracket, a quote) and
|
||||
// the next starts one (the reference itself, when the code ends there)
|
||||
// rather than an operator or a `.` continuing the expression.
|
||||
let before = i - 1;
|
||||
while (before >= 0 && WHITESPACE.test(code[before]!)) before--;
|
||||
let after = i + 1;
|
||||
while (after < code.length && WHITESPACE.test(code[after]!)) after++;
|
||||
if (JS_OPERAND_END.test(code[before] ?? '') && (after === code.length || JS_OPERAND_START.test(code[after]!))) return false;
|
||||
i = after - 1;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a declaration in a `for` head, at `at`, is still in scope at the
|
||||
* end of `code`: in the head, or in the statement after it.
|
||||
*/
|
||||
function jsForStatementOpenAtEnd(code: string, at: number): boolean {
|
||||
let depth = 0;
|
||||
for (let i = at; i < code.length; i++) {
|
||||
const c = code[i];
|
||||
if (c === '(' || c === '[' || c === '{') depth++;
|
||||
else if ((c === ')' || c === ']' || c === '}') && --depth < 0) return c === ')' && jsBodyOpenAtEnd(code, i + 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an import-resolved `result` is the file's import of the reference's
|
||||
* name while the calling JS/TS function binds that name itself, in a scope
|
||||
* that holds at the reference (jsBindingEnclosesReference): bitwarden's
|
||||
* `pick(…, isAvailable: (f: string) => boolean)` calling `isAvailable(f)`
|
||||
* means its parameter, not the file's `import { isAvailable } from './sdk'`.
|
||||
* The import resolver answers before matchReference's check, which reads the
|
||||
* function's whole text — too coarse for an import, whose link is usually right.
|
||||
*/
|
||||
export function isImportShadowedByJsBinding(result: ResolvedRef, ref: UnresolvedRef, context: ResolutionContext): boolean {
|
||||
if (!JS_FAMILY.has(ref.language) || !JS_LOCAL_REF_KINDS.has(ref.referenceKind) || !/^[A-Za-z_$][\w$]*$/.test(ref.referenceName)) return false;
|
||||
const target = context.getNodeById?.(result.targetNodeId);
|
||||
// (A parameter or local is a value: it hides no interface or type alias, `let coords: Bounds`.)
|
||||
if (!target || target.name !== ref.referenceName || target.kind === 'interface' || target.kind === 'type_alias') return false;
|
||||
const fn = context.getNodeById?.(ref.fromNodeId);
|
||||
if (!fn || (fn.kind !== 'function' && fn.kind !== 'method') || fn.startLine > ref.line || fn.endLine < ref.line) return false;
|
||||
if (target.filePath === ref.filePath && target.startLine >= fn.startLine && target.startLine <= fn.endLine) return false;
|
||||
// No function binds a name its file never does (read once per file and name).
|
||||
if (!isLocallyBoundJsName(ref.referenceName, ref.filePath, context)) return false;
|
||||
if (ref.referenceKind === 'calls' && bareCallReceiver(ref, context) !== null) return false;
|
||||
return jsBindingEnclosesReference(ref.referenceName, fn, ref, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per context: the comment-stripped lines of the functions jsCodeBindsName
|
||||
* read last, and where each line ends in that text, most recent last.
|
||||
@@ -11241,28 +11383,50 @@ const JS_BINDING_NAME = /^[\w$]+$/;
|
||||
const ARROW_HEAD_CHAR = /[\w$.]/;
|
||||
const IMPORT_BINDING_VALUE = /^\s*(?:await\s+)?(?:require|import)\s*\(/;
|
||||
|
||||
// The balanced groups a parameter's type or default can hold, two levels deep.
|
||||
const PARAM_PAREN = '\\((?:[^()]|\\([^()]*\\))*\\)';
|
||||
const PARAM_BRACE = '\\{(?:[^{}]|\\{[^{}]*\\})*\\}';
|
||||
const PARAM_BRACKET = '\\[(?:[^\\[\\]]|\\[[^\\[\\]]*\\])*\\]';
|
||||
const PARAM_ANGLE = '<(?!\\s)(?:=>|=(?!>)|[^<>=]|<(?:=>|=(?!>)|[^<>=])*>)*>';
|
||||
/**
|
||||
* One piece of a parameter's type or default: a character other than a comma,
|
||||
* or a balanced group, which may hold commas (`Map<K, V>`, `{ a: A, b: B }`,
|
||||
* `(a: number, b: number) => number`). Every alternative starts with a
|
||||
* character of its own — `=>` is the arrow of a function type, and a `<` or
|
||||
* `>` spaced like an operator (`f.length > 0`) is no bracket — so a type has
|
||||
* one parse.
|
||||
*/
|
||||
const PARAM_PART = `(?:=>|=(?!>)|[^,(){}\\[\\]<>=]|<(?=\\s)|>(?=[\\s=])|${PARAM_PAREN}|${PARAM_BRACE}|${PARAM_BRACKET}|${PARAM_ANGLE})`;
|
||||
/** A TypeScript parameter property's modifiers: `constructor(private readonly store: Store)`. */
|
||||
const PARAM_MODIFIERS = '(?:(?:public|private|protected|readonly|override)\\s+)*';
|
||||
|
||||
function localBindingPatterns(name: string, flags: string): LocalBindingPatterns {
|
||||
const n = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return {
|
||||
// `const { name } = require('./m')` / `= await import('./m')` binds an IMPORT,
|
||||
// not a shadow: the symbol lives in the other file and the call means it.
|
||||
decl: new RegExp(
|
||||
'\\b(?:const|let|var)\\s+(?:' + n + '\\b|[{\\[][^;=]*?\\b' + n + '\\b[^;=]*?[}\\]])\\s*(?:=\\s*([^;\\n]*))?',
|
||||
'\\b(?:const|let|var)\\s+(?:' + n + '(?![\\w$])|[{\\[][^;=]*?\\b' + n + '(?![\\w$])[^;=]*?[}\\]])\\s*(?:=\\s*([^;\\n]*))?',
|
||||
flags
|
||||
),
|
||||
fn: new RegExp('\\b(?:function|class)\\s+' + n + '\\b', flags),
|
||||
fn: new RegExp('\\b(?:function|class)\\s+' + n + '(?![\\w$])', flags),
|
||||
// a parameter: every token before the name in the list is itself a
|
||||
// parameter (identifier, optional type, optional default) — so a string
|
||||
// argument containing the word cannot match.
|
||||
// parameter (identifier or destructuring, optional type, optional
|
||||
// default) — so a string argument containing the word cannot match.
|
||||
// Each earlier parameter has exactly one parse: its first non-space
|
||||
// character after the identifier picks the type (`?`/`:`), default (`=`)
|
||||
// or bare alternative. Written as `(type)?(default)?\s*`, the same
|
||||
// strings split several ways per parameter, and a failing search
|
||||
// backtracked through every combination — 30-40s per name on a vscode
|
||||
// test file whose helper takes nine `name: T = value` parameters.
|
||||
// or bare alternative, and a type or default runs to the comma. Written
|
||||
// as `(type)?(default)?\s*`, the same strings split several ways per
|
||||
// parameter, and a failing search backtracked through every combination
|
||||
// — 30-40s per name on a vscode test file whose helper takes nine `name:
|
||||
// T = value` parameters. A type or default may hold balanced groups
|
||||
// (PARAM_PART): `isAvailable: (f: string) => boolean`, `cb: () => void`
|
||||
// before the name, `compare = (a, b) => a - b`.
|
||||
param: new RegExp(
|
||||
'\\(\\s*(?:(?:\\.\\.\\.)?[\\w$]+(?:\\s*(?:\\?\\s*)?:[^,()]+|\\s*=[^,()]+|\\s*),\\s*)*' +
|
||||
n + '\\b(?:\\s*\\??\\s*:[^,()]*)?(?:\\s*=[^,()]*)?(?:\\s*,\\s*[^()]*)?\\)\\s*(?::[^=;{]*)?(?:=>|\\{)',
|
||||
'\\(\\s*(?:(?:' + PARAM_MODIFIERS + '(?:\\.\\.\\.)?[\\w$]+|' + PARAM_BRACE + '|' + PARAM_BRACKET + ')' +
|
||||
'(?:\\s*(?:\\?\\s*)?:' + PARAM_PART + '+|\\s*=' + PARAM_PART + '+|\\s*),\\s*)*' +
|
||||
PARAM_MODIFIERS + n + '(?![\\w$])(?:\\s*\\??\\s*:' + PARAM_PART + '*)?(?:\\s*=' + PARAM_PART + '*)?' +
|
||||
'(?:\\s*,(?:[^()]|' + PARAM_PAREN + ')*)?\\)\\s*(?::[^=;{]*)?(?:=>|\\{)',
|
||||
flags
|
||||
),
|
||||
};
|
||||
@@ -11308,12 +11472,31 @@ function bindsAnywhere(source: string, name: string): boolean {
|
||||
return fn.test(source) || param.test(source) || new RegExp('(?:^|[^\\w$.])' + n + '\\s*=>').test(source);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `(` of the parenthesized list `source[at]` sits in, read back over the
|
||||
* balanced groups before it (an earlier parameter's `cb: () => void` or `{ a,
|
||||
* b }`), or -1 when the name sits in a block, an object or an array, after a
|
||||
* statement's end, or too far back to be in a parameter list.
|
||||
*/
|
||||
function enclosingOpenParen(source: string, at: number): number {
|
||||
let depth = 0;
|
||||
for (let i = at - 1; i >= 0 && at - i <= 2000; i--) {
|
||||
const c = source[i];
|
||||
if (c === ')' || c === ']' || c === '}') depth++;
|
||||
else if (c === '(' || c === '[' || c === '{') {
|
||||
if (depth === 0) return c === '(' ? i : -1;
|
||||
depth--;
|
||||
} else if (c === ';' && depth === 0) return -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* bindsAnywhere for a plain identifier, tried only where a match can start:
|
||||
* a declaration at its keyword (in order, resuming past each match exactly as
|
||||
* the global search does), a parameter at the `(` its list opens with — the
|
||||
* last `(` before an occurrence of the name, with no `)` between, since no
|
||||
* earlier parameter can hold a parenthesis — and `name =>` at each arrow.
|
||||
* the global search does), a parameter at the `(` its list opens with
|
||||
* (enclosingOpenParen, from an occurrence of the name), and `name =>` at each
|
||||
* arrow.
|
||||
*/
|
||||
function bindsAtSites(source: string, name: string, sites: LocalBindingSites): boolean {
|
||||
let patterns = LOCAL_BINDING_PATTERNS.get(name);
|
||||
@@ -11340,8 +11523,8 @@ function bindsAtSites(source: string, name: string, sites: LocalBindingSites): b
|
||||
}
|
||||
let tried = -1;
|
||||
for (let at = source.indexOf(name); at !== -1; at = source.indexOf(name, at + 1)) {
|
||||
const open = at > 0 ? source.lastIndexOf('(', at - 1) : -1;
|
||||
if (open < 0 || open === tried || source.lastIndexOf(')', at - 1) > open) continue;
|
||||
const open = enclosingOpenParen(source, at);
|
||||
if (open < 0 || open === tried) continue;
|
||||
tried = open;
|
||||
param.lastIndex = open;
|
||||
if (param.test(source)) return true;
|
||||
|
||||
Reference in New Issue
Block a user