fix(tsjs): extract named object literal members with scoped callers - #2310
danusha2345 wants to merge 1 commit into
Conversation
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 (colbymchenry#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 colbymchenry#2300.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ba42d71 to
8c46409
Compare
…ly exported ones (#2300) (#2363) Issue #2300: a function written inside an object literal became a symbol only when the object was an `export const`. A plain `const api = {...}`, an object declared inside an IIFE or a function, and a namespace hung on the page (`window.WS = {...}`, `ns.mod = {...}`) produced no member nodes, and no node for `WS` either. Calls made inside those members were credited to the enclosing constant or lost, and calls into them resolved to nothing. Script-tag JavaScript, written almost entirely this way, was mostly missing from callers and impact. Cause: the TS/JS extractor, and the kernel's tsjs mirror of it, minted members only for exported object-of-functions; any other literal was walked as one opaque initializer, and an assignment to a member path was never a declaration. Resolution had no way to reach a member through `App.init()`, `window.App.init()` or `App.utils.pad()`. Fix (extraction, TS and kernel byte-identical): a named object literal owns its function members (method shorthand, `key: function`, `key: () =>`, generators; static keys only), whether it is declared at module scope, in a function body or IIFE, assigned to a path (`window.App = {...}`, `App.utils = {...}`) or assigned to a plain name at module level (`dw_page = {...}`). Members are `function` nodes qualified under the owner (`api::load`, `window.App::init`, `App.utils::pad`), the exported case included; a path owner is named by its last link and qualified by the path. Calls in a member are the member's; other values are walked where they were before. CommonJS exports, prototypes, `this.x = {...}`, call-argument literals, a name reassigned inside a function, and generated or minified files keep the old shape. Minified files are now also recognised by content, in both extractors, and the kernel's `.min.js` pattern matches the TS one. Fix (resolution): a member is reached through its object only: `App.init()`, `window.App.init()`, `App.utils.pad()`, a sibling's `this.render()`, a `const { init } = App` binding; never a bare `init()`. A same-file holder is chosen by lexical block (an IIFE's own `App` first); otherwise a global one (`window.App = {...}`, or a classic script's top-level `App`) when the caller neither imports nor binds the name. Arrow members keep the `this` of the method around the literal, in the `this.x` resolvers too. A dotted-path holder is never reached by its last name alone, and Svelte's `$store` rule now applies only in `.svelte` components, so the new local holders are not taken for `$n` in plain scripts. The lookups read each file once (destructuring patterns only when the raw text has a `} =`) and keep only the last 32 files' scans. Contributor PR #2310 (@danusha2345): adopted its model (owner-qualified members exported or not, path owners, IIFE/local owners, static keys, no bare-name reach, `<script setup>` contains edges, SFC languages, test scenarios), re-implemented on current main. Not taken: the EXTRACTION_VERSION bump (already 28), the extraction-time binding oracle with its ref/edge metadata and kernel ref patching (replaced by resolution-time lookups), naming the owner `window.WS`, rewriting `window.X.m()` ref names, flipping exported members' isExported, and unrelated bare-call changes. Verification: new js-object-literal-members suite (native + wasm, 18 tests); the issue's shapes fail on main and the guard test fails without the guards. kernel-tsjs-parity passes with CODEGRAPH_KERNEL_EXPECT=1 (object-literal owners LF/CRLF, minified bundles); kernel parity sweeps show 0 diffs on DokuWiki, vue-realworld, TodoMVC, excalidraw and this repo. Validation (kernel loaded, before = origin/main 023fc31): - DokuWiki: nodes +85, calls +105; 17 removed edges = 13 moved to the member, 4 re-resolved from a wrong LinkWizard::init to the right init. - TodoMVC: nodes +427, calls +148; 306 removed = 226 moved, 33 re-resolved (14 fixed `this.render()`/`this.save()`/`this.track()`, 19 wrong-to-wrong fallback guesses), 47 dropped wrong edges (43 to a helper local to jQuery Mobile's scrollstart setup). - excalidraw: nodes +86, calls +75; 34 removed = 33 moved, 1 fuzzy 0.3 guess dropped. - vue-realworld: nodes +10, calls +11; 7 calls re-resolved from the ApiService constant to its members. New self-loops are real recursion, bar one lazily redefined method that calls itself as written. 81 sampled added edges, 80 correct (the other is main's own window.open guess, re-attributed). Whole-run index time, median of 3 interleaved: vue-realworld 1.2s -> 1.2s, DokuWiki 8.6s -> 9.1s, excalidraw 10.8s -> 10.7s, TodoMVC 16.9s -> 16.9s (within run-to-run noise). Co-authored-by: danusha2345 <ewidusoc498@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks @danusha2345! This landed in #2363, which takes your model: members qualified under their owner whether exported or not, owners hung on a path, IIFE- and function-local owners, static keys only, no reaching a member by bare name, the |
Problem
Object-literal members became nodes only when the object was an
export const(#2300). The reporter's repro:indexes neither
iifeMnorwsM(norWS), and the same holds for a plain non-exportedconst o = { m() {} }. Calls made inside such a member were attributed to the enclosing constant (callers helperlistsconstant store), and insidewindow.api = { load() { … } }they were lost entirely. Script-tag JavaScript that hangs its namespaces offwindowis mostly invisible: the reporter's app has 670 method-shorthand definitions in object literals and none of them in the graph.Fix
const/let/var X = {…}(inside a function body only when the literal holds a function),X.y = {…}under such a literal, andwindow.X = {…}/globalThis.X = {…}/self.X = {…}written anywhere, including in an IIFE. Its static-key members (method shorthand,key: function/arrow/generator,key: Effect.fn(…)(function* …)) becomefunctionnodes qualifiedX::m/window.App::load, contained by the owner. Thecontainsedges carry the owner's lexical scope (jsObject/jsObjectMembermetadata), so a same-named literal in a sibling IIFE or another function never lends its member. Members of anexport constobject are qualified the same way now (api::getinstead ofget). Like the methods of an exported class, they are not flagged exported themselves; the object is. So the dead-code check now weighs them the way it weighs class methods.Api.run()carries qualified candidates (Api::run) when the receiver's binding is proven to be that literal. The proof comes from a lexical scope walk that handles IIFEs, hoistedvar, catch and function parameters, and named function expressions. An explicit write such asApi.run = otherorApi = make()invalidates it.window.App.run()nameswindow.App::run. The resolver binds such a call only to its candidates. When the nearest literal lacks the member, nothing is guessed, and competingwindow.App = {…}assignments resolve to nothing.syncreplays a candidate-carrying edge against the edited file instead of reattaching it by short name.EXTRACTION_VERSION27 → 28.Kept as on main
window.MyNs.ping()with nowindow.MyNs = {…}in the index still resolvespingby name to a free function. This is thewindowescape from TS/JS: a call through a chained receiver (chrome.storage.local.get, this.map.get, a.b.text()) reaches the resolver as the bare method name and exact-matches any project symbol with that name #1707, and UMD-style namespaces (root.FlightView = factory()) depend on it. It no longer lands on a class method that only shares the name.const self = this; self.m()andcls.m().Deliberately unresolved
api[key](),getApi().run(),[k]() {}), aliasing (const a = Api; a.run()), namespaces built by property assignment (App.load = function () {}), and{…} as const/satisfiesinitializers.window.App = {…}in one file andApp.load()withoutwindow.in another..then(({ run }) => run())), no longer borrows a same-named project function.Tests
__tests__/js-named-object-members.test.ts(new): 14 scenarios, each on a real temp project with SQLite, run on both native and WASM extraction. They cover sibling IIFEs on one line after Unicode, global namespaces created inside IIFEs and inside functions, shadowed and parameterwindow, the UMD fallback, shorthand aliases, the nearest-scope rule, writes and competing assignments, named function expressions, SFC offsets, target-only sync and reopen, a same-line target reorder, and parallel parse/resolve workers.__tests__/kernel-tsjs-parity.test.ts: kernel/WASM parity on a literal-heavy fixture for ts, tsx, js and jsx.extraction,js-builtin-method-calls,route-inline-handler-calls,ts-this-field-callandvue-store-extraction, where members are nowOwner::member.ts-chained-receiverkeeps its TS/JS: a call through a chained receiver (chrome.storage.local.get, this.map.get, a.b.text()) reaches the resolver as the bare method name and exact-matches any project symbol with that name #1707 assertion and gains awindow.Known = { ping }case.expression-receiver-callsnow recordswindow.Api.start()as written.CODEGRAPH_KERNEL_EXPECT=1andCODEGRAPH_KERNEL=0: the 15 touched or related files pass (866/866) and 84 more JS/TS, framework and sync test files pass (584/584). All kernel parity suites pass (173/173).scripts/kernel-parity.mjsreports byte parity on this repo'ssrc/andui/srcand on two JS apps.Real repos (main → this branch, native kernel)
src/+ui/src(TS/Svelte, 352 files)window(60 files)Index time, two runs each on a loaded machine: this repo 15.9–17.6 s → 15.7–16.4 s, unchanged within noise. The renderer went from 3.8 s to 4.3–4.6 s with 31% more nodes. The Vue app stayed at 1.4–1.6 s.
Owner::member. 167 call sites gained a target, such aschalk.bold(…)→main::chalk::boldandc.dim(…)→c::dim. 3 lost one: a callback parameterfn(m)that was bound to an interface property, a defaulted parameterlog(…)that was bound toUpgradeDeps::log, and.then(({ runInstaller }) => runInstaller()), which was right before (see the parameter rule).window.rtc = {…}andwindow.rcjoy = {…}namespaces. One of them,window.rtc.start(…), used to land on an unrelatedstartin another file. The other losses are 13 single-letter guesses inside a bundled minified library.apiparameter call that was bound to an unrelated constant.window.ReactNativeWebView?.postMessage(…)no longer reaches thepostMessageproperty signature the app declares for that global, because thewindowfallback now accepts free functions only. One wrong edge is new:(this.$refs.x as T)?.reset()now matches the interface methodStore::resetalone, because the literal'sresetleft the bare-name pool. main makes the same edge whenever only the interface exists.resetabove.🤖 Generated with Claude Code