fix(js,ts): index the functions in every named object literal, not only 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 023fc311):
- 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 commit is contained in:
Colby Mchenry
2026-10-05 22:45:43 +00:00
committed by GitHub
parent 023fc3111b
commit f40db4b9cc
16 changed files with 1496 additions and 62 deletions
+51 -1
View File
@@ -55,7 +55,7 @@ pub fn is_generated_file(file_path: &str) -> bool {
r"\.pb\.[jt]s$",
r"_pb\.[jt]s$",
r"_grpc_pb\.[jt]s$",
r"\.min\.m?js$",
r"[.-]min\.m?js$",
r"_pb2(_grpc)?\.py$",
r"_pb2\.pyi$",
r"\.pb\.(cc|h)$",
@@ -78,6 +78,56 @@ pub fn is_generated_file(file_path: &str) -> bool {
patterns.iter().any(|p| p.is_match(file_path))
}
// isMinifiedContent's WEBPACK_RUNTIME (`\b` ASCII, as in JS).
re!(webpack_runtime, r"(?-u:\b)function __webpack_require__\s*\(");
/// isMinifiedContent (src/extraction/generated-detection.ts): a `.js` / `.mjs`
/// / `.cjs` file of at least 4000 UTF-16 units whose text sits mostly on lines
/// of 1000 units or more, dense with `;{}(),` — or that carries a webpack
/// runtime. Lengths are counted in UTF-16 units, as the JS string ops count
/// them, so both extractors agree on every file.
pub fn is_minified_content(file_path: &str, content: &str) -> bool {
const LINE: usize = 1000;
let lower = file_path.to_ascii_lowercase();
if !(lower.ends_with(".js") || lower.ends_with(".mjs") || lower.ends_with(".cjs")) {
return false;
}
// UTF-16 units never outnumber UTF-8 bytes: a short file is out at once.
if content.len() < 4 * LINE {
return false;
}
let total = content.encode_utf16().count();
if total < 4 * LINE {
return false;
}
if webpack_runtime().is_match(content) {
return true;
}
let (mut long, mut punctuation) = (0usize, 0usize);
let (mut line_len, mut line_punct) = (0usize, 0usize);
for unit in content.encode_utf16() {
if unit == 10 {
if line_len >= LINE {
long += line_len;
punctuation += line_punct;
}
line_len = 0;
line_punct = 0;
continue;
}
line_len += 1;
// ; { } ( ) ,
if matches!(unit, 59 | 123 | 125 | 40 | 41 | 44) {
line_punct += 1;
}
}
if line_len >= LINE {
long += line_len;
punctuation += line_punct;
}
long as f64 >= total as f64 * 0.5 && punctuation as f64 >= long as f64 * 0.03
}
/// Byte offsets of each line start, for UTF-16 column conversion.
pub fn line_starts(src: &str) -> Vec<usize> {
let mut out = vec![0usize];
+194 -2
View File
@@ -5,8 +5,8 @@
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,
body_of, is_builtin_type, is_host_global_root, is_literal_receiver, is_object_member_function,
is_react_hoc, is_static_object_key, is_variable_type, is_vue_collection_name, Extra, Scope, Walker,
};
use crate::buffers::edge_kind_index;
use tree_sitter::Node;
@@ -449,6 +449,15 @@ impl<'t> Walker<'t> {
self.extract_variable_type_annotation(child, row);
}
// A named object literal owns its function members, exported or
// not (#2300): `const api = { load() {…} }` gives `api::load`. The
// rest of the literal is walked under the owner (#693). Mirrors
// the ownedObject branch of TreeSitterExtractor.extractVariable.
if let (Some(row), Some(obj)) = (var_row, self.owned_object_value(value)) {
self.extract_owned_object_members(obj, row, kind, &name, true, false);
continue;
}
// Exported const object-of-functions / store shapes.
let object_of_fns: Option<Node> = match value {
Some(v) if matches!(v.kind(), "object" | "object_expression") => Some(v),
@@ -552,6 +561,189 @@ impl<'t> Walker<'t> {
}
}
// --- owned object literals (#2300) ------------------------------------------------
/// ownsObjectLiterals: a generated or minified bundle — named so or not —
/// keeps the old shape. (Every tsjs variant is TS/JS, so only that check
/// remains; it is computed once per file.)
fn owns_object_literals(&self) -> bool {
self.owns_objects
}
/// ownedMemberFunction: the function an owned literal's member becomes,
/// named by its static key — `load() {…}`, `load: () => {…}`,
/// `load: function () {…}`, `load: function* () {…}`. None for every other
/// member (a computed key, a value, a shorthand, a spread).
fn owned_member_function(&self, member: Node<'t>) -> Option<(Node<'t>, String)> {
if member.kind() == "method_definition" {
let key = member.child_by_field_name("name")?;
if !is_static_object_key(key.kind()) {
return None;
}
return Some((member, util::object_key_name(self.text(key))));
}
if member.kind() != "pair" {
return None;
}
let key = member.child_by_field_name("key")?;
let value = member.child_by_field_name("value")?;
if !is_static_object_key(key.kind()) || !is_object_member_function(value.kind()) {
return None;
}
Some((value, util::object_key_name(self.text(key))))
}
/// ownedObjectValue: `value` when it is an object literal owning at least
/// one function member.
pub(super) fn owned_object_value(&self, value: Option<Node<'t>>) -> Option<Node<'t>> {
let value = value?;
if !matches!(value.kind(), "object" | "object_expression") || !self.owns_object_literals() {
return None;
}
for i in 0..value.named_child_count() {
if let Some(member) = value.named_child(i) {
if self.owned_member_function(member).is_some() {
return Some(value);
}
}
}
None
}
/// Put an owner on the stack — on top of the file alone for one on the
/// global object — returning what was set aside for leave_owner.
fn enter_owner(&mut self, row: u32, kind: &'static str, name: &str, global: bool) -> Vec<Scope> {
let rest = if global { self.stack.split_off(1) } else { Vec::new() };
self.stack.push(Scope { row, kind, name: name.to_string() });
rest
}
fn leave_owner(&mut self, rest: Vec<Scope>) {
self.stack.pop();
self.stack.extend(rest);
}
/// extractOwnedObjectMembers: each function member becomes its own node,
/// qualified under the owner (at file scope for one on the global object).
/// Every other member runs where the literal is written: under the owner
/// for a module-scope declaration (`values_under_owner`, #693), under the
/// enclosing scope for a local or an assignment. The literal's shorthand
/// members go to the function-as-value capture the same way.
fn extract_owned_object_members(&mut self, obj: Node<'t>, row: u32, kind: &'static str, name: &str, values_under_owner: bool, global: bool) {
let held = values_under_owner.then(|| self.enter_owner(row, kind, name, global));
self.maybe_capture_fn_refs(obj);
for i in 0..obj.named_child_count() {
let Some(member) = obj.named_child(i) else { continue };
if let Some((func, member_name)) = self.owned_member_function(member) {
if values_under_owner {
self.extract_function(func, Some(member_name));
} else {
let rest = self.enter_owner(row, kind, name, global);
self.extract_function(func, Some(member_name));
self.leave_owner(rest);
}
} else {
self.visit_function_body(member);
}
}
if let Some(rest) = held {
self.leave_owner(rest);
}
}
/// extractLocalObjectOwner: `const api = { load() {…} }` in a function body
/// or an IIFE gets the node a module-scope declaration would, with its
/// members under it. False when the declarator holds no owned literal.
pub(super) fn extract_local_object_owner(&mut self, declarator: Node<'t>) -> bool {
let Some(name_node) = declarator.child_by_field_name("name") else { return false };
if name_node.kind() != "identifier" {
return false;
}
let Some(obj) = self.owned_object_value(declarator.child_by_field_name("value")) else {
return false;
};
let is_const = declarator.parent().map(|d| self.is_const_decl(d)).unwrap_or(false);
let kind: &'static str = if is_const { "constant" } else { "variable" };
let name = self.text(name_node).to_string();
// A local: never the target of another scope's value read.
let extra = Extra {
docstring: crate::docstring::preceding_docstring(declarator, self.src),
signature: Some(util::init_signature(self.text(obj))),
is_exported: Some(false),
not_value_target: true,
..Extra::default()
};
let Some(row) = self.create_node(kind, &name, declarator, extra) else { return false };
self.extract_variable_type_annotation(declarator, row);
self.extract_owned_object_members(obj, row, kind, &name, false, false);
true
}
/// extractAssignedObjectOwner: `window.App = {…}` / `App.utils = {…}` /
/// `dw_page = {…}`. A plain identifier target is qualified like a
/// declaration. A path's owner is named by its last link and qualified by
/// the path as written, and so are its members (`window.App::init`); a
/// path on the global object is global wherever it is written, so it is
/// contained by the file. False (nothing extracted) for any other assignment.
pub(super) fn extract_assigned_object_owner(&mut self, node: Node<'t>, module_level: bool) -> bool {
let Some(obj) = self.owned_object_value(node.child_by_field_name("right")) else { return false };
let Some(path) = self.object_owner_path(node.child_by_field_name("left")) else { return false };
// A plain name assigned at module level is a global (`dw_page = {…}`);
// in a function it is a local being reassigned.
if path.len() == 1 && !module_level {
return false;
}
let qualified_name = (path.len() > 1).then(|| path.join("."));
let global = path.len() > 1 && is_host_global_root(&path[0]);
let rest = if global { self.stack.split_off(1) } else { Vec::new() };
let statement = node.parent().filter(|p| p.kind() == "expression_statement").unwrap_or(node);
let name = path.last().cloned().unwrap_or_default();
// `App.utils` is read as `App.utils`, never as a bare `utils`.
let extra = Extra {
docstring: crate::docstring::preceding_docstring(statement, self.src),
signature: Some(util::init_signature(self.text(obj))),
is_exported: Some(false),
qualified_name: qualified_name.clone(),
not_value_target: !(path.len() == 1 || global),
..Extra::default()
};
let row = self.create_node("variable", &name, node, extra);
self.stack.extend(rest);
let Some(row) = row else { return false };
if let Some(qualified_name) = qualified_name {
self.object_path_owners.insert(row, qualified_name);
}
self.extract_owned_object_members(obj, row, "variable", &name, false, global);
true
}
/// objectOwnerPath: an assignment target's links written as plain names
/// (`dw_page` → [dw_page], `App.utils.dom` → [App, utils, dom]), or None
/// for anything else — `this.x`, `a[k]`, a call, CommonJS's
/// `module.exports` / `exports.x`, or a prototype.
fn object_owner_path(&self, left: Option<Node<'t>>) -> Option<Vec<String>> {
let mut path: Vec<String> = Vec::new();
let mut cur = left?;
while cur.kind() == "member_expression" {
let property = cur.child_by_field_name("property")?;
if property.kind() != "property_identifier" {
return None;
}
path.push(self.text(property).to_string());
cur = cur.child_by_field_name("object")?;
}
if cur.kind() != "identifier" {
return None;
}
let root = self.text(cur);
if root == "module" || root == "exports" || path.iter().any(|p| p == "prototype") {
return None;
}
path.push(root.to_string());
path.reverse();
Some(path)
}
// --- object-literal / store helpers -------------------------------------------------
pub(super) fn extract_object_literal_functions(&mut self, obj: Node<'t>) {
+57 -3
View File
@@ -136,6 +136,24 @@ fn is_vue_collection_name(name: &str) -> bool {
matches!(name, "actions" | "mutations" | "getters")
}
/// OBJECT_MEMBER_FUNCTION_TYPES (tree-sitter.ts, #2300): the function values an
/// owned object literal's member can hold.
fn is_object_member_function(kind: &str) -> bool {
matches!(kind, "arrow_function" | "function_expression" | "generator_function")
}
/// STATIC_OBJECT_KEY_TYPES (tree-sitter.ts, #2300): keys that name a member — a
/// computed `[expr]` key names nothing static.
fn is_static_object_key(kind: &str) -> bool {
matches!(kind, "property_identifier" | "string" | "number")
}
/// HOST_GLOBAL_ROOTS (tree-sitter.ts, #2300): `window.App = {…}` defines the
/// global `App`. (`self` is usually `var self = this` in page code, not the global.)
fn is_host_global_root(name: &str) -> bool {
matches!(name, "window" | "globalThis")
}
/// One scope-stack entry (TS keeps node IDs; rows are our equivalent).
struct Scope {
row: u32,
@@ -153,6 +171,9 @@ struct Extra {
is_async: Option<bool>,
is_static: Option<bool>,
qualified_name: Option<String>,
/// Never a value-read target: a local, or an object hung on a dotted path
/// (createNode's `valueTarget = false`, #2300).
not_value_target: bool,
}
struct ValueScope<'t> {
@@ -185,6 +206,12 @@ pub struct Walker<'t> {
fs_value_counts: HashMap<String, u32>,
value_scopes: Vec<ValueScope<'t>>,
vue_store_file: Option<bool>,
/// An object literal hung on a path (`window.App = {…}`) is qualified by
/// the path, and so is what it holds (#2300): row → that qualified name.
/// Mirrors TreeSitterExtractor.objectPathOwners.
object_path_owners: HashMap<u32, String>,
/// ownsObjectLiterals: false for a generated or minified bundle (#2300).
owns_objects: bool,
}
const MAX_VALUE_REF_NODES: usize = 20_000;
@@ -234,6 +261,8 @@ pub fn extract(file_path: &str, source: &str, language: &str) -> Result<EmitOut,
fs_value_counts: HashMap::new(),
value_scopes: Vec::new(),
vue_store_file: None,
object_path_owners: HashMap::new(),
owns_objects: !util::is_generated_file(file_path) && !util::is_minified_content(file_path, source),
};
// File node (TreeSitterExtractor.extract): id `file:<path>`, endLine =
@@ -352,6 +381,7 @@ impl<'t> Walker<'t> {
let start_line = self.line_of(node);
let column = self.col_of(node);
let id = self.node_id_allocator.generate(self.file_path, kind, name, start_line, column);
let value_target = !extra.not_value_target;
// endLine body extension: resolveBody only (TS/JS: function-valued
// class fields whose body nests in the arrow / HOF-wrapped arrow).
@@ -369,6 +399,13 @@ impl<'t> Walker<'t> {
let qualified = extra.qualified_name.unwrap_or_else(|| {
let mut parts: Vec<&str> = Vec::new();
for s in &self.stack {
// A path-hung object literal qualifies what it holds by its
// path (`window.App::init`), which carries its own scope.
if let Some(path) = self.object_path_owners.get(&s.row) {
parts.clear();
parts.push(path);
continue;
}
if s.kind != "file" {
parts.push(&s.name);
}
@@ -434,18 +471,19 @@ impl<'t> Walker<'t> {
if kind == "function" || kind == "method" {
self.defined_fn_names.insert(name.to_string());
}
self.capture_value_ref_scope(kind, name, row, node);
self.capture_value_ref_scope(kind, name, row, node, value_target);
Some(row)
}
// --- value references (captureValueRefScope / flushValueRefs) --------------
fn capture_value_ref_scope(&mut self, kind: &'static str, name: &str, row: u32, node: Node<'t>) {
fn capture_value_ref_scope(&mut self, kind: &'static str, name: &str, row: u32, node: Node<'t>, value_target: bool) {
if !self.variant.value_refs() {
return;
}
let target_kind_ok = kind == "constant" || kind == "variable";
if target_kind_ok
if value_target
&& target_kind_ok
&& util::utf16_len(name) >= 3
&& util::has_upper_or_underscore().is_match(name)
{
@@ -640,6 +678,12 @@ impl<'t> Walker<'t> {
// Function-as-value capture — independent of the dispatch ladder.
self.maybe_capture_fn_refs(node);
// `window.App = {…}` / `App.utils = {…}`: the object's functions are
// the path's members (#2300). Its whole subtree is handled there.
if kind == "assignment_expression" && self.extract_assigned_object_owner(node, true) {
return;
}
if is_function_type(kind) {
// (the isInsideClassLike + methodTypes overlap is Python/Ruby-only)
self.extract_function(node, None);
@@ -729,6 +773,16 @@ impl<'t> Walker<'t> {
let kind = node.kind();
self.maybe_capture_fn_refs(node);
// A named object literal in a body (an IIFE's `const App = {…}`) owns
// its function members as one at module scope does, and so does
// `window.App = {…}` written in here (#2300). Each handles its subtree.
if kind == "variable_declarator" && self.extract_local_object_owner(node) {
return;
}
if kind == "assignment_expression" && self.extract_assigned_object_owner(node, false) {
return;
}
if kind == "call_expression" {
self.extract_call(node);
} else if kind == "new_expression" {