From 8c46409355d32f7ad2815ed8cc32d2ac649644ca Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Sat, 3 Oct 2026 11:56:34 +0300 Subject: [PATCH] fix(tsjs): extract named object literal members with scoped callers Methods of a JS/TS object literal held in a plain const, declared inside an IIFE, or assigned to window/globalThis/self (`window.App = {...}`) now get their own `Owner::member` definitions, not only those of an `export const`. A call binds to a member only when its receiver is proven to be that literal (its lexical binding, or the host global for `window.X.m()`), and the calls inside a member belong to it. The native kernel and the WASM extractor emit the same nodes, containment metadata and candidates. A `window.X.m()` with no `window.X = {...}` in the index still reaches a same-named free function (#1707), and calls the extractor names by their bare member (`self.m()`, `cls.m()`) keep their targets unless a literal proves the receiver. EXTRACTION_VERSION 27 -> 28. Refs #2300. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + __tests__/expression-receiver-calls.test.ts | 2 +- __tests__/extraction.test.ts | 11 +- __tests__/js-builtin-method-calls.test.ts | 4 +- __tests__/js-named-object-members.test.ts | 299 +++++++++++++++ __tests__/kernel-tsjs-parity.test.ts | 31 +- __tests__/route-inline-handler-calls.test.ts | 2 +- __tests__/ts-chained-receiver.test.ts | 6 +- __tests__/ts-this-field-call.test.ts | 5 +- __tests__/vue-store-extraction.test.ts | 20 +- codegraph-kernel/src/buffers.rs | 10 + codegraph-kernel/src/tsjs/extractors.rs | 119 +++++- codegraph-kernel/src/tsjs/mod.rs | 375 ++++++++++++++++++- src/extraction/extraction-version.ts | 2 +- src/extraction/index.ts | 11 + src/extraction/js-object-bindings.ts | 158 ++++++++ src/extraction/kernel/decode.ts | 2 +- src/extraction/sfc-script.ts | 15 +- src/extraction/tree-sitter.ts | 172 ++++++++- src/resolution/index.ts | 51 ++- src/resolution/js-object-members.ts | 76 ++++ src/resolution/name-matcher.ts | 81 +++- src/resolution/types.ts | 2 + 23 files changed, 1383 insertions(+), 75 deletions(-) create mode 100644 __tests__/js-named-object-members.test.ts create mode 100644 src/extraction/js-object-bindings.ts create mode 100644 src/resolution/js-object-members.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ca319d98..037dc00d42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- In JavaScript and TypeScript, the methods of a named object literal now get their own symbols even when the object isn't exported: a plain `const api = { load() {…} }`, an object declared inside an IIFE, or a namespace hung off the page like `window.App = { load() {…} }`. A call like `api.load()` or `window.App.load()` now reaches that method, and the calls made inside it are its own instead of the object's, so script-tag apps no longer lose most of their code from callers and impact. Re-index JavaScript and TypeScript projects after upgrading. (#2300) + ## [1.6.2] - 2026-10-03 diff --git a/__tests__/expression-receiver-calls.test.ts b/__tests__/expression-receiver-calls.test.ts index e22de1f745..704afe258a 100644 --- a/__tests__/expression-receiver-calls.test.ts +++ b/__tests__/expression-receiver-calls.test.ts @@ -124,7 +124,7 @@ describe('expression receivers', () => { .map((r) => r.referenceName); expect(refs).toEqual([ 'list().map', 'list', 'x.run', 'y.run', 'x.stop', 'getTarget().install', 'getTarget', - 'f', 'run', 'go', 'start', + 'f', 'run', 'go', 'window.Api.start', ]); }); }); diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index c6a522decf..73d9aac138 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(); }; @@ -1190,15 +1190,12 @@ export const exported = { handler: () => target() }; expect(callersOf('load')).toEqual(['constant:eager']); }); - 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. + it('plain and exported object methods own their calls; eager values keep their constant owner', () => { 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 d2d1f8f4c7..d850f3fdff 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-named-object-members.test.ts b/__tests__/js-named-object-members.test.ts new file mode 100644 index 0000000000..b09a3cf913 --- /dev/null +++ b/__tests__/js-named-object-members.test.ts @@ -0,0 +1,299 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { CodeGraph } from '../src'; +import { resetKernelForTests, getKernel } from '../src/extraction/kernel'; + +describe.each(['native', 'wasm'])('named literal ownership (%s)', mode => { + let root: string; + let cg: CodeGraph | undefined; + beforeEach(() => { + vi.stubEnv('CODEGRAPH_KERNEL', mode === 'wasm' ? '0' : '1'); + vi.stubEnv('CODEGRAPH_KERNEL_LANGS', 'all'); + resetKernelForTests(); + if (mode === 'native') expect(getKernel()).not.toBeNull(); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-named-literal-')); + }); + afterEach(() => { + cg?.close(); + cg = undefined; + fs.rmSync(root, { recursive: true, force: true }); + vi.unstubAllEnvs(); + resetKernelForTests(); + }); + function write(file: string, source: string) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), source); + } + async function index(files: Record) { + for (const [file, source] of Object.entries(files)) write(file, source); + cg = await CodeGraph.init(root, { index: true }); + } + const symbol = (name: string) => { + const nodes = cg!.getNodesByQualifiedName(name); + expect(nodes, name).toHaveLength(1); + return nodes[0]!; + }; + const callees = (name: string) => cg!.getOutgoingEdgesFrom([symbol(name).id]).filter(e => e.kind === 'calls').map(e => cg!.getNode(e.target)!); + + it('direct plain/exported members own their calls, while primitive locals and factory noise remain absent', async () => { + await index({ 'objects.js': `function helper() {} +const Api = { read() { helper(); }, close: () => helper(), eager: helper() }; +export const Exported = { save: function () { helper(); } }; +function useApi() { Api.read(); Api.close(); } +function bare() { read(); setTimeout(read); } +const factory = wrap(() => ({ shouldNotExtract() { helper(); } })); +consume({ ephemeral() { helper(); } }); +function local() { const primitive = 1; const data = { x: 1 }; const wrapped = wrap(() => ({ noise() {} })); } +` }); + const owner = symbol('Api'); + const read = symbol('Api::read'); + expect(cg!.getOutgoingEdgesFrom([owner.id]).find(e => e.target === read.id && e.kind === 'contains')?.metadata?.jsObjectMember).toBe(true); + expect(callees('Api::read').map(n => n.name)).toEqual(['helper']); + expect(callees('Api::close').map(n => n.name)).toEqual(['helper']); + expect(callees('Exported::save').map(n => n.name)).toEqual(['helper']); + expect(callees('useApi').map(n => n.id).sort()).toEqual([read.id, symbol('Api::close').id].sort()); + expect(callees('bare')).toEqual([]); + for (const name of ['shouldNotExtract', 'ephemeral', 'primitive', 'data', 'wrapped', 'noise']) expect(cg!.getNodesByName(name)).toEqual([]); + expect(callees('Api').filter(n => n.name === 'helper')).toHaveLength(1); // eager value, not method-body duplication. + }); + + it('same-line sibling IIFEs retain exact lexical ownership after Unicode and reject outside use', async () => { + const source = '/* русский 😀 */ (function(){const Api={run(){left()}};Api.run()})(); (function(){const Api={run(){right()}};Api.run()})(); function left(){} function right(){} function outside(){Api.run()}'; + await index({ 'iife.js': source }); + const members = cg!.getNodesByQualifiedName('Api::run').sort((a, b) => a.startColumn - b.startColumn); + expect(members).toHaveLength(2); + expect(members.map(n => n.startColumn)).toEqual([source.indexOf('run(){left'), source.indexOf('run(){right')]); + const incoming = cg!.getOutgoingEdgesFrom(cg!.getNodesInFile('iife.js').map(n => n.id)).filter(e => e.kind === 'calls' && members.some(n => n.id === e.target)); + expect(incoming.map(e => e.target).sort()).toEqual(members.map(n => n.id).sort()); + expect(cg!.getOutgoingEdgesFrom([members[0]!.id]).filter(e => e.kind === 'calls').map(e => cg!.getNode(e.target)!.name)).toEqual(['left']); + expect(cg!.getOutgoingEdgesFrom([members[1]!.id]).filter(e => e.kind === 'calls').map(e => cg!.getNode(e.target)!.name)).toEqual(['right']); + expect(callees('outside')).toEqual([]); + }); + + it('global namespaces from IIFEs reach their own full paths, never namesakes or shadowed roots', async () => { + await index({ 'namespace.js': `function left(){} function right(){} +(function(){window.A={run(){left()}}})(); +window.B={run(){right()}}; +window.Known={run:left}; function useAlias(){window.Known.run()} +function useA(){window.A.run()} function useB(){window.B.run()} +function missing(){window.Missing.run();window.deep.A.run()} +function shadowed(window){window.A.run();setTimeout(window.A.run)} +const ns={}; ns.mod={run(){left()}}; function useNs(){ns.mod.run()} +function setup(){ns.late={run(){right()}}} function useLate(){ns.late.run()} +` }); + expect(callees('useA').map(n => n.id)).toEqual([symbol('window.A::run').id]); + expect(callees('useB').map(n => n.id)).toEqual([symbol('window.B::run').id]); + expect(callees('useAlias').map(n => n.id)).toEqual([symbol('left').id]); + expect(callees('useNs').map(n => n.id)).toEqual([symbol('ns.mod::run').id]); + expect(callees('useLate').map(n => n.id)).toEqual([symbol('ns.late::run').id]); + expect(callees('missing')).toEqual([]); + expect(callees('shadowed')).toEqual([]); + }); + + it('unknown host namespaces reject class decoys while lexical window holders/typed parameters keep their targets', async () => { + await index({ + 'umd.js': `(function (root, factory) { root.Umd = factory(); })(this, function () { function create(){} return { create }; }); +function useUmd(){window.Umd.create()} +`, + 'hosts.ts': `class Service { ping(){} } +function unknownWindow(){window.Missing.ping()} +function unknownGlobal(){globalThis.Missing.ping()} +function unknownSelf(){self.Missing.ping()} +function typedWindow(window:Service){window.ping()} +`, + 'lexical.ts': `const window={}; window.MyNs={ping(){return 1}}; +function lexicalWindow(){window.MyNs.ping()} +`, + }); + for (const name of ['unknownWindow', 'unknownGlobal', 'unknownSelf']) expect(callees(name)).toEqual([]); + // No `window.Umd = {…}` is indexed: the #1707 window escape still reaches a free function. + expect(callees('useUmd').map(n => n.id)).toEqual([symbol('create').id]); + expect(callees('typedWindow').map(n => n.id)).toEqual([symbol('Service::ping').id]); + expect(callees('lexicalWindow').map(n => n.id)).toEqual([symbol('window.MyNs::ping').id]); + }); + + it('calls named by their bare member keep their usual targets unless a literal proves the receiver', async () => { + await index({ 'bare.ts': `class Node { childForFieldName(name: string): Node | null { return null; } } +function typed(root: Node) { const cls: Node = root; return cls.childForFieldName('body'); } +class K { a() {} b() { const self = this; return () => self.a(); } } +const cls = { pick() {} }; +function literal() { cls.pick(); } +` }); + expect(callees('typed').map(n => n.id)).toEqual([symbol('Node::childForFieldName').id]); + expect(callees('K::b').map(n => n.id)).toEqual([symbol('K::a').id]); + expect(callees('literal').map(n => n.id)).toEqual([symbol('cls::pick').id]); + }); + + it('global shorthand aliases use the initializer binding, including anonymous scopes and unindexed shadows', async () => { + await index({ 'aliases.js': `function ping(){} +window.Root={ping}; function rootAlias(){window.Root.ping()} +(function(ping){window.Parameter={ping}})(external); +(function({ping}){window.Destructured={ping}})(external); +(function(){const ping=external;window.Value={ping}})(); +(function(){window.Closure={ping}})(); +(function(){const ping=()=>1;window.Local={ping}})(); +const Named={run:function ping(){window.Named={ping}}}; +(function ping(){window.NamedIife={ping}})(); +function parameterAlias(){window.Parameter.ping()} +function destructuredAlias(){window.Destructured.ping()} +function valueAlias(){window.Value.ping()} +function closureAlias(){window.Closure.ping()} +function localAlias(){window.Local.ping()} +function namedAlias(){window.Named.ping()} +function namedIifeAlias(){window.NamedIife.ping()} +` }); + const outer = cg!.getNodesByQualifiedName('ping').find(n => n.startLine === 1)!; + const inner = cg!.getNodesByQualifiedName('ping').find(n => n.startLine !== 1)!; + expect(outer).toBeDefined(); + expect(inner).toBeDefined(); + expect(callees('rootAlias').map(n => n.id)).toEqual([outer.id]); + expect(callees('closureAlias').map(n => n.id)).toEqual([outer.id]); + expect(callees('localAlias').map(n => n.id)).toEqual([inner.id]); + for (const name of ['parameterAlias', 'destructuredAlias', 'valueAlias', 'namedAlias', 'namedIifeAlias']) expect(callees(name)).toEqual([]); + }); + + it('nearest literals close missing-member fallback; parameters and nonliteral class values preserve typed calls', async () => { + await index({ 'shadow.ts': `class Service { read(){return 1} } +const Api={read(){return 2},Api(){return Api.read()},window(){return window.A.run()}}; +window.A={run(){return 3}}; +function parameter(Api:Service){return Api.read()} +function nonliteral(){const Api=new Service();return Api.read()} +function block(){const Api={other(){}};Api.read();setTimeout(Api.read);const alias=Api.read} +function hoisted(){if(flag){var Local={read(){return 4}}}Local.read()} +function caught(){try{}catch(Api){Api.read()}} +` }); + expect(callees('parameter').map(n => n.id)).toEqual([symbol('Service::read').id]); + expect(callees('nonliteral').map(n => n.id)).toEqual([symbol('Service::read').id]); + expect(callees('block')).toEqual([]); + expect(callees('caught').some(n => n.id === symbol('Api::read').id)).toBe(false); + expect(callees('hoisted').map(n => n.id)).toEqual([symbol('hoisted::Local::read').id]); + expect(callees('Api::Api').map(n => n.id)).toEqual([symbol('Api::read').id]); + expect(callees('Api::window').map(n => n.id)).toEqual([symbol('window.A::run').id]); + }); + + it('last own property and unknown writes keep their established precision boundary', async () => { + await index({ 'writes.js': `const Replaced={run(){},run:0}; +const SpreadAfter={run(){},...unknown}; +const SpreadBefore={...unknown,run(){}}; +const Computed={run(){},[unknownKey]:0}; +const Nested={box:{run(){}},other(){function run(){};run()}}; +function replaced(){Replaced.run()} function after(){SpreadAfter.run()} +function before(){SpreadBefore.run()} function computed(){Computed.run()} +function nested(){Nested.run()} +` }); + expect(callees('replaced')).toEqual([]); + expect(callees('after')).toEqual([]); + expect(callees('computed')).toEqual([]); + expect(callees('nested')).toEqual([]); + expect(callees('before').map(n => n.id)).toEqual([symbol('SpreadBefore::run').id]); + }); + + it('explicit reassignments/writes and competing globals never keep an obsolete literal target', async () => { + await index({ 'mutations.js': `let Reassigned={read(){oldHelper()}}; Reassigned=makeExternal(); +const Overwritten={read(){oldHelper()},kept(){}}; Overwritten.read=external; +window.A={read(){oldHelper()}}; +function redefine(){window.A={read(){newerHelper()}}} +function reassign(){Reassigned.read()} function overwrite(){Overwritten.read()} +function kept(){Overwritten.kept()} function global(){window.A.read()} +function oldHelper(){} function newerHelper(){} +` }); + expect(callees('reassign')).toEqual([]); + expect(callees('overwrite')).toEqual([]); + expect(callees('global')).toEqual([]); + expect(callees('kept').map(n => n.id)).toEqual([symbol('Overwritten::kept').id]); + }); + + it('named function expressions retain their real self binding; a parameter closes self/outer guesses', async () => { + await index({ 'self.js': `function helper(){} +const Api={read:function helper(){helper()}, opaque:function helper(helper){helper()}, method(){method()}}; +function use(){Api.read();Api.opaque(external)} +` }); + expect(callees('Api::read').every(n => n.id === symbol('Api::read').id)).toBe(true); // self edges may be omitted by the public graph. + expect(callees('Api::opaque')).toEqual([]); + expect(callees('Api::method')).toEqual([]); + }); + + it('script folding keeps containment provenance and offsets for SFC literal holders/members', async () => { + await index({ 'App.vue': ` + +` }); + expect(callees('consume').map(n => n.id)).toEqual([symbol('Api::read').id]); + expect(callees('nested').map(n => n.id)).toEqual([symbol('nested::inner::read').id]); + const owner = symbol('Api'); + const edge = cg!.getOutgoingEdgesFrom(cg!.getNodesInFile('App.vue').map(n => n.id)).find(e => e.kind === 'contains' && e.target === owner.id)!; + expect((edge.metadata?.jsObject as { binding: string }).binding).toBe(`binding:${owner.startLine}:${owner.startColumn}`); + expect(cg!.getOutgoingEdgesFrom([owner.id]).find(e => e.target === symbol('Api::read').id && e.kind === 'contains')?.metadata?.jsObjectMember).toBe(true); + }); + + it('deferred candidates support later declarations and survive target-only sync/reopen', async () => { + await index({ + 'api.js': 'window.B={read(){return 2}};window.A={read(){return 1}};\n', + 'use.js': 'export function consume(){window.A.read()}\n', + 'later.js': 'export const later=()=>Api.read();const Api={read(){return 3}};\n', + }); + expect(callees('later').map(n => n.id)).toEqual([symbol('Api::read').id]); + expect(callees('consume').map(n => n.id)).toEqual([symbol('window.A::read').id]); + const edge = cg!.getOutgoingEdgesFrom([symbol('consume').id]).find(e => e.kind === 'calls')!; + expect(edge.metadata?.refCandidates).toEqual(['window.A::read']); + write('api.js', 'window.A={read(){return 5}};window.B={read(){return 4}};\n'); + await cg!.sync(); + expect(callees('consume').map(n => n.id)).toEqual([symbol('window.A::read').id]); + cg!.close(); + cg = CodeGraph.openSync(root); + expect(callees('consume').map(n => n.id)).toEqual([symbol('window.A::read').id]); + expect(cg.getOutgoingEdgesFrom([symbol('consume').id]).find(e => e.kind === 'calls')?.metadata?.refCandidates).toEqual(['window.A::read']); + write('api.js', 'window.A={renamed(){return 6}};window.B={read(){return 4}};\n'); + await cg.sync(); + expect(callees('consume')).toEqual([]); + const unavailable = cg.getUnresolvedReferencesFrom(symbol('consume').id) + .find(ref => ref.referenceName === 'window.A.read'); + expect(unavailable?.candidates).toEqual(['window.A::read']); + cg.close(); + cg = CodeGraph.openSync(root); + expect(callees('consume')).toEqual([]); + }); + + it('imported literal members keep qualified ownership through same-line target reorder and reopen', async () => { + await index({ + 'api.js': 'export const B={read(){return 2}};export const A={read(){return 1}};\n', + 'use.js': "import { A } from './api';export function consume(){A.read()}\n", + }); + expect(callees('consume').map(n => n.id)).toEqual([symbol('A::read').id]); + const edge = cg!.getOutgoingEdgesFrom([symbol('consume').id]).find(e => e.kind === 'calls')!; + expect(edge.metadata?.refName).toBe('A.read'); + expect(edge.metadata?.refCandidates).toBeUndefined(); + write('api.js', 'export const A={read(){return 5}};export const B={read(){return 4}};\n'); + await cg!.sync(); + expect(callees('consume').map(n => n.id)).toEqual([symbol('A::read').id]); + cg!.close(); + cg = CodeGraph.openSync(root); + expect(callees('consume').map(n => n.id)).toEqual([symbol('A::read').id]); + write('api.js', 'export const A={renamed(){return 6}};export const B={read(){return 4}};\n'); + await cg.sync(); + expect(callees('consume')).toEqual([]); + cg.close(); + cg = CodeGraph.openSync(root); + expect(callees('consume')).toEqual([]); + }); + + it('parallel parse/store/resolution preserves ownership instead of borrowing a same-named file', async () => { + vi.stubEnv('CODEGRAPH_PARSE_WORKERS', '2'); + vi.stubEnv('CODEGRAPH_PARALLEL_RESOLVE_MIN', '0'); + vi.stubEnv('CODEGRAPH_RESOLVE_WORKERS', '2'); + const files: Record = {}; + for (let i = 0; i < 20; i++) files[`file-${i}.js`] = `const Api={run(){return ${i}}};function consume${i}(){Api.run()}`; + await index(files); + for (let i = 0; i < 20; i++) { + const result = callees(`consume${i}`); + expect(result).toHaveLength(1); + expect(result[0]!.filePath).toBe(`file-${i}.js`); + expect(result[0]!.qualifiedName).toBe('Api::run'); + } + }); +}); diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index dd4c8e13ae..17d42c570d 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -96,6 +96,35 @@ describe.skipIf(!kernelBuilt)('kernel TS/JS extraction parity', () => { return viaWasm; } + it.each([ + ['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'], + ] as const)('named literal owners, source scopes and qualified candidates: %s (#2300)', (ext, language) => { + const source = `/* русский 😀 */ const Api={read(){helper()},close:()=>helper()}; +function helper(){} +function use(){Api.read()} +(function(){const Local={read(){helper()}};Local.read();window.A={run(){Local.read()}}})(); +function global(){window.A.run()} +function shadow(window){window.A.run()} +function varScope(){if(flag){var Local={read(){helper()}}}Local.read()} +function mixed(){const Members={run(){helper()}}, data${language === 'typescript' || language === 'tsx' ? ': Foo' : ''}={x:1};Members.run()} +const Rename={read:function recur(){recur()},opaque:function recur(recur){recur()}}; +const Mutable={run(){helper()}};Mutable.run=external;function changed(){Mutable.run()} +function init(){Api.sub={go(){helper()}}} +`; + const result = assertParity(`literal.${ext}`, source, language); + expect(result.nodes.some(node => node.qualifiedName === 'Api::read')).toBe(true); + expect(result.edges.some(edge => edge.metadata?.jsObjectMember === true)).toBe(true); + expect(result.unresolvedReferences.some(ref => ref.referenceName === 'window.A.run' && ref.candidates?.includes('window.A::run'))).toBe(true); + expect(result.unresolvedReferences.some(ref => ref.candidates?.length === 0)).toBe(true); + expect(result.nodes.some(node => node.name === 'data')).toBe(false); + expect(result.nodes.find(node => node.qualifiedName === 'mixed::Members::run')?.kind).toBe('function'); + expect(result.nodes.find(node => node.name === 'go')?.qualifiedName).toBe('Api.sub::go'); + if (language === 'typescript' || language === 'tsx') { + const mixed = result.nodes.find(node => node.qualifiedName === 'mixed')!; + expect(result.unresolvedReferences.filter(ref => ref.referenceName === 'Foo').map(ref => ref.fromNodeId)).toEqual([mixed.id]); + } + }); + it.each([ ['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'], ] as const)('same-line accessors retain distinct identities after Unicode: %s (#1349)', (ext, language) => { @@ -184,7 +213,7 @@ async function exprReceivers(x, y) { .map((r) => r.referenceName)).toEqual([ 'list().map', 'list', 'x.run', ...(typed ? ['x.run', 'y.run', 'x.stop', 'getTarget().install', 'getTarget', 'has'] : []), - 'f', 'run', 'stop', 'go', 'start', + 'f', 'run', 'stop', 'go', 'window.Api.start', ]); }); diff --git a/__tests__/route-inline-handler-calls.test.ts b/__tests__/route-inline-handler-calls.test.ts index dc7519a6e0..d4891ccf17 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')).toEqual(['src/services.ts: userService::lookup']); }); }); diff --git a/__tests__/ts-chained-receiver.test.ts b/__tests__/ts-chained-receiver.test.ts index 6bc5a76cd8..29be66fe40 100644 --- a/__tests__/ts-chained-receiver.test.ts +++ b/__tests__/ts-chained-receiver.test.ts @@ -5,7 +5,8 @@ * symbol shared the name, so a storage wrapper's `get` called itself (#1707). * Those calls stay unresolved, as do untyped identifier chains (#1566); * their qualified source references remain available for effect reporting. The existing - * `window.MyNs.run()` and `this..m()` paths remain outside that guard. + * `window.MyNs.run()` and `this..m()` paths remain outside that guard; a + * `window.Known = { ping }` literal in the index names its own member (#2300). */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -44,6 +45,8 @@ beforeAll(async () => { 'export function viaGlobal(): string {\n' + ' return window.MyNs.ping();\n' + '}\n' + + 'window.Known = { ping };\n' + + 'export function viaKnown(): string { return window.Known.ping(); }\n' + 'export class PingService { ping(): string { return "service"; } }\n' + 'export class Runner {\n' + ' constructor(private svc: PingService) {}\n' + @@ -92,6 +95,7 @@ describe('TS/JS call through a host-global chain (#1707)', () => { it('keeps a chain rooted at a project value — window.MyNs.m() and this..m()', () => { const ping = fn('ping', 'service.ts').id; expect(callTargets(fn('viaGlobal', 'service.ts').id)).toContain(ping); + expect(callTargets(fn('viaKnown', 'service.ts').id)).toEqual([ping]); expect(callTargets(method('Runner::run').id)).toEqual([method('PingService::ping').id]); }); diff --git a/__tests__/ts-this-field-call.test.ts b/__tests__/ts-this-field-call.test.ts index 4a194fb8c1..25d23b7dba 100644 --- a/__tests__/ts-this-field-call.test.ts +++ b/__tests__/ts-this-field-call.test.ts @@ -114,9 +114,8 @@ 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']); + 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 9fc2f9edaf..b2f68dddee 100644 --- a/__tests__/vue-store-extraction.test.ts +++ b/__tests__/vue-store-extraction.test.ts @@ -9,8 +9,8 @@ * - Vuex module: non-exported `const actions = {…}` / `const mutations = {…}`. * - 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. + * Plain named literals also have generic member definitions; their presence + * does not turn dynamic dispatch into a Vue store call. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; @@ -111,9 +111,7 @@ export const useChatStore = defineStore('chat', () => { cg.close?.(); }); - it('does not extract a non-exported `const actions = {…}` outside a Vue store file', 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. + it('plain named actions have generic ownership without synthesizing dynamic Vue dispatch', async () => { fs.writeFileSync( path.join(dir, 'commands.js'), `const actions = { @@ -128,10 +126,14 @@ 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' }, + ]); + const run = cg.getNodesByQualifiedName('run'); + expect(run.map(n => n.kind)).toEqual(['function']); + expect(cg.getOutgoingEdgesFrom(run.map(n => n.id)).filter(e => e.kind === 'calls')).toEqual([]); cg.close?.(); }); diff --git a/codegraph-kernel/src/buffers.rs b/codegraph-kernel/src/buffers.rs index f9dd70d5b0..69e0394a7a 100644 --- a/codegraph-kernel/src/buffers.rs +++ b/codegraph-kernel/src/buffers.rs @@ -75,6 +75,9 @@ pub const META_SIZE: usize = 36; pub const NODE_ROW_SIZE: usize = 96; pub const EDGE_ROW_SIZE: usize = 44; pub const REF_ROW_SIZE: usize = 40; +/// Byte offsets of a ref row's string fields (layout above), for `patch_ref_str`. +pub const REF_REFERENCE_NAME: usize = 16; +pub const REF_CANDIDATES: usize = 24; /// Mirror of NODE_KINDS in src/types.ts — order is the wire contract. pub const NODE_KINDS: [&str; 23] = [ @@ -315,6 +318,13 @@ impl Tables { self.push_ref_flagged(r, 0); } + /// Rewrite one string field of a ref row already pushed at `row_offset` in `refs`. + pub fn patch_ref_str(&mut self, row_offset: usize, field: usize, value: StrRef) { + let at = row_offset + field; + self.refs[at..at + 4].copy_from_slice(&value.0.to_le_bytes()); + self.refs[at + 4..at + 8].copy_from_slice(&value.1.to_le_bytes()); + } + pub fn push_ref_flagged(&mut self, r: &RefRow, flags: u8) { let buf = &mut self.refs; buf.extend_from_slice(&r.from_idx.to_le_bytes()); diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index 6426db3156..aaa5eea024 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -7,6 +7,7 @@ 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, + JsObjectOwner, js_object_metadata, }; use crate::buffers::edge_kind_index; use tree_sitter::Node; @@ -15,6 +16,10 @@ impl<'t> Walker<'t> { // --- extractFunction -------------------------------------------------------- pub(super) fn extract_function(&mut self, node: Node<'t>, name_override: Option) { + self.extract_function_with_metadata(node, name_override, None); + } + + fn extract_function_with_metadata(&mut self, node: Node<'t>, name_override: Option, contains_metadata: Option) { let mut name = name_override .clone() .unwrap_or_else(|| self.extract_name(node)); @@ -50,18 +55,25 @@ impl<'t> Walker<'t> { return; } + let object_member = contains_metadata.is_some(); let extra = Extra { docstring: crate::docstring::preceding_docstring(node, self.src), signature: self.signature_of(node), visibility: self.visibility_of(node), - is_exported: Some(common_js_export || self.is_exported(node)), + is_exported: Some(!object_member && (common_js_export || self.is_exported(node))), is_async: Some(self.is_async(node)), is_static: self.is_static(node), + contains_metadata, ..Extra::default() }; let Some(row) = self.create_node("function", &name, node, extra) else { return; }; + if object_member { + let point = self.js_binding_point(node); + let qualified_name = self.js_qualified_name(&name); + self.js_object_members.entry(point).or_insert(qualified_name); + } self.extract_type_annotations(node, row); self.extract_decorators_for(node, row); @@ -374,6 +386,25 @@ impl<'t> Walker<'t> { } pub(super) fn extract_variable(&mut self, node: Node<'t>) { + self.extract_variable_selection(node, None); + } + + pub(super) fn extract_local_objects(&mut self, node: Node<'t>) -> bool { + if !is_variable_type(node.kind()) { return false; } + let objects: Vec = (0..node.named_child_count()).filter_map(|i| node.named_child(i)).filter(|child| + child.kind() == "variable_declarator" && child.child_by_field_name("value").is_some_and(|value| + matches!(value.kind(), "object" | "object_expression") && self.object_has_inline_functions(value))).collect(); + if objects.is_empty() { return false; } + for child in &objects { self.extract_variable_selection(node, Some(*child)); } + self.scan_fn_ref_subtree(node, 0); + for i in 0..node.named_child_count() { + let Some(child) = node.named_child(i).filter(|n| n.kind() == "variable_declarator") else { continue }; + if !objects.iter().any(|object| object.id() == child.id()) { self.visit_function_body(child); } + } + true + } + + fn extract_variable_selection(&mut self, node: Node<'t>, selected: Option>) { let is_const = self.is_const_decl(node); let kind: &'static str = if is_const { "constant" } else { "variable" }; let docstring = crate::docstring::preceding_docstring(node, self.src); @@ -384,6 +415,7 @@ impl<'t> Walker<'t> { if child.kind() != "variable_declarator" { continue; } + if selected.is_some_and(|n| n.id() != child.id()) { continue; } let Some(name_node) = child.child_by_field_name("name") else { continue }; let value = child.child_by_field_name("value"); @@ -398,6 +430,7 @@ impl<'t> Walker<'t> { continue; } let name = self.text(name_node).to_string(); + let direct_object = value.filter(|v| matches!(v.kind(), "object" | "object_expression")); // Arrow/function/generator values extract as functions, named by the declarator. if let Some(v) = value { @@ -429,6 +462,9 @@ impl<'t> Walker<'t> { } } + let object_info = direct_object.map(|_| self.js_object_info(child, &name, true)); + let contains_metadata = object_info.as_ref().map(js_object_metadata); + let qualified_name = self.js_qualified_name(&name); let var_row = self.create_node( kind, &name, @@ -437,10 +473,14 @@ impl<'t> Walker<'t> { docstring: docstring.clone(), signature: init_signature.clone(), is_exported: Some(is_exported), + contains_metadata, ..Extra::default() }, ); if let Some(row) = var_row { + if let Some(info) = object_info { + self.js_object_owners.push(JsObjectOwner { row, qualified_name, info }); + } self.extract_variable_type_annotation(child, row); } @@ -458,7 +498,7 @@ impl<'t> Walker<'t> { // shape most React Native stores are written in. Mirrors // TreeSitterExtractor.isExportedLater. let extract_object_methods = - (is_exported || self.is_exported_later(&name)) && object_of_fns.is_some() && has_inline_fns; + (direct_object.is_some() || is_exported || self.is_exported_later(&name)) && object_of_fns.is_some() && has_inline_fns; let rtk_endpoints = match value { Some(v) if v.kind() == "call_expression" => self.find_rtk_endpoints_object(v), @@ -508,7 +548,13 @@ impl<'t> Walker<'t> { if extract_object_methods { if let Some(obj) = object_of_fns { - self.extract_object_literal_functions(obj); + if let (Some(_), Some(row)) = (direct_object, var_row) { + self.stack.push(Scope { row, kind, name: name.clone() }); + self.extract_owned_object_literal_functions(obj); + self.stack.pop(); + } else { + self.extract_object_literal_functions(obj); + } } } if let Some(rtk) = rtk_endpoints { @@ -549,6 +595,68 @@ impl<'t> Walker<'t> { // --- object-literal / store helpers ------------------------------------------------- + fn static_object_key(&self, key: Node<'t>) -> Option { + if !matches!(key.kind(), "property_identifier" | "string" | "number") { return None; } + Some(util::object_key_name(self.text(key))) + } + + fn extract_owned_object_literal_functions(&mut self, obj: Node<'t>) { + for i in 0..obj.named_child_count() { + let Some(member) = obj.named_child(i) else { continue }; + if member.kind() == "pair" { + let key = member.child_by_field_name("key").and_then(|key| self.static_object_key(key)); + if let Some(value) = member.child_by_field_name("value") { + if let Some(name) = key.as_ref().filter(|_| matches!(value.kind(), "arrow_function" | "function_expression" | "generator_function")) { + self.extract_function_with_metadata(value, Some(name.clone()), Some("{\"jsObjectMember\":true}".to_string())); + } else if value.kind() == "call_expression" { + let function = value.child_by_field_name("arguments").and_then(|args| args.named_child(0)); + let bound = function.and_then(|function| self.curried_wrapper_bound_name(function)); + if let (Some(function), Some(bound), Some(_)) = (function, bound, key) { + self.extract_function_with_metadata(function, Some(bound), Some("{\"jsObjectMember\":true}".to_string())); + } else { self.visit_function_body(value); } + } else { self.visit_function_body(value); } + } + } else if member.kind() == "method_definition" { + if let Some(name) = member.child_by_field_name("name").and_then(|key| self.static_object_key(key)) { + self.extract_function_with_metadata(member, Some(name), Some("{\"jsObjectMember\":true}".to_string())); + } else if let Some(body) = member.child_by_field_name("body") { self.visit_function_body(body); } + } else if member.kind() == "spread_element" { + self.visit_function_body(member); + } + } + } + + pub(super) fn extract_object_assignment(&mut self, node: Node<'t>) -> bool { + if node.kind() != "assignment_expression" { return false; } + let Some(object) = node.child_by_field_name("right").filter(|n| matches!(n.kind(), "object" | "object_expression")) else { return false }; + let Some(path) = self.js_member_path(node.child_by_field_name("left")).filter(|path| path.contains('.')) else { return false }; + let proof = self.js_object_root(node, path.split('.').next().unwrap_or(&path)); + let root = path.split('.').next().unwrap_or(&path); + let root_owner = self.js_object_owners.iter().find(|owner| owner.info.path == root && owner.info.binding == proof); + let root_info = root_owner.map(|owner| (owner.qualified_name.clone(), owner.info.scope)); + if !proof.starts_with("global:") && root_info.is_none() { return false; } + let mut info = self.js_object_info(node, &path, false); + let qualified_name = if let Some((qualified_name, scope)) = root_info { + info.scope = scope; + format!("{qualified_name}{}", &path[root.len()..]) + } else { path.clone() }; + let contains_metadata = js_object_metadata(&info); + let signature = format!("= {}", util::slice_utf16(self.text(object), 100).0); + let Some(row) = self.create_node("variable", &path, node, Extra { + signature: Some(signature), + is_exported: Some(false), + contains_metadata: Some(contains_metadata), + qualified_name: Some(qualified_name.clone()), + ..Extra::default() + }) else { return false }; + self.js_object_owners.push(JsObjectOwner { row, qualified_name, info }); + self.stack.push(Scope { row, kind: "variable", name: path }); + self.extract_owned_object_literal_functions(object); + self.stack.pop(); + self.scan_fn_ref_subtree(node, 0); + true + } + pub(super) fn extract_object_literal_functions(&mut self, obj: Node<'t>) { for i in 0..obj.named_child_count() { let Some(member) = obj.named_child(i) else { continue }; @@ -646,7 +754,7 @@ impl<'t> Walker<'t> { } if member.kind() == "pair" { if let Some(v) = member.child_by_field_name("value") { - if matches!(v.kind(), "arrow_function" | "function_expression") { + if matches!(v.kind(), "arrow_function" | "function_expression" | "generator_function") { return true; } } @@ -1165,6 +1273,7 @@ impl<'t> Walker<'t> { let func = node .child_by_field_name("function") .or_else(|| node.named_child(0)); + let object_path = self.js_member_path(func); let mut callee_name = String::new(); if let Some(func) = func { @@ -1240,7 +1349,7 @@ impl<'t> Walker<'t> { } if !callee_name.is_empty() { - self.push_call_ref(&callee_name.clone(), node); + self.push_object_call_ref(&callee_name, node, object_path.as_deref()); } } diff --git a/codegraph-kernel/src/tsjs/mod.rs b/codegraph-kernel/src/tsjs/mod.rs index bdc88e8aba..246e82ecfa 100644 --- a/codegraph-kernel/src/tsjs/mod.rs +++ b/codegraph-kernel/src/tsjs/mod.rs @@ -16,7 +16,7 @@ use crate::textutil as util; use crate::buffers::{ build_meta, edge_kind_index, node_kind_index, Arena, BoolFlags, EdgeRow, EmitOut, NodeRow, RefRow, StrRef, Tables, FLAG_IS_ASYNC, FLAG_IS_EXPORTED, FLAG_IS_STATIC, FUNCTION_REF_CODE, - NONE, NONE_STR, + NONE, NONE_STR, REF_CANDIDATES, REF_REFERENCE_NAME, }; use crate::ids; use crate::langs; @@ -93,6 +93,36 @@ fn is_variable_type(kind: &str) -> bool { matches!(kind, "lexical_declaration" | "variable_declaration") } +fn is_js_binding_function(kind: &str) -> bool { + is_function_type(kind) || kind == "method_definition" +} + +fn is_js_binding_scope(kind: &str) -> bool { + matches!(kind, "program" | "statement_block" | "catch_clause" | "for_statement" | "for_in_statement") +} + +fn add_js_binding(bindings: &mut HashMap, name: String, proof: String) { + let proof = if bindings.get(&name).is_some_and(|old| old != &proof) { "unknown".to_string() } else { proof }; + bindings.insert(name, proof); +} + +fn json_string(value: &str) -> String { + let mut result = String::from("\""); + for ch in value.chars() { + match ch { + '"' => result.push_str("\\\""), + '\\' => result.push_str("\\\\"), + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + ch if ch < ' ' => result.push_str(&format!("\\u{:04x}", ch as u32)), + _ => result.push(ch), + } + } + result.push('"'); + result +} + /// LITERAL_RECEIVER_TYPES (tree-sitter.ts) — full set; only a handful occur in /// TS/JS grammars but membership is what the TS code tests. fn is_literal_receiver(kind: &str) -> bool { @@ -153,6 +183,7 @@ struct Extra { is_async: Option, is_static: Option, qualified_name: Option, + contains_metadata: Option, } struct ValueScope<'t> { @@ -161,6 +192,32 @@ struct ValueScope<'t> { name: String, } +#[derive(Clone)] +struct JsObjectInfo { + path: String, + binding: String, + scope: [u32; 4], +} + +struct JsObjectOwner { + row: u32, + qualified_name: String, + info: JsObjectInfo, +} + +struct PendingObjectCall { + row_offset: usize, + path: String, + proof: String, + /// extract_call named the call by its bare member, not by `path`. + collapsed: bool, +} + +fn js_object_metadata(info: &JsObjectInfo) -> String { + format!("{{\"jsObject\":{{\"path\":{},\"binding\":{},\"scope\":[{},{},{},{}]}}}}", json_string(&info.path), json_string(&info.binding), + info.scope[0], info.scope[1], info.scope[2], info.scope[3]) +} + pub struct Walker<'t> { src: &'t str, file_path: &'t str, @@ -185,6 +242,12 @@ pub struct Walker<'t> { fs_value_counts: HashMap, value_scopes: Vec>, vue_store_file: Option, + /// AST lexical binding proofs, including anonymous and same-line scopes. + js_object_scopes: HashMap>, + js_object_owners: Vec, + js_object_members: HashMap, + js_object_writes: Option>>, + pending_object_calls: Vec, } const MAX_VALUE_REF_NODES: usize = 20_000; @@ -234,6 +297,11 @@ pub fn extract(file_path: &str, source: &str, language: &str) -> Result`, endLine = @@ -267,6 +335,7 @@ pub fn extract(file_path: &str, source: &str, language: &str) -> Result Walker<'t> { self.push_ref(self.top_row(), name, edge_kind_index("calls").unwrap(), node); } + fn push_object_call_ref(&mut self, name: &str, node: Node<'t>, path: Option<&str>) { + let Some(path) = path else { + self.push_call_ref(name, node); + return; + }; + let proof = self.js_object_root(node, path.split('.').next().unwrap_or(path)); + if !path.contains('.') { + let candidates = self.js_object_members.get(&proof).map(|name| vec![name.clone()]) + .or_else(|| proof.starts_with("parameter:").then(Vec::new)); + if let Some(candidates) = candidates { + self.push_object_candidates(name, node, &candidates); + } else { self.push_call_ref(name, node); } + return; + } + let proof = if self.js_object_is_written(node, path) { "unknown".to_string() } else { proof }; + if proof == "import" { + self.push_call_ref(name, node); + return; + } + let row_offset = self.tables.refs.len(); + self.push_object_candidates(name, node, &[]); + self.pending_object_calls.push(PendingObjectCall { row_offset, path: path.to_string(), proof, collapsed: name != path }); + } + + fn push_object_candidates(&mut self, name: &str, node: Node, candidates: &[String]) { + let candidates = self.arena.put(&candidates.join("\0")); + let reference_name = self.arena.put(name); + self.tables.push_ref(&RefRow { + from_idx: self.top_row(), + kind: edge_kind_index("calls").unwrap(), + line: self.line_of(node), + column: self.col_of(node), + reference_name, + candidates, + from_id_str: NONE_STR, + }); + } + + fn js_binding_point(&self, node: Node) -> String { + format!("binding:{}:{}", self.line_of(node), self.col_of(node)) + } + + fn js_parameter_point(&self, node: Node) -> String { + format!("parameter:{}:{}", self.line_of(node), self.col_of(node)) + } + + fn js_member_path(&self, node: Option>) -> Option { + stack_guard!(); + let node = node?; + if node.kind() == "identifier" { return Some(self.text(node).to_string()); } + if node.kind() != "member_expression" { return None; } + let object = self.js_member_path(node.child_by_field_name("object"))?; + let property = node.child_by_field_name("property")?; + if property.kind() != "property_identifier" { return None; } + Some(format!("{object}.{}", self.text(property))) + } + + fn js_pattern_names(&self, pattern: Option>) -> Vec { + stack_guard!(); + let Some(pattern) = pattern else { return Vec::new() }; + match pattern.kind() { + "identifier" | "shorthand_property_identifier_pattern" => vec![self.text(pattern).to_string()], + "pair_pattern" => self.js_pattern_names(pattern.child_by_field_name("value")), + "assignment_pattern" | "object_assignment_pattern" => self.js_pattern_names(pattern.child_by_field_name("left")), + "required_parameter" | "optional_parameter" => self.js_pattern_names( + pattern.child_by_field_name("pattern").or_else(|| pattern.child_by_field_name("name")), + ), + "formal_parameters" | "object_pattern" | "array_pattern" | "rest_pattern" => (0..pattern.named_child_count()) + .flat_map(|i| self.js_pattern_names(pattern.named_child(i))).collect(), + _ => Vec::new(), + } + } + + fn collect_js_import_bindings(&self, node: Node<'t>, bindings: &mut HashMap) { + stack_guard!(); + match node.kind() { + "import_specifier" => { + if let Some(name) = node.child_by_field_name("alias").or_else(|| node.child_by_field_name("name")) { + add_js_binding(bindings, self.text(name).to_string(), "import".to_string()); + } + } + "namespace_import" => { + if let Some(name) = node.named_child_count().checked_sub(1).and_then(|i| node.named_child(i)) { + add_js_binding(bindings, self.text(name).to_string(), "import".to_string()); + } + } + "import_clause" => { + for i in 0..node.named_child_count() { + let Some(child) = node.named_child(i) else { continue }; + if child.kind() == "identifier" { + add_js_binding(bindings, self.text(child).to_string(), "import".to_string()); + } else { + self.collect_js_import_bindings(child, bindings); + } + } + } + _ => for i in 0..node.named_child_count() { + if let Some(child) = node.named_child(i) { self.collect_js_import_bindings(child, bindings); } + }, + } + } + + fn collect_js_scope_bindings(&self, node: Node<'t>, direct: bool, bindings: &mut HashMap) { + stack_guard!(); + match node.kind() { + "export_statement" => for i in 0..node.named_child_count() { + if let Some(child) = node.named_child(i) { self.collect_js_scope_bindings(child, direct, bindings); } + }, + "lexical_declaration" | "variable_declaration" => { + if direct || node.kind() == "variable_declaration" { + for i in 0..node.named_child_count() { + let Some(child) = node.named_child(i).filter(|n| n.kind() == "variable_declarator") else { continue }; + for name in self.js_pattern_names(child.child_by_field_name("name")) { + add_js_binding(bindings, name, self.js_binding_point(child)); + } + } + } + } + "import_statement" => self.collect_js_import_bindings(node, bindings), + _ if is_js_binding_function(node.kind()) || node.kind() == "class_declaration" => { + if direct { + if let Some(name) = node.child_by_field_name("name") { + add_js_binding(bindings, self.text(name).to_string(), self.js_binding_point(node)); + } + } + } + _ => for i in 0..node.named_child_count() { + if let Some(child) = node.named_child(i) { self.collect_js_scope_bindings(child, false, bindings); } + }, + } + } + + fn js_scope_binding(&mut self, scope: Node<'t>, name: &str) -> Option { + if !self.js_object_scopes.contains_key(&scope.id()) { + let mut bindings = HashMap::new(); + if scope.kind() == "catch_clause" { + for name in self.js_pattern_names(scope.child_by_field_name("parameter")) { + add_js_binding(&mut bindings, name, self.js_parameter_point(scope)); + } + } else if is_js_binding_function(scope.kind()) { + for name in self.js_pattern_names(scope.child_by_field_name("parameters").or_else(|| scope.child_by_field_name("parameter"))) { + add_js_binding(&mut bindings, name, self.js_parameter_point(scope)); + } + if let Some(name) = scope.child_by_field_name("name").filter(|_| scope.kind() != "method_definition") { + if !bindings.contains_key(self.text(name)) { + add_js_binding(&mut bindings, self.text(name).to_string(), self.js_binding_point(scope)); + } + } + } else { + for i in 0..scope.named_child_count() { + if let Some(child) = scope.named_child(i) { self.collect_js_scope_bindings(child, true, &mut bindings); } + } + } + self.js_object_scopes.insert(scope.id(), bindings); + } + self.js_object_scopes.get(&scope.id()).and_then(|bindings| bindings.get(name)).cloned() + } + + fn js_object_root(&mut self, node: Node<'t>, name: &str) -> String { + let mut parent = node.parent(); + while let Some(scope) = parent { + if is_js_binding_scope(scope.kind()) || is_js_binding_function(scope.kind()) { + if let Some(proof) = self.js_scope_binding(scope, name) { return proof; } + } + parent = scope.parent(); + } + if matches!(name, "window" | "globalThis" | "self") { format!("global:{name}") } else { "unknown".to_string() } + } + + fn collect_js_object_writes(&mut self, node: Node<'t>, writes: &mut HashMap>) { + stack_guard!(); + let assignment = matches!(node.kind(), "assignment_expression" | "augmented_assignment_expression"); + let deletion = node.kind() == "unary_expression" && self.text(node).strip_prefix("delete").is_some_and(|tail| + tail.chars().next().map_or(true, |ch| !ch.is_ascii_alphanumeric() && ch != '_')); + let left = if assignment { node.child_by_field_name("left") } + else if node.kind() == "update_expression" || deletion { node.child_by_field_name("argument") } + else { None }; + if let Some(left) = left { + let written = self.js_member_path(Some(left)); + let literal_namespace = node.kind() == "assignment_expression" && written.as_ref().is_some_and(|path| path.contains('.')) + && node.child_by_field_name("right").is_some_and(|right| matches!(right.kind(), "object" | "object_expression")); + if !literal_namespace { + let mut root = left; + while matches!(root.kind(), "member_expression" | "subscript_expression") { + let Some(inner) = root.child_by_field_name("object") else { break }; + root = inner; + } + if root.kind() == "identifier" { + let name = self.text(root).to_string(); + let proof = self.js_object_root(node, &name); + if !matches!(proof.as_str(), "unknown" | "import") { + let suffix = written.map(|path| path[name.len()..].to_string()).unwrap_or_else(|| "*".to_string()); + writes.entry(proof).or_default().insert(suffix); + } + } + } + } + for i in 0..node.named_child_count() { + if let Some(child) = node.named_child(i) { self.collect_js_object_writes(child, writes); } + } + } + + fn js_object_is_written(&mut self, node: Node<'t>, path: &str) -> bool { + if self.js_object_writes.is_none() { + let mut program = node; + while let Some(parent) = program.parent() { program = parent; } + let mut writes = HashMap::new(); + self.collect_js_object_writes(program, &mut writes); + self.js_object_writes = Some(writes); + } + let name = path.split('.').next().unwrap_or(path); + let suffix = &path[name.len()..]; + let proof = self.js_object_root(node, name); + self.js_object_writes.as_ref().and_then(|writes| writes.get(&proof)).is_some_and(|writes| + writes.iter().any(|write| write == "*" || write.is_empty() || suffix == write || suffix.starts_with(&format!("{write}.")))) + } + + fn js_object_info(&mut self, node: Node<'t>, path: &str, direct_binding: bool) -> JsObjectInfo { + let mut scope = node.parent(); + let hoisted = direct_binding && scope.is_some_and(|scope| scope.kind() == "variable_declaration"); + while let Some(current) = scope { + let boundary = if hoisted { + is_js_binding_function(current.kind()) || current.kind() == "program" + } else { is_js_binding_scope(current.kind()) }; + if current.parent().is_none() || boundary { break; } + scope = current.parent(); + } + let range = if hoisted { + scope.filter(|scope| is_js_binding_function(scope.kind())).and_then(|scope| scope.child_by_field_name("body")).or(scope).unwrap_or(node) + } else { scope.unwrap_or(node) }; + let binding = if direct_binding { self.js_binding_point(node) } else { self.js_object_root(node, path.split('.').next().unwrap_or(path)) }; + JsObjectInfo { path: path.to_string(), binding, + scope: [self.line_of(range), self.col_of(range), range.end_position().row as u32 + 1, self.end_col_of(range)] } + } + + fn finalize_object_call_candidates(&mut self) { + for call in std::mem::take(&mut self.pending_object_calls) { + let Some((receiver, member)) = call.path.rsplit_once('.') else { continue }; + let root = call.path.split('.').next().unwrap_or(&call.path); + let global = call.proof.starts_with("global:"); + let candidates = if global { + vec![format!("{receiver}::{member}")] + } else { + let mut names = Vec::new(); + for owner in self.js_object_owners.iter().filter(|owner| owner.info.path == root && owner.info.binding == call.proof) { + let name = format!("{}{}::{member}", owner.qualified_name, &receiver[root.len()..]); + if !names.contains(&name) { names.push(name); } + } + names + }; + if call.collapsed { + // extract_call named this call by its bare member (`window.X.m()`, + // `self.m()`, `cls.m()`): keep that name and no candidates unless + // the path names a namespace on the host global or a proven literal. + let keep_name = if global { !receiver.contains('.') } else { candidates.is_empty() }; + if keep_name { + self.tables.patch_ref_str(call.row_offset, REF_CANDIDATES, NONE_STR); + continue; + } + let name = self.arena.put(&call.path); + self.tables.patch_ref_str(call.row_offset, REF_REFERENCE_NAME, name); + } + let candidates = self.arena.put(&candidates.join("\0")); + self.tables.patch_ref_str(call.row_offset, REF_CANDIDATES, candidates); + } + } + + fn js_qualified_name(&self, name: &str) -> String { + let mut parts: Vec<&str> = Vec::new(); + for scope in &self.stack { + if scope.kind == "file" { continue; } + if let Some(owner) = self.js_object_owners.iter().find(|owner| owner.row == scope.row) { + parts.clear(); + parts.push(&owner.qualified_name); + } else { + parts.push(&scope.name); + } + } + parts.push(name); + parts.join("::") + } + // --- createNode ----------------------------------------------------------- /// createNode (tree-sitter.ts): id, qualified name from the scope stack, @@ -366,20 +717,7 @@ impl<'t> Walker<'t> { } } - let qualified = extra.qualified_name.unwrap_or_else(|| { - let mut parts: Vec<&str> = Vec::new(); - for s in &self.stack { - if s.kind != "file" { - parts.push(&s.name); - } - } - let mut qn = parts.join("::"); - if !qn.is_empty() { - qn.push_str("::"); - } - qn.push_str(name); - qn - }); + let qualified = extra.qualified_name.unwrap_or_else(|| self.js_qualified_name(name)); let mut flags = BoolFlags::default(); if let Some(v) = extra.is_exported { @@ -397,6 +735,7 @@ impl<'t> Walker<'t> { let id_ref = self.arena.put(&id); let doc_ref = opt_str(&mut self.arena, extra.docstring.as_deref()); let sig_ref = opt_str(&mut self.arena, extra.signature.as_deref()); + let contains_metadata = opt_str(&mut self.arena, extra.contains_metadata.as_deref()); let row = self.tables.push_node(&NodeRow { kind: node_kind_index(kind).unwrap(), visibility: extra.visibility.unwrap_or(0), @@ -425,7 +764,7 @@ impl<'t> Walker<'t> { provenance: 0, line: NONE, column: NONE, - metadata_json: NONE_STR, + metadata_json: contains_metadata, source_id_str: NONE_STR, target_id_str: NONE_STR, }); @@ -640,6 +979,8 @@ impl<'t> Walker<'t> { // Function-as-value capture — independent of the dispatch ladder. self.maybe_capture_fn_refs(node); + if self.extract_object_assignment(node) { return; } + if is_function_type(kind) { // (the isInsideClassLike + methodTypes overlap is Python/Ruby-only) self.extract_function(node, None); @@ -729,6 +1070,8 @@ impl<'t> Walker<'t> { let kind = node.kind(); self.maybe_capture_fn_refs(node); + if self.extract_local_objects(node) || self.extract_object_assignment(node) { return; } + if kind == "call_expression" { self.extract_call(node); } else if kind == "new_expression" { diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts index 3691e95f0a..e41b5f6149 100644 --- a/src/extraction/extraction-version.ts +++ b/src/extraction/extraction-version.ts @@ -21,4 +21,4 @@ * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty * in the product is load-bearing"). */ -export const EXTRACTION_VERSION = 27; +export const EXTRACTION_VERSION = 28; diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 0ca3145be2..b855ae1ead 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -1785,6 +1785,8 @@ function resurrectRefFromDroppedEdge( column: e.column ?? 0, filePath: e.sourceFilePath, language: e.sourceLanguage, + ...(Array.isArray(e.metadata?.refCandidates) && e.metadata.refCandidates.every(candidate => typeof candidate === 'string') + ? { candidates: e.metadata.refCandidates as string[] } : {}), }; } @@ -3162,6 +3164,15 @@ export class ExtractionOrchestrator { const reinserted: Edge[] = []; const resurrected: UnresolvedReference[] = []; for (const e of crossFileIncomingEdges) { + // Literal ownership candidates must be replayed against the edited file; + // retaining a definition identity alone does not retain its value proof. + if (Array.isArray(e.metadata?.refCandidates)) { + const ref = resurrectRefFromDroppedEdge(e); + if (ref) { + resurrected.push(ref); + continue; + } + } const newTargetId = replacementOf.get(e.target); if (newTargetId) { reinserted.push({ source: e.source, target: newTargetId, kind: e.kind, metadata: e.metadata, line: e.line, column: e.column, provenance: e.provenance }); diff --git a/src/extraction/js-object-bindings.ts b/src/extraction/js-object-bindings.ts new file mode 100644 index 0000000000..c96f9e6bd6 --- /dev/null +++ b/src/extraction/js-object-bindings.ts @@ -0,0 +1,158 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getChildByField, getNodeText } from './tree-sitter-helpers'; + +const FUNCTIONS = new Set(['function_declaration', 'generator_function_declaration', 'function_expression', 'generator_function', 'arrow_function', 'method_definition']); +const SCOPES = new Set(['program', 'statement_block', 'catch_clause', 'for_statement', 'for_in_statement']); +const GLOBALS = new Set(['window', 'globalThis', 'self']); + +export interface JsObjectInfo { + path: string; + binding: string; + /** AST lexical provenance: 1-based lines and 0-based UTF-16 columns. */ + scope: [number, number, number, number]; + ownerId?: string; +} + +/** Only static dotted member paths; dynamic subscripts and call-result receivers stay opaque. */ +export function jsMemberPath(node: SyntaxNode | null, source: string): string | null { + if (!node) return null; + if (node.type === 'identifier') return getNodeText(node, source); + if (node.type !== 'member_expression') return null; + const object = jsMemberPath(getChildByField(node, 'object'), source); + const property = getChildByField(node, 'property'); + return object && property?.type === 'property_identifier' ? `${object}.${getNodeText(property, source)}` : null; +} + +/** Per-extraction AST binding oracle, including anonymous IIFEs and same-line scopes. */ +export class JsObjectBindings { + private readonly scopes = new Map>(); + private writes: Map> | null = null; + constructor(private readonly source: string) {} + + private point(node: SyntaxNode): string { + return `binding:${node.startPosition.row + 1}:${node.startPosition.column}`; + } + + private names(pattern: SyntaxNode | null): string[] { + if (!pattern) return []; + if (pattern.type === 'identifier' || pattern.type === 'shorthand_property_identifier_pattern') return [getNodeText(pattern, this.source)]; + if (pattern.type === 'pair_pattern') return this.names(getChildByField(pattern, 'value')); + if (pattern.type === 'assignment_pattern' || pattern.type === 'object_assignment_pattern') return this.names(getChildByField(pattern, 'left')); + if (pattern.type === 'required_parameter' || pattern.type === 'optional_parameter') return this.names(getChildByField(pattern, 'pattern') ?? getChildByField(pattern, 'name')); + if (['formal_parameters', 'object_pattern', 'array_pattern', 'rest_pattern'].includes(pattern.type)) return pattern.namedChildren.flatMap(child => this.names(child)); + return []; + } + + private bindings(scope: SyntaxNode): Map { + const key = `${scope.type}:${scope.startIndex}:${scope.endIndex}`; + const cached = this.scopes.get(key); + if (cached) return cached; + const result = new Map(); + const add = (name: string, proof: string) => { + const old = result.get(name); + result.set(name, old !== undefined && old !== proof ? 'unknown' : proof); + }; + const visit = (node: SyntaxNode, direct: boolean): void => { + if (node.type === 'export_statement') { for (const child of node.namedChildren) visit(child, direct); return; } + if (node.type === 'lexical_declaration' || node.type === 'variable_declaration') { + if (direct || node.type === 'variable_declaration') for (const child of node.namedChildren) { + if (child.type !== 'variable_declarator') continue; + for (const name of this.names(getChildByField(child, 'name'))) add(name, this.point(child)); + } + return; + } + if (FUNCTIONS.has(node.type) || node.type === 'class_declaration') { + const name = getChildByField(node, 'name'); + if (direct && name) add(getNodeText(name, this.source), this.point(node)); + return; // another function's declarations never enter this scope. + } + if (node.type === 'import_statement') { + const collect = (item: SyntaxNode): void => { + if (item.type === 'import_specifier') { + const name = getChildByField(item, 'alias') ?? getChildByField(item, 'name'); + if (name) add(getNodeText(name, this.source), 'import'); + } else if (item.type === 'namespace_import') { + const name = item.namedChildren.at(-1); + if (name) add(getNodeText(name, this.source), 'import'); + } else if (item.type === 'import_clause') { + for (const child of item.namedChildren) { + if (child.type === 'identifier') add(getNodeText(child, this.source), 'import'); + else collect(child); + } + } else for (const child of item.namedChildren) collect(child); + }; + collect(node); + return; + } + for (const child of node.namedChildren) visit(child, false); // function-scoped var hoists through blocks. + }; + if (scope.type === 'catch_clause') { + for (const name of this.names(getChildByField(scope, 'parameter'))) add(name, `parameter:${scope.startPosition.row + 1}:${scope.startPosition.column}`); + } else if (FUNCTIONS.has(scope.type)) { + for (const name of this.names(getChildByField(scope, 'parameters') ?? getChildByField(scope, 'parameter'))) add(name, `parameter:${scope.startPosition.row + 1}:${scope.startPosition.column}`); + const name = scope.type === 'method_definition' ? null : getChildByField(scope, 'name'); + if (name && !result.has(getNodeText(name, this.source))) add(getNodeText(name, this.source), this.point(scope)); + } else for (const child of scope.namedChildren) visit(child, true); + this.scopes.set(key, result); + return result; + } + + root(node: SyntaxNode, name: string): string { + for (let parent = node.parent; parent; parent = parent.parent) { + if (!SCOPES.has(parent.type) && !FUNCTIONS.has(parent.type)) continue; + const proof = this.bindings(parent).get(name); + if (proof !== undefined) return proof; + } + return GLOBALS.has(name) ? `global:${name}` : 'unknown'; + } + + /** Proven explicit writes invalidate literal targets; this does not infer aliases or execution order. */ + isWritten(node: SyntaxNode, path: string): boolean { + if (this.writes === null) { + this.writes = new Map(); + const writes = this.writes; + let program = node; + while (program.parent) program = program.parent; + const visit = (item: SyntaxNode): void => { + const assignment = item.type === 'assignment_expression' || item.type === 'augmented_assignment_expression'; + const deletion = item.type === 'unary_expression' && /^delete\b/.test(getNodeText(item, this.source)); + const left = assignment ? getChildByField(item, 'left') : item.type === 'update_expression' || deletion ? getChildByField(item, 'argument') : null; + if (left) { + const written = jsMemberPath(left, this.source); + const literalNamespace = item.type === 'assignment_expression' && written?.includes('.') && ['object', 'object_expression'].includes(getChildByField(item, 'right')?.type ?? ''); + if (!literalNamespace) { + let root = left; + while (root.type === 'member_expression' || root.type === 'subscript_expression') { + const inner = getChildByField(root, 'object'); + if (!inner) break; + root = inner; + } + if (root.type === 'identifier') { + const name = getNodeText(root, this.source); + const proof = this.root(item, name); + if (proof !== 'unknown' && proof !== 'import') { + const paths = writes.get(proof) ?? new Set(); + paths.add(written === null ? '*' : written.slice(name.length)); + writes.set(proof, paths); + } + } + } + } + for (const child of item.namedChildren) visit(child); + }; + visit(program); + } + const name = path.split('.')[0]!; + const suffix = path.slice(name.length); + return [...(this.writes.get(this.root(node, name)) ?? [])].some(write => write === '*' || write === '' || suffix === write || suffix.startsWith(`${write}.`)); + } + + info(node: SyntaxNode, path: string, directBinding: boolean): JsObjectInfo { + let scope = node.parent; + const hoisted = directBinding && scope?.type === 'variable_declaration'; + while (scope?.parent && (hoisted ? !FUNCTIONS.has(scope.type) && scope.type !== 'program' : !SCOPES.has(scope.type))) scope = scope.parent; + const range = hoisted && scope && FUNCTIONS.has(scope.type) ? getChildByField(scope, 'body') ?? scope : scope ?? node; + return { path, binding: directBinding ? this.point(node) : this.root(node, path.split('.')[0]!), + scope: [range.startPosition.row + 1, range.startPosition.column, range.endPosition.row + 1, range.endPosition.column] }; + } +} diff --git a/src/extraction/kernel/decode.ts b/src/extraction/kernel/decode.ts index 6111733733..b113f09618 100644 --- a/src/extraction/kernel/decode.ts +++ b/src/extraction/kernel/decode.ts @@ -46,7 +46,7 @@ function str(arena: Buffer, row: Buffer, at: number): string | undefined { /** NUL-joined list field; undefined when absent. */ function strList(arena: Buffer, row: Buffer, at: number): string[] | undefined { const joined = str(arena, row, at); - return joined === undefined ? undefined : joined.split('\0'); + return joined === undefined ? undefined : joined === '' ? [] : joined.split('\0'); } /** Tri-state boolean from a (present, value) bit pair. */ diff --git a/src/extraction/sfc-script.ts b/src/extraction/sfc-script.ts index 65a26ae30b..a4e53ee589 100644 --- a/src/extraction/sfc-script.ts +++ b/src/extraction/sfc-script.ts @@ -65,6 +65,16 @@ const INSTANCE_REF_KINDS: ReadonlySet = new Set(['calls', 'references', export function foldScriptResult(result: ExtractionResult, fold: ScriptFold, sink: ScriptSink): void { const blockFile = `file:${fold.filePath}`; + const topMetadata = new Map(); + for (const edge of result.edges) { + const object = edge.metadata?.jsObject; + if (object && typeof object === 'object') { + const info = object as { binding?: string; scope?: number[] }; + if (typeof info.binding === 'string') info.binding = info.binding.replace(/^binding:(\d+):/, (_, line: string) => `binding:${Number(line) + fold.lineOffset}:`); + if (Array.isArray(info.scope) && info.scope.length === 4) info.scope = info.scope.map((value, i) => i === 0 || i === 2 ? value + fold.lineOffset : value); + } + if (edge.kind === 'contains' && edge.source === blockFile) topMetadata.set(edge.target, edge.metadata); + } // What already has a parent inside the block — a method's class, a nested // function's function. The block's file holding something is not a parent: // the component is, now. @@ -89,13 +99,14 @@ export function foldScriptResult(result: ExtractionResult, fold: ScriptFold, sin node.endLine += fold.lineOffset; node.language = fold.language; sink.nodes.push(node); - if (!parented.has(node.id)) sink.edges.push({ source: fold.componentNodeId, target: node.id, kind: 'contains' }); + if (!parented.has(node.id)) sink.edges.push({ source: fold.componentNodeId, target: node.id, kind: 'contains', + ...(topMetadata.get(node.id) !== undefined ? { metadata: topMetadata.get(node.id) } : {}) }); } 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; + 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 5c27523acc..73eba37f55 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -33,6 +33,7 @@ import { MyBatisExtractor } from './mybatis-extractor'; import { CfmlExtractor } from './cfml-extractor'; import { tryKernelExtract, takeDeferredPreParse } from './kernel'; import { commonJsRequireRefs } from './commonjs-requires'; +import { JsObjectBindings, jsMemberPath, type JsObjectInfo } from './js-object-bindings'; import { getAllFrameworkResolvers, getApplicableFrameworks, @@ -522,6 +523,12 @@ export class TreeSitterExtractor { private errors: ExtractionError[] = []; private extractor: LanguageExtractor | null = null; private nodeStack: string[] = []; // Stack of parent node IDs + private jsObjectBindings: JsObjectBindings | null = null; + private jsObjectOwners: Array<{ node: Node; info: JsObjectInfo }> = []; + private jsObjectOwnerIds = new Set(); + /** An owned member's binding point → its qualified name (first wins, as in the kernel). */ + private jsObjectMembers = new Map(); + private jsObjectCalls: Array<{ ref: UnresolvedReference; path: string; proof: string }> = []; // C/C++ enclosing `namespace ns { … }` names, prepended to every contained // symbol's qualifiedName (see visitNode). Prefix-only by design — no // namespace NODE is created: `namespace cutlass {` opens in thousands of @@ -649,6 +656,7 @@ export class TreeSitterExtractor { if (packageNodeId) this.nodeStack.push(packageNodeId); this.visitNode(this.tree.rootNode); + this.flushJsObjectCalls(); // Gate + flush function-as-value candidates (#756) while the file's // nodes and import refs are complete and the file node is still pushed. @@ -1078,6 +1086,7 @@ export class TreeSitterExtractor { const nodeType = node.type; let skipChildren = false; + if (this.extractJsObjectAssignment(node)) return; // Language-specific custom visitor hook if (this.extractor.visitNode) { @@ -1655,13 +1664,94 @@ export class TreeSitterExtractor { for (const nodeId of this.nodeStack) { const node = this.nodes.find((n) => n.id === nodeId); if (node && node.kind !== 'file') { - parts.push(node.name); + // An object-literal owner qualifies what it holds by its own qualified + // name, which differs from the stack path for `ns.mod = {…}` written + // inside a function (mirrors the kernel's js_qualified_name). + if (this.jsObjectOwnerIds.has(node.id)) parts.splice(0, parts.length, node.qualifiedName); + else parts.push(node.name); } } parts.push(name); return parts.join('::'); } + private isJsObjectLanguage(): boolean { + return ['typescript', 'tsx', 'javascript', 'jsx'].includes(this.language); + } + + private jsObjects(): JsObjectBindings { + return this.jsObjectBindings ??= new JsObjectBindings(this.source); + } + + private markJsContainment(node: Node, metadata: Record): void { + for (let i = this.edges.length - 1; i >= 0; i--) { + const edge = this.edges[i]!; + if (edge.kind === 'contains' && edge.target === node.id) { + edge.metadata = { ...edge.metadata, ...metadata }; + return; + } + } + } + + private markJsObjectOwner(node: Node, info: JsObjectInfo): void { + this.markJsContainment(node, { jsObject: info }); + this.jsObjectOwners.push({ node, info }); + this.jsObjectOwnerIds.add(node.id); + } + + private flushJsObjectCalls(): void { + for (const { ref, path, proof } of this.jsObjectCalls) { + if (proof === 'import') continue; + const split = path.lastIndexOf('.'); + const receiver = path.slice(0, split); + const member = path.slice(split + 1); + const root = receiver.split('.')[0]!; + const global = proof.startsWith('global:'); + let candidates: string[]; + if (global) candidates = [`${receiver}::${member}`]; + else { + const owners = this.jsObjectOwners.filter(entry => entry.info.path === root && entry.info.binding === proof); + candidates = [...new Set(owners.map(entry => `${entry.node.qualifiedName}${receiver.slice(root.length)}::${member}`))]; + } + if (ref.referenceName !== path) { + // extractCall named this call by its bare member (`window.X.m()`, + // `self.m()`, `cls.m()`): keep that name and no candidates unless the + // path names a namespace on the host global or a proven literal. + if (global ? !receiver.includes('.') : candidates.length === 0) continue; + ref.referenceName = path; + } + ref.candidates = candidates; + } + } + + /** A literal assignment names a namespace only through a proven lexical/global root. */ + private extractJsObjectAssignment(node: SyntaxNode): boolean { + if (!this.isJsObjectLanguage() || node.type !== 'assignment_expression') return false; + const value = getChildByField(node, 'right'); + const path = jsMemberPath(getChildByField(node, 'left'), this.source); + if (!path?.includes('.') || !value || !['object', 'object_expression'].includes(value.type)) return false; + const info = this.jsObjects().info(node, path, false); + const root = path.split('.')[0]!; + const rootOwner = this.jsObjectOwners.find(entry => entry.info.path === root && entry.info.binding === info.binding); + if (!info.binding.startsWith('global:') && !rootOwner) return false; + if (rootOwner) info.scope = rootOwner.info.scope; + const owner = this.createNode('variable', path, node, { + signature: `= ${getNodeText(value, this.source).slice(0, 100)}`, + isExported: false, + qualifiedName: info.binding.startsWith('global:') ? path : `${rootOwner!.node.qualifiedName}${path.slice(root.length)}`, + }); + if (!owner) return false; + this.markJsObjectOwner(owner, info); + const previousStack = this.nodeStack; + if (info.binding.startsWith('global:')) this.nodeStack = this.nodeStack.slice(0, 1); + this.nodeStack.push(owner.id); + this.extractObjectLiteralFunctions(value, true); + this.nodeStack.pop(); + this.nodeStack = previousStack; + this.scanFnRefSubtree(node, 0); + return true; + } + /** * Build an ExtractorContext for passing to language-specific visitNode hooks. */ @@ -1720,7 +1810,7 @@ export class TreeSitterExtractor { /** * Extract a function */ - private extractFunction(node: SyntaxNode, nameOverride?: string): void { + private extractFunction(node: SyntaxNode, nameOverride?: string, objectMember = false): void { if (!this.extractor) return; // If the language provides getReceiverType and this function has a receiver @@ -1803,12 +1893,17 @@ export class TreeSitterExtractor { docstring, signature, visibility, - isExported, + isExported: objectMember ? false : isExported, isAsync, isStatic, returnType, }); if (!funcNode) return; + if (objectMember) { + this.markJsContainment(funcNode, { jsObjectMember: true }); + const point = `binding:${funcNode.startLine}:${funcNode.startColumn}`; + if (!this.jsObjectMembers.has(point)) this.jsObjectMembers.set(point, funcNode.qualifiedName); + } // Extract type annotations (parameter types and return type) this.extractTypeAnnotations(node, funcNode.id); @@ -2441,20 +2536,24 @@ export class TreeSitterExtractor { * object returned by a store-initializer call. Handles both `key: () => {}` / * `key: function() {}` pairs and method shorthand `key() {}`. */ - private extractObjectLiteralFunctions(obj: SyntaxNode): void { + private extractObjectLiteralFunctions(obj: SyntaxNode, scoped = false): void { for (let i = 0; i < obj.namedChildCount; i++) { const member = obj.namedChild(i); if (!member) continue; if (member.type === 'pair') { const key = getChildByField(member, 'key'); const value = getChildByField(member, 'value'); - if (key && value && (value.type === 'arrow_function' || value.type === 'function_expression')) { - this.extractFunction(value, this.objectKeyName(key)); + const staticKey = key && (key.type === 'property_identifier' || key.type === 'string' || key.type === 'number'); + if (key && value && (!scoped || staticKey) && (value.type === 'arrow_function' || value.type === 'function_expression' || (scoped && value.type === 'generator_function'))) { + this.extractFunction(value, this.objectKeyName(key), scoped); } else if (value?.type === 'call_expression') { // `key: Effect.fn("…")(function* () {…})` — see curriedWrapperBoundName. const fn = getChildByField(value, 'arguments')?.namedChild(0); const bound = fn ? this.curriedWrapperBoundName(fn) : null; - if (fn && bound) this.extractFunction(fn, bound); + if (fn && bound && (!scoped || staticKey)) this.extractFunction(fn, bound, scoped); + else if (scoped && value) this.visitFunctionBody(value, ''); + } else if (scoped && value) { + this.visitFunctionBody(value, ''); } } else if (member.type === 'method_definition') { // Method shorthand: `{ fetchUser() {...} }`. extractMethod deliberately @@ -2462,7 +2561,13 @@ export class TreeSitterExtractor { // explicit name (method_definition exposes a `body` field, so resolveBody // falls through to it and the node spans the full method). const key = getChildByField(member, 'name'); - if (key) this.extractFunction(member, this.objectKeyName(key)); + if (key && (!scoped || ['property_identifier', 'string', 'number'].includes(key.type))) this.extractFunction(member, this.objectKeyName(key), scoped); + else if (scoped) { + const body = getChildByField(member, 'body'); + if (body) this.visitFunctionBody(body, ''); + } + } else if (scoped && member.type === 'spread_element') { + this.visitFunctionBody(member, ''); } } } @@ -2730,7 +2835,7 @@ export class TreeSitterExtractor { if (member?.type === 'method_definition') return true; if (member?.type === 'pair') { const v = getChildByField(member, 'value'); - if (v?.type === 'arrow_function' || v?.type === 'function_expression') return true; + if (v?.type === 'arrow_function' || v?.type === 'function_expression' || v?.type === 'generator_function') return true; } } return false; @@ -2838,7 +2943,7 @@ export class TreeSitterExtractor { * Extracts top-level and module-level variable declarations. * Captures the variable name and first 100 chars of initializer in signature for searchability. */ - private extractVariable(node: SyntaxNode): void { + private extractVariable(node: SyntaxNode, objectOnly = false): void { if (!this.extractor) return; // Different languages have different variable declaration structures @@ -2862,6 +2967,8 @@ export class TreeSitterExtractor { if (child?.type === 'variable_declarator') { const nameNode = getChildByField(child, 'name'); const valueNode = getChildByField(child, 'value'); + const directObject = this.isJsObjectLanguage() && !!valueNode && ['object', 'object_expression'].includes(valueNode.type); + if (objectOnly && (!directObject || !this.objectHasInlineFunctions(valueNode!))) continue; if (nameNode) { // Skip destructured patterns (e.g., `let { x, y } = $props()` in Svelte) @@ -2912,6 +3019,7 @@ export class TreeSitterExtractor { signature: initSignature, isExported, }); + if (varNode && directObject) this.markJsObjectOwner(varNode, this.jsObjects().info(child, name, true)); // Extract type annotation references (e.g., const x: ITextModel = ...) if (varNode) { @@ -2950,7 +3058,7 @@ export class TreeSitterExtractor { // normal body walk (extracting those consts), not be skipped here. const hasInlineFns = !!objectOfFns && this.objectHasInlineFunctions(objectOfFns); const extractObjectMethods = - (isExported || this.isExportedLater(name)) && !!objectOfFns && hasInlineFns; + (directObject || isExported || this.isExportedLater(name)) && !!objectOfFns && hasInlineFns; // RTK Query: `createApi`/`injectEndpoints` define endpoints as // object-literal properties whose values are `build.query/mutation(...)` @@ -3004,7 +3112,9 @@ export class TreeSitterExtractor { } if (extractObjectMethods && objectOfFns) { - this.extractObjectLiteralFunctions(objectOfFns); + if (directObject && varNode) this.nodeStack.push(varNode.id); + this.extractObjectLiteralFunctions(objectOfFns, directObject); + if (directObject && varNode) this.nodeStack.pop(); } if (rtkEndpoints) { this.extractRtkEndpoints(rtkEndpoints); @@ -5177,13 +5287,27 @@ export class TreeSitterExtractor { } if (calleeName) { - this.unresolvedReferences.push({ + const ref: UnresolvedReference = { fromNodeId: callerId, referenceName: calleeName, referenceKind: 'calls', line: node.startPosition.row + 1, column: node.startPosition.column, - }); + }; + if (this.isJsObjectLanguage()) { + const functionNode = getChildByField(node, 'function'); + const full = jsMemberPath(functionNode, this.source); + if (full?.includes('.')) { + const root = full.split('.')[0]!; + this.jsObjectCalls.push({ ref, path: full, proof: this.jsObjects().isWritten(node, full) ? 'unknown' : this.jsObjects().root(node, root) }); + } else if (functionNode?.type === 'identifier') { + const proof = this.jsObjects().root(node, getNodeText(functionNode, this.source)); + const self = this.jsObjectMembers.get(proof); + if (self !== undefined) ref.candidates = [self]; + else if (proof.startsWith('parameter:')) ref.candidates = []; + } + } + this.unresolvedReferences.push(ref); } } @@ -5947,6 +6071,26 @@ export class TreeSitterExtractor { const visitForCallsAndStructure = (node: SyntaxNode): void => { const nodeType = node.type; + if (this.isJsObjectLanguage()) { + if (this.extractJsObjectAssignment(node)) return; + if (nodeType === 'lexical_declaration' || nodeType === 'variable_declaration') { + const isCallableObject = (child: SyntaxNode): boolean => { + const value = getChildByField(child, 'value'); + return child.type === 'variable_declarator' && !!value && + ['object', 'object_expression'].includes(value.type) && this.objectHasInlineFunctions(value); + }; + const hasObject = node.namedChildren.some(isCallableObject); + if (hasObject) { + this.extractVariable(node, true); + this.scanFnRefSubtree(node, 0); + // Retain annotation/call ownership for ordinary locals in a mixed declaration. + for (const child of node.namedChildren) if (child.type === 'variable_declarator' && !isCallableObject(child)) { + visitForCallsAndStructure(child); + } + return; + } + } + } // A function-like macro defined inside a body is still a macro (#1838). if ((this.language === 'c' || this.language === 'cpp') && nodeType === 'preproc_function_def') { diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 1eaf14b145..f41acff359 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -29,6 +29,8 @@ import { gateTypeParameter, clearTypeParameterMemos } from './type-parameters'; import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isJsPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos, resolveImportPath, isExternalImport } from './import-resolver'; import { ResolverPool, minRefsForPool, shouldEngageAdaptively } from './resolver-pool'; import { resolveAliasBinding } from './alias-binding'; +import { resolveJsObjectCall, JS_OBJECT_LANGUAGES } from './js-object-members'; +import type { JsObjectInfo } from '../extraction/js-object-bindings'; import { detectFrameworks } from './frameworks'; import { synthesizeCallbackEdges } from './callback-synthesizer'; import { createYielder, type MaybeYield } from './cooperative-yield'; @@ -298,6 +300,7 @@ export class ReferenceResolver { // resolution pass (same lifetime assumption as nameCache); clearCaches() resets // it between passes. Callers must treat the returned array as read-only. private nodesByKindCache = new Map(); + private jsObjectInfoCache = new Map(); // Filesystem existence probes behind context.fileExists (paths not in knownFiles). private fileExistsMemo = new Map(); private knownNames: Set | null = null; // all known symbol names for fast pre-filtering @@ -444,6 +447,7 @@ export class ReferenceResolver { this.supertypeMemo.clear(); this.supertypeGen++; this.nodesByKindCache.clear(); + this.jsObjectInfoCache.clear(); this.fileExistsMemo.clear(); this.manifestScopes.clear(); this.knownNames = null; @@ -506,8 +510,35 @@ export class ReferenceResolver { /** * Create the resolution context */ + private jsObjectInfo(nodeId: string): JsObjectInfo | null { + if (this.jsObjectInfoCache.has(nodeId)) return this.jsObjectInfoCache.get(nodeId)!; + this.jsObjectInfoCache.set(nodeId, null); // a corrupt containment cycle must not recurse forever. + for (const edge of this.queries.getIncomingEdges(nodeId, ['contains'])) { + const info = edge.metadata?.jsObject; + if (info && typeof info === 'object') { + const object = info as Partial; + if (typeof object.path === 'string' && typeof object.binding === 'string' && + Array.isArray(object.scope) && object.scope.length === 4 && object.scope.every(value => Number.isInteger(value) && value >= 0)) { + const result: JsObjectInfo = { path: object.path, binding: object.binding, scope: object.scope as JsObjectInfo['scope'] }; + this.jsObjectInfoCache.set(nodeId, result); + return result; + } + } + if (edge.metadata?.jsObjectMember === true) { + const owner = this.jsObjectInfo(edge.source); + if (owner) { + const result = { ...owner, ownerId: edge.source }; + this.jsObjectInfoCache.set(nodeId, result); + return result; + } + } + } + return null; + } + private createContext(): ResolutionContext { return { + getJsObjectInfo: (nodeId) => this.jsObjectInfo(nodeId), resolveImport: (ref) => resolveViaImport(ref, this.context), isOutOfRepoImport: (source, fromFile, language) => isExternalImport(source, language, this.context) && @@ -869,6 +900,7 @@ export class ReferenceResolver { filePath: ref.filePath || this.getFilePathFromNodeId(ref.fromNodeId), language: ref.language || this.getLanguageFromNodeId(ref.fromNodeId), rowId: ref.rowId, + candidates: ref.candidates, })); const total = refs.length; @@ -1042,7 +1074,17 @@ export class ReferenceResolver { ref, this.context, ); - const scoped = this.gateRustScope(candidate, ref); + const objectInfo = candidate && + (ref.candidates !== undefined || (ref.referenceKind === 'calls' && ref.referenceName.includes('.'))) && + JS_OBJECT_LANGUAGES.has(this.nodeById(candidate.targetNodeId)?.language ?? '') + ? this.jsObjectInfo(candidate.targetNodeId) : null; + const literal = candidate && ( + (objectInfo?.ownerId && ref.candidates !== undefined && + !ref.candidates.includes(this.nodeById(candidate.targetNodeId)?.qualifiedName ?? '')) || + // A direct literal holder proves the receiver, not a missing callable member. + (objectInfo && !objectInfo.ownerId && ref.referenceKind === 'calls' && ref.referenceName.includes('.')) + ) ? null : candidate; + const scoped = this.gateRustScope(literal, ref); const resolved = this.gateSuperSelfCall( scoped?.resolvedBy === 'framework' ? this.gateFrameworkLanguage(scoped, ref) : this.gateLanguage(scoped, ref), ref, @@ -1098,6 +1140,10 @@ export class ReferenceResolver { const phpQualified = resolvePhpQualifiedClassRef(ref, this.context); if (phpQualified !== undefined) return this.gateLanguage(phpQualified, ref); + const objectCall = resolveJsObjectCall(ref, this.context, + (name) => this.resolveOneInner({ ...ref, referenceName: name, candidates: undefined })); + if (objectCall !== undefined) return this.gateLanguage(objectCall, ref); + // Fast pre-filter: skip if no symbol with this name exists anywhere // AND the name doesn't match a local import. The import escape is // necessary because re-export rename chains (`import { login } @@ -1401,6 +1447,7 @@ export class ReferenceResolver { // wrong rebind; edges without refName (pre-#1240, synthesized) are // deliberately NOT resurrected for the same reason. refName: ref.original.referenceName, + ...(ref.original.candidates !== undefined ? { refCandidates: ref.original.candidates } : {}), ...(ref.original.referenceKind !== kind ? { refKind: ref.original.referenceKind } : {}), // Uniform marker for function-as-value edges (#756), regardless of // which strategy resolved them (import vs matchFunctionRef) — lets @@ -1690,6 +1737,7 @@ export class ReferenceResolver { filePath: raw.filePath || this.getFilePathFromNodeId(raw.fromNodeId), language: raw.language || this.getLanguageFromNodeId(raw.fromNodeId), rowId: raw.rowId, + candidates: raw.candidates, }; const result = this.resolveOneTimed(ref); if (result) { @@ -1810,6 +1858,7 @@ export class ReferenceResolver { filePath: raw.filePath || this.getFilePathFromNodeId(raw.fromNodeId), language: raw.language || this.getLanguageFromNodeId(raw.fromNodeId), rowId: raw.rowId, + candidates: raw.candidates, }; const result = this.resolveOneTimed(ref); if (result) { diff --git a/src/resolution/js-object-members.ts b/src/resolution/js-object-members.ts new file mode 100644 index 0000000000..1c6cda68b6 --- /dev/null +++ b/src/resolution/js-object-members.ts @@ -0,0 +1,76 @@ +import type { Node } from '../types'; +import type { ResolutionContext, UnresolvedRef, ResolvedRef } from './types'; +import { resolveObjectLiteralMember, resolveObjectLiteralBinding } from './name-matcher'; + +export const JS_OBJECT_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx', 'vue', 'svelte', 'astro']); + +function contains(scope: [number, number, number, number], ref: UnresolvedRef): boolean { + const [sl, sc, el, ec] = scope; + return (ref.line > sl || (ref.line === sl && ref.column >= sc)) && + (ref.line < el || (ref.line === el && ref.column < ec)); +} + +/** + * Resolve only AST-derived qualified candidates; an owned missing member closes fallback. + * `resolveBare` resolves a bare member name the ordinary way (the `window` escape below). + */ +export function resolveJsObjectCall( + ref: UnresolvedRef, + context: ResolutionContext, + resolveBare?: (name: string) => ResolvedRef | null, +): ResolvedRef | null | undefined { + if (!JS_OBJECT_LANGUAGES.has(ref.language) || ref.referenceKind !== 'calls' || ref.candidates === undefined) return undefined; + const dot = ref.referenceName.lastIndexOf('.'); + if (dot < 0) { + if (ref.candidates.length === 0) return null; // an AST-proven parameter is an opaque callable value. + // A named function expression keeps its lexical self-name even when the property is renamed. + const self = ref.candidates.flatMap(candidate => context.getNodesByQualifiedName(candidate)).filter(node => + context.getJsObjectInfo?.(node.id)?.ownerId && node.filePath === ref.filePath && + (ref.line > node.startLine || (ref.line === node.startLine && ref.column >= node.startColumn)) && + (ref.line < node.endLine || (ref.line === node.endLine && ref.column < node.endColumn))); + return self.length === 1 ? { original: ref, targetNodeId: self[0]!.id, confidence: 0.95, resolvedBy: 'qualified-name' } : undefined; + } + if (ref.candidates.length === 0) return undefined; + const receiver = ref.referenceName.slice(0, dot); + const member = ref.referenceName.slice(dot + 1); + const holders: Array<{ node: Node; scope: [number, number, number, number] }> = []; + for (const candidate of ref.candidates) { + if (!candidate.endsWith(`::${member}`)) continue; + const name = candidate.slice(0, -member.length - 2); + for (const node of context.getNodesByQualifiedName(name)) { + const info = context.getJsObjectInfo?.(node.id); + if (!info || info.ownerId || info.path !== receiver) continue; + if (!info.binding.startsWith('global:') && (node.filePath !== ref.filePath || !contains(info.scope, ref))) continue; + holders.push({ node, scope: info.scope }); + } + } + if (!holders.length) { + // A proven host-global path names only its own namespace, never a class/free leaf decoy. + if (/^(?:window|globalThis|self)\./.test(receiver) && ref.candidates.includes(`${receiver}::${member}`)) { + const root = receiver.split('.')[0]!; + const lexicalRoot = context.getNodesByQualifiedName(root).some(node => { + const info = context.getJsObjectInfo?.(node.id); + return node.filePath === ref.filePath && info?.path === root && !info.binding.startsWith('global:') && contains(info.scope, ref); + }); + if (!lexicalRoot) { + // No `window.X = {…}` is indexed. `window` stays the project-global + // escape (#1707): a UMD or `root.X = api` namespace still reaches its + // free function by name, never a class method that shares it. + if (!receiver.startsWith('window.') || !resolveBare) return null; + const bare = resolveBare(member); + return bare && context.getNodeById?.(bare.targetNodeId)?.kind === 'function' ? { ...bare, original: ref } : null; + } + } + return undefined; // typed/imported receivers keep their own resolution strategies. + } + // Nested lexical scopes win before asking whether the selected value has this member. + const lexical = holders.filter(entry => !context.getJsObjectInfo?.(entry.node.id)?.binding.startsWith('global:')); + if (lexical.length === 0 && holders.length !== 1) return null; + const pool = lexical.length ? lexical : holders; + pool.sort((a, b) => b.scope[0] - a.scope[0] || b.scope[1] - a.scope[1] || a.scope[2] - b.scope[2] || a.scope[3] - b.scope[3]); + const selected = pool[0]!; + const nearest = pool.filter(entry => entry.scope.every((value, i) => value === selected.scope[i])); + if (nearest.length !== 1) return null; // competing assignments do not prove which runtime object won. + return resolveObjectLiteralMember(selected.node, member, ref, context, 0.95, 'instance-method') ?? + resolveObjectLiteralBinding(selected.node, member, ref, context); +} diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index e0b4ad28b8..5a866f217c 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1143,6 +1143,21 @@ export function isLexicallyReachable( ref: UnresolvedRef, context: ResolutionContext ): boolean { + // Literal members are value-owned definitions, never free functions named by a bare call. + const object = candidate.kind === 'function' && ['typescript', 'tsx', 'javascript', 'jsx', 'vue', 'svelte', 'astro'].includes(ref.language) + ? context.getJsObjectInfo?.(candidate.id) : null; + if (object?.ownerId) { + if (ref.candidates !== undefined && !ref.candidates.includes(candidate.qualifiedName)) return false; + if (!/[.:]/.test(ref.referenceName)) { + if (candidate.filePath !== ref.filePath || ref.line < candidate.startLine || ref.line > candidate.endLine) return false; + const line = context.getFileLines?.(candidate.filePath)?.[candidate.startLine - 1] ?? context.readFile(candidate.filePath)?.split('\n')[candidate.startLine - 1] ?? ''; + if (!new RegExp(`^(?:async\\s+)?function\\s*\\*?\\s*${ref.referenceName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(line.slice(candidate.startColumn))) return false; + } + if (!object.binding.startsWith('global:') && candidate.filePath === ref.filePath) { + const [sl, sc, el, ec] = object.scope; + if ((ref.line < sl || (ref.line === sl && ref.column < sc)) || (ref.line > el || (ref.line === el && ref.column >= ec))) 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()`. @@ -5716,7 +5731,7 @@ 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). */ -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 { @@ -5739,13 +5754,11 @@ function sameRange(a: Node, b: Node): boolean { /** * Resolve `container.member` where `container` is a VALUE holding an object - * literal — `export const api = { call() {…}, get: () => {…} }` used as the - * module's namespace (#1573). The members are extracted as plain functions - * with BARE qualified names inside the constant's source extent (there is no - * `api::call`), so neither the `Container::member` lookup the class-shaped - * kinds use (#825) nor the declared-type inference for singleton instances - * (#1292) can reach them, and every such call resolved to nothing — or, via - * an import, to the constant itself. This looks the member up by CONTAINMENT: + * literal — `const api = { call() {…}, get: () => {…} }` used as a namespace + * (#1573). Current literal members are qualified under their holder; legacy + * indexes and exported factory-return members retain bare qualified names. + * The class-shaped lookup (#825) and singleton type inference (#1292) cannot + * validate that ownership across both forms. Look the member up by CONTAINMENT: * a node named `member` whose range lies inside the container's, in the * container's own file. A helper declared inside a member's body is not a * member and is skipped; nothing else in the file can donate a match. Calls @@ -5904,8 +5917,7 @@ export function resolveObjectLiteralBinding( const code = blankStringContents(stripCommentsForRegex(lines.join('\n'), 'typescript')); const offsets = [0]; for (let i = 0; i < code.length; i++) if (code[i] === '\n') offsets.push(i + 1); - const scopeAt = (node: Node): number[] => { - const end = (offsets[node.startLine - 1] ?? code.length) + node.startColumn; + const scopeAtOffset = (end: number): number[] => { const scope: number[] = []; for (let i = 0; i < end; i++) { if (code[i] === '{') scope.push(i); @@ -5913,8 +5925,35 @@ export function resolveObjectLiteralBinding( } return scope; }; + const offsetOf = (node: Node): number => (offsets[node.startLine - 1] ?? code.length) + node.startColumn; + const scopeAt = (node: Node): number[] => scopeAtOffset(offsetOf(node)); const scope = scopeAt(container); + // Anonymous wrappers have no function node. Only headers of ACTIVE braces + // can bind this alias: a sibling IIFE's parameters are already out of scope. + const escaped = binding.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const headerAt = (end: number): string => { + let start = end - 1; + let depth = 0; + for (; start >= 0; start--) { + const ch = code[start]; + if (ch === ')' || ch === ']' || ch === '}') depth++; + else if (ch === '(' || ch === '[' || ch === '{') { + if (depth === 0) break; + depth--; + } else if (depth === 0 && (ch === ';' || ch === ',' || (ch === '=' && code[start + 1] !== '>'))) break; + } + return `${code.slice(start + 1, end)}{`; + }; + if (scope.some(open => hasParameterBinding(headerAt(open), escaped))) return null; + // An expression-body arrow can put the assignment directly after its head. + const before = code.slice(0, offsetOf(container)); + const arrowHead = /=>\s*(?:\(\s*)*$/.exec(before); + if (arrowHead && hasParameterBinding(headerAt(arrowHead.index + 2), escaped)) return null; + const selfHeader = new RegExp(`\\bfunction(?:\\s*\\*\\s*|\\s+)${escaped}(?=\\s*(?:<|\\())`); + let selfDepth = 0; + for (const [index, open] of scope.entries()) if (selfHeader.test(headerAt(open))) selfDepth = index + 1; + const callable = (n: Node) => n.kind === 'function' || n.kind === 'method' || n.kind === 'class'; const accepts = ref.referenceKind === 'calls' @@ -5930,6 +5969,27 @@ export function resolveObjectLiteralBinding( // Select the lexical binding BEFORE checking callability: a nearer value // shadows an outer function even if that value cannot be called. const local = locals[0]?.node; + // Primitive locals are deliberately not indexed. Their source declaration + // still shadows an outer callable; only an indexed function starting at + // this initializer is evidence that the binding itself is callable. + const declarations = [...code.matchAll(localBindingPatterns(binding, 'g').decl)] + .map(match => ({ match, scope: scopeAtOffset(match.index!) })) + .filter(entry => entry.scope.every((position, i) => scope[i] === position)) + .sort((a, b) => b.scope.length - a.scope.length); + const declared = declarations[0]; + if (declared && (!locals.length || declared.scope.length >= locals[0]!.scope.length)) { + const equals = declared.match[0].indexOf('='); + let valueAt = declared.match.index! + equals + 1; + while (/\s/.test(code[valueAt] ?? '') && valueAt < code.length) valueAt++; + const nameAt = declared.match.index! + (/^(?:const|let|var)\s+/.exec(declared.match[0])?.[0].length ?? 0); + const declaredValue = local && ['constant', 'variable', 'component'].includes(local.kind) && offsetOf(local) === nameAt; + if (!local || equals < 0 || !accepts(local) || (!declaredValue && offsetOf(local) !== valueAt) || + declarations.some(entry => entry !== declared && entry.scope.length === declared.scope.length)) return null; + } + // A named function expression's self-binding is not the outer namesake. + // Keep a proven nearer local; otherwise this alias boundary stays opaque. + if (selfDepth > 0 && (!locals.length || locals[0]!.scope.length < selfDepth) && + (!declared || declared.scope.length < selfDepth)) return null; if (local) return accepts(local) ? { original: ref, targetNodeId: local.id, confidence: 0.85, resolvedBy: 'instance-method' } : null; @@ -7891,6 +7951,7 @@ export function matchMethodCall( (n) => (n.kind === 'constant' || n.kind === 'variable') && n.filePath === ref.filePath ); for (const holder of holders) { + if (context.getJsObjectInfo?.(holder.id) && ref.candidates !== undefined && !ref.candidates.includes(`${holder.qualifiedName}::${methodName}`)) continue; const hit = resolveObjectLiteralMember(holder, methodName!, ref, context, 0.85, 'instance-method') ?? resolveObjectLiteralBinding(holder, methodName!, ref, context); diff --git a/src/resolution/types.ts b/src/resolution/types.ts index 1b1f11226a..27e5cd0d20 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -90,6 +90,8 @@ export interface ResolutionResult { export interface ResolutionContext { /** Get all nodes in a file */ getNodesInFile(filePath: string): Node[]; + /** AST-proven literal ownership persisted on containment edges (including anonymous scopes). */ + getJsObjectInfo?(nodeId: string): import('../extraction/js-object-bindings').JsObjectInfo | null; /** Whether any node in the file is exported (`getNodesInFile(f).some(n => n.isExported)`), as one indexed probe. */ fileHasExportedNode?(filePath: string): boolean; /** `getNodesInFile(f).filter(n => n.isExported)`, without decoding the rest of the file. */