Repository navigation
fix(js,ts): index the functions in every named object literal, not only exported ones (#2300) - #2363
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 anexport const. A plainconst 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 forWSeither. 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 fromcallersand 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 throughApp.init(),window.App.init()orApp.utils.pad().Fix
Extraction (TS extractor and kernel
tsjs, byte-identical)key: function () {},key: () => {}and generators with a static key. The object can be:window.App = {…},App.utils = {…}),dw_page = {…}, DokuWiki's style).functionnodes 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, socodegraph_node App.initfinds it. The existing exported case now uses the same qualification (exported::handlerinstead of a flathandler).module.exports/exports.x,X.prototype = {…},this.x = {…}, literals passed as call arguments, a plain name reassigned inside a function (a bundle'se = {…}), and generated or minified files. Minified files are now detected by content as well as by name, so a vendored bundle not named*.min.jsdoesn't become hundreds of single-letter members. That check is ported to the kernel, and the kernel's.min.jspattern now matches the TS one ([.-]min.m?js).<script setup>keeps the owner → membercontainsedges.Resolution
App.init(),window.App.init(),App.utils.pad(), a sibling'sthis.render(), and aconst { init } = Appbinding. A bareinit()or asetTimeout(init)never reaches it.Appwins over the file's. Otherwise a global holder is used when the caller neither imports nor binds the name. A global holder iswindow.App = {…}anywhere, orAppat the top level of a classic script (noimport,exportorrequire). This is howWS.wsM()from another script on the page resolves.thisof the method around the literal (class App { api() { return { refresh: () => this.update() } } }). An object's own method'sthisis the object. Thethis.xresolvers use the same rule, so excalidraw'screateExcalidrawAPIkeeps its edges.this.swipe()is not$.event.special.swipe = {…}.$storerule now applies only in.sveltecomponents, which is the only place Svelte allows that syntax. In a plain script,$nis just a name. Without this, GWT output'snew $n()linked to an IIFE-local objectnin another example.Contributor PR #2310 (@danusha2345)
Credited in the commit (
Co-authored-by) and in the CHANGELOG. Adopted, re-implemented on current main:functionnodes qualified under the owner, exported or not.<script setup>fold keeps owner → membercontainsedges.vue/svelte/astroare added to the object-literal languages.Not taken, or changed:
EXTRACTION_VERSIONbump. Main is already at 28.jsObjectmetadata oncontainsedges, 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.window.WS = {…}window.WS. With that name,codegraph_node WS/callers WScan't find it by name. Here it is namedWSand qualifiedwindow.WS.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 bindWS.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.isExportedto false, which would affect dead-code results. Kept as before.dw_page = {…}owners, the minified-content gate,thisscoping 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):this;syncedits of the defining script;this.state,.min.js, an unnamed minifiedbundle.js, a bundle's IIFE reassignment);<script setup>;The issue-shape cases fail on main. The guard test fails with the two guards disabled.
__tests__/kernel-tsjs-parity.test.tspasses underCODEGRAPH_KERNEL_EXPECT=1. It now includes object-literal owners (LF and CRLF) and minified bundles namedbundle.js,vendor-min.jsandvendor.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'ssrc+__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 anEBUSYunlinking 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/mainat 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.callsedgescontainsedgesLinkWizard::inittoDokuCookie::init(×2, viathis.init()),dw_qsearch::initanddw_tree::initApiServiceconstant to its membersApiService::post/get/put/deleteRemoved edges, explained
this.render()now go toApp::renderinstead of another example'sduel::render.this.save()now go toApp::app::saveinstead of another example'ssave.this.track()now go toenyo.gesture.drag::trackinstead of a function in the React bundle.completedcalls switch from one wrong cross-example guess to another. Preact'scompletedis now an object member, so the name matcher's fallback picks Ember'sRepo::completed.ninside jQuery'smapswitch between two same-file functions namedn. Both are wrong: parameters have no node.this.trigger(…)calls in Lavaca and enyo pointed at a helpertriggerlocal to jQuery Mobile's$.event.special.scrollstart.setup. That helper is now nested in its member, so it's out of scope.storereference pointed at a Lavaca model member.n(t, e)in enyo, now shadowed correctly inside its member.preventDefault.this.collab.excalidrawAPI.getFiles()was a fuzzy 0.3 guess atFileManager::getFiles. The real target,api::getFiles, is now a node, so the fuzzy fallback no longer has a single candidate.Break.Chain(×4);dw_mediamanager.setOpt(×2);jQuery.event.trigger,jQuery.event.removeand theCallbacks.addinneradd, and enyo'sfindTargetTraverse;listen(this.listen = …; this.listen()), which the source does write as a self-call.Spot-checks of added edges: 81 sampled, 80 correct. Breakdown:
Regex/Breakutility namespaces, the EmscriptenFS/PATH/SYSCALLSobjects in the woff2 bindings,api.onChange→Emitter::on,getEmbedLink'sret.srcdoc. The one wrong one is main's existingwindow.open→Portal::openguess; it only moved to the member it is written in.Y.event.add(),enyo.logging.log()and$.event.special.swipe.start(), plusthis.*sibling calls.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 initnow prints it, median of 3 interleaved runs per build: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:
} =.Left out
$.extend({…}),enyo.kind({…}),Vue.component('x', {…})), prototype objects,module.exports = {…}, and revealing-modulereturn {…}objects. Their members stay unowned, as before.completedcalls above). That behaviour predates this change.🤖 Generated with Claude Code