Skip to content

fix(js,ts): index the functions in every named object literal, not only exported ones (#2300) - #2363

Merged
colbymchenry merged 1 commit into
mainfrom
fix/2300-js-object-literal-members
Oct 5, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
fix/2300-js-object-literal-members

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

Issue #2300 (reported by @tkhoaaa): in JavaScript and TypeScript, the functions inside an object literal (load() {}, load: function () {}, load: () => {}) only became symbols 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 is written almost entirely this way, so most of it was missing from callers and impact.

This PR gives every named object literal its members, exported or not, and resolves calls to them through the object they are written on.

Cause

The TS/JS extractor, and the native kernel's copy of it, created member nodes only for exported object-of-functions. Every other object literal was walked as one opaque initializer. Assignments to a member path (window.App = {…}) were never treated as declarations. On the resolution side, nothing could reach a member through App.init(), window.App.init() or App.utils.pad().

Fix

Extraction (TS extractor and kernel tsjs, byte-identical)

  • A named object literal owns its function members: method shorthand, key: function () {}, key: () => {} and generators with a static key. The object can be:
    • declared at module scope (exported or not),
    • declared inside a function body or an IIFE,
    • assigned to a path (window.App = {…}, App.utils = {…}),
    • assigned to a plain name at module level (dw_page = {…}, DokuWiki's style).
  • Members are function nodes qualified under their owner: api::load, window.App::init, App.utils::pad. A path owner is named by its last link (App, utils) and qualified by the whole path, so codegraph_node App.init finds it. The existing exported case now uses the same qualification (exported::handler instead of a flat handler).
  • Calls inside a member belong to that member. The literal's other values are walked where they were before: under the constant for a module-scope declaration (CodeGraph does not index call edges from anonymous/lambda functions #693), under the enclosing function otherwise.
  • These keep the old shape: CommonJS module.exports / exports.x, X.prototype = {…}, this.x = {…}, literals passed as call arguments, a plain name reassigned inside a function (a bundle's e = {…}), and generated or minified files. Minified files are now detected by content as well as by name, so a vendored bundle not named *.min.js doesn't become hundreds of single-letter members. That check is ported to the kernel, and the kernel's .min.js pattern now matches the TS one ([.-]min.m?js).
  • <script setup> keeps the owner → member contains edges.

Resolution

  • A member is reached through its object: App.init(), window.App.init(), App.utils.pad(), a sibling's this.render(), and a const { init } = App binding. A bare init() or a setTimeout(init) never reaches it.
  • In the caller's own file, the holder is chosen by lexical block, so an IIFE's own App wins over the file's. Otherwise a global holder is used when the caller neither imports nor binds the name. A global holder is window.App = {…} anywhere, or App at the top level of a classic script (no import, export or require). This is how WS.wsM() from another script on the page resolves.
  • Arrow members keep the this of the method around the literal (class App { api() { return { refresh: () => this.update() } } }). An object's own method's this is the object. The this.x resolvers use the same rule, so excalidraw's createExcalidrawAPI keeps its edges.
  • Two guards keep the new nodes from being picked up by unrelated names:
    • An object hung on a dotted path is never reached by its last name alone. this.swipe() is not $.event.special.swipe = {…}.
    • Svelte's $store rule now applies only in .svelte components, which is the only place Svelte allows that syntax. In a plain script, $n is just a name. Without this, GWT output's new $n() linked to an IIFE-local object n in another example.

Contributor PR #2310 (@danusha2345)

Credited in the commit (Co-authored-by) and in the CHANGELOG. Adopted, re-implemented on current main:

  • Members of a direct object literal become function nodes qualified under the owner, exported or not.
  • Path owners are qualified by the path, and host-global paths are contained by the file.
  • IIFE and function-local literals are owners; only static keys count; generator members are included.
  • CommonJS exports and call-argument literals are left alone.
  • A member is never reachable by a bare name, except a named function expression calling itself.
  • The <script setup> fold keeps owner → member contains edges.
  • vue / svelte / astro are added to the object-literal languages.
  • Several of its test scenarios: sibling IIFEs, a shadowing parameter, the nearest-scope rule.

Not taken, or changed:

  • The EXTRACTION_VERSION bump. Main is already at 28.
  • The extraction-time binding oracle: lexical scope records on refs, jsObject metadata on contains edges, kernel ref patching, and the decode/sync special cases. These are replaced by resolution-time lookups over the graph and the masked source, so the ref and edge formats are unchanged and sync needs no special path.
  • Naming the owner of window.WS = {…} window.WS. With that name, codegraph_node WS / callers WS can't find it by name. Here it is named WS and qualified window.WS.
  • Leaving WS.wsM() from another script unresolved. The PR does this by design; here it resolves when the holder is a global and the caller doesn't import or bind WS.
  • Rewriting the ref name of window.X.m(), which changed the expectations of other suites. Here the ref keeps its name (the 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 escape) and the receiver is read from the call site.
  • Flipping exported members' isExported to false, which would affect dead-code results. Kept as before.
  • Bare-call behaviour changes outside this issue.
  • Added beyond the PR: module-level dw_page = {…} owners, the minified-content gate, this scoping for arrow members, no value-read targets for locals and dotted paths, and the two guards above.

On DokuWiki, measured against main, #2310 adds +113 nodes but only +5 call edges. 36 of those nodes are junk members of a minified jQuery, and DokuWiki's own dw_page = {…} namespaces get no members. This branch adds +85 nodes and +105 call edges.

Verification

  • New __tests__/js-object-literal-members.test.ts, run on both the native kernel and wasm (18 tests):

    • every container shape from the issue, and the calls inside a member;
    • resolution through the object, and never by bare name;
    • IIFE and function scoping, and module privacy;
    • arrow-member this;
    • a cross-file member edge surviving sync edits of the defining script;
    • imported literals and destructured bindings;
    • shapes that must not change (CommonJS, prototype, call argument, data, this.state, .min.js, an unnamed minified bundle.js, a bundle's IIFE reassignment);
    • <script setup>;
    • the path-holder and Svelte guards.

    The issue-shape cases fail on main. The guard test fails with the two guards disabled.

  • __tests__/kernel-tsjs-parity.test.ts passes under CODEGRAPH_KERNEL_EXPECT=1. It now includes object-literal owners (LF and CRLF) and minified bundles named bundle.js, vendor-min.js and vendor.min.js.

  • Kernel-vs-wasm parity sweeps (scripts/kernel-parity.mjs) found 0 diffs on DokuWiki and vue-realworld (45/45 files), TodoMVC (454/460), excalidraw (698/703) and this repo's src + __tests__ (798/812). The remaining files are deferred to wasm by policy.

  • Updated expectations, now the qualified owner names: extraction, js-builtin-method-calls, route-inline-handler-calls, ts-this-field-call, vue-store-extraction.

  • Full suite on Windows: 6110 passed, 82 skipped, 1 failed. The failure was mcp-status-freshness.test.ts, with an EBUSY unlinking its temp database during teardown while the machine was loaded. It passes when run alone (4/4) and doesn't touch this code.

Validation

Kernel loaded. "Before" is origin/main at 023fc31, this branch's merge base, with its own kernel. The same diff taken against 8998697, before main's Go, Dart, COBOL, Rust and JS-performance commits, gives the same changes.

repo nodes calls edges removed non-contains edges
DokuWiki (script-tag JS + PHP, 1676 files) 11292 → 11377 (+85) 13116 → 13221 (+105) 17: 13 moved to the member that makes the call; 4 re-resolved from a wrong LinkWizard::init to DokuCookie::init (×2, via this.init()), dw_qsearch::init and dw_tree::init
TodoMVC (vanilla/jQuery/Backbone/… examples, 379 files) 11257 → 11684 (+427) 21726 → 21874 (+148) 306: 226 moved to the member; 33 re-resolved; 47 dropped (below)
excalidraw (TS app, 717 files) 13340 → 13426 (+86) 25206 → 25281 (+75) 34: 33 moved to the member; 1 dropped (a fuzzy 0.3 guess, below)
vue-realworld (Vue 2 SPA, 39 files) 353 → 363 (+10) 152 → 163 (+11) 15: 8 moved; 7 re-resolved from the ApiService constant to its members ApiService::post/get/put/delete

Removed edges, explained

  • TodoMVC re-resolved (33):
    • 7 jQuery this.render() now go to App::render instead of another example's duel::render.
    • 5 Aurelia this.save() now go to App::app::save instead of another example's save.
    • 2 enyo this.track() now go to enyo.gesture.drag::track instead of a function in the React bundle.
    • 13 completed calls switch from one wrong cross-example guess to another. Preact's completed is now an object member, so the name matcher's fallback picks Ember's Repo::completed.
    • 6 calls of a parameter n inside jQuery's map switch between two same-file functions named n. Both are wrong: parameters have no node.
  • TodoMVC dropped (47), all wrong edges:
    • 43 this.trigger(…) calls in Lavaca and enyo pointed at a helper trigger local to jQuery Mobile's $.event.special.scrollstart.setup. That helper is now nested in its member, so it's out of scope.
    • A react-redux store reference pointed at a Lavaca model member.
    • A callback parameter n(t, e) in enyo, now shadowed correctly inside its member.
    • Two edges in Closure-compiled output: a local function out of scope, and a fuzzy 0.3 preventDefault.
  • excalidraw dropped (1): this.collab.excalidrawAPI.getFiles() was a fuzzy 0.3 guess at FileManager::getFiles. The real target, api::getFiles, is now a node, so the fuzzy fallback no longer has a single candidate.
  • New self-loops are real recursion, with one exception:
    • excalidraw Break.Chain (×4);
    • DokuWiki dw_mediamanager.setOpt (×2);
    • TodoMVC jQuery's jQuery.event.trigger, jQuery.event.remove and the Callbacks.add inner add, and enyo's findTargetTraverse;
    • the exception is enyo's lazily redefined listen (this.listen = …; this.listen()), which the source does write as a self-call.

Spot-checks of added edges: 81 sampled, 80 correct. Breakdown:

  • excalidraw: 26 sampled, 25 correct. Regex/Break utility namespaces, the Emscripten FS/PATH/SYSCALLS objects in the woff2 bindings, api.onChange → Emitter::on, getEmbedLink's ret.srcdoc. The one wrong one is main's existing window.open → Portal::open guess; it only moved to the member it is written in.
  • TodoMVC: 21/21. jQuery/enyo/Lavaca path namespaces such as Y.event.add(), enyo.logging.log() and $.event.special.swipe.start(), plus this.* sibling calls.
  • DokuWiki: 21/21.
  • vue-realworld: 13/13.

Every node TodoMVC gains is in a file the minified check passes. Before that check existed, TodoMVC gained +707 nodes.

Index time. Whole-run time as codegraph init now prints it, median of 3 interleaved runs per build:

repo main this branch
vue-realworld 1.2 s 1.2 s
DokuWiki 8.6 s 9.1 s
excalidraw 10.8 s 10.7 s
TodoMVC 16.9 s 16.9 s

These differences are run-to-run noise on this shared machine: DokuWiki's runs spread over 8.3–10.0 s on both builds.

The first version of the resolution lookups cost TodoMVC about 30% once #2362 had made main's JS resolution fast. The fix, profiled to its cause:

  • Each file's destructuring patterns are read once, and only when the raw text holds a } =.
  • A call written bare skips the receiver read.
  • The lookup checks the calling file's bindings only after a global holder with the member is found.
  • The per-file scans (blanked source, block index, binding scopes) keep only the last 32 files, as main's destructuring cache does. A large project's files are never all held at once.

Left out

  • Object literals passed as arguments ($.extend({…}), enyo.kind({…}), Vue.component('x', {…})), prototype objects, module.exports = {…}, and revealing-module return {…} objects. Their members stay unowned, as before.
  • Name-matcher fallbacks that swap one wrong cross-file guess for another (the completed calls above). That behaviour predates this change.

🤖 Generated with Claude Code

…ly exported ones (#2300)

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>
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.

1 participant