mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
fix(js,ts): index the functions in every named object literal, not only exported ones (#2300) (#2363)
Issue #2300: a function written inside an object literal became a symbol
only when the object was an `export const`. A plain `const api = {...}`,
an object declared inside an IIFE or a function, and a namespace hung on
the page (`window.WS = {...}`, `ns.mod = {...}`) produced no member nodes,
and no node for `WS` either. Calls made inside those members were credited
to the enclosing constant or lost, and calls into them resolved to
nothing. Script-tag JavaScript, written almost entirely this way, was
mostly missing from callers and impact.
Cause: the TS/JS extractor, and the kernel's tsjs mirror of it, minted
members only for exported object-of-functions; any other literal was
walked as one opaque initializer, and an assignment to a member path was
never a declaration. Resolution had no way to reach a member through
`App.init()`, `window.App.init()` or `App.utils.pad()`.
Fix (extraction, TS and kernel byte-identical): a named object literal
owns its function members (method shorthand, `key: function`, `key: () =>`,
generators; static keys only), whether it is declared at module scope,
in a function body or IIFE, assigned to a path (`window.App = {...}`,
`App.utils = {...}`) or assigned to a plain name at module level
(`dw_page = {...}`). Members are `function` nodes qualified under the owner
(`api::load`, `window.App::init`, `App.utils::pad`), the exported case
included; a path owner is named by its last link and qualified by the
path. Calls in a member are the member's; other values are walked where
they were before. CommonJS exports, prototypes, `this.x = {...}`,
call-argument literals, a name reassigned inside a function, and
generated or minified files keep the old shape. Minified files are now
also recognised by content, in both extractors, and the kernel's
`.min.js` pattern matches the TS one.
Fix (resolution): a member is reached through its object only:
`App.init()`, `window.App.init()`, `App.utils.pad()`, a sibling's
`this.render()`, a `const { init } = App` binding; never a bare `init()`.
A same-file holder is chosen by lexical block (an IIFE's own `App` first);
otherwise a global one (`window.App = {...}`, or a classic script's
top-level `App`) when the caller neither imports nor binds the name.
Arrow members keep the `this` of the method around the literal, in the
`this.x` resolvers too. A dotted-path holder is never reached by its last
name alone, and Svelte's `$store` rule now applies only in `.svelte`
components, so the new local holders are not taken for `$n` in plain
scripts. The lookups read each file once (destructuring patterns only
when the raw text has a `} =`) and keep only the last 32 files' scans.
Contributor PR #2310 (@danusha2345): adopted its model (owner-qualified
members exported or not, path owners, IIFE/local owners, static keys,
no bare-name reach, `<script setup>` contains edges, SFC languages, test
scenarios), re-implemented on current main. Not taken: the
EXTRACTION_VERSION bump (already 28), the extraction-time binding oracle
with its ref/edge metadata and kernel ref patching (replaced by
resolution-time lookups), naming the owner `window.WS`, rewriting
`window.X.m()` ref names, flipping exported members' isExported, and
unrelated bare-call changes.
Verification: new js-object-literal-members suite (native + wasm, 18
tests); the issue's shapes fail on main and the guard test fails without
the guards. kernel-tsjs-parity passes with CODEGRAPH_KERNEL_EXPECT=1
(object-literal owners LF/CRLF, minified bundles); kernel parity sweeps
show 0 diffs on DokuWiki, vue-realworld, TodoMVC, excalidraw and this repo.
Validation (kernel loaded, before = origin/main 023fc311):
- DokuWiki: nodes +85, calls +105; 17 removed edges = 13 moved to the
member, 4 re-resolved from a wrong LinkWizard::init to the right init.
- TodoMVC: nodes +427, calls +148; 306 removed = 226 moved, 33
re-resolved (14 fixed `this.render()`/`this.save()`/`this.track()`, 19
wrong-to-wrong fallback guesses), 47 dropped wrong edges (43 to a
helper local to jQuery Mobile's scrollstart setup).
- excalidraw: nodes +86, calls +75; 34 removed = 33 moved, 1 fuzzy 0.3
guess dropped.
- vue-realworld: nodes +10, calls +11; 7 calls re-resolved from the
ApiService constant to its members.
New self-loops are real recursion, bar one lazily redefined method that
calls itself as written. 81 sampled added edges, 80 correct
(the other is main's own window.open guess, re-attributed). Whole-run
index time, median of 3 interleaved: vue-realworld 1.2s -> 1.2s,
DokuWiki 8.6s -> 9.1s, excalidraw 10.8s -> 10.7s, TodoMVC 16.9s -> 16.9s
(within run-to-run noise).
Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- 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)
|
||||
- In JavaScript and TypeScript, the functions written inside a named object literal are now symbols of their own even when the object isn't exported: a plain `const api = { load() {…} }`, an object declared inside an IIFE or a function, and a namespace hung on the page or on another object, like `window.App = { init() {…} }`, `App.utils = {…}` or `dw_page = {…}`. Calls such as `api.load()`, `window.App.init()`, `App.utils.pad()`, a sibling's `this.render()`, and `App.init()` from another script on the page now reach them, the calls made inside a member belong to that member instead of the object, and a bare `init()` is no longer taken for `App.init`, so script-tag apps no longer lose most of their code from `callers` and impact. A member's qualified name now carries its object, exported or not (`api::load`, `window.App::init`), so asking for `App.init` finds it. Re-index JavaScript and TypeScript projects after upgrading. Thanks @tkhoaaa for the report and @danusha2345. (#2300)
|
||||
|
||||
## [1.6.2] - 2026-10-03
|
||||
|
||||
|
||||
@@ -1180,7 +1180,7 @@ export const exported = { handler: () => target() };
|
||||
return result.unresolvedReferences
|
||||
.filter((u) => u.referenceKind === 'calls' && u.referenceName === name)
|
||||
.map((u) => byId.get(u.fromNodeId))
|
||||
.map((n) => (n ? `${n.kind}:${n.name}` : '?'))
|
||||
.map((n) => (n ? `${n.kind}:${n.qualifiedName}` : '?'))
|
||||
.sort();
|
||||
};
|
||||
|
||||
@@ -1191,14 +1191,14 @@ export const exported = { handler: () => target() };
|
||||
});
|
||||
|
||||
it('a non-exported object literal contributes calls (it was skipped outright)', () => {
|
||||
// `exported`'s members are minted as their own function nodes, so its
|
||||
// arrow's call comes from `handler`; the non-exported ones attribute to
|
||||
// the declared constant.
|
||||
// A named literal's function members are its own nodes, exported or not
|
||||
// (#2300), so each arrow's call comes from its `handler`; the literal's
|
||||
// eager value and the array's arrow attribute to the declared constant.
|
||||
expect(callersOf('target')).toEqual([
|
||||
'constant:list',
|
||||
'constant:obj',
|
||||
'constant:obj',
|
||||
'function:handler',
|
||||
'function:exported::handler',
|
||||
'function:obj::handler',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,8 +108,8 @@ ${typed ? 'export function typed(cart: Cart) { cart.map(); }' : ''}
|
||||
for (const name of ['tidy', 'cached', 'unknownCache', 'literalArray', 'literalString']) expect(callees(name), name).toEqual([]);
|
||||
expect(callees('constructed')).toContain('Cart::add');
|
||||
expect(callees('imported')).toContain('Cart::bind');
|
||||
expect(callees('objectLiteral')).toEqual(['map']);
|
||||
expect(callees('localLiteral')).toEqual(['map']);
|
||||
expect(callees('objectLiteral')).toEqual(['api::map']);
|
||||
expect(callees('localLiteral')).toEqual(['local::map']);
|
||||
if (typed) expect(callees('typed')).toEqual(['Cart::map']);
|
||||
const self = cg.getNodesByKind('method').find(n => n.qualifiedName === 'Cart::self')!;
|
||||
expect(cg.getCallees(self.id).map(c => c.node.qualifiedName)).toEqual(['Cart::add']);
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* A named object literal owns its function members (#2300). Members used to
|
||||
* become nodes only when the object was an `export const`; a plain `const`, an
|
||||
* object inside an IIFE and one hung on a path (`window.WS = {…}`) produced
|
||||
* none, so script-tag JavaScript was nearly invisible and the calls inside a
|
||||
* member were credited to the enclosing constant (or lost).
|
||||
*
|
||||
* Now each member is a `function` node qualified under its owner
|
||||
* (`store::shorthand`, `window.WS::wsM`), the calls written in it are its own,
|
||||
* and the calls that reach it — `App.init()`, `window.App.init()`, a sibling's
|
||||
* `this.render()`, `App.utils.pad()`, a classic script's `WS.wsM()` from
|
||||
* another file — resolve to it. A bare `init()` never does.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
import { getKernel, resetKernelForTests } from '../src/extraction/kernel';
|
||||
|
||||
const kernelBuilt = fs.existsSync(path.join(__dirname, '../codegraph-kernel/prebuilds',
|
||||
`${process.platform}-${process.arch}`, 'codegraph-kernel.node'));
|
||||
|
||||
describe.each(['native', 'wasm'].filter((backend) => backend === 'wasm' || kernelBuilt))(
|
||||
'object-literal members (%s, #2300)', (backend) => {
|
||||
let dir: string;
|
||||
let cg: CodeGraph | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('CODEGRAPH_KERNEL', backend === 'wasm' ? '0' : '1');
|
||||
vi.stubEnv('CODEGRAPH_KERNEL_LANGS', 'all');
|
||||
resetKernelForTests();
|
||||
if (backend === 'native') expect(getKernel()).not.toBeNull();
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-2300-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cg?.close();
|
||||
cg = undefined;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
vi.unstubAllEnvs();
|
||||
resetKernelForTests();
|
||||
});
|
||||
|
||||
async function index(files: Record<string, string>): Promise<CodeGraph> {
|
||||
for (const [rel, body] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, rel), body);
|
||||
}
|
||||
cg = await CodeGraph.init(dir, { index: true });
|
||||
return cg;
|
||||
}
|
||||
|
||||
/** The one node with this qualified name (in `file`, when several share it). */
|
||||
function node(g: CodeGraph, qualifiedName: string, file?: string) {
|
||||
const found = g.getNodesByQualifiedName(qualifiedName).filter((n) => !file || n.filePath === file);
|
||||
expect(found, qualifiedName).toHaveLength(1);
|
||||
return found[0]!;
|
||||
}
|
||||
/** The qualified names at the other end of a node's `calls` edges. */
|
||||
const calls = (g: CodeGraph, id: string, direction: 'in' | 'out') =>
|
||||
(direction === 'in' ? g.getIncomingEdges(id) : g.getOutgoingEdges(id))
|
||||
.filter((e) => e.kind === 'calls')
|
||||
.map((e) => g.getNode(direction === 'in' ? e.source : e.target)!.qualifiedName)
|
||||
.sort();
|
||||
const callers = (g: CodeGraph, qn: string, file?: string) => calls(g, node(g, qn, file).id, 'in');
|
||||
const callees = (g: CodeGraph, qn: string, file?: string) => calls(g, node(g, qn, file).id, 'out');
|
||||
|
||||
it('gives every container shape from the issue its members, and the calls in a member to that member', async () => {
|
||||
const g = await index({
|
||||
'src/a_export.ts': 'export const objTsExp = { tsExpM() { return 1; }, tsExpProp: () => 1 };\n',
|
||||
'src/b_plain.ts': 'const objTsPlain = { tsPlainM() { return 1; }, tsPlainProp: () => 1 };\n',
|
||||
'src/c_export.js': 'export const objJsExp = { jsExpM() { return 1; }, jsExpProp: () => 1 };\n',
|
||||
'src/d_plain.js': 'const objJsPlain = { jsPlainM() { return 1; }, jsPlainProp: () => 1 };\n',
|
||||
'src/e_iife.js': [
|
||||
'(function () {',
|
||||
' const objIife = { iifeM() { return 1; } };',
|
||||
' window.WS = { wsM() { return objIife.iifeM(); } };',
|
||||
'})();',
|
||||
'',
|
||||
].join('\n'),
|
||||
'src/f_calls.js': [
|
||||
'function helper() { return 2; }',
|
||||
'var store = { shorthand() { return helper(); } };',
|
||||
'function useStore() { return store.shorthand(); }',
|
||||
'window.api = { load() { return store.shorthand(); } };',
|
||||
'function viaWindow() { return window.WS.wsM(); }',
|
||||
'function viaGlobal() { return WS.wsM(); }',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
for (const qn of [
|
||||
'objTsExp::tsExpM', 'objTsExp::tsExpProp', 'objTsPlain::tsPlainM', 'objTsPlain::tsPlainProp',
|
||||
'objJsExp::jsExpM', 'objJsExp::jsExpProp', 'objJsPlain::jsPlainM', 'objJsPlain::jsPlainProp',
|
||||
'objIife::iifeM', 'window.WS::wsM', 'store::shorthand', 'window.api::load',
|
||||
]) {
|
||||
expect(node(g, qn).kind, qn).toBe('function');
|
||||
}
|
||||
// The member belongs to its owner, and `window.WS` is the global `WS`.
|
||||
const plain = node(g, 'objTsPlain');
|
||||
expect(g.getOutgoingEdges(plain.id).filter((e) => e.kind === 'contains').map((e) => g.getNode(e.target)!.name).sort())
|
||||
.toEqual(['tsPlainM', 'tsPlainProp']);
|
||||
expect(g.getNodesByName('WS').map((n) => `${n.kind} ${n.qualifiedName}`)).toEqual(['variable window.WS']);
|
||||
// An exported literal's members stay exported, a plain one's do not.
|
||||
expect(node(g, 'objTsExp::tsExpM').isExported).toBe(true);
|
||||
expect(node(g, 'objTsPlain::tsPlainM').isExported).toBeFalsy();
|
||||
|
||||
// The call inside a member is the member's, not the constant's.
|
||||
expect(callers(g, 'helper')).toEqual(['store::shorthand']);
|
||||
expect(callers(g, 'objIife::iifeM')).toEqual(['window.WS::wsM']);
|
||||
expect(callers(g, 'store::shorthand')).toEqual(['useStore', 'window.api::load']);
|
||||
// `window.WS.wsM()`, and `WS.wsM()` from another classic script.
|
||||
expect(callers(g, 'window.WS::wsM')).toEqual(['viaGlobal', 'viaWindow']);
|
||||
});
|
||||
|
||||
it('resolves through the object the call names — never by the bare name', async () => {
|
||||
const g = await index({
|
||||
'app.js': [
|
||||
'function init() { return "global"; }',
|
||||
'var App = {',
|
||||
' init: function () { this.render(); helpers.fmt(); },',
|
||||
' render: function () { return 1; },',
|
||||
'};',
|
||||
'var helpers = { fmt() { return 2; } };',
|
||||
'App.utils = { pad(s) { return s; } };',
|
||||
'function boot() { App.init(); App.utils.pad("x"); init(); }',
|
||||
'dw_page = { start() { App.render(); } };',
|
||||
'',
|
||||
].join('\n'),
|
||||
'page.js': [
|
||||
'function main() { App.init(); window.App.render(); dw_page.start(); App.utils.pad("y"); }',
|
||||
'function shadow(App) { return App.init(); }',
|
||||
'function bare() { return render(); }',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
expect(callees(g, 'boot')).toEqual(['App.utils::pad', 'App::init', 'init']);
|
||||
expect(callees(g, 'App::init')).toEqual(['App::render', 'helpers::fmt']);
|
||||
expect(callees(g, 'dw_page::start')).toEqual(['App::render']);
|
||||
// Another script reaches the page's globals; a parameter of the same name does not.
|
||||
expect(callees(g, 'main')).toEqual(['App.utils::pad', 'App::init', 'App::render', 'dw_page::start']);
|
||||
expect(callees(g, 'shadow')).toEqual([]);
|
||||
// `render()` alone is not `App.render`, and `init()` stays the global function.
|
||||
expect(callees(g, 'bare')).toEqual([]);
|
||||
expect(callers(g, 'init')).toEqual(['boot']);
|
||||
});
|
||||
|
||||
it('never takes an object hung on a path, or one local to an IIFE, for a name elsewhere', async () => {
|
||||
const g = await index({
|
||||
// A Svelte project, where `$count` reads the store `count` — in a `.svelte` component.
|
||||
'package.json': '{ "dependencies": { "svelte": "^4.0.0" } }\n',
|
||||
'lib.js': [
|
||||
'$.event.special.swipe = { setup: function () { return 1; } };',
|
||||
'todos.model = { add: function () { return 2; } };',
|
||||
'(function () { var n = { touch: function () { return 3; } }; n.touch(); })();',
|
||||
'',
|
||||
].join('\n'),
|
||||
'widget.js': [
|
||||
'var Widget = {',
|
||||
' drag: function () { this.swipe(1); },',
|
||||
' make: function () { return new this.model(); },',
|
||||
'};',
|
||||
'',
|
||||
].join('\n'),
|
||||
// Two compiled scripts, each with its own `$n`.
|
||||
'gwt1.js': 'function $n(a) { this.a = a; }\nfunction make1() { return new $n(1); }\n',
|
||||
'gwt2.js': 'function $n(a) { this.a = a; }\nfunction make2() { return new $n(2); }\n',
|
||||
});
|
||||
const edges = (qn: string) => g.getOutgoingEdges(node(g, qn).id).filter((e) => e.kind !== 'contains')
|
||||
.map((e) => `${e.kind} ${g.getNode(e.target)!.qualifiedName} ${g.getNode(e.target)!.filePath}`);
|
||||
// `this.swipe()` and `new this.model()` are not the objects at `$.event.special.swipe` and `todos.model`.
|
||||
expect(edges('Widget::drag')).toEqual([]);
|
||||
expect(edges('Widget::make')).toEqual([]);
|
||||
// A plain script's `$n` is its own function, not a store read of the IIFE's `n`.
|
||||
expect(edges('make1')).toEqual(['instantiates $n gwt1.js']);
|
||||
expect(edges('make2')).toEqual(['instantiates $n gwt2.js']);
|
||||
});
|
||||
|
||||
it('keeps each IIFE and function its own literal, and a module its private ones', async () => {
|
||||
const g = await index({
|
||||
'iife.js': [
|
||||
'(function () { const Api = { run() { left(); } }; Api.run(); })();',
|
||||
'(function () { const Api = { run() { right(); } }; Api.run(); })();',
|
||||
'function left() {}',
|
||||
'function right() {}',
|
||||
'function outside() { return Api.run(); }',
|
||||
'function a() { const local = { go() { left(); } }; local.go(); }',
|
||||
'function b() { const local = { go() { right(); } }; local.go(); }',
|
||||
'',
|
||||
].join('\n'),
|
||||
'mod.js': 'import { x } from "./x.js";\nconst Local = { run() { return x; } };\nexport function useLocal() { return Local.run(); }\n',
|
||||
'other.js': 'import { y } from "./y.js";\nexport function nope() { return Local.run(y); }\n',
|
||||
});
|
||||
|
||||
const [first, second] = g.getNodesByQualifiedName('Api::run').sort((p, q) => p.startLine - q.startLine);
|
||||
expect(calls(g, first!.id, 'out')).toEqual(['left']);
|
||||
expect(calls(g, second!.id, 'out')).toEqual(['right']);
|
||||
// Each IIFE's `Api.run()` reaches its own literal (the file runs both).
|
||||
expect(calls(g, first!.id, 'in')).toEqual(['iife.js']);
|
||||
expect(calls(g, second!.id, 'in')).toEqual(['iife.js']);
|
||||
// Outside both IIFEs, `Api` is neither of them — not even as a value read.
|
||||
expect(callees(g, 'outside')).toEqual([]);
|
||||
expect(g.getOutgoingEdges(node(g, 'outside').id).filter((e) => e.kind !== 'contains')).toEqual([]);
|
||||
expect(callees(g, 'a')).toEqual(['a::local::go']);
|
||||
expect(callees(g, 'b')).toEqual(['b::local::go']);
|
||||
expect(callees(g, 'useLocal')).toEqual(['Local::run']);
|
||||
expect(callees(g, 'nope')).toEqual([]);
|
||||
});
|
||||
|
||||
it('gives arrow members and values the `this` of the method around the literal', async () => {
|
||||
const g = await index({
|
||||
'app.ts': [
|
||||
'class Emitter { on(cb: () => void) { return cb; } }',
|
||||
'export class App {',
|
||||
' private emitter = new Emitter();',
|
||||
' updateScene() { return 1; }',
|
||||
' createApi() {',
|
||||
' const api = {',
|
||||
' updateScene: this.updateScene,',
|
||||
' onChange: (cb: () => void) => this.emitter.on(cb),',
|
||||
' refresh: () => this.updateScene(),',
|
||||
' own() { return this.refresh(); },',
|
||||
' };',
|
||||
' return api;',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
// A value in a method's literal is that method's own reference, as before.
|
||||
const createApi = node(g, 'App::createApi');
|
||||
expect(g.getOutgoingEdges(createApi.id).filter((e) => e.kind === 'references')
|
||||
.map((e) => g.getNode(e.target)!.qualifiedName)).toContain('App::updateScene');
|
||||
// An arrow member's `this` is the class instance…
|
||||
expect(callees(g, 'App::createApi::api::onChange')).toEqual(['Emitter::on']);
|
||||
expect(callees(g, 'App::createApi::api::refresh')).toEqual(['App::updateScene']);
|
||||
// …an object's own method's `this` is the object.
|
||||
expect(callees(g, 'App::createApi::api::own')).toEqual(['App::createApi::api::refresh']);
|
||||
});
|
||||
|
||||
it('keeps a cross-file member edge through edits of the defining script', async () => {
|
||||
const g = await index({
|
||||
'ns.js': 'window.WS = { a() { return 1; }, wsM() { return 2; } };\n',
|
||||
'use.js': 'function viaGlobal() { return WS.wsM(); }\n',
|
||||
});
|
||||
expect(callers(g, 'window.WS::wsM')).toEqual(['viaGlobal']);
|
||||
fs.writeFileSync(path.join(dir, 'ns.js'), '// moved\nwindow.WS = {\n wsM() { return 3; },\n};\n');
|
||||
await g.sync();
|
||||
expect(callers(g, 'window.WS::wsM')).toEqual(['viaGlobal']);
|
||||
fs.writeFileSync(path.join(dir, 'ns.js'), 'window.WS = { renamed() { return 4; } };\n');
|
||||
await g.sync();
|
||||
expect(g.getNodesByQualifiedName('window.WS::wsM')).toEqual([]);
|
||||
expect(callees(g, 'viaGlobal')).toEqual([]);
|
||||
}, 30_000);
|
||||
|
||||
it('reaches an imported literal and a binding destructured off one', async () => {
|
||||
const g = await index({
|
||||
'api.js': [
|
||||
'export const api = { get() { return 1; }, post() { return 2; } };',
|
||||
'const { post } = api;',
|
||||
'export function b() { return post(); }',
|
||||
'export function c() { return get(); }',
|
||||
'',
|
||||
].join('\n'),
|
||||
'use.js': [
|
||||
'import { api } from "./api.js";',
|
||||
'export function a() { return api.get(); }',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
expect(callees(g, 'a')).toEqual(['api::get']);
|
||||
expect(callees(g, 'b')).toEqual(['api::post']);
|
||||
// `get` was never destructured: a bare `get()` is not `api.get`.
|
||||
expect(callees(g, 'c')).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves CommonJS exports, prototypes, call arguments, data and minified bundles as they were', async () => {
|
||||
const g = await index({
|
||||
'shapes.js': [
|
||||
'module.exports = { cjs() { return 1; } };',
|
||||
'Foo.prototype = { proto() { return 1; } };',
|
||||
'consume({ ephemeral() { return 1; } });',
|
||||
'const data = { a: 1, b: [1, 2] };',
|
||||
'this.state = { onThis() { return 1; } };',
|
||||
'',
|
||||
].join('\n'),
|
||||
'vendor/lib.min.js': 'var a={b:function(){return 1},c:function(){return 2}};window.L={d:function(){return a.b()}};\n',
|
||||
// A bundle not named so: its lines are what gives it away.
|
||||
'dist/bundle.js': `${'var q={b:function(){return r(1,2)},c:function(s){return s}};window.M={d:function(){return q.b()}};'.repeat(60)}\n`.repeat(2),
|
||||
'iife.js': '(function () { var e; if (!e) { e = { getItem: function () { return 1; } }; } })();\n',
|
||||
});
|
||||
// …and a plain name reassigned inside a function (a bundle's `e = {…}`) is a local.
|
||||
for (const name of ['cjs', 'proto', 'ephemeral', 'onThis', 'b', 'c', 'd', 'getItem']) {
|
||||
expect(g.getNodesByName(name), name).toEqual([]);
|
||||
}
|
||||
expect(node(g, 'data').kind).toBe('constant');
|
||||
});
|
||||
|
||||
it('keeps a <script setup> literal\'s members under their owner', async () => {
|
||||
const g = await index({
|
||||
'Panel.vue': [
|
||||
'<template><button @click="save">Save</button></template>',
|
||||
'<script setup>',
|
||||
'const handlers = { onSave() { persist(); } };',
|
||||
'function persist() {}',
|
||||
'function save() { handlers.onSave(); }',
|
||||
'</script>',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
const owner = node(g, 'handlers');
|
||||
const member = node(g, 'handlers::onSave');
|
||||
expect(g.getIncomingEdges(member.id).filter((e) => e.kind === 'contains').map((e) => e.source)).toEqual([owner.id]);
|
||||
expect(callees(g, 'handlers::onSave')).toEqual(['persist']);
|
||||
expect(callees(g, 'save')).toEqual(['handlers::onSave']);
|
||||
});
|
||||
});
|
||||
@@ -96,6 +96,43 @@ describe.skipIf(!kernelBuilt)('kernel TS/JS extraction parity', () => {
|
||||
return viaWasm;
|
||||
}
|
||||
|
||||
it.each([
|
||||
['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'],
|
||||
] as const)('named object literals own their members: %s (#2300)', (ext, language) => {
|
||||
const source = [
|
||||
'/* é😀 */ const Api = { read() { helper(); }, close: () => helper(), gen: function* () { yield 1; }, \'quoted-key\': function () {}, [dyn()]() { helper(); }, eager: helper(), alias: helper, helper };',
|
||||
'function helper() {}',
|
||||
'/** Saved. */',
|
||||
'export const Exported = { save() { helper(); } };',
|
||||
'(function () { const Local = { run() { helper(); } }, data = { x: 1 }; Local.run(); window.WS = { ws() { Local.run(); } }; })();',
|
||||
'App.utils = { pad(s) { return s; } };',
|
||||
'// The page.',
|
||||
'dw_page = { start() { helper(); } };',
|
||||
'function setup() { ns.late = { go() {} }; let h; h = { on() {} }; }',
|
||||
'module.exports = { cjs() {} };',
|
||||
'Foo.prototype = { proto() {} };',
|
||||
'self.handlers = { click() {} };',
|
||||
'consume({ ephemeral() {} });',
|
||||
'',
|
||||
].join('\n');
|
||||
for (const ending of ['\n', '\r\n']) {
|
||||
const result = assertParity(`literal.${ext}`, source.replace(/\n/g, ending), language);
|
||||
expect(result.nodes.map((n) => n.qualifiedName)).toEqual(expect.arrayContaining([
|
||||
'Api::read', 'Api::close', 'Api::gen', 'Api::quoted-key', 'Exported::save', 'Local::run', 'window.WS',
|
||||
'window.WS::ws', 'App.utils::pad', 'dw_page::start', 'ns.late::go', 'self.handlers::click',
|
||||
]));
|
||||
// A plain name reassigned inside a function is a local, not a namespace.
|
||||
for (const name of ['cjs', 'proto', 'ephemeral', 'data', 'on']) expect(result.nodes.some((n) => n.name === name), name).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['bundle.js', 'vendor-min.js', 'vendor.min.js'])('a minified bundle keeps its literals unowned: %s (#2300)', (file) => {
|
||||
// Mostly long lines dense with code punctuation, as a minifier writes them.
|
||||
const line = `var a={b:function(){return c(1,2)},d:function(e){return e}};window.L={f:function(){return a.b()}};`.repeat(40);
|
||||
const result = assertParity(file, `${line}\n${line}\n`, 'javascript');
|
||||
expect(result.nodes.some((n) => n.kind === 'function' && (n.name === 'b' || n.name === 'f'))).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'],
|
||||
] as const)('same-line accessors retain distinct identities after Unicode: %s (#1349)', (ext, language) => {
|
||||
|
||||
@@ -70,6 +70,6 @@ describe('inline route handler calls', () => {
|
||||
});
|
||||
|
||||
it('reach the service a member call names', () => {
|
||||
expect(routeCalls('/users/:id')).toContain('src/services.ts: lookup');
|
||||
expect(routeCalls('/users/:id')).toContain('src/services.ts: userService::lookup');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,9 +114,10 @@ describe('this.<field>.<method>() (#1496)', () => {
|
||||
});
|
||||
|
||||
it('resolves a field typed `typeof <objectLiteral>` onto the literal\'s member', () => {
|
||||
// The members are bare-named functions inside the constant's extent (#1573).
|
||||
expect(calleesOf('Keeper::settings')).toEqual(['getSettings']);
|
||||
expect(calleesOf('Keeper::get')).toEqual(['get']);
|
||||
// The members are functions inside the constant's extent (#1573), qualified
|
||||
// under it (#2300).
|
||||
expect(calleesOf('Keeper::settings')).toEqual(['DraftHubStorage::getSettings']);
|
||||
expect(calleesOf('Keeper::get')).toEqual(['DraftHubStorage::get']);
|
||||
const self = cg.getCallers(method('Keeper::get').id).some(({ node }) => node.id === method('Keeper::get').id);
|
||||
expect(self).toBe(false);
|
||||
});
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* - Pinia options: `defineStore({ actions: {…}, getters: {…} })`.
|
||||
* - Pinia setup: `defineStore('id', () => { const foo = …; return { foo } })`.
|
||||
* And the precision gate: a non-exported `const actions = {…}` in a file that
|
||||
* isn't a Vue store contributes nothing.
|
||||
* isn't a Vue store is not treated as one. (Its members are nodes anyway, as
|
||||
* every named object literal's are since #2300 — `actions::doThing`.)
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
@@ -111,9 +112,11 @@ export const useChatStore = defineStore('chat', () => {
|
||||
cg.close?.();
|
||||
});
|
||||
|
||||
it('does not extract a non-exported `const actions = {…}` outside a Vue store file', async () => {
|
||||
it('does not treat a non-exported `const actions = {…}` outside a Vue store file as a store', async () => {
|
||||
// A plain module that happens to hold a non-exported `const actions` object of
|
||||
// functions, but lacks any second Vue-store signal — the gate must not fire.
|
||||
// The literal's members are its own nodes (#2300), and nothing reads the
|
||||
// dynamic `actions[key]()` as a call to one of them.
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'commands.js'),
|
||||
`const actions = {
|
||||
@@ -128,10 +131,15 @@ export function run(key) { return actions[key](); }
|
||||
await cg.indexAll();
|
||||
const db = (cg as any).db.db;
|
||||
|
||||
expect(db.prepare(`SELECT count(*) c FROM nodes WHERE name = 'doThing'`).get().c).toBe(0);
|
||||
expect(db.prepare(`SELECT count(*) c FROM nodes WHERE name = 'doOther'`).get().c).toBe(0);
|
||||
// The real exported function is still extracted normally.
|
||||
expect(db.prepare(`SELECT count(*) c FROM nodes WHERE name = 'run' AND kind='function'`).get().c).toBeGreaterThan(0);
|
||||
expect(db.prepare(`SELECT kind, qualified_name FROM nodes WHERE name IN ('doThing', 'doOther') ORDER BY qualified_name`).all())
|
||||
.toEqual([
|
||||
{ kind: 'function', qualified_name: 'actions::doOther' },
|
||||
{ kind: 'function', qualified_name: 'actions::doThing' },
|
||||
]);
|
||||
// The real exported function is still extracted normally, and calls nothing.
|
||||
const run = cg.getNodesByQualifiedName('run');
|
||||
expect(run.map((n) => n.kind)).toEqual(['function']);
|
||||
expect(cg.getOutgoingEdges(run[0]!.id).filter((e) => e.kind === 'calls')).toEqual([]);
|
||||
|
||||
cg.close?.();
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ pub fn is_generated_file(file_path: &str) -> bool {
|
||||
r"\.pb\.[jt]s$",
|
||||
r"_pb\.[jt]s$",
|
||||
r"_grpc_pb\.[jt]s$",
|
||||
r"\.min\.m?js$",
|
||||
r"[.-]min\.m?js$",
|
||||
r"_pb2(_grpc)?\.py$",
|
||||
r"_pb2\.pyi$",
|
||||
r"\.pb\.(cc|h)$",
|
||||
@@ -78,6 +78,56 @@ pub fn is_generated_file(file_path: &str) -> bool {
|
||||
patterns.iter().any(|p| p.is_match(file_path))
|
||||
}
|
||||
|
||||
// isMinifiedContent's WEBPACK_RUNTIME (`\b` ASCII, as in JS).
|
||||
re!(webpack_runtime, r"(?-u:\b)function __webpack_require__\s*\(");
|
||||
|
||||
/// isMinifiedContent (src/extraction/generated-detection.ts): a `.js` / `.mjs`
|
||||
/// / `.cjs` file of at least 4000 UTF-16 units whose text sits mostly on lines
|
||||
/// of 1000 units or more, dense with `;{}(),` — or that carries a webpack
|
||||
/// runtime. Lengths are counted in UTF-16 units, as the JS string ops count
|
||||
/// them, so both extractors agree on every file.
|
||||
pub fn is_minified_content(file_path: &str, content: &str) -> bool {
|
||||
const LINE: usize = 1000;
|
||||
let lower = file_path.to_ascii_lowercase();
|
||||
if !(lower.ends_with(".js") || lower.ends_with(".mjs") || lower.ends_with(".cjs")) {
|
||||
return false;
|
||||
}
|
||||
// UTF-16 units never outnumber UTF-8 bytes: a short file is out at once.
|
||||
if content.len() < 4 * LINE {
|
||||
return false;
|
||||
}
|
||||
let total = content.encode_utf16().count();
|
||||
if total < 4 * LINE {
|
||||
return false;
|
||||
}
|
||||
if webpack_runtime().is_match(content) {
|
||||
return true;
|
||||
}
|
||||
let (mut long, mut punctuation) = (0usize, 0usize);
|
||||
let (mut line_len, mut line_punct) = (0usize, 0usize);
|
||||
for unit in content.encode_utf16() {
|
||||
if unit == 10 {
|
||||
if line_len >= LINE {
|
||||
long += line_len;
|
||||
punctuation += line_punct;
|
||||
}
|
||||
line_len = 0;
|
||||
line_punct = 0;
|
||||
continue;
|
||||
}
|
||||
line_len += 1;
|
||||
// ; { } ( ) ,
|
||||
if matches!(unit, 59 | 123 | 125 | 40 | 41 | 44) {
|
||||
line_punct += 1;
|
||||
}
|
||||
}
|
||||
if line_len >= LINE {
|
||||
long += line_len;
|
||||
punctuation += line_punct;
|
||||
}
|
||||
long as f64 >= total as f64 * 0.5 && punctuation as f64 >= long as f64 * 0.03
|
||||
}
|
||||
|
||||
/// Byte offsets of each line start, for UTF-16 column conversion.
|
||||
pub fn line_starts(src: &str) -> Vec<usize> {
|
||||
let mut out = vec![0usize];
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
use crate::textutil as util;
|
||||
use super::{
|
||||
body_of, is_builtin_type, is_literal_receiver, is_react_hoc, is_variable_type,
|
||||
is_vue_collection_name, Extra, Scope, Walker,
|
||||
body_of, is_builtin_type, is_host_global_root, is_literal_receiver, is_object_member_function,
|
||||
is_react_hoc, is_static_object_key, is_variable_type, is_vue_collection_name, Extra, Scope, Walker,
|
||||
};
|
||||
use crate::buffers::edge_kind_index;
|
||||
use tree_sitter::Node;
|
||||
@@ -449,6 +449,15 @@ impl<'t> Walker<'t> {
|
||||
self.extract_variable_type_annotation(child, row);
|
||||
}
|
||||
|
||||
// A named object literal owns its function members, exported or
|
||||
// not (#2300): `const api = { load() {…} }` gives `api::load`. The
|
||||
// rest of the literal is walked under the owner (#693). Mirrors
|
||||
// the ownedObject branch of TreeSitterExtractor.extractVariable.
|
||||
if let (Some(row), Some(obj)) = (var_row, self.owned_object_value(value)) {
|
||||
self.extract_owned_object_members(obj, row, kind, &name, true, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Exported const object-of-functions / store shapes.
|
||||
let object_of_fns: Option<Node> = match value {
|
||||
Some(v) if matches!(v.kind(), "object" | "object_expression") => Some(v),
|
||||
@@ -552,6 +561,189 @@ impl<'t> Walker<'t> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- owned object literals (#2300) ------------------------------------------------
|
||||
|
||||
/// ownsObjectLiterals: a generated or minified bundle — named so or not —
|
||||
/// keeps the old shape. (Every tsjs variant is TS/JS, so only that check
|
||||
/// remains; it is computed once per file.)
|
||||
fn owns_object_literals(&self) -> bool {
|
||||
self.owns_objects
|
||||
}
|
||||
|
||||
/// ownedMemberFunction: the function an owned literal's member becomes,
|
||||
/// named by its static key — `load() {…}`, `load: () => {…}`,
|
||||
/// `load: function () {…}`, `load: function* () {…}`. None for every other
|
||||
/// member (a computed key, a value, a shorthand, a spread).
|
||||
fn owned_member_function(&self, member: Node<'t>) -> Option<(Node<'t>, String)> {
|
||||
if member.kind() == "method_definition" {
|
||||
let key = member.child_by_field_name("name")?;
|
||||
if !is_static_object_key(key.kind()) {
|
||||
return None;
|
||||
}
|
||||
return Some((member, util::object_key_name(self.text(key))));
|
||||
}
|
||||
if member.kind() != "pair" {
|
||||
return None;
|
||||
}
|
||||
let key = member.child_by_field_name("key")?;
|
||||
let value = member.child_by_field_name("value")?;
|
||||
if !is_static_object_key(key.kind()) || !is_object_member_function(value.kind()) {
|
||||
return None;
|
||||
}
|
||||
Some((value, util::object_key_name(self.text(key))))
|
||||
}
|
||||
|
||||
/// ownedObjectValue: `value` when it is an object literal owning at least
|
||||
/// one function member.
|
||||
pub(super) fn owned_object_value(&self, value: Option<Node<'t>>) -> Option<Node<'t>> {
|
||||
let value = value?;
|
||||
if !matches!(value.kind(), "object" | "object_expression") || !self.owns_object_literals() {
|
||||
return None;
|
||||
}
|
||||
for i in 0..value.named_child_count() {
|
||||
if let Some(member) = value.named_child(i) {
|
||||
if self.owned_member_function(member).is_some() {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Put an owner on the stack — on top of the file alone for one on the
|
||||
/// global object — returning what was set aside for leave_owner.
|
||||
fn enter_owner(&mut self, row: u32, kind: &'static str, name: &str, global: bool) -> Vec<Scope> {
|
||||
let rest = if global { self.stack.split_off(1) } else { Vec::new() };
|
||||
self.stack.push(Scope { row, kind, name: name.to_string() });
|
||||
rest
|
||||
}
|
||||
|
||||
fn leave_owner(&mut self, rest: Vec<Scope>) {
|
||||
self.stack.pop();
|
||||
self.stack.extend(rest);
|
||||
}
|
||||
|
||||
/// extractOwnedObjectMembers: each function member becomes its own node,
|
||||
/// qualified under the owner (at file scope for one on the global object).
|
||||
/// Every other member runs where the literal is written: under the owner
|
||||
/// for a module-scope declaration (`values_under_owner`, #693), under the
|
||||
/// enclosing scope for a local or an assignment. The literal's shorthand
|
||||
/// members go to the function-as-value capture the same way.
|
||||
fn extract_owned_object_members(&mut self, obj: Node<'t>, row: u32, kind: &'static str, name: &str, values_under_owner: bool, global: bool) {
|
||||
let held = values_under_owner.then(|| self.enter_owner(row, kind, name, global));
|
||||
self.maybe_capture_fn_refs(obj);
|
||||
for i in 0..obj.named_child_count() {
|
||||
let Some(member) = obj.named_child(i) else { continue };
|
||||
if let Some((func, member_name)) = self.owned_member_function(member) {
|
||||
if values_under_owner {
|
||||
self.extract_function(func, Some(member_name));
|
||||
} else {
|
||||
let rest = self.enter_owner(row, kind, name, global);
|
||||
self.extract_function(func, Some(member_name));
|
||||
self.leave_owner(rest);
|
||||
}
|
||||
} else {
|
||||
self.visit_function_body(member);
|
||||
}
|
||||
}
|
||||
if let Some(rest) = held {
|
||||
self.leave_owner(rest);
|
||||
}
|
||||
}
|
||||
|
||||
/// extractLocalObjectOwner: `const api = { load() {…} }` in a function body
|
||||
/// or an IIFE gets the node a module-scope declaration would, with its
|
||||
/// members under it. False when the declarator holds no owned literal.
|
||||
pub(super) fn extract_local_object_owner(&mut self, declarator: Node<'t>) -> bool {
|
||||
let Some(name_node) = declarator.child_by_field_name("name") else { return false };
|
||||
if name_node.kind() != "identifier" {
|
||||
return false;
|
||||
}
|
||||
let Some(obj) = self.owned_object_value(declarator.child_by_field_name("value")) else {
|
||||
return false;
|
||||
};
|
||||
let is_const = declarator.parent().map(|d| self.is_const_decl(d)).unwrap_or(false);
|
||||
let kind: &'static str = if is_const { "constant" } else { "variable" };
|
||||
let name = self.text(name_node).to_string();
|
||||
// A local: never the target of another scope's value read.
|
||||
let extra = Extra {
|
||||
docstring: crate::docstring::preceding_docstring(declarator, self.src),
|
||||
signature: Some(util::init_signature(self.text(obj))),
|
||||
is_exported: Some(false),
|
||||
not_value_target: true,
|
||||
..Extra::default()
|
||||
};
|
||||
let Some(row) = self.create_node(kind, &name, declarator, extra) else { return false };
|
||||
self.extract_variable_type_annotation(declarator, row);
|
||||
self.extract_owned_object_members(obj, row, kind, &name, false, false);
|
||||
true
|
||||
}
|
||||
|
||||
/// extractAssignedObjectOwner: `window.App = {…}` / `App.utils = {…}` /
|
||||
/// `dw_page = {…}`. A plain identifier target is qualified like a
|
||||
/// declaration. A path's owner is named by its last link and qualified by
|
||||
/// the path as written, and so are its members (`window.App::init`); a
|
||||
/// path on the global object is global wherever it is written, so it is
|
||||
/// contained by the file. False (nothing extracted) for any other assignment.
|
||||
pub(super) fn extract_assigned_object_owner(&mut self, node: Node<'t>, module_level: bool) -> bool {
|
||||
let Some(obj) = self.owned_object_value(node.child_by_field_name("right")) else { return false };
|
||||
let Some(path) = self.object_owner_path(node.child_by_field_name("left")) else { return false };
|
||||
// A plain name assigned at module level is a global (`dw_page = {…}`);
|
||||
// in a function it is a local being reassigned.
|
||||
if path.len() == 1 && !module_level {
|
||||
return false;
|
||||
}
|
||||
let qualified_name = (path.len() > 1).then(|| path.join("."));
|
||||
let global = path.len() > 1 && is_host_global_root(&path[0]);
|
||||
let rest = if global { self.stack.split_off(1) } else { Vec::new() };
|
||||
let statement = node.parent().filter(|p| p.kind() == "expression_statement").unwrap_or(node);
|
||||
let name = path.last().cloned().unwrap_or_default();
|
||||
// `App.utils` is read as `App.utils`, never as a bare `utils`.
|
||||
let extra = Extra {
|
||||
docstring: crate::docstring::preceding_docstring(statement, self.src),
|
||||
signature: Some(util::init_signature(self.text(obj))),
|
||||
is_exported: Some(false),
|
||||
qualified_name: qualified_name.clone(),
|
||||
not_value_target: !(path.len() == 1 || global),
|
||||
..Extra::default()
|
||||
};
|
||||
let row = self.create_node("variable", &name, node, extra);
|
||||
self.stack.extend(rest);
|
||||
let Some(row) = row else { return false };
|
||||
if let Some(qualified_name) = qualified_name {
|
||||
self.object_path_owners.insert(row, qualified_name);
|
||||
}
|
||||
self.extract_owned_object_members(obj, row, "variable", &name, false, global);
|
||||
true
|
||||
}
|
||||
|
||||
/// objectOwnerPath: an assignment target's links written as plain names
|
||||
/// (`dw_page` → [dw_page], `App.utils.dom` → [App, utils, dom]), or None
|
||||
/// for anything else — `this.x`, `a[k]`, a call, CommonJS's
|
||||
/// `module.exports` / `exports.x`, or a prototype.
|
||||
fn object_owner_path(&self, left: Option<Node<'t>>) -> Option<Vec<String>> {
|
||||
let mut path: Vec<String> = Vec::new();
|
||||
let mut cur = left?;
|
||||
while cur.kind() == "member_expression" {
|
||||
let property = cur.child_by_field_name("property")?;
|
||||
if property.kind() != "property_identifier" {
|
||||
return None;
|
||||
}
|
||||
path.push(self.text(property).to_string());
|
||||
cur = cur.child_by_field_name("object")?;
|
||||
}
|
||||
if cur.kind() != "identifier" {
|
||||
return None;
|
||||
}
|
||||
let root = self.text(cur);
|
||||
if root == "module" || root == "exports" || path.iter().any(|p| p == "prototype") {
|
||||
return None;
|
||||
}
|
||||
path.push(root.to_string());
|
||||
path.reverse();
|
||||
Some(path)
|
||||
}
|
||||
|
||||
// --- object-literal / store helpers -------------------------------------------------
|
||||
|
||||
pub(super) fn extract_object_literal_functions(&mut self, obj: Node<'t>) {
|
||||
|
||||
@@ -136,6 +136,24 @@ fn is_vue_collection_name(name: &str) -> bool {
|
||||
matches!(name, "actions" | "mutations" | "getters")
|
||||
}
|
||||
|
||||
/// OBJECT_MEMBER_FUNCTION_TYPES (tree-sitter.ts, #2300): the function values an
|
||||
/// owned object literal's member can hold.
|
||||
fn is_object_member_function(kind: &str) -> bool {
|
||||
matches!(kind, "arrow_function" | "function_expression" | "generator_function")
|
||||
}
|
||||
|
||||
/// STATIC_OBJECT_KEY_TYPES (tree-sitter.ts, #2300): keys that name a member — a
|
||||
/// computed `[expr]` key names nothing static.
|
||||
fn is_static_object_key(kind: &str) -> bool {
|
||||
matches!(kind, "property_identifier" | "string" | "number")
|
||||
}
|
||||
|
||||
/// HOST_GLOBAL_ROOTS (tree-sitter.ts, #2300): `window.App = {…}` defines the
|
||||
/// global `App`. (`self` is usually `var self = this` in page code, not the global.)
|
||||
fn is_host_global_root(name: &str) -> bool {
|
||||
matches!(name, "window" | "globalThis")
|
||||
}
|
||||
|
||||
/// One scope-stack entry (TS keeps node IDs; rows are our equivalent).
|
||||
struct Scope {
|
||||
row: u32,
|
||||
@@ -153,6 +171,9 @@ struct Extra {
|
||||
is_async: Option<bool>,
|
||||
is_static: Option<bool>,
|
||||
qualified_name: Option<String>,
|
||||
/// Never a value-read target: a local, or an object hung on a dotted path
|
||||
/// (createNode's `valueTarget = false`, #2300).
|
||||
not_value_target: bool,
|
||||
}
|
||||
|
||||
struct ValueScope<'t> {
|
||||
@@ -185,6 +206,12 @@ pub struct Walker<'t> {
|
||||
fs_value_counts: HashMap<String, u32>,
|
||||
value_scopes: Vec<ValueScope<'t>>,
|
||||
vue_store_file: Option<bool>,
|
||||
/// An object literal hung on a path (`window.App = {…}`) is qualified by
|
||||
/// the path, and so is what it holds (#2300): row → that qualified name.
|
||||
/// Mirrors TreeSitterExtractor.objectPathOwners.
|
||||
object_path_owners: HashMap<u32, String>,
|
||||
/// ownsObjectLiterals: false for a generated or minified bundle (#2300).
|
||||
owns_objects: bool,
|
||||
}
|
||||
|
||||
const MAX_VALUE_REF_NODES: usize = 20_000;
|
||||
@@ -234,6 +261,8 @@ pub fn extract(file_path: &str, source: &str, language: &str) -> Result<EmitOut,
|
||||
fs_value_counts: HashMap::new(),
|
||||
value_scopes: Vec::new(),
|
||||
vue_store_file: None,
|
||||
object_path_owners: HashMap::new(),
|
||||
owns_objects: !util::is_generated_file(file_path) && !util::is_minified_content(file_path, source),
|
||||
};
|
||||
|
||||
// File node (TreeSitterExtractor.extract): id `file:<path>`, endLine =
|
||||
@@ -352,6 +381,7 @@ impl<'t> Walker<'t> {
|
||||
let start_line = self.line_of(node);
|
||||
let column = self.col_of(node);
|
||||
let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column);
|
||||
let value_target = !extra.not_value_target;
|
||||
|
||||
// endLine body extension: resolveBody only (TS/JS: function-valued
|
||||
// class fields whose body nests in the arrow / HOF-wrapped arrow).
|
||||
@@ -369,6 +399,13 @@ impl<'t> Walker<'t> {
|
||||
let qualified = extra.qualified_name.unwrap_or_else(|| {
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
for s in &self.stack {
|
||||
// A path-hung object literal qualifies what it holds by its
|
||||
// path (`window.App::init`), which carries its own scope.
|
||||
if let Some(path) = self.object_path_owners.get(&s.row) {
|
||||
parts.clear();
|
||||
parts.push(path);
|
||||
continue;
|
||||
}
|
||||
if s.kind != "file" {
|
||||
parts.push(&s.name);
|
||||
}
|
||||
@@ -434,18 +471,19 @@ impl<'t> Walker<'t> {
|
||||
if kind == "function" || kind == "method" {
|
||||
self.defined_fn_names.insert(name.to_string());
|
||||
}
|
||||
self.capture_value_ref_scope(kind, name, row, node);
|
||||
self.capture_value_ref_scope(kind, name, row, node, value_target);
|
||||
Some(row)
|
||||
}
|
||||
|
||||
// --- value references (captureValueRefScope / flushValueRefs) --------------
|
||||
|
||||
fn capture_value_ref_scope(&mut self, kind: &'static str, name: &str, row: u32, node: Node<'t>) {
|
||||
fn capture_value_ref_scope(&mut self, kind: &'static str, name: &str, row: u32, node: Node<'t>, value_target: bool) {
|
||||
if !self.variant.value_refs() {
|
||||
return;
|
||||
}
|
||||
let target_kind_ok = kind == "constant" || kind == "variable";
|
||||
if target_kind_ok
|
||||
if value_target
|
||||
&& target_kind_ok
|
||||
&& util::utf16_len(name) >= 3
|
||||
&& util::has_upper_or_underscore().is_match(name)
|
||||
{
|
||||
@@ -640,6 +678,12 @@ impl<'t> Walker<'t> {
|
||||
// Function-as-value capture — independent of the dispatch ladder.
|
||||
self.maybe_capture_fn_refs(node);
|
||||
|
||||
// `window.App = {…}` / `App.utils = {…}`: the object's functions are
|
||||
// the path's members (#2300). Its whole subtree is handled there.
|
||||
if kind == "assignment_expression" && self.extract_assigned_object_owner(node, true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if is_function_type(kind) {
|
||||
// (the isInsideClassLike + methodTypes overlap is Python/Ruby-only)
|
||||
self.extract_function(node, None);
|
||||
@@ -729,6 +773,16 @@ impl<'t> Walker<'t> {
|
||||
let kind = node.kind();
|
||||
self.maybe_capture_fn_refs(node);
|
||||
|
||||
// A named object literal in a body (an IIFE's `const App = {…}`) owns
|
||||
// its function members as one at module scope does, and so does
|
||||
// `window.App = {…}` written in here (#2300). Each handles its subtree.
|
||||
if kind == "variable_declarator" && self.extract_local_object_owner(node) {
|
||||
return;
|
||||
}
|
||||
if kind == "assignment_expression" && self.extract_assigned_object_owner(node, false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if kind == "call_expression" {
|
||||
self.extract_call(node);
|
||||
} else if kind == "new_expression" {
|
||||
|
||||
@@ -95,7 +95,11 @@ export function foldScriptResult(result: ExtractionResult, fold: ScriptFold, sin
|
||||
for (const edge of result.edges) {
|
||||
if (edge.kind === 'contains' && edge.source === blockFile) continue;
|
||||
if (edge.line) edge.line += fold.lineOffset;
|
||||
if (fold.perInstance && runsAsComponent(edge.source) && edge.kind !== 'imports') edge.source = fold.componentNodeId;
|
||||
// What a top-level value DOES is the component's; what it HOLDS stays its
|
||||
// own — `const api = { load() {…} }` keeps `api::load` (#2300).
|
||||
if (fold.perInstance && runsAsComponent(edge.source) && edge.kind !== 'imports' && edge.kind !== 'contains') {
|
||||
edge.source = fold.componentNodeId;
|
||||
}
|
||||
sink.edges.push(edge);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { getParser, detectLanguage, isLanguageSupported, isFileLevelOnlyLanguage } from './grammars';
|
||||
import { NodeIdAllocator, getNodeText, getChildByField, getPrecedingDocstring, BUILTIN_TYPE_NAMES } from './tree-sitter-helpers';
|
||||
import { FN_REF_SPECS, captureFnRefCandidates, type FnRefSpec, type FnRefCandidate } from './function-ref';
|
||||
import { isGeneratedFile } from './generated-detection';
|
||||
import { isGeneratedFile, isMinifiedContent } from './generated-detection';
|
||||
import type { LanguageExtractor, ExtractorContext } from './tree-sitter-types';
|
||||
import { EXTRACTORS } from './languages';
|
||||
import { stripCppTemplateArgs, isCppConstructorDeclaration } from './languages/c-cpp';
|
||||
@@ -456,6 +456,25 @@ const TS_JS_CHAIN_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx']
|
||||
|
||||
/** Receiver node types (TS/JS grammars) that continue a member chain downward. */
|
||||
const TS_JS_CHAIN_RECEIVER_TYPES = new Set(['member_expression', 'subscript_expression']);
|
||||
|
||||
/**
|
||||
* A named object literal owns its function members (#2300): `const api = {
|
||||
* load() {…}, save: () => {…} }`, the same object inside an IIFE or another
|
||||
* function, or one hung on a path — `window.App = {…}`, `App.utils = {…}`.
|
||||
* Each member is a `function` node qualified under its owner (`api::load`,
|
||||
* `window.App::init`), and the calls written in it are its own. These are the
|
||||
* values a member can hold, and the keys that name one — a computed `[expr]`
|
||||
* key names nothing static, so its member's code stays with the owner.
|
||||
* Mirrored in the kernel (tsjs/extractors.rs).
|
||||
*/
|
||||
const OBJECT_MEMBER_FUNCTION_TYPES: ReadonlySet<string> = new Set(['arrow_function', 'function_expression', 'generator_function']);
|
||||
const STATIC_OBJECT_KEY_TYPES: ReadonlySet<string> = new Set(['property_identifier', 'string', 'number']);
|
||||
/**
|
||||
* Path roots that are the global object: `window.App = {…}` defines the global
|
||||
* `App`. (`self` is the global only in a worker; in page code it is far more
|
||||
* often `var self = this`, so `self.x = {…}` stays an ordinary path.)
|
||||
*/
|
||||
const HOST_GLOBAL_ROOTS: ReadonlySet<string> = new Set(['window', 'globalThis']);
|
||||
/** The field of a `this.<field>.<method>()` receiver: public or ES private (#1496, #1987). */
|
||||
const THIS_FIELD_PROPERTY_TYPES = new Set(['property_identifier', 'private_property_identifier']);
|
||||
/** A Swift receiver that is a path of types, `API.PackageController.GetRoute` — two segments or more, each capitalized. */
|
||||
@@ -590,6 +609,12 @@ export class TreeSitterExtractor {
|
||||
private fnRefCandidates: Array<FnRefCandidate & { fromNodeId: string }> = [];
|
||||
// Memoized "is this a Vue store file" verdict (per-extractor = per-file).
|
||||
private vueStoreFile: boolean | null = null;
|
||||
// An object literal hung on a path (`window.App = {…}`, `ns.mod = {…}`) is
|
||||
// named by the path's last link but qualified by the whole path, which its
|
||||
// members are then qualified under (see buildQualifiedName, #2300).
|
||||
private objectPathOwners = new Map<string, string>();
|
||||
// Memoized ownsObjectLiterals verdict (per-extractor = per-file).
|
||||
private ownsObjects: boolean | null = null;
|
||||
// Source already went through the extractor's preParse at the kernel route
|
||||
// point (this instance is the wasm fallback for a kernel-deferred file) —
|
||||
// don't blank it a second time.
|
||||
@@ -920,7 +945,7 @@ export class TreeSitterExtractor {
|
||||
* distinctive names become reference targets; function/method/const/var symbols become reader
|
||||
* scopes whose bodies flushValueRefs scans.
|
||||
*/
|
||||
private captureValueRefScope(kind: NodeKind, name: string, id: string, node: SyntaxNode): void {
|
||||
private captureValueRefScope(kind: NodeKind, name: string, id: string, node: SyntaxNode, valueTarget = true): void {
|
||||
// Pascal targets `constant` only: its extractor emits function PARAMETERS
|
||||
// (`Dest: TBufferWriter`) and class fields (`declField`) as `variable` at the
|
||||
// enclosing scope, which would otherwise become noisy targets (a param name
|
||||
@@ -929,7 +954,7 @@ export class TreeSitterExtractor {
|
||||
// `var` globals are the rare cost; the parameter/field noise dominates.)
|
||||
const targetKindOk =
|
||||
this.language === 'pascal' ? kind === 'constant' : kind === 'constant' || kind === 'variable';
|
||||
if (targetKindOk && name.length >= 3 && /[A-Z_]/.test(name)) {
|
||||
if (valueTarget && targetKindOk && name.length >= 3 && /[A-Z_]/.test(name)) {
|
||||
const parentId = this.nodeStack[this.nodeStack.length - 1];
|
||||
// file-scope OR class/module/struct/enum-scope constants are targets.
|
||||
// Class/module scope matters for languages (Ruby) that keep nearly all
|
||||
@@ -1224,6 +1249,10 @@ export class TreeSitterExtractor {
|
||||
// can never shadow or be shadowed by an extraction branch.
|
||||
this.maybeCaptureFnRefs(node, nodeType);
|
||||
|
||||
// `window.App = {…}` / `App.utils = {…}`: the object's functions are the
|
||||
// path's members (#2300). Its whole subtree is handled there.
|
||||
if (nodeType === 'assignment_expression' && this.extractAssignedObjectOwner(node, true)) return;
|
||||
|
||||
// Check for function declarations
|
||||
// For Python/Ruby, function_definition inside a class should be treated as method
|
||||
if (this.extractor.functionTypes.includes(nodeType)) {
|
||||
@@ -1573,7 +1602,10 @@ export class TreeSitterExtractor {
|
||||
kind: NodeKind,
|
||||
name: string,
|
||||
node: SyntaxNode,
|
||||
extra?: Partial<Node>
|
||||
extra?: Partial<Node>,
|
||||
// False for a value no other scope names by this name — a local, or an
|
||||
// object hung on a dotted path (see captureValueRefScope).
|
||||
valueTarget = true
|
||||
): Node | null {
|
||||
// Skip nodes with empty/missing names — they are not meaningful symbols
|
||||
// and would cause FK violations when edges reference them (see issue #42)
|
||||
@@ -1635,7 +1667,7 @@ export class TreeSitterExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.valueRefsEnabled) this.captureValueRefScope(kind, name, id, node);
|
||||
if (this.valueRefsEnabled) this.captureValueRefScope(kind, name, id, node, valueTarget);
|
||||
|
||||
return newNode;
|
||||
}
|
||||
@@ -1714,6 +1746,13 @@ export class TreeSitterExtractor {
|
||||
// C/C++ enclosing namespaces prefix first (empty for every other language).
|
||||
const parts: string[] = [...this.namespacePrefix];
|
||||
for (const nodeId of this.nodeStack) {
|
||||
// An object literal hung on a path qualifies what it holds by that path
|
||||
// (`window.App::init`), which already carries its own scope.
|
||||
const pathOwner = this.objectPathOwners.get(nodeId);
|
||||
if (pathOwner !== undefined) {
|
||||
parts.splice(0, parts.length, pathOwner);
|
||||
continue;
|
||||
}
|
||||
const node = this.nodes.find((n) => n.id === nodeId);
|
||||
if (node && node.kind !== 'file') {
|
||||
parts.push(node.name);
|
||||
@@ -2562,6 +2601,164 @@ export class TreeSitterExtractor {
|
||||
return walked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a named object literal in this file owns its function members
|
||||
* (#2300). TS/JS only; a generated or minified bundle — named so
|
||||
* (`*.min.js`) or not (a vendored `bundle.js`, by its content) — keeps the
|
||||
* old shape, so its single-letter objects don't become hundreds of nodes.
|
||||
* Mirrored in the kernel (tsjs/extractors.rs owns_object_literals).
|
||||
*/
|
||||
private ownsObjectLiterals(): boolean {
|
||||
this.ownsObjects ??= TS_JS_CHAIN_LANGUAGES.has(this.language) && !isGeneratedFile(this.filePath) &&
|
||||
!isMinifiedContent(this.filePath, this.source);
|
||||
return this.ownsObjects;
|
||||
}
|
||||
|
||||
/**
|
||||
* The function an owned literal's member becomes, named by its static key:
|
||||
* `load() {…}`, `load: () => {…}`, `load: function () {…}`, `load: function* () {…}`.
|
||||
* Null for every other member — a computed key, a value, a shorthand, a spread.
|
||||
*/
|
||||
private ownedMemberFunction(member: SyntaxNode): { fn: SyntaxNode; name: string } | null {
|
||||
if (member.type === 'method_definition') {
|
||||
const key = getChildByField(member, 'name');
|
||||
return key && STATIC_OBJECT_KEY_TYPES.has(key.type) ? { fn: member, name: this.objectKeyName(key) } : null;
|
||||
}
|
||||
if (member.type !== 'pair') return null;
|
||||
const key = getChildByField(member, 'key');
|
||||
const value = getChildByField(member, 'value');
|
||||
if (!key || !value || !STATIC_OBJECT_KEY_TYPES.has(key.type) || !OBJECT_MEMBER_FUNCTION_TYPES.has(value.type)) return null;
|
||||
return { fn: value, name: this.objectKeyName(key) };
|
||||
}
|
||||
|
||||
/** `value` when it is an object literal that owns at least one function member, else null. */
|
||||
private ownedObjectValue(value: SyntaxNode | null): SyntaxNode | null {
|
||||
if (!value || (value.type !== 'object' && value.type !== 'object_expression') || !this.ownsObjectLiterals()) return null;
|
||||
for (let i = 0; i < value.namedChildCount; i++) {
|
||||
const member = value.namedChild(i);
|
||||
if (member && this.ownedMemberFunction(member)) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an owned literal's members: each function member becomes a node
|
||||
* of its own, qualified under the owner (at file scope for one on the global
|
||||
* object, `global`). Every other member — a value, a computed key, a spread —
|
||||
* runs where the literal is written, so it is walked there: under the owner
|
||||
* for a module-scope declaration (`valuesUnderOwner`, its initializer's calls
|
||||
* are the constant's, #693), under the enclosing function for a local or an
|
||||
* assignment, as they were before members had nodes. The literal's shorthand
|
||||
* members (`{ load }`) go to the function-as-value capture the same way.
|
||||
*/
|
||||
private extractOwnedObjectMembers(obj: SyntaxNode, ownerId: string, valuesUnderOwner: boolean, global: boolean): void {
|
||||
const enclosing = this.nodeStack;
|
||||
const ownerStack = [...(global ? enclosing.slice(0, 1) : enclosing), ownerId];
|
||||
const valueStack = valuesUnderOwner ? ownerStack : enclosing;
|
||||
this.nodeStack = valueStack;
|
||||
this.maybeCaptureFnRefs(obj, obj.type);
|
||||
for (let i = 0; i < obj.namedChildCount; i++) {
|
||||
const member = obj.namedChild(i);
|
||||
if (!member) continue;
|
||||
const owned = this.ownedMemberFunction(member);
|
||||
this.nodeStack = owned ? ownerStack : valueStack;
|
||||
if (owned) this.extractFunction(owned.fn, owned.name);
|
||||
else this.visitFunctionBody(member, '');
|
||||
}
|
||||
this.nodeStack = enclosing;
|
||||
}
|
||||
|
||||
/**
|
||||
* `const api = { load() {…} }` written in a function body or an IIFE: the
|
||||
* owner gets the node a module-scope declaration would, and its members are
|
||||
* extracted under it (#2300). Returns false when the declarator holds no
|
||||
* owned literal, leaving it to the walker.
|
||||
*/
|
||||
private extractLocalObjectOwner(declarator: SyntaxNode): boolean {
|
||||
const nameNode = getChildByField(declarator, 'name');
|
||||
if (nameNode?.type !== 'identifier') return false;
|
||||
const obj = this.ownedObjectValue(getChildByField(declarator, 'value'));
|
||||
if (!obj) return false;
|
||||
const declaration = declarator.parent;
|
||||
const isConst = declaration ? (this.extractor?.isConst?.(declaration) ?? false) : false;
|
||||
const initValue = getNodeText(obj, this.source).slice(0, 100);
|
||||
// A local: never the target of another scope's value read.
|
||||
const owner = this.createNode(isConst ? 'constant' : 'variable', getNodeText(nameNode, this.source), declarator, {
|
||||
docstring: this.docstringFor(declarator),
|
||||
signature: `= ${initValue}${initValue.length >= 100 ? '...' : ''}`,
|
||||
isExported: false,
|
||||
}, false);
|
||||
if (!owner) return false;
|
||||
this.extractVariableTypeAnnotation(declarator, owner.id);
|
||||
this.extractOwnedObjectMembers(obj, owner.id, false, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `window.App = {…}` / `App.utils = {…}` / `dw_page = {…}`: an object
|
||||
* literal assigned to a name owns its function members like a declared one
|
||||
* (#2300). Assigned to a plain identifier — an implicit global, or a binding
|
||||
* declared elsewhere — it is qualified like a declaration. Hung on a path,
|
||||
* the owner is named by the path's last link and qualified by the path as
|
||||
* written — `window.App`, `App.utils` — so its members read
|
||||
* `window.App::init`: a property is reached through its object, not through
|
||||
* the function that happened to assign it (Lua's `M.helpers::a` reads the
|
||||
* same way). A path on the global object is global wherever it is written,
|
||||
* so it is also contained by the file. CommonJS export objects and
|
||||
* prototypes are not namespaces and are left alone. Returns false (nothing
|
||||
* extracted) for any other assignment.
|
||||
*/
|
||||
private extractAssignedObjectOwner(node: SyntaxNode, moduleLevel: boolean): boolean {
|
||||
const obj = this.ownedObjectValue(getChildByField(node, 'right'));
|
||||
if (!obj) return false;
|
||||
const path = this.objectOwnerPath(getChildByField(node, 'left'));
|
||||
// A plain name assigned at module level is a global (`dw_page = {…}`); in
|
||||
// a function it is a local being reassigned (`e = {…}` in a bundle's IIFE).
|
||||
if (!path || (path.length === 1 && !moduleLevel)) return false;
|
||||
const qualifiedName = path.length > 1 ? path.join('.') : undefined;
|
||||
const global = path.length > 1 && HOST_GLOBAL_ROOTS.has(path[0]!);
|
||||
const saved = this.nodeStack;
|
||||
if (global) this.nodeStack = saved.slice(0, 1);
|
||||
const initValue = getNodeText(obj, this.source).slice(0, 100);
|
||||
const statement = node.parent?.type === 'expression_statement' ? node.parent : node;
|
||||
// `App.utils` is read as `App.utils`, never as a bare `utils`: only a name
|
||||
// the code reads by that name is a value-read target.
|
||||
const owner = this.createNode('variable', path[path.length - 1]!, node, {
|
||||
docstring: this.docstringFor(statement),
|
||||
signature: `= ${initValue}${initValue.length >= 100 ? '...' : ''}`,
|
||||
isExported: false,
|
||||
...(qualifiedName !== undefined ? { qualifiedName } : {}),
|
||||
}, path.length === 1 || global);
|
||||
this.nodeStack = saved;
|
||||
if (!owner) return false;
|
||||
if (qualifiedName !== undefined) this.objectPathOwners.set(owner.id, qualifiedName);
|
||||
this.extractOwnedObjectMembers(obj, owner.id, false, global);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The links of an assignment target written as plain names — `dw_page` →
|
||||
* `['dw_page']`, `window.App` → `['window', 'App']`, `App.utils.dom` →
|
||||
* `['App', 'utils', 'dom']` — or null for anything else: `this.x`, a
|
||||
* computed `a[k]`, a call, CommonJS's `module.exports` / `exports.x`, or a
|
||||
* prototype.
|
||||
*/
|
||||
private objectOwnerPath(left: SyntaxNode | null): string[] | null {
|
||||
const path: string[] = [];
|
||||
let cur = left;
|
||||
while (cur?.type === 'member_expression') {
|
||||
const property = getChildByField(cur, 'property');
|
||||
if (property?.type !== 'property_identifier') return null;
|
||||
path.unshift(getNodeText(property, this.source));
|
||||
cur = getChildByField(cur, 'object');
|
||||
}
|
||||
if (cur?.type !== 'identifier') return null;
|
||||
const root = getNodeText(cur, this.source);
|
||||
if (root === 'module' || root === 'exports' || path.includes('prototype')) return null;
|
||||
path.unshift(root);
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract function-valued properties of an object literal as named function
|
||||
* nodes (named by their property key). Shared by the two object-of-functions
|
||||
@@ -3054,6 +3251,17 @@ export class TreeSitterExtractor {
|
||||
this.extractVariableTypeAnnotation(child, varNode.id);
|
||||
}
|
||||
|
||||
// A named object literal owns its function members, exported or
|
||||
// not (#2300): `const api = { load() {…} }` gives `api::load`, and
|
||||
// the calls written in a member are the member's. The rest of the
|
||||
// literal is walked under the owner. (The same literal inside a
|
||||
// function or an IIFE goes through visitFunctionBody.)
|
||||
const ownedObject = varNode ? this.ownedObjectValue(valueNode) : null;
|
||||
if (varNode && ownedObject) {
|
||||
this.extractOwnedObjectMembers(ownedObject, varNode.id, true, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Exported const object-of-functions — extract each function-valued
|
||||
// property as a function named by its key + walk its body so its
|
||||
// calls are captured. Two shapes, both keyed on AST shape (not on any
|
||||
@@ -6154,6 +6362,13 @@ export class TreeSitterExtractor {
|
||||
// not in visitNode, so the capture hook must fire in both walkers.
|
||||
this.maybeCaptureFnRefs(node, nodeType);
|
||||
|
||||
// A named object literal in a body — an IIFE's `const App = {…}`, a
|
||||
// handler map in a function — owns its function members as one at
|
||||
// module scope does, and so does `window.App = {…}` written in here
|
||||
// (#2300). Each handles its whole subtree.
|
||||
if (nodeType === 'variable_declarator' && this.extractLocalObjectOwner(node)) return;
|
||||
if (nodeType === 'assignment_expression' && this.extractAssignedObjectOwner(node, false)) return;
|
||||
|
||||
// Rocket route-registration macros (`routes![…]` / `catchers![…]`): the
|
||||
// handler paths live in a raw token tree the call walker can't see.
|
||||
if (nodeType === 'macro_invocation') this.extractRustRouteMacro(node);
|
||||
|
||||
@@ -80,8 +80,10 @@ export const svelteResolver: FrameworkResolver = {
|
||||
};
|
||||
}
|
||||
|
||||
// Pattern 2: Store auto-subscriptions ($storeName)
|
||||
if (ref.referenceName.startsWith('$') && !ref.referenceName.startsWith('$$')) {
|
||||
// Pattern 2: Store auto-subscriptions ($storeName) — a `.svelte`
|
||||
// component's syntax only. In any other script `$n` is a name of its own
|
||||
// (jQuery's `$el`, a compiler's `$n`), never the store `n`.
|
||||
if (ref.language === 'svelte' && ref.referenceName.startsWith('$') && !ref.referenceName.startsWith('$$')) {
|
||||
const storeName = ref.referenceName.substring(1);
|
||||
const storeNode = context.getNodesByName(storeName).find(
|
||||
(n) => n.kind === 'variable' || n.kind === 'constant'
|
||||
|
||||
+11
-6
@@ -21,7 +21,7 @@ import {
|
||||
isImportableKind,
|
||||
CPP_DEFINE_SIGNATURE,
|
||||
} from './types';
|
||||
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead } from './name-matcher';
|
||||
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead } from './name-matcher';
|
||||
import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility';
|
||||
import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor';
|
||||
import { gateSwiftTypeTarget, clearSwiftTypeVisibility, swiftExtendedConformances } from './swift-type-visibility';
|
||||
@@ -1292,7 +1292,8 @@ export class ReferenceResolver {
|
||||
if (isUnresolvedJsMemberCall(ref)) {
|
||||
const root = ref.referenceName.slice(0, ref.referenceName.indexOf('.'));
|
||||
const namespace = this.context.getImportMappings(ref.filePath, ref.language).some((m) => m.isNamespace && m.localName === root);
|
||||
if (!namespace) return null;
|
||||
// `App.utils.fmt()` through a path an object literal was hung on (#2300).
|
||||
if (!namespace) return this.gateLanguage(matchObjectPathCall(ref, this.context), ref);
|
||||
const viaNamespace = this.gateLanguage(resolveViaImport(ref, this.context), ref);
|
||||
const target = viaNamespace ? this.nodeById(viaNamespace.targetNodeId) : null;
|
||||
return target && (target.kind === 'function' || target.kind === 'method' || target.kind === 'class' || target.kind === 'constant' || target.kind === 'variable')
|
||||
@@ -2797,8 +2798,11 @@ export class ReferenceResolver {
|
||||
private resolveThisMemberFnRef(ref: UnresolvedRef): ResolvedRef | null {
|
||||
const member = ref.referenceName.slice('this.'.length);
|
||||
if (!member) return null;
|
||||
const fromNode = this.nodeById(ref.fromNodeId);
|
||||
if (!fromNode) return null;
|
||||
const written = this.nodeById(ref.fromNodeId);
|
||||
if (!written) return null;
|
||||
// Inside an object literal: its own method's `this` is the object, an
|
||||
// arrow member's is the method around the literal (#2300).
|
||||
const fromNode = thisScopeCaller(written, this.context);
|
||||
// A hook declared at class-body level (Ruby `before_action :authenticate`)
|
||||
// attributes to the CLASS node itself — its qualified name IS the scope.
|
||||
// For members, strip the member segment.
|
||||
@@ -2885,8 +2889,9 @@ export class ReferenceResolver {
|
||||
for (const ref of deferred) {
|
||||
await maybeYield();
|
||||
const member = ref.referenceName.slice('this.'.length);
|
||||
const fromNode = this.nodeById(ref.fromNodeId);
|
||||
if (!fromNode || !member) continue;
|
||||
const written = this.nodeById(ref.fromNodeId);
|
||||
if (!written || !member) continue;
|
||||
const fromNode = thisScopeCaller(written, this.context);
|
||||
// Class-body-level hooks (Ruby) attribute to the CLASS node itself.
|
||||
let className: string;
|
||||
if (SUPERTYPE_BEARING_KINDS.has(fromNode.kind) || fromNode.kind === 'module') {
|
||||
|
||||
+569
-24
@@ -1215,6 +1215,17 @@ export function isLexicallyReachable(
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext
|
||||
): boolean {
|
||||
// An object literal's member is reached through its object (#2300): a bare
|
||||
// `load()` or `setTimeout(load)` never means `App.load`, whatever shares
|
||||
// the name — see isObjectMemberReachableByName for the ways that do.
|
||||
if (candidate.kind === 'function' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName) &&
|
||||
!isObjectMemberReachableByName(candidate, ref, context)) return false;
|
||||
// An object hung on a path (`App.utils = {…}`, `$.event.special.swipe =
|
||||
// {…}`) is a property, not a binding: no bare name reaches it — a
|
||||
// `this.swipe()` elsewhere is not that object. One hung on the global object
|
||||
// (`window.App = {…}`) is the global `App`.
|
||||
if (isPathHolder(candidate) && isObjectLiteralOwner(candidate) && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName) &&
|
||||
!HOST_GLOBAL_PREFIX.test(lastQualifiedSegment(candidate.qualifiedName))) return false;
|
||||
// A `val` / `const` declared in a function body is that body's alone —
|
||||
// okio's `(source as Source).buffer()` bound to a `val buffer = Buffer()`
|
||||
// inside another test file's `pipe()`.
|
||||
@@ -6115,9 +6126,10 @@ export function preferCallSiteFile(nodes: Node[], callSiteFile: string): Node[]
|
||||
|
||||
/**
|
||||
* Languages whose object literals declare callable members — `export const
|
||||
* api = { call() {…}, get: () => {…} }` used as a namespace (#1573).
|
||||
* api = { call() {…}, get: () => {…} }` used as a namespace (#1573) — including
|
||||
* the script blocks of single-file components (#2300).
|
||||
*/
|
||||
const OBJECT_LITERAL_LANGUAGES = new Set<string>(['typescript', 'tsx', 'javascript', 'jsx', 'arkts']);
|
||||
const OBJECT_LITERAL_LANGUAGES = new Set<string>(['typescript', 'tsx', 'javascript', 'jsx', 'arkts', 'vue', 'svelte', 'astro']);
|
||||
|
||||
/** True when `inner`'s source range lies within `outer`'s (lines, then columns on a shared line). */
|
||||
function rangeWithin(inner: Node, outer: Node): boolean {
|
||||
@@ -6343,6 +6355,533 @@ export function resolveObjectLiteralBinding(
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Object literals that own their members (#2300) ──────────────────────────────
|
||||
//
|
||||
// A named object literal — `const App = {…}`, the same inside an IIFE or a
|
||||
// function, `App = {…}`, or one hung on a path (`window.App = {…}`, `App.utils
|
||||
// = {…}`) — makes each function member a node qualified under its owner
|
||||
// (`App::init`, `window.App::init`). An owner is a constant or variable whose
|
||||
// initializer is an object literal. A member is reached THROUGH its object:
|
||||
// `App.init()`, `window.App.init()`, `App.utils.fmt()`, a sibling's
|
||||
// `this.init()`, a `const { init } = App` binding — never by its name alone.
|
||||
|
||||
/**
|
||||
* A path on the global object — `window.`, `globalThis.`. (`self` is the
|
||||
* global only in a worker; in page code it is far more often `var self = this`.)
|
||||
*/
|
||||
const HOST_GLOBAL_PREFIX = /^(?:window|globalThis)\./;
|
||||
|
||||
/** The last `::` segment of a qualified name. */
|
||||
function lastQualifiedSegment(qualifiedName: string): string {
|
||||
const cut = qualifiedName.lastIndexOf('::');
|
||||
return cut < 0 ? qualifiedName : qualifiedName.slice(cut + 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* The path a constant or variable is reached by: its name, or — for an object
|
||||
* hung on a path by assignment — that path, any global-object root dropped
|
||||
* (`window.App = {…}` defines the global `App`; `App.utils = {…}` is `App.utils`).
|
||||
*/
|
||||
function holderPath(n: Node): string {
|
||||
const last = lastQualifiedSegment(n.qualifiedName);
|
||||
return last.includes('.') ? last.replace(HOST_GLOBAL_PREFIX, '') : n.name;
|
||||
}
|
||||
|
||||
/** Whether a constant or variable was hung on a dotted path by assignment rather than declared. */
|
||||
function isPathHolder(n: Node): boolean {
|
||||
return lastQualifiedSegment(n.qualifiedName).includes('.');
|
||||
}
|
||||
|
||||
/** Whether `n` holds an object literal: an owner its members can belong to. */
|
||||
function isObjectLiteralOwner(n: Node): boolean {
|
||||
return (n.kind === 'constant' || n.kind === 'variable') && JS_FAMILY.has(n.language) && /^=\s*\{/.test(n.signature ?? '');
|
||||
}
|
||||
|
||||
const OBJECT_OWNER_OF = new WeakMap<ResolutionContext, Map<string, Node | null>>();
|
||||
|
||||
/**
|
||||
* The object literal a function node is a member of — its qualified parent,
|
||||
* an owner in its file whose extent holds it — or null for every other
|
||||
* function: a declaration, a helper nested in a function, a function inside
|
||||
* an initializer that is not an object literal (a module IIFE's own helpers).
|
||||
*/
|
||||
function objectLiteralOwnerOf(n: Node, context: ResolutionContext): Node | null {
|
||||
if (n.kind !== 'function' || !JS_FAMILY.has(n.language)) return null;
|
||||
const cut = n.qualifiedName.lastIndexOf('::');
|
||||
if (cut <= 0) return null;
|
||||
let memo = OBJECT_OWNER_OF.get(context);
|
||||
if (!memo) OBJECT_OWNER_OF.set(context, (memo = new Map()));
|
||||
const hit = memo.get(n.id);
|
||||
if (hit !== undefined) return hit;
|
||||
const parent = n.qualifiedName.slice(0, cut);
|
||||
const owner = context.getNodesByQualifiedName(parent).find((o) =>
|
||||
o.filePath === n.filePath && o.id !== n.id && isObjectLiteralOwner(o) && rangeWithin(n, o)) ?? null;
|
||||
memo.set(n.id, owner);
|
||||
return owner;
|
||||
}
|
||||
|
||||
/** The node a qualified name's parent names, in `n`'s file and holding it. */
|
||||
function qualifiedParentOf(n: Node, context: ResolutionContext): Node | null {
|
||||
const cut = n.qualifiedName.lastIndexOf('::');
|
||||
if (cut <= 0) return null;
|
||||
return context.getNodesByQualifiedName(n.qualifiedName.slice(0, cut))
|
||||
.find((p) => p.filePath === n.filePath && p.id !== n.id && rangeWithin(n, p)) ?? null;
|
||||
}
|
||||
|
||||
/** An arrow function's text: `(a) =>`, `async (a) =>`, `a =>`, `<T>(a: T) =>` — not `name(…) {`, `function …`. */
|
||||
const ARROW_HEAD = /^(?:async\s*)?(?:[(<]|[A-Za-z_$][\w$]*\s*=>)/;
|
||||
|
||||
/** Whether an object literal's member is an arrow function — one that has no `this` of its own. */
|
||||
function isArrowMember(n: Node, context: ResolutionContext): boolean {
|
||||
const line = (context.getFileLines?.(n.filePath) ?? context.readFile(n.filePath)?.split('\n'))?.[n.startLine - 1] ?? '';
|
||||
return ARROW_HEAD.test(line.slice(n.startColumn));
|
||||
}
|
||||
|
||||
const THIS_CALLERS = new WeakMap<ResolutionContext, Map<string, Node>>();
|
||||
|
||||
/**
|
||||
* The caller whose scope `this` belongs to, for code written in `caller`
|
||||
* (#2300). An object literal's own method — `load() {…}`, `load: function () {…}`
|
||||
* — has the object: it is returned as is, so its qualified parent is the
|
||||
* owner. An arrow member has no `this` of its own, and a value written in the
|
||||
* literal runs where the literal is: both take the `this` of the code around
|
||||
* the literal, a class method's in `class App { api() { return { load: () =>
|
||||
* this.x() } } }`. Any other caller is its own scope.
|
||||
*/
|
||||
export function thisScopeCaller(caller: Node, context: ResolutionContext): Node {
|
||||
if (!JS_FAMILY.has(caller.language)) return caller;
|
||||
let memo = THIS_CALLERS.get(context);
|
||||
if (!memo) THIS_CALLERS.set(context, (memo = new Map()));
|
||||
const hit = memo.get(caller.id);
|
||||
if (hit) return hit;
|
||||
let cur = caller;
|
||||
for (let depth = 0; depth < 8; depth++) {
|
||||
const owner = isObjectLiteralOwner(cur) ? cur
|
||||
: objectLiteralOwnerOf(cur, context) !== null && isArrowMember(cur, context) ? objectLiteralOwnerOf(cur, context) : null;
|
||||
if (!owner) break;
|
||||
const up = qualifiedParentOf(owner, context);
|
||||
if (!up) break;
|
||||
cur = up;
|
||||
}
|
||||
memo.set(caller.id, cur);
|
||||
return cur;
|
||||
}
|
||||
|
||||
/** The object literal `this` is inside `caller`, when it is one: the owner of an object's own method. */
|
||||
function thisObjectOf(caller: Node, context: ResolutionContext): Node | null {
|
||||
const scope = thisScopeCaller(caller, context);
|
||||
const owner = objectLiteralOwnerOf(scope, context);
|
||||
return owner && !isArrowMember(scope, context) ? owner : null;
|
||||
}
|
||||
|
||||
/** Whether (line, column) falls inside `n`'s source range. */
|
||||
function positionWithin(line: number, column: number, n: Node): boolean {
|
||||
const end = n.endLine ?? n.startLine;
|
||||
if (line < n.startLine || line > end) return false;
|
||||
if (line === n.startLine && column < n.startColumn) return false;
|
||||
return !(line === end && column >= n.endColumn);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the object-literal lookups read from a JS/TS file (#2300), each part
|
||||
* built on first use: its source with comments and string contents blanked
|
||||
* (offsets kept), its `{…}` blocks, the scopes it binds a name in and the
|
||||
* names it destructures off a path. References arrive file by file, so only
|
||||
* the last few files' are kept: no file is read twice in a row, and a large
|
||||
* project's files are never all held at once (#2334).
|
||||
*/
|
||||
interface JsFileScan {
|
||||
source: string | null;
|
||||
code?: string | null;
|
||||
blocks?: JsBlockIndex | null;
|
||||
bindings?: Map<string, Array<[number, number]>>;
|
||||
destructured?: Map<string, Set<string>>;
|
||||
}
|
||||
|
||||
const JS_FILE_SCANS = new WeakMap<ResolutionContext, Map<string, JsFileScan>>();
|
||||
const JS_FILE_SCANS_KEEP = 32;
|
||||
|
||||
function jsFileScan(filePath: string, context: ResolutionContext): JsFileScan {
|
||||
let files = JS_FILE_SCANS.get(context);
|
||||
if (!files) JS_FILE_SCANS.set(context, (files = new Map()));
|
||||
let scan = files.get(filePath);
|
||||
if (scan) {
|
||||
files.delete(filePath); // re-added below: the most recently read goes last
|
||||
} else {
|
||||
const source = context.readFile(filePath);
|
||||
scan = { source: typeof source === 'string' ? source : null };
|
||||
if (files.size >= JS_FILE_SCANS_KEEP) files.delete(files.keys().next().value!);
|
||||
}
|
||||
files.set(filePath, scan);
|
||||
return scan;
|
||||
}
|
||||
|
||||
function scannedCode(scan: JsFileScan): string | null {
|
||||
if (scan.code === undefined) {
|
||||
scan.code = scan.source === null ? null : blankStringContents(stripCommentsForRegex(scan.source, 'typescript'));
|
||||
}
|
||||
return scan.code;
|
||||
}
|
||||
|
||||
/** A JS/TS file's source with comments and string contents blanked, offsets kept. */
|
||||
function maskedJsSource(filePath: string, context: ResolutionContext): string | null {
|
||||
return scannedCode(jsFileScan(filePath, context));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a reference by NAME alone can mean an object literal's member. Only
|
||||
* three ways write one: the owner's own `this.load()` (a sibling member), a
|
||||
* named function expression calling itself (`load: function load() { load() }`),
|
||||
* and a binding destructured off the owner (`const { load } = App`). A bare
|
||||
* `load()`, a callback `setTimeout(load)`, `window.Other.load()` mean a
|
||||
* function of that name — or nothing.
|
||||
*/
|
||||
function isObjectMemberReachableByName(n: Node, ref: UnresolvedRef, context: ResolutionContext): boolean {
|
||||
const owner = objectLiteralOwnerOf(n, context);
|
||||
if (!owner) return true;
|
||||
if (ref.filePath === n.filePath) {
|
||||
if (ref.referenceKind === 'calls' && positionWithin(ref.line, ref.column, owner) &&
|
||||
bareCallReceiver(ref, context)?.receiver === 'self') {
|
||||
// `this` must be that object: written in one of its own methods, not in an arrow member.
|
||||
const caller = context.getNodeById?.(ref.fromNodeId);
|
||||
if (caller && thisObjectOf(caller, context)?.id === owner.id) return true;
|
||||
}
|
||||
if (positionWithin(ref.line, ref.column, n)) {
|
||||
const line = (context.getFileLines?.(n.filePath) ?? context.readFile(n.filePath)?.split('\n'))?.[n.startLine - 1] ?? '';
|
||||
const self = new RegExp(`^(?:async\\s+)?function\\s*\\*?\\s*${n.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w$])`);
|
||||
if (self.test(line.slice(n.startColumn))) return true;
|
||||
}
|
||||
}
|
||||
return destructuresMember(ref.filePath, holderPath(owner), n.name, context);
|
||||
}
|
||||
|
||||
/** `{ … } = App` / `= window.App.utils`: a pattern destructured off a plain path (the whole right-hand side). */
|
||||
const DESTRUCTURED_OFF_PATH = /\{([^{}]*)\}\s*=\s*(?:(?:window|globalThis)\s*\.\s*)?([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)(?![\w$])(?!\s*[.(\[])/g;
|
||||
/**
|
||||
* A `}` then `=` with only space and comments between — what any such pattern
|
||||
* holds, read off the raw text. Each comment form matches one way only (a
|
||||
* line comment through its newline, a block comment to its first `*\/`), so
|
||||
* a `////////` banner can't make the match backtrack without end.
|
||||
*/
|
||||
const CLOSE_THEN_ASSIGN = /\}(?:\s|\/\*(?:[^*]|\*(?!\/))*\*\/|\/\/[^\n]*\n)*=(?!=)/;
|
||||
|
||||
/**
|
||||
* Whether a file binds `member` by destructuring it off the object at `path`:
|
||||
* `{ load }` or `{ a, load = fallback }` off `App` — not `{ x: load }`, which
|
||||
* binds `load` to `App.x`. The file's patterns are read once, on first use.
|
||||
*/
|
||||
function destructuresMember(filePath: string, path: string, member: string, context: ResolutionContext): boolean {
|
||||
const scan = jsFileScan(filePath, context);
|
||||
if (!scan.destructured) {
|
||||
const paths = new Map<string, Set<string>>();
|
||||
const code = scan.source !== null && CLOSE_THEN_ASSIGN.test(scan.source) ? scannedCode(scan) : null;
|
||||
for (const m of code?.matchAll(DESTRUCTURED_OFF_PATH) ?? []) {
|
||||
const own = [...destructuredKeys(m[1]!)].filter(([name, key]) => name === key).map(([name]) => name);
|
||||
if (own.length === 0) continue;
|
||||
const at = m[2]!.replace(/\s+/g, '');
|
||||
const names = paths.get(at);
|
||||
if (names) for (const name of own) names.add(name);
|
||||
else paths.set(at, new Set(own));
|
||||
}
|
||||
scan.destructured = paths;
|
||||
}
|
||||
return scan.destructured.get(path)?.has(member) ?? false;
|
||||
}
|
||||
|
||||
/** A file's `{…}` blocks: for any offset, the innermost one open there (see jsBlockAt). */
|
||||
interface JsBlockIndex {
|
||||
lineStarts: number[];
|
||||
/** Offsets of every `{` and `}`, in order. */
|
||||
events: number[];
|
||||
/** The innermost block still open just after each event (its `{` offset), -1 at the top level. */
|
||||
open: number[];
|
||||
close: Map<number, number>;
|
||||
length: number;
|
||||
}
|
||||
|
||||
function jsBlockIndex(filePath: string, context: ResolutionContext): JsBlockIndex | null {
|
||||
const scan = jsFileScan(filePath, context);
|
||||
if (scan.blocks === undefined) {
|
||||
const code = scannedCode(scan);
|
||||
scan.blocks = code === null ? null : readJsBlocks(code);
|
||||
}
|
||||
return scan.blocks;
|
||||
}
|
||||
|
||||
function readJsBlocks(code: string): JsBlockIndex {
|
||||
const lineStarts = [0];
|
||||
const events: number[] = [];
|
||||
const open: number[] = [];
|
||||
const close = new Map<number, number>();
|
||||
const stack: number[] = [];
|
||||
for (let i = 0; i < code.length; i++) {
|
||||
const ch = code.charCodeAt(i);
|
||||
if (ch === 10) { lineStarts.push(i + 1); continue; }
|
||||
if (ch === 123) stack.push(i);
|
||||
else if (ch === 125) {
|
||||
const at = stack.pop();
|
||||
if (at !== undefined) close.set(at, i);
|
||||
} else continue;
|
||||
events.push(i);
|
||||
open.push(stack.length > 0 ? stack[stack.length - 1]! : -1);
|
||||
}
|
||||
return { lineStarts, events, open, close, length: code.length };
|
||||
}
|
||||
|
||||
/** The offset of (line, column) in a file. */
|
||||
function jsOffset(index: JsBlockIndex, line: number, column: number): number {
|
||||
return (index.lineStarts[line - 1] ?? index.length) + column;
|
||||
}
|
||||
|
||||
/** The innermost `{…}` holding an offset, as [open, close] — null at the file's top level. */
|
||||
function jsBlockAt(index: JsBlockIndex, offset: number): [number, number] | null {
|
||||
let lo = 0;
|
||||
let hi = index.events.length - 1;
|
||||
let at = -1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (index.events[mid]! < offset) { at = mid; lo = mid + 1; } else hi = mid - 1;
|
||||
}
|
||||
const open = at < 0 ? -1 : index.open[at]!;
|
||||
return open < 0 ? null : [open, index.close.get(open) ?? index.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* The offset ranges a JS/TS file binds `name` in: a declaration's or a
|
||||
* function/class name's enclosing block (the whole file at the top level), a
|
||||
* parameter's function body (an expression-bodied arrow's, to the end of its
|
||||
* enclosing block). The patterns are isLocallyBoundJsName's; only the scope is
|
||||
* added, so one function's parameter does not shadow a global in another.
|
||||
*/
|
||||
function bindingScopes(name: string, filePath: string, context: ResolutionContext): Array<[number, number]> {
|
||||
const scan = jsFileScan(filePath, context);
|
||||
const memo = (scan.bindings ??= new Map());
|
||||
const hit = memo.get(name);
|
||||
if (hit) return hit;
|
||||
const scopes: Array<[number, number]> = [];
|
||||
const code = scannedCode(scan);
|
||||
const index = jsBlockIndex(filePath, context);
|
||||
if (code && index && code.includes(name)) {
|
||||
const blockOf = (at: number): [number, number] => jsBlockAt(index, at) ?? [-1, code.length];
|
||||
// The body a parameter list (ending in `{` or `=>`) opens.
|
||||
const bodyAfter = (start: number, end: number): [number, number] => {
|
||||
if (code[end - 1] === '{') return [end - 1, index.close.get(end - 1) ?? code.length];
|
||||
let i = end;
|
||||
while (i < code.length && /\s/.test(code[i]!)) i++;
|
||||
if (code[i] === '{') return [i, index.close.get(i) ?? code.length];
|
||||
return [start, blockOf(start)[1]];
|
||||
};
|
||||
const { decl, fn, param } = localBindingPatterns(name, 'g');
|
||||
for (const m of code.matchAll(decl)) scopes.push(blockOf(m.index!));
|
||||
for (const m of code.matchAll(fn)) scopes.push(blockOf(m.index!));
|
||||
for (const m of code.matchAll(param)) scopes.push(bodyAfter(m.index!, m.index! + m[0].length));
|
||||
const arrow = new RegExp(`(?<![\\w$.])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=>`, 'g');
|
||||
for (const m of code.matchAll(arrow)) scopes.push(bodyAfter(m.index!, m.index! + m[0].length));
|
||||
}
|
||||
memo.set(name, scopes);
|
||||
return scopes;
|
||||
}
|
||||
|
||||
/** Whether a binding of `name` in the reference's own file encloses the reference. */
|
||||
function bindsNameAt(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean {
|
||||
const index = jsBlockIndex(ref.filePath, context);
|
||||
if (!index) return false;
|
||||
const at = jsOffset(index, ref.line, ref.column);
|
||||
return bindingScopes(name, ref.filePath, context).some(([start, end]) => at > start && at < end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a same-file holder of an object can be seen from: the block it is
|
||||
* declared in (an IIFE's, a function's), or the whole file for one at the top
|
||||
* level — and for a path hung by assignment, which is a property of its root
|
||||
* object wherever that is. Returns the depth to rank by (the block's `{`
|
||||
* offset; -1 for the whole file), or null when the reference cannot see it —
|
||||
* outside its block, or where the calling function binds the root name itself.
|
||||
*/
|
||||
function holderScopeAt(holder: Node, ref: UnresolvedRef, viaGlobal: boolean, context: ResolutionContext): number | null {
|
||||
let depth = -1;
|
||||
// A single-file component's source holds its template and styles too, whose
|
||||
// braces are not blocks: a holder there is read as the script's top level.
|
||||
if (!isPathHolder(holder) && JS_TS.has(holder.language)) {
|
||||
const index = jsBlockIndex(holder.filePath, context);
|
||||
const block = index ? jsBlockAt(index, jsOffset(index, holder.startLine, holder.startColumn)) : null;
|
||||
if (index && block) {
|
||||
const at = jsOffset(index, ref.line, ref.column);
|
||||
if (at <= block[0] || at >= block[1]) return null;
|
||||
depth = block[0];
|
||||
}
|
||||
}
|
||||
const root = viaGlobal ? null : holderPath(holder).split('.')[0]!;
|
||||
const local = root ? jsFunctionLocalScope(root, ref, context) : null;
|
||||
if (local && !(holder.filePath === ref.filePath && holder.startLine >= local.start && holder.startLine <= local.end)) return null;
|
||||
return depth;
|
||||
}
|
||||
|
||||
const CLASSIC_SCRIPTS = new WeakMap<ResolutionContext, Map<string, boolean>>();
|
||||
|
||||
/**
|
||||
* Whether a JS/TS file is a classic script — no `import`, `export`, CommonJS
|
||||
* export or `require(…)` — whose top-level names are globals every other
|
||||
* script on the page shares (#2300).
|
||||
*/
|
||||
function isClassicScript(filePath: string, context: ResolutionContext): boolean {
|
||||
let memo = CLASSIC_SCRIPTS.get(context);
|
||||
if (!memo) CLASSIC_SCRIPTS.set(context, (memo = new Map()));
|
||||
const hit = memo.get(filePath);
|
||||
if (hit !== undefined) return hit;
|
||||
const source = context.readFile(filePath);
|
||||
let classic = false;
|
||||
if (typeof source === 'string' && !/\.mjs$|\.cjs$/i.test(filePath) && !HAS_CJS_EXPORT.test(source)) {
|
||||
const code = maskedJsSource(filePath, context) ?? '';
|
||||
classic = !HAS_IMPORT_STATEMENT.test(code) && !HAS_ESM_EXPORT.test(code) && !/(?:^|[^\w$.])require\s*\(/.test(code);
|
||||
}
|
||||
memo.set(filePath, classic);
|
||||
return classic;
|
||||
}
|
||||
|
||||
const GLOBAL_HOLDERS = new WeakMap<ResolutionContext, Map<string, boolean>>();
|
||||
|
||||
/** Whether a holder in another file is a global: on the global object, or at the top level of a classic script. */
|
||||
function isGlobalHolder(n: Node, context: ResolutionContext): boolean {
|
||||
let memo = GLOBAL_HOLDERS.get(context);
|
||||
if (!memo) GLOBAL_HOLDERS.set(context, (memo = new Map()));
|
||||
const hit = memo.get(n.id);
|
||||
if (hit !== undefined) return hit;
|
||||
let global: boolean;
|
||||
if (HOST_GLOBAL_PREFIX.test(lastQualifiedSegment(n.qualifiedName))) global = true;
|
||||
else if (!JS_TS.has(n.language) || !isClassicScript(n.filePath, context)) global = false;
|
||||
else if (!isPathHolder(n)) {
|
||||
const index = jsBlockIndex(n.filePath, context);
|
||||
global = !!index && jsBlockAt(index, jsOffset(index, n.startLine, n.startColumn)) === null;
|
||||
} else {
|
||||
// `App.utils = {…}` is global when `App` is: declared at a classic script's top level, or on the global object.
|
||||
const root = holderPath(n).split('.')[0]!;
|
||||
global = context.getNodesByName(root).some((r) => (r.kind === 'constant' || r.kind === 'variable') &&
|
||||
JS_FAMILY.has(r.language) && holderPath(r) === root && r.id !== n.id && isGlobalHolder(r, context));
|
||||
}
|
||||
memo.set(n.id, global);
|
||||
return global;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `member` on the object a dotted `path` names, at `ref` (#2300):
|
||||
* `App` for `App.init()` / `window.App.init()`, `App.utils` for
|
||||
* `App.utils.fmt()`. A same-file holder the reference can see comes first, the
|
||||
* innermost; then — when the file neither imports nor binds the path's root —
|
||||
* a global one: `window.App = {…}` anywhere, or a classic script's top-level
|
||||
* `App`. Several equally near holders that all have the member are no answer.
|
||||
* `host` is the global object the call was written on (`window.App.init()`),
|
||||
* null for a bare `App.init()`. Undefined when no holder of the path exists at
|
||||
* all (so a caller can try its other strategies), null when one exists but
|
||||
* none has the member.
|
||||
*/
|
||||
function resolveObjectPathMember(
|
||||
path: string,
|
||||
member: string,
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext,
|
||||
host: string | null,
|
||||
): ResolvedRef | null | undefined {
|
||||
const viaGlobal = host !== null;
|
||||
const tail = path.slice(path.lastIndexOf('.') + 1);
|
||||
const named = context.getNodesByName(tail).filter((n) =>
|
||||
(n.kind === 'constant' || n.kind === 'variable') && OBJECT_LITERAL_LANGUAGES.has(n.language) &&
|
||||
sameLanguageFamily(n.language, ref.language) && holderPath(n) === path);
|
||||
if (named.length === 0) return undefined;
|
||||
const hitOn = (holder: Node): ResolvedRef | null =>
|
||||
resolveObjectLiteralMember(holder, member, ref, context, 0.85, 'instance-method') ??
|
||||
resolveObjectLiteralBinding(holder, member, ref, context);
|
||||
|
||||
const local = named
|
||||
.filter((n) => n.filePath === ref.filePath)
|
||||
.map((n) => ({ n, depth: holderScopeAt(n, ref, viaGlobal, context) }))
|
||||
.filter((e): e is { n: Node; depth: number } => e.depth !== null)
|
||||
.sort((a, b) => b.depth - a.depth);
|
||||
if (local.length > 0) {
|
||||
// The nearest holders only: an outer one is shadowed where an inner one exists.
|
||||
const nearest = local.filter((e) => e.depth === local[0]!.depth);
|
||||
for (const { n } of nearest) {
|
||||
const hit = hitOn(n);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const hits = named
|
||||
.filter((n) => n.filePath !== ref.filePath && isObjectLiteralOwner(n) && isGlobalHolder(n, context))
|
||||
.map((n) => ({ n, hit: hitOn(n) }))
|
||||
.filter((e): e is { n: Node; hit: ResolvedRef } => e.hit !== null);
|
||||
if (hits.length === 0) return null;
|
||||
// A global is what the call names only where nothing nearer binds the name:
|
||||
// the file's own import of it, a parameter or local around the call — or,
|
||||
// for `window.App`, a `window` of its own. (Checked once a global holder has
|
||||
// the member: it reads the calling file.)
|
||||
const root = path.split('.')[0]!;
|
||||
if (host !== null ? bindsNameAt(host, ref, context)
|
||||
: isImportBinding(root, ref, context) || bindsNameAt(root, ref, context)) return null;
|
||||
const targets = new Set(hits.map((e) => e.hit.targetNodeId));
|
||||
if (targets.size === 1) return { ...hits[0]!.hit, confidence: 0.8 };
|
||||
// The same namespace defined twice (a page's copy and a build's): the caller's own directory decides, or nothing does.
|
||||
const dir = ref.filePath.slice(0, ref.filePath.lastIndexOf('/') + 1);
|
||||
const near = hits.filter((e) => e.n.filePath.startsWith(dir) && !e.n.filePath.slice(dir.length).includes('/'));
|
||||
return new Set(near.map((e) => e.hit.targetNodeId)).size === 1 ? { ...near[0]!.hit, confidence: 0.75 } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `a.b.m()` through a namespace an object literal was hung on (`App.utils =
|
||||
* {…}` then `App.utils.fmt()`, #2300). Identifier-rooted chains carry no type
|
||||
* (#1566), so nothing else may resolve them: this answers only when a holder
|
||||
* of exactly that path exists, and is null otherwise.
|
||||
*/
|
||||
export function matchObjectPathCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
||||
if (ref.referenceKind !== 'calls' || !JS_FAMILY.has(ref.language)) return null;
|
||||
const dot = ref.referenceName.lastIndexOf('.');
|
||||
if (dot <= 0) return null;
|
||||
let path = ref.referenceName.slice(0, dot);
|
||||
const host = HOST_GLOBAL_PREFIX.test(path) ? path.slice(0, path.indexOf('.')) : null;
|
||||
if (host !== null) path = path.slice(host.length + 1);
|
||||
if (!path.includes('.') && host === null) return null;
|
||||
return resolveObjectPathMember(path, ref.referenceName.slice(dot + 1), ref, context, host) ?? null;
|
||||
}
|
||||
|
||||
/** `(` after optional space, at `lastIndex`. */
|
||||
const OPEN_PAREN_AT = /\s*\(/y;
|
||||
|
||||
/** `window.App.utils` at the end of a receiver's text → host `window`, path `App.utils`. */
|
||||
const HOST_GLOBAL_RECEIVER =/(?:^|[^\w$.])(window|globalThis)\s*\??\.\s*([A-Za-z_$][\w$]*(?:\s*\??\.\s*[A-Za-z_$][\w$]*)*)$/;
|
||||
|
||||
/**
|
||||
* A call the extractor recorded by its bare name although it was written on
|
||||
* an object (#2300): `window.App.init()` — the `window` chain keeps its bare
|
||||
* name as the project-global escape (#1707) — and `this.render()` inside an
|
||||
* object literal's member. Both name the owner of the member they call, which
|
||||
* is tried first. Undefined when the call is neither, or no such owner has the
|
||||
* member (the bare-name strategies then run as before).
|
||||
*/
|
||||
export function matchCollapsedObjectCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | undefined {
|
||||
if (ref.referenceKind !== 'calls' || !JS_FAMILY.has(ref.language) || !/^[A-Za-z_$][\w$]*$/.test(ref.referenceName)) return undefined;
|
||||
// A call written bare starts with its own name and `(`: no receiver to read.
|
||||
const line = (context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/))?.[ref.line - 1];
|
||||
if (line?.startsWith(ref.referenceName, ref.column)) {
|
||||
OPEN_PAREN_AT.lastIndex = ref.column + ref.referenceName.length;
|
||||
if (OPEN_PAREN_AT.test(line)) return undefined;
|
||||
}
|
||||
const written = bareCallReceiver(ref, context);
|
||||
if (!written) return undefined;
|
||||
if (written.receiver === 'self') {
|
||||
// The object literal `this` is — the owner of the method the call is
|
||||
// written in (an arrow member's `this` is the code around the literal's) —
|
||||
// when it has the member.
|
||||
const caller = context.getNodeById?.(ref.fromNodeId);
|
||||
const owner = caller ? thisObjectOf(caller, context) : null;
|
||||
const hit = owner ? resolveObjectLiteralMember(owner, ref.referenceName, ref, context, 0.85, 'instance-method') : null;
|
||||
return hit ?? undefined;
|
||||
}
|
||||
const host = HOST_GLOBAL_RECEIVER.exec(written.receiver);
|
||||
if (!host) return undefined;
|
||||
return resolveObjectPathMember(host[2]!.replace(/[\s?]/g, ''), ref.referenceName, ref, context, host[1]!) ?? undefined;
|
||||
}
|
||||
|
||||
// Exported for the precedence unit tests (#1079): they assert the
|
||||
// preferredFqn → same-file → matches[0] ordering directly.
|
||||
export function resolveMethodOnType(
|
||||
@@ -7078,6 +7617,11 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
|
||||
SCALA_SUPERS.delete(context);
|
||||
SCALA_IMPORTS.delete(context);
|
||||
ESM_EXPORT_LISTS.delete(context);
|
||||
JS_FILE_SCANS.delete(context);
|
||||
OBJECT_OWNER_OF.delete(context);
|
||||
THIS_CALLERS.delete(context);
|
||||
GLOBAL_HOLDERS.delete(context);
|
||||
CLASSIC_SCRIPTS.delete(context);
|
||||
LUA_LOCALS.delete(context);
|
||||
LUA_MEMBERS.delete(context);
|
||||
JVM_PACKAGES.delete(context);
|
||||
@@ -8342,26 +8886,17 @@ export function matchMethodCall(
|
||||
}
|
||||
|
||||
// Object-literal namespace receiver (#1573): `api.call()` where `api` is a
|
||||
// same-file `const api = { call() {…}, get: () => {…} }`. Its members are
|
||||
// plain functions with bare names inside the constant's extent — no
|
||||
// `Container::member` qualified name — so none of the class-shaped
|
||||
// strategies below can see them (Strategy 3 only considers `method`
|
||||
// kinds) and the call resolved to nothing at all. Same file only: a
|
||||
// cross-file use reaches the same helper through the import path.
|
||||
// `const api = { call() {…}, get: () => {…} }`. Its members are functions
|
||||
// inside the constant's extent, found by containment — none of the
|
||||
// class-shaped strategies below can see them (Strategy 3 only considers
|
||||
// `method` kinds). The holder is the one the call can see: in its own file,
|
||||
// the nearest enclosing declaration (an IIFE's `const App` before the file's);
|
||||
// otherwise a global — `window.App = {…}`, or a classic script's top-level
|
||||
// `App` — when the file doesn't import or bind the name itself (#2300). An
|
||||
// imported holder reaches the same member through the import path.
|
||||
if (dotMatch && !objectOrClass!.includes('.') && OBJECT_LITERAL_LANGUAGES.has(ref.language)) {
|
||||
const literalMatch = nmTimedT('mc-literal', ref, (): ResolvedRef | null => {
|
||||
// Same-file holders only, so the call-site-first ordering is moot.
|
||||
const holders = context.getNodesByName(objectOrClass!).filter(
|
||||
(n) => (n.kind === 'constant' || n.kind === 'variable') && n.filePath === ref.filePath
|
||||
);
|
||||
for (const holder of holders) {
|
||||
const hit =
|
||||
resolveObjectLiteralMember(holder, methodName!, ref, context, 0.85, 'instance-method') ??
|
||||
resolveObjectLiteralBinding(holder, methodName!, ref, context);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const literalMatch = nmTimedT('mc-literal', ref, (): ResolvedRef | null =>
|
||||
resolveObjectPathMember(objectOrClass!, methodName!, ref, context, null) ?? null);
|
||||
if (literalMatch) return literalMatch;
|
||||
}
|
||||
|
||||
@@ -9258,8 +9793,11 @@ function matchTsThisFieldCall(
|
||||
context: ResolutionContext,
|
||||
): ResolvedRef | null {
|
||||
if (!field || field.includes('.')) return null;
|
||||
const caller = context.getNodeById?.(ref.fromNodeId);
|
||||
if (!caller) return null;
|
||||
const written = context.getNodeById?.(ref.fromNodeId);
|
||||
if (!written) return null;
|
||||
// An arrow member of an object literal written in a method has that
|
||||
// method's `this` (#2300).
|
||||
const caller = thisScopeCaller(written, context);
|
||||
const sep = caller.qualifiedName.lastIndexOf('::');
|
||||
if (sep <= 0) return null; // not inside a class
|
||||
const owner = caller.qualifiedName.slice(0, sep).split('::').pop();
|
||||
@@ -10568,7 +11106,14 @@ function matchReferenceInner(
|
||||
}
|
||||
}
|
||||
|
||||
if (isUnresolvedJsMemberCall(ref)) return null;
|
||||
// An identifier-rooted chain has no type to resolve through (#1566) — unless
|
||||
// its receiver is a path an object literal was hung on (#2300).
|
||||
if (isUnresolvedJsMemberCall(ref)) return matchObjectPathCall(ref, context);
|
||||
|
||||
// `window.App.init()` / a sibling's `this.init()`, recorded by the bare
|
||||
// name: the object they are written on comes first (#2300).
|
||||
const collapsed = matchCollapsedObjectCall(ref, context);
|
||||
if (collapsed) return collapsed;
|
||||
|
||||
// A Swift call through a type path (`API.PackageController.GetRoute.query`)
|
||||
// resolves on the type the path names, or not at all: the strategies below
|
||||
|
||||
Reference in New Issue
Block a user