Skip to content

fix(tsjs): extract named object literal members with scoped callers - #2310

Closed
danusha2345 wants to merge 1 commit into
colbymchenry:mainfrom
danusha2345:codex/js-object-member-definitions
Closed

danusha2345 wants to merge 1 commit into
colbymchenry:mainfrom
danusha2345:codex/js-object-member-definitions

Conversation

@danusha2345

@danusha2345 danusha2345 commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Object-literal members became nodes only when the object was an export const (#2300). The reporter's repro:

// src/e_iife.js
(function () {
  const objIife = { iifeM() { return 1; } };
  window.WS = { wsM() { return objIife.iifeM(); } };
})();

indexes neither iifeM nor wsM (nor WS), and the same holds for a plain non-exported const o = { m() {} }. Calls made inside such a member were attributed to the enclosing constant (callers helper lists constant store), and inside window.api = { load() { … } } they were lost entirely. Script-tag JavaScript that hangs its namespaces off window is mostly invisible: the reporter's app has 670 method-shorthand definitions in object literals and none of them in the graph.

Fix

  • Extraction (native kernel and WASM produce identical output): a directly named object literal is an owner. That covers const/let/var X = {…} (inside a function body only when the literal holds a function), X.y = {…} under such a literal, and window.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* …)) become function nodes qualified X::m / window.App::load, contained by the owner. The contains edges carry the owner's lexical scope (jsObject / jsObjectMember metadata), so a same-named literal in a sibling IIFE or another function never lends its member. Members of an export const object are qualified the same way now (api::get instead of get). 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.
  • Calls: 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, hoisted var, catch and function parameters, and named function expressions. An explicit write such as Api.run = other or Api = make() invalidates it. window.App.run() names window.App::run. The resolver binds such a call only to its candidates. When the nearest literal lacks the member, nothing is guessed, and competing window.App = {…} assignments resolve to nothing.
  • Vue, Svelte and Astro script folding shifts the scope metadata along with the block. Target-only sync replays a candidate-carrying edge against the edited file instead of reattaching it by short name.
  • EXTRACTION_VERSION 27 → 28.

Kept as on main

Deliberately unresolved

  • Dynamic receivers and keys (api[key](), getApi().run(), [k]() {}), aliasing (const a = Api; a.run()), namespaces built by property assignment (App.load = function () {}), and {…} as const / satisfies initializers.
  • An implicit global: window.App = {…} in one file and App.load() without window. in another.
  • A call through a parameter, destructured ones included (.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 parameter window, 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.
  • Updated expectations in extraction, js-builtin-method-calls, route-inline-handler-calls, ts-this-field-call and vue-store-extraction, where members are now Owner::member. ts-chained-receiver keeps 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 a window.Known = { ping } case. expression-receiver-calls now records window.Api.start() as written.
  • Linux, both CODEGRAPH_KERNEL_EXPECT=1 and CODEGRAPH_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.mjs reports byte parity on this repo's src/ and ui/src and on two JS apps.

Real repos (main → this branch, native kernel)

nodes edges calls
this repo's src/ + ui/src (TS/Svelte, 352 files) 12,966 → 13,001 37,768 → 37,958 12,530 → 12,694
vanilla-JS desktop renderer, namespaces on window (60 files) 873 → 1,141 4,060 → 4,386 2,570 → 2,572
Vue 3 + TS front end (115 script files) 6,463 → 6,463 12,118 → 12,117 2,284 → 2,283

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.

  • This repo: 35 new nodes. About 370 existing nodes are re-qualified, mostly exported-object members that are now Owner::member. 167 call sites gained a target, such as chalk.bold(…) → main::chalk::bold and c.dim(…) → c::dim. 3 lost one: a callback parameter fn(m) that was bound to an interface property, a defaulted parameter log(…) that was bound to UpgradeDeps::log, and .then(({ runInstaller }) => runInstaller()), which was right before (see the parameter rule).
  • Renderer: 216 new member nodes and 52 new owner nodes. 16 call sites gained a target through the window.rtc = {…} and window.rcjoy = {…} namespaces. One of them, window.rtc.start(…), used to land on an unrelated start in another file. The other losses are 13 single-letter guesses inside a bundled minified library.
  • Vue app: 4 members renamed. One wrong edge is gone: an api parameter call that was bound to an unrelated constant. window.ReactNativeWebView?.postMessage(…) no longer reaches the postMessage property signature the app declares for that global, because the window fallback now accepts free functions only. One wrong edge is new: (this.$refs.x as T)?.reset() now matches the interface method Store::reset alone, because the literal's reset left the bare-name pool. main makes the same edge whenever only the interface exists.
  • I read 10 changed edges at their call sites: 9 are correct, and the wrong one is the reset above.

🤖 Generated with Claude Code

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>
@danusha2345
danusha2345 force-pushed the codex/js-object-member-definitions branch from ba42d71 to 8c46409 Compare October 3, 2026 09:03
colbymchenry added a commit that referenced this pull request Oct 5, 2026
…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>
@colbymchenry

Copy link
Copy Markdown
Owner

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 <script setup> fix, and several of your test scenarios. It was rebuilt on current main with a smaller footprint. It records no scope data at extraction time, so the ref and edge formats and sync stay as they were. A window.WS owner keeps the name WS so it's still found by name, WS.wsM() from another script resolves, and exported members keep isExported. Two guards were added after validation: an object hung on a dotted path isn't reached by its last name alone, and Svelte's $store rule applies only in .svelte files. The EXTRACTION_VERSION bump was dropped because it was already bumped this release. You're credited as a co-author on the commit and in the changelog. Closing this one in favor of #2363.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants