* fix(js): index CommonJS `exports.foo = function () {}` exports (#2723)
Functions assigned to an `exports` / `module.exports` property were not
indexed at all. On a CommonJS codebase — the dominant pre-ESM Node style
(Express, Firebase Functions) — the graph held every internal helper and
missed the entire public API: `impact({target: 'areVariablesValid'})`
answered `Target not found` for the one symbol whose blast radius mattered.
The gap had two halves, and fixing either alone leaves the feature broken:
1. `tree-sitter-queries.ts` carried `@definition.function` rules for every
declaration form and every variable-binding closure form, but none for
`assignment_expression` — so no `Function` node was created.
2. The scope-resolution queries (`languages/{javascript,typescript}/query.ts`)
likewise had no `@declaration.function` for the shape. Adding only (1)
moves `impact` from "not found" to "found, zero callers", because call
resolution reaches a definition through the scope declaration, not
through the graph node.
Both layers now carry the rule, for `function` / `async function` / arrow /
async arrow / generator right-hand sides, in JavaScript and TypeScript. The
receiver is pinned to `exports` / `module.exports` with `#eq?` predicates:
the general `X.foo = function () {}` shape also covers `Foo.prototype.bar`
and `this.handler`, which are member constructs with their own ownership
questions, and a broader rule would emit ownerless top-level Functions for
them. The declaration binds the bare property name into the module scope,
which is what importers see, so `const { foo } = require('./m')` matches by
name and a namespace `m.foo()` walks the module's defs.
Verified end to end: node emission for every listed form plus TS parity, and
CALLS edges for same-file `exports.foo()`, cross-file namespace `m.foo()`,
and cross-file destructured `require()`. The generator call-resolution case
was confirmed to fail against the pre-fix build before the rule landed.
Fixes#2723
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(js): stop the CJS export rule from shadowing a declared function (#2723)
Review of the previous commit caught a regression it introduced. The CJS
`@declaration.function` rules bind the exported property name into the module
scope — which is the point, since that is what importers resolve against. But
when the file ALSO declares that name lexically:
function dup(v) { return v; }
exports.dup = function (v) { return !v; };
function callIt(v) { return dup(v); }
the module scope ends up holding two declarations named `dup`, the name is
ambiguous, and the resolver drops `callIt -> dup` entirely — an edge that
resolved fine before #2723. Confirmed by rebuilding both states: present at
ff86ccf1e, missing at f302916c. A silently missing caller is worse than the
gap #2723 set out to close; it is the impact-under-reporting class this repo
has been bitten by before.
The emitter now drops the CJS `@declaration.function` in exactly that case.
The lexical declaration already supplies the module-scope name, so importers
still resolve through it and intra-module resolution returns to its pre-#2723
behavior — verified by re-running the probe that found the regression.
Implemented at the established seam: a shared pure helper both capture
emitters import and apply at the existing `@declaration.function` filter,
mirroring `array-callback.ts` (#1876), which solves the same
"drop a spurious declaration emit-side" problem.
The module-scope name set is computed once per file and memoized per program
root in a WeakMap, rather than walked per export — a 1000-export CommonJS
module is precisely the shape #2723 was reported against, and the per-export
walk would be quadratic there. `tree.rootNode` was probed to confirm it
returns a stable object identity, so the memo actually hits; measured scaling
across 250/500/1000/2000 exports is linear.
Only the scope declaration is suppressed. The graph node comes from a
separate query and collapses onto the lexical declaration's node by name, so
no node is lost.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* perf(js): fold the CJS export RHS forms into one pattern per receiver
Benchmarking the #2723 rules found the only reproducible cost is tree-sitter
query COMPILE, paid once per worker process when the lazy Query singleton is
built. Steady-state per-file emit cost and memory proved to sit below the
measurement noise floor, so there is nothing to win there.
The six CJS scope-query patterns per language (3 right-hand-side forms x 2
receiver forms) collapse to two, folding the RHS forms into an inner leaf
alternation. Measured over 5 runs per build, variance under 1ms:
query compile base before after
JavaScript 42.2ms 50.6ms 45.1ms
TypeScript 116.5ms 136.4ms 123.3ms
That recovers ~65% of the added compile cost in both grammars — about 19ms
per worker process, so ~75ms on a 4-worker analyze — and removes 45 lines of
duplicated query text.
The alternation is deliberately the INNER LEAF form. tree-sitter 0.21.1 has a
known hazard where a top-level `[...]` alternation makes sibling branches
share a single predicate bucket, silently dropping matches with no compile
error (it has bitten this repo twice: #1904, #1912). Here every predicate
sits on a capture OUTSIDE the alternation — `@_cjs.exports` / `@_cjs.module`
are on the left-hand side and bound in every branch — which is the documented
safe shape. Verified rather than assumed: a probe asserts all six receiver x
RHS combinations still bind both `@declaration.function` and
`@declaration.name` in both grammars, and that `exportz.x` / `module.other` /
`Foo.prototype.bar` / `this.handler` / aliased `exports` are still rejected —
26/26 checks, so the predicate bucket is intact.
No behavior change: the graph output on a 600-file corpus is identical
node-for-node and edge-for-edge, and the 142 JS/TS integration tests plus
1299 scope-resolution unit tests are unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(js): index prototype and `this` member assignments as Methods (#2723)
Follow-up on the known limitations listed with the CJS export fix. Three of
them close here; the rest are recorded below with what they actually cost.
`Foo.prototype.bar = function () {}` is the dominant pre-ES6 method form — the
same population as the CJS exports this PR started with — and it was equally
invisible: no node at all, so `impact` could not reach a single prototype
method. `this.handler = function () {}` inside a constructor is its sibling,
and the pre-ES6 form of the closure-valued class field #2693 already models as
a Method.
Both now emit a `Method` with an owner edge:
function Foo() {}
Foo.prototype.bar = function (v) { return v; };
// Method:f.js:Foo.bar, HAS_METHOD Function:f.js:Foo -> Method:f.js:Foo.bar
The label comes from `provider.labelOverride` (Function -> Method) and the
owner from a sibling of `findObjectLiteralBindingInfo` — the helper that
already answers "this Method's owner is named by syntax, not by an enclosing
container" for object-literal methods. No new shared-code seam was invented.
Ownership resolves to what the file actually declares, so the edge points at a
node that exists: `function Foo` gives a `Function` owner, `class Foo` a
`Class` owner, and an owner the file does not declare
(`External.prototype.x = …`) claims NO owner edge rather than one pointing at
a fabricated node. A `this.x = fn` inside a class constructor needs none of
this — parse-worker resolves its owner from the enclosing class first.
Member ids qualify by owner (`Method:f.js:Foo.bar`). Without that, two
constructors in one file that each define `bar` collapse onto a single
`Method:f.js:bar` — the same identity collapse #2699 fixed for function-local
callables. Only the new prototype/`this` path qualifies, so object-literal
method ids are byte-identical to before.
Third fix, the orphan twin: `class Dup {}` plus `exports.Dup = function () {}`
emitted `Class:f:Dup` AND an unreachable `Function:f:Dup`. The scope
declaration for a shadowed CJS export is suppressed (previous commit), so the
node had nothing that could resolve to it; with a `function` of that name the
node collapsed by id anyway, but with a `class` the labels differ so it
lingered. `labelOverride` now returns null for that case and no node is
emitted.
## Still open, with measured cost
- Receiver-typed CALLS to a prototype method (`f.bar()`) do not resolve yet. A
class method resolves because the class owns a scope the resolver attaches
members to; a prototype assignment has no such scope, so this needs the
scope layer to associate members with the constructor's type. Verified as a
control that `new KlassC().meth()` does resolve, so this is specifically the
missing half, not a general gap.
- `exports.fwd = lib.imported` still does not forward to the original
definition — resolution/finalize-layer aliasing, reachable by no query rule.
Note `exports.localFn = localFn` (declare-then-export, by far the more
common idiom) ALREADY resolves and needed no work.
- Aliased `const e = exports; e.foo = fn` and module-top-level `this.x = fn`
remain unindexed. The latter is only an export under CommonJS semantics; in
ESM top-level `this` is undefined, so it needs a CJS gate rather than being
applied to every `.js` file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(js): index CJS exports assigned through an alias (#2723)
`const e = exports; e.foo = function () {}` exports `foo` exactly as
`exports.foo = fn` does, but no query can express "an identifier that happens
to alias the exports object" — the receiver is only knowable per file.
So the member-assignment rules now match ANY identifier receiver and the
emitters classify. A file's module-scope aliases (`const e = exports`,
`const m = module.exports`) are collected once per program root and memoized
beside the declared-name set, so the answer costs one top-level pass no matter
how many assignments ask.
The widening is only safe because the pruning is exact, and that is the risk
worth stating plainly: without it every `obj.handler = function () {}` in every
JS/TS file would emit a spurious top-level `Function` named `handler`. Both
layers prune:
- graph nodes, in `labelOverride`: an assignment-anchored capture that is not
a recognised shape returns null, so no node is emitted at all;
- scope declarations, in both capture emitters: a receiver that is not the
exports object declares nothing at module scope.
Verified on both sides. `obj.notAnExport`, `self.alsoNot` and
`localThing.nope` produce no node and no declaration, while an aliased export
resolves cross-file through both the namespace and destructured `require()`
forms.
## Cost
Re-benchmarked, because this widens a query the previous commit had just
optimized. Query compile over 3 runs: JavaScript 46.5ms, TypeScript 123.8ms —
+1.4ms and +0.5ms against the optimized state, since dropping the `#eq?`
predicates offsets the added patterns. Steady state on the assignment-heavy
corpus (200 files x 30 member assignments, the worst case for a widened
receiver) stays inside the +-4% noise band established earlier. Heap unchanged.
One measurement artifact worth recording so it is not mistaken for a
regression later: the real-repo TS corpus went 93,586 -> 93,748 captures across
these commits. That is corpus drift, not over-matching — the benchmark walks a
sorted file list and takes the first 400, and this work added a new source file
to that tree. The repo's own TypeScript contains zero occurrences of the
`identifier.property = function` shape, so the widened rule contributes nothing
there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(js): treat module-level `this.X = fn` as a CommonJS export (#2723)
In CommonJS, module-level `this` IS `module.exports`, so
this.handler = function (data) { … };
at the top of a `.js` file exports `handler` exactly as `exports.handler`
does. It previously produced an ownerless `Method` that no importer could
reach.
The CommonJS gate is the whole point of the change, not a detail. Top-level
`this` is `undefined` in ESM, so the same line exports nothing there — treating
it as an export would mis-index every `.mjs`, every `"type": "module"` package,
and every `.ts` that compiles to ESM. Detection is deliberately asymmetric: an
`import`/`export` statement settles the file as ESM immediately, a `require()`
call or an `exports`/`module` reference marks it CommonJS, and a file carrying
NEITHER signal is left alone — silence is not evidence of CommonJS.
`this` nesting follows the receiver rule the scope queries already encode
(#2701): an arrow does not bind `this`, so a top-level arrow's `this` is still
the module's and passes through the walk, while every other function form binds
its own receiver and stops it — that is an instance member, which keeps the
Method-plus-owner treatment from the previous commit.
Verified across all three cases rather than just the happy path: a CJS file
exports both the `function` and arrow forms and they resolve through a
cross-file destructured `require()`; an ESM file's identical line produces no
export; and a file with no module-system signal produces none either.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(js): forward CJS re-exports to the original definition (#2723)
Last of the known limitations. `exports.fwd = lib.imported` assigns an
EXISTING symbol rather than a function literal, so no definition rule reaches
it and importers of `fwd` resolved to nothing:
const lib = require('./lib');
exports.forwarded = lib.imported; // via a namespace binding
const { second } = require('./lib');
exports.alsoForwarded = second; // via a named binding
Both forms are now synthesized as re-export markers in the same post-query
pass that already decomposes `require()`, reusing the decomposer's existing
vocabulary rather than adding a case to it.
The kind is the whole fix, and it was established by measurement, not by
reading. Emitted first as `named-alias` — the shape the destructured
`require()` form uses — the forwarding still did not resolve: an import
binding is PRIVATE to its module, exactly as in ESM, where `import { X }`
does not re-export X. `reexport-alias` (`export { X as Y } from './m'`) is
what a CJS forwarding assignment actually is, and with it the call resolves
through the forwarding module to the original definition.
`exports.foo = localFn`, where the right-hand side is a locally DECLARED
function, is deliberately not handled here: the module scope already binds
`localFn`, importers already resolve through it (verified before writing any
code), and synthesizing a second binding would re-create the ambiguity the
shadow guard exists to prevent.
JavaScript only, matching where CJS `require()` decomposition already lives —
`typescript/captures.ts` has no require pass at all, since a `.ts` file using
CJS forwarding is vanishingly rare next to the cost of a second
implementation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(js): document the CommonJS export surface now that #2723 closed it
The known-limitation note still described `exports.X` as unmodeled and listed
the re-export edge as missing. Both are stale: the note now states which forms
declare a module-scope name, which two cases are deliberate non-cases (a
locally declared value needs no second binding; a name the module also declares
lexically is suppressed rather than made ambiguous), and that member
assignments through a receiver are Methods with an owner edge.
`module.exports = fn` — an anonymous default with no name to bind — remains
the one genuine limitation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(js): index the CommonJS default export `module.exports = fn` (#2723)
The last documented limitation. `module.exports = function () {}` exports the
whole module as a callable, so there is no property to take a name from and
nothing declared it.
Named after the file by `deriveDefaultExportHocName` — the convention this
repo already applies to anonymous default exports — so `index.js` takes its
parent directory. A NAMED function expression keeps its own name instead,
which is more informative than the file.
Two traps, both found by probing rather than reading:
- The widened member-assignment rule already matches this shape, capturing
the LEFT property as the name — the literal `exports`. Left alone it
produced `Function:<file>:exports`, and for a named function expression a
SECOND node beside the real one. The worker now overrides the captured
name for this shape, which is why it takes precedence over `nameNode`.
- `labelOverride` was suppressing the node entirely. That is the widening's
safety net working as designed — an assignment-anchored capture that is
not a recognised shape emits nothing — and this was simply a shape it had
not been taught.
The scope declaration is synthesized in the capture emitter rather than the
query, because a tree-sitter pattern has no access to the file path the
anonymous name derives from. Without it the node would exist with nothing
resolving to it, the half-fixed state this issue already had to correct once.
`exports = fn` is deliberately NOT indexed, and there is a test pinning that:
reassigning the `exports` binding does not export anything in CommonJS, it
only breaks the alias to `module.exports`, so indexing it would invent an
export that does not exist.
## Limit worth knowing
`const m = require('./mod'); m()` resolves only when the local binding name
matches the derived name — a naming coincidence, not a mechanism. Resolving a
renamed binding (`const renamed = require('./mod'); renamed()`) needs the
finalize layer to treat a called namespace binding as the target module's
default export, which is separate work. The node itself is always emitted, so
`impact` / `context` / `rename` reach it either way — which is what #2723
asked for.
Adjacent gap found while measuring, NOT addressed here: ESM
`export default function () {}` (anonymous) is equally unindexed. Same class,
different construct, and widening to it would change behaviour for files this
issue never touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(js): record module.exports = fn and the two remaining default-export gaps
The note still listed `module.exports = fn` as unmodeled. It is indexed now;
what remains is narrower and worth stating precisely: resolving a CALL through
a RENAMED default-export binding needs finalize-layer work, and anonymous ESM
`export default function () {}` is unindexed for the same underlying reason
but is a different construct.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(js): close every finding from the #2729 tri-review
A 15-lane review (Claude swarm + ce personas, Codex gpt-5.6-sol swarm + ce +
adversarial) ran the real pipeline against both this branch and its base and
diffed the graphs. On canonical CommonJS shapes the branch was DELETING call
edges that existed at base and FABRICATING edges present in no source. A
fabricated edge is worse than the gap #2723 set out to close: it hands
`impact` a caller that does not exist.
Almost all of it reduced to two root causes.
**1. The exports receiver was identified by TEXT, with no scope lookup.**
The canonical UMD wrapper takes the exports object as a PARAMETER:
(function (exports) { exports.publicApi = function () {}; })(this);
A text match called that a module export, invented a symbol, and — because the
invented name then collided with module scope — deleted the factory's real call
edges. The same blindness made `const helper = require('./helper');
exports.helper = fn` resolve an importer into a DIFFERENT module's function.
Receivers (and aliases) are now rejected where a parameter or enclosing local
shadows them.
**2. The shadow guard reached one of four export forms.**
It was wrong in four distinct ways: it never fired for an aliased receiver
(`root` was not forwarded), for module-level `this`, or for the default export
— each dropping a real edge, and the default-export case merging two functions
onto one node so the inner call resolved to itself. And it fired when it should
NOT have, deleting a genuine export whose name merely collided with a
non-callable variable:
let cache = null;
exports.cache = function (v) { cache = v; return cache; };
There is now one entry point (`cjsExportedName`) covering direct, alias, `this`
and default forms, comparing against CALLABLE declarations only.
Also fixed:
- Prototype owners bound to variables. `var Foo = function () {}` is the
dominant pre-ES6 constructor — the population this work targets — and owner
lookup handled only declarations, so two same-named members collapsed onto
one unqualified node with no owner edges at all.
- TypeScript parity: the default/re-export declaration synthesis lived only in
the JavaScript emitter, so a `.ts` file emitted the node with nothing
declaring it. Extracted to a shared module used by both.
- Module-level `this.X = fn` in ESM or a no-signal file no longer mints an
ownerless `Method`; `.cjs`/`.cts` and `.mjs`/`.mts` are now positive
module-system signals where the file path is available.
- The MCP graph-schema resource documented HAS_METHOD as Class-owned only,
while this work adds Function (constructor) owners.
- Two dead exports removed; an orphaned JSDoc reattached to the function it
describes.
- Tests: the `exports = fn` negative test passed trivially (no JS/TS query
matches a bare-identifier LHS at all, so it would pass with every guard
deleted) — it now carries a positive control in the same fixture. A
bounds-y `.some(...)` assertion was replaced per DoD.md:82. Six regressions
added, each confirmed failing against the pre-fix build.
**Schema constants bumped LAST, deliberately.** `INCREMENTAL_SCHEMA_VERSION`
20->21 and parse-cache `SCHEMA_BUMP` 28->29, with the pin and reuse-gate tests
updated. This change alters what is emitted for source whose content has not
changed, so without the bump an existing index keeps serving the pre-fix graph
for every unchanged CommonJS file — breaching DoD.md:61. Bumping it BEFORE the
correctness fixes would have been worse: it would have propagated the fabricated
and deleted edges to every index on upgrade.
One review finding was withdrawn rather than fixed: a claimed O(n^2) memo
failure did not survive verification. Clean production-shaped measurement
(fresh parse per file, no instrumentation) shows linear scaling — 0.355, 0.221,
0.216, 0.212 ms/declaration at N=500/1000/2000/4000. The earlier
"reproduction" was an artifact of replacing `globalThis.WeakMap` to count
misses, which perturbs the identity semantics under test.
Verified: 1469 tests across 89 files, including the full scope-resolution unit
suite, the JS/TS resolver suites, closure-binding labels, const-function-twin
and the pipeline golden.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>