diff --git a/CHANGELOG.md b/CHANGELOG.md index 16c79d3d3..7dcabc1ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 20fc91ef3..3ce4a524d 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -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', ]); }); }); diff --git a/__tests__/js-builtin-method-calls.test.ts b/__tests__/js-builtin-method-calls.test.ts index d2d1f8f4c..d850f3fdf 100644 --- a/__tests__/js-builtin-method-calls.test.ts +++ b/__tests__/js-builtin-method-calls.test.ts @@ -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']); diff --git a/__tests__/js-object-literal-members.test.ts b/__tests__/js-object-literal-members.test.ts new file mode 100644 index 000000000..f24270e39 --- /dev/null +++ b/__tests__/js-object-literal-members.test.ts @@ -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): Promise { + 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 ', + '', + ].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']); + }); +}); diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index b885c747c..05114c9d0 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -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) => { diff --git a/__tests__/route-inline-handler-calls.test.ts b/__tests__/route-inline-handler-calls.test.ts index dc7519a6e..9a006a937 100644 --- a/__tests__/route-inline-handler-calls.test.ts +++ b/__tests__/route-inline-handler-calls.test.ts @@ -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'); }); }); diff --git a/__tests__/ts-this-field-call.test.ts b/__tests__/ts-this-field-call.test.ts index 4a194fb8c..e8fd233e8 100644 --- a/__tests__/ts-this-field-call.test.ts +++ b/__tests__/ts-this-field-call.test.ts @@ -114,9 +114,10 @@ describe('this..() (#1496)', () => { }); it('resolves a field typed `typeof ` 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); }); diff --git a/__tests__/vue-store-extraction.test.ts b/__tests__/vue-store-extraction.test.ts index 9fc2f9eda..2354e6318 100644 --- a/__tests__/vue-store-extraction.test.ts +++ b/__tests__/vue-store-extraction.test.ts @@ -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?.(); }); diff --git a/codegraph-kernel/src/textutil.rs b/codegraph-kernel/src/textutil.rs index d322a1990..69f5aea82 100644 --- a/codegraph-kernel/src/textutil.rs +++ b/codegraph-kernel/src/textutil.rs @@ -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 { let mut out = vec![0usize]; diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index d8d087fc5..d2a152293 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -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 = 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>) -> Option> { + 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 { + 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) { + 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>) -> Option> { + let mut path: Vec = 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>) { diff --git a/codegraph-kernel/src/tsjs/mod.rs b/codegraph-kernel/src/tsjs/mod.rs index bdc88e8ab..2658226ba 100644 --- a/codegraph-kernel/src/tsjs/mod.rs +++ b/codegraph-kernel/src/tsjs/mod.rs @@ -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, is_static: Option, qualified_name: Option, + /// 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, value_scopes: Vec>, vue_store_file: Option, + /// 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, + /// 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`, 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" { diff --git a/src/extraction/sfc-script.ts b/src/extraction/sfc-script.ts index 65a26ae30..b5b608fc0 100644 --- a/src/extraction/sfc-script.ts +++ b/src/extraction/sfc-script.ts @@ -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); } diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 39e93b588..031f200cf 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -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 = new Set(['arrow_function', 'function_expression', 'generator_function']); +const STATIC_OBJECT_KEY_TYPES: ReadonlySet = 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 = new Set(['window', 'globalThis']); /** The field of a `this..()` 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 = []; // 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(); + // 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 + extra?: Partial, + // 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); diff --git a/src/resolution/frameworks/svelte.ts b/src/resolution/frameworks/svelte.ts index 9598f9810..8a8495fc5 100644 --- a/src/resolution/frameworks/svelte.ts +++ b/src/resolution/frameworks/svelte.ts @@ -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' diff --git a/src/resolution/index.ts b/src/resolution/index.ts index b3303c9e2..7364c3e25 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -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') { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index fa867a106..cf151c7aa 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -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(['typescript', 'tsx', 'javascript', 'jsx', 'arkts']); +const OBJECT_LITERAL_LANGUAGES = new Set(['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>(); + +/** + * 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 =>`, `(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>(); + +/** + * 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>; + destructured?: Map>; +} + +const JS_FILE_SCANS = new WeakMap>(); +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>(); + 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; + 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(); + 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(`(?`, '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>(); + +/** + * 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>(); + +/** 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