mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
* 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>
605 lines
22 KiB
TypeScript
605 lines
22 KiB
TypeScript
/**
|
|
* #2723 — `exports.foo = function () {}` must emit a callable `Function` node.
|
|
*
|
|
* CommonJS property-assignment exports are the dominant export style in
|
|
* pre-ESM Node (Express apps, Firebase Functions). Declared functions were
|
|
* indexed; the assignment form was not, so on a CJS codebase the graph held
|
|
* the internals and missed the public API.
|
|
*/
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
|
import {
|
|
DIST_WORKER_URL,
|
|
distWorkerExists,
|
|
parseFilesWithWorkers,
|
|
} from '../helpers/worker-parse.js';
|
|
|
|
vi.setConfig({ testTimeout: 90_000 });
|
|
|
|
const labelsFor = async (path: string, content: string, name: string): Promise<string[]> => {
|
|
const { graph } = await parseFilesWithWorkers([{ path, content }]);
|
|
return graph.nodes
|
|
.filter((node) => node.properties.name === name)
|
|
.map((node) => node.label)
|
|
.sort();
|
|
};
|
|
|
|
describe('#2723 CommonJS export assignment emits a Function node', () => {
|
|
it('exports.foo = function () {}', async () => {
|
|
expect(
|
|
await labelsFor(
|
|
'src/a.js',
|
|
'exports.areVariablesValid = function (variables) { return !!variables; };\n',
|
|
'areVariablesValid',
|
|
),
|
|
).toEqual(['Function']);
|
|
});
|
|
|
|
it('exports.foo = async function () {}', async () => {
|
|
expect(
|
|
await labelsFor(
|
|
'src/b.js',
|
|
'exports.loadUser = async function (id) { return id; };\n',
|
|
'loadUser',
|
|
),
|
|
).toEqual(['Function']);
|
|
});
|
|
|
|
it('exports.foo = (a) => {}', async () => {
|
|
expect(await labelsFor('src/c.js', 'exports.toId = (a) => a.id;\n', 'toId')).toEqual([
|
|
'Function',
|
|
]);
|
|
});
|
|
|
|
it('module.exports.foo = function () {}', async () => {
|
|
expect(
|
|
await labelsFor('src/d.js', 'module.exports.render = function () { return 1; };\n', 'render'),
|
|
).toEqual(['Function']);
|
|
});
|
|
|
|
it('module.exports = { foo } re-exports the declared function only once', async () => {
|
|
expect(
|
|
await labelsFor(
|
|
'src/e.js',
|
|
'function helper() { return 1; }\nmodule.exports = { helper };\n',
|
|
'helper',
|
|
),
|
|
).toEqual(['Function']);
|
|
});
|
|
|
|
it('exports.foo = function* () {}', async () => {
|
|
expect(
|
|
await labelsFor('src/g.js', 'exports.walk = function* () { yield 1; };\n', 'walk'),
|
|
).toEqual(['Function']);
|
|
});
|
|
|
|
it('TS parity: exports.foo = function () {}', async () => {
|
|
expect(
|
|
await labelsFor(
|
|
'src/f.ts',
|
|
'exports.tsExport = function (x: number) { return x; };\n',
|
|
'tsExport',
|
|
),
|
|
).toEqual(['Function']);
|
|
});
|
|
});
|
|
|
|
describe('#2723 follow-up: callable member assignments', () => {
|
|
const nodesFor = async (
|
|
path: string,
|
|
content: string,
|
|
): Promise<{ labels: string[]; ids: string[]; owners: string[] }> => {
|
|
const { graph } = await parseFilesWithWorkers([{ path, content }]);
|
|
return {
|
|
labels: graph.nodes.map((n) => n.label).sort(),
|
|
ids: graph.nodes.map((n) => n.id).sort(),
|
|
owners: graph.relationships
|
|
.filter((r) => r.type === 'HAS_METHOD')
|
|
.map((r) => `${r.sourceId} -> ${r.targetId}`)
|
|
.sort(),
|
|
};
|
|
};
|
|
|
|
it('Foo.prototype.bar = fn is a Method owned by the constructor', async () => {
|
|
const { ids, owners } = await nodesFor(
|
|
'src/proto.js',
|
|
'function Foo() {}\nFoo.prototype.bar = function (v) { return v; };\n',
|
|
);
|
|
expect(ids).toContain('Method:src/proto.js:Foo.bar');
|
|
expect(owners).toEqual(['Function:src/proto.js:Foo -> Method:src/proto.js:Foo.bar']);
|
|
});
|
|
|
|
it('a class owner gets a Class-sourced owner edge', async () => {
|
|
const { owners } = await nodesFor(
|
|
'src/protocls.js',
|
|
'class Ctl {}\nCtl.prototype.run = function () { return 1; };\n',
|
|
);
|
|
expect(owners).toEqual(['Class:src/protocls.js:Ctl -> Method:src/protocls.js:Ctl.run']);
|
|
});
|
|
|
|
// Two constructors defining the same member name must stay distinct nodes;
|
|
// an unqualified `Method:<file>:bar` would collapse them into one.
|
|
it('same-named prototype members on different owners do not collide', async () => {
|
|
const { ids } = await nodesFor(
|
|
'src/two.js',
|
|
'function Foo() {}\nFoo.prototype.bar = function () { return 1; };\n' +
|
|
'function Baz() {}\nBaz.prototype.bar = function () { return 2; };\n',
|
|
);
|
|
expect(ids).toContain('Method:src/two.js:Foo.bar');
|
|
expect(ids).toContain('Method:src/two.js:Baz.bar');
|
|
});
|
|
|
|
// An owner the file does not declare cannot be resolved to a node, so no
|
|
// owner edge is claimed rather than one pointing at a fabricated node.
|
|
it('an undeclared prototype owner claims no owner edge', async () => {
|
|
const { owners } = await nodesFor(
|
|
'src/ext.js',
|
|
'External.prototype.skipped = function () { return 1; };\n',
|
|
);
|
|
expect(owners).toEqual([]);
|
|
});
|
|
|
|
it('this.handler = fn in a constructor is a Method owned by it', async () => {
|
|
const { owners } = await nodesFor(
|
|
'src/this.js',
|
|
'function Widget() {\n this.handler = function (v) { return v; };\n}\n',
|
|
);
|
|
expect(owners).toHaveLength(1);
|
|
expect(owners[0]).toMatch(/^Function:src\/this\.js:Widget -> Method:src\/this\.js:Widget\./);
|
|
});
|
|
|
|
it('this.cb = fn in a class constructor is owned by the class', async () => {
|
|
const { owners } = await nodesFor(
|
|
'src/thiscls.js',
|
|
'class Klass {\n constructor() { this.cb = function () { return 1; }; }\n}\n',
|
|
);
|
|
// The class owns its `constructor` AND the `cb` member assigned inside it.
|
|
const cbOwners = owners.filter((o) => o.includes('.cb'));
|
|
expect(cbOwners).toHaveLength(1);
|
|
expect(cbOwners[0]).toMatch(/^Class:src\/thiscls\.js:Klass -> Method:src\/thiscls\.js:Klass\./);
|
|
});
|
|
|
|
// The scope declaration for a shadowed CJS export is suppressed, so its graph
|
|
// node would be unreachable. With a `class` of the same name the labels
|
|
// differ, so it does not even collapse — it lingers as an orphan.
|
|
// The member-assignment rule matches ANY identifier receiver so an exports
|
|
// alias can be recognised; everything else must be pruned emit-side. Without
|
|
// that, every `obj.handler = fn` would emit a top-level `Function`.
|
|
// `module.exports = fn` — the whole module is the callable, so there is no
|
|
// property to name it after. Anonymous forms take the file-derived name
|
|
// (`deriveDefaultExportHocName`), the convention already used for anonymous
|
|
// default exports; a named function expression keeps its own name.
|
|
it('module.exports = function () {} is named after the file', async () => {
|
|
const { ids } = await nodesFor(
|
|
'src/cjsdef.js',
|
|
'module.exports = function (v) { return v; };\n',
|
|
);
|
|
expect(ids).toContain('Function:src/cjsdef.js:cjsdef');
|
|
});
|
|
|
|
it('module.exports = (v) => v is named after the file', async () => {
|
|
const { ids } = await nodesFor('src/cjsarrow.js', 'module.exports = (v) => v;\n');
|
|
expect(ids).toContain('Function:src/cjsarrow.js:cjsarrow');
|
|
});
|
|
|
|
// The member-assignment rule captures the LEFT property as the name, which
|
|
// for this shape is the literal `exports`. That must not survive.
|
|
it('a named module.exports function keeps its own name and emits no `exports` node', async () => {
|
|
const { ids } = await nodesFor(
|
|
'src/cjsnamed.js',
|
|
'module.exports = function namedFn(v) { return v; };\n',
|
|
);
|
|
expect(ids).toContain('Function:src/cjsnamed.js:namedFn');
|
|
expect(ids).not.toContain('Function:src/cjsnamed.js:exports');
|
|
});
|
|
|
|
// Reassigning `exports` does NOT export in CommonJS — it only breaks the
|
|
// alias to `module.exports` — so indexing it would invent an export.
|
|
// Paired with a positive control in the SAME fixture. Without it this test
|
|
// passes trivially: no JS/TS query matches a bare-identifier LHS at all, so
|
|
// an empty graph — from a parse failure or an unrelated guard misfire —
|
|
// would satisfy it just as well as correct behaviour (#2729 review F9).
|
|
it('exports = fn is not treated as a default export', async () => {
|
|
const { ids } = await nodesFor(
|
|
'src/rebind.js',
|
|
'exports = function (v) { return v; };\nmodule.exports.ok = function (v) { return v; };\n',
|
|
);
|
|
expect(ids).toContain('Function:src/rebind.js:ok');
|
|
expect(ids).not.toContain('Function:src/rebind.js:exports');
|
|
expect(ids).not.toContain('Function:src/rebind.js:rebind');
|
|
});
|
|
|
|
// F4: an anonymous default takes the FILE name. When that collides with a
|
|
// callable the module already declares, the two merged onto one node and the
|
|
// inner call resolved to itself — fabricating a self-recursion edge present
|
|
// in no source. A fabricated edge is worse than a missing one.
|
|
it('a default export whose derived name collides emits no merged node', async () => {
|
|
const { ids } = await nodesFor(
|
|
'src/format.js',
|
|
'function format(v) { return String(v); }\nmodule.exports = function (v) { return format(v); };\n',
|
|
);
|
|
expect(ids).toContain('Function:src/format.js:format');
|
|
expect(ids).not.toContain('Function:src/format.js:exports');
|
|
expect(ids.filter((id) => id.endsWith(':format'))).toHaveLength(1);
|
|
});
|
|
|
|
// F6: `var Foo = function(){}` is the dominant pre-ES6 constructor form.
|
|
// Owner lookup handled only declarations, so two same-named members
|
|
// collapsed onto one unqualified node with no owner edges at all.
|
|
it('variable-bound constructors own their prototype methods distinctly', async () => {
|
|
const { ids, owners } = await nodesFor(
|
|
'src/varctor.js',
|
|
'var Foo = function () {};\nFoo.prototype.run = function (a) { return a; };\n' +
|
|
'var Baz = function () {};\nBaz.prototype.run = function (a) { return !a; };\n',
|
|
);
|
|
expect(ids).toContain('Method:src/varctor.js:Foo.run');
|
|
expect(ids).toContain('Method:src/varctor.js:Baz.run');
|
|
expect(owners).toEqual([
|
|
'Function:src/varctor.js:Baz -> Method:src/varctor.js:Baz.run',
|
|
'Function:src/varctor.js:Foo -> Method:src/varctor.js:Foo.run',
|
|
]);
|
|
});
|
|
|
|
it('a non-exports receiver emits no node', async () => {
|
|
const { labels } = await nodesFor(
|
|
'src/plain.js',
|
|
'const obj = {};\nobj.notAnExport = function () { return 1; };\n' +
|
|
'self.alsoNot = () => 1;\nlocalThing.nope = function () { return 2; };\n',
|
|
);
|
|
expect(labels.filter((l) => l === 'Function' || l === 'Method')).toEqual([]);
|
|
});
|
|
|
|
// Top-level `this` is `undefined` in ESM, so it exports nothing there. This
|
|
// gate is what keeps the CJS rule above from mis-indexing every ESM file.
|
|
it('module-level this.X = fn in an ESM file is not an export', async () => {
|
|
const { ids } = await nodesFor(
|
|
'src/esmmod.js',
|
|
"import { helper } from './helper.js';\n" +
|
|
'this.notAnExport = function (v) { return helper(v); };\n' +
|
|
'export const real = 1;\n',
|
|
);
|
|
expect(ids).not.toContain('Function:src/esmmod.js:notAnExport');
|
|
});
|
|
|
|
it('a file with no CJS or ESM signal does not claim a this export', async () => {
|
|
const { ids } = await nodesFor(
|
|
'src/neither.js',
|
|
'this.ambiguous = function () { return 1; };\n',
|
|
);
|
|
expect(ids).not.toContain('Function:src/neither.js:ambiguous');
|
|
});
|
|
|
|
it('a CJS export shadowing a class emits no orphan Function twin', async () => {
|
|
const { labels, ids } = await nodesFor(
|
|
'src/twin.js',
|
|
'class Dup { run() { return 1; } }\nexports.Dup = function () { return 2; };\n',
|
|
);
|
|
expect(ids).not.toContain('Function:src/twin.js:Dup');
|
|
expect(labels.filter((l) => l === 'Function')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
const describeIfWorkerBuilt = distWorkerExists() ? describe : describe.skip;
|
|
|
|
describeIfWorkerBuilt('#2723 calls resolve to the CJS-exported function', () => {
|
|
/** Names of the symbols that CALL `name`, resolved through the real pipeline. */
|
|
const callersOf = async (
|
|
files: { path: string; content: string }[],
|
|
name: string,
|
|
inFile?: string,
|
|
): Promise<string[]> => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-2723-'));
|
|
try {
|
|
for (const file of files) {
|
|
const full = path.join(dir, file.path);
|
|
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
fs.writeFileSync(full, file.content, 'utf-8');
|
|
}
|
|
const { graph } = await runPipelineFromRepo(dir, () => {}, {
|
|
workerPoolSize: 1,
|
|
workerUrlForTest: DIST_WORKER_URL,
|
|
});
|
|
const target = graph.nodes.find(
|
|
(n) =>
|
|
n.properties.name === name &&
|
|
n.label === 'Function' &&
|
|
(inFile === undefined || n.id.includes(`:${inFile}:`)),
|
|
);
|
|
expect(target).toBeDefined();
|
|
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
return graph.relationships
|
|
.filter((rel) => rel.type === 'CALLS' && rel.targetId === target!.id)
|
|
.map((rel) => String(byId.get(rel.sourceId)?.properties.name ?? rel.sourceId))
|
|
.sort();
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
};
|
|
|
|
it('same-file call resolves', async () => {
|
|
expect(
|
|
await callersOf(
|
|
[
|
|
{
|
|
path: 'src/validate.js',
|
|
content:
|
|
'exports.areVariablesValid = function (v) { return !!v; };\n' +
|
|
'exports.check = function (v) { return exports.areVariablesValid(v); };\n',
|
|
},
|
|
],
|
|
'areVariablesValid',
|
|
),
|
|
).toEqual(['check']);
|
|
});
|
|
|
|
it('cross-file require() member call resolves', async () => {
|
|
expect(
|
|
await callersOf(
|
|
[
|
|
{
|
|
path: 'src/validate.js',
|
|
content: 'exports.areVariablesValid = function (v) { return !!v; };\n',
|
|
},
|
|
{
|
|
path: 'src/handler.js',
|
|
content:
|
|
"const validate = require('./validate');\n" +
|
|
'function handle(v) { return validate.areVariablesValid(v); }\n',
|
|
},
|
|
],
|
|
'areVariablesValid',
|
|
),
|
|
).toEqual(['handle']);
|
|
});
|
|
|
|
// The graph-node rules accept `generator_function` for this form, so without
|
|
// the matching scope declaration the node existed and nothing resolved to it.
|
|
it('generator export resolves through both receiver forms', async () => {
|
|
const files = [
|
|
{
|
|
path: 'src/gen.js',
|
|
content:
|
|
'exports.walk = function* () { yield 1; };\n' +
|
|
'module.exports.crawl = function* () { yield 2; };\n',
|
|
},
|
|
{
|
|
path: 'src/use.js',
|
|
content:
|
|
"const { walk, crawl } = require('./gen');\n" +
|
|
'function drive() { return [...walk(), ...crawl()]; }\n',
|
|
},
|
|
];
|
|
expect(await callersOf(files, 'walk')).toEqual(['drive']);
|
|
expect(await callersOf(files, 'crawl')).toEqual(['drive']);
|
|
});
|
|
|
|
// A CJS export assignment must not shadow a same-named `function X(){}` in
|
|
// the same file. Registering a second module-scope declaration for `dup`
|
|
// makes the name ambiguous and the resolver drops the intra-module edge
|
|
// entirely — a silently missing caller, which is worse than the gap #2723
|
|
// set out to close. Verified against base ff86ccf1e, where this edge exists.
|
|
it('an exports assignment does not shadow a same-named declared function', async () => {
|
|
expect(
|
|
await callersOf(
|
|
[
|
|
{
|
|
path: 'src/collide.js',
|
|
content:
|
|
'function dup(v) { return v; }\n' +
|
|
'exports.dup = function (v) { return !v; };\n' +
|
|
'function callIt(v) { return dup(v); }\n',
|
|
},
|
|
],
|
|
'dup',
|
|
),
|
|
).toEqual(['callIt']);
|
|
});
|
|
|
|
it('an exports alias resolves like a direct export', async () => {
|
|
const files = [
|
|
{
|
|
path: 'src/alias.js',
|
|
content:
|
|
'const e = exports;\n' +
|
|
'const m = module.exports;\n' +
|
|
'e.aliased = function (v) { return v; };\n' +
|
|
'm.viaModule = (v) => v;\n',
|
|
},
|
|
{
|
|
path: 'src/aliasuse.js',
|
|
content:
|
|
"const { aliased, viaModule } = require('./alias');\n" +
|
|
'function drive(v) { return [aliased(v), viaModule(v)]; }\n',
|
|
},
|
|
];
|
|
expect(await callersOf(files, 'aliased')).toEqual(['drive']);
|
|
expect(await callersOf(files, 'viaModule')).toEqual(['drive']);
|
|
});
|
|
|
|
// In CommonJS, module-level `this` IS `module.exports`.
|
|
it('module-level this.X = fn in a CJS file is an export', async () => {
|
|
expect(
|
|
await callersOf(
|
|
[
|
|
{
|
|
path: 'src/cjsmod.js',
|
|
content:
|
|
"const dep = require('./dep');\n" +
|
|
'this.topExport = function (v) { return dep(v); };\n',
|
|
},
|
|
{ path: 'src/dep.js', content: 'module.exports = function (v) { return v; };\n' },
|
|
{
|
|
path: 'src/usecjs.js',
|
|
content:
|
|
"const { topExport } = require('./cjsmod');\n" +
|
|
'function drive(v) { return topExport(v); }\n',
|
|
},
|
|
],
|
|
'topExport',
|
|
),
|
|
).toEqual(['drive']);
|
|
});
|
|
|
|
// `exports.fwd = lib.imported` assigns an existing symbol rather than a
|
|
// literal, so no definition rule reaches it. Modelled as a re-export, NOT a
|
|
// plain import binding: an import is private to its module, so importers of
|
|
// the forwarding module resolved to nothing.
|
|
it('a CJS re-export forwards to the original definition', async () => {
|
|
const files = [
|
|
{
|
|
path: 'src/lib.js',
|
|
content:
|
|
'exports.imported = function (v) { return v; };\n' +
|
|
'exports.second = function (v) { return !v; };\n',
|
|
},
|
|
{
|
|
path: 'src/fwd.js',
|
|
content:
|
|
"const lib = require('./lib');\n" +
|
|
"const { second } = require('./lib');\n" +
|
|
'exports.forwarded = lib.imported;\n' +
|
|
'exports.alsoForwarded = second;\n',
|
|
},
|
|
{
|
|
path: 'src/usefwd.js',
|
|
content:
|
|
"const { forwarded, alsoForwarded } = require('./fwd');\n" +
|
|
'function drive(v) { return [forwarded(v), alsoForwarded(v)]; }\n',
|
|
},
|
|
];
|
|
expect(await callersOf(files, 'imported')).toEqual(['drive']);
|
|
expect(await callersOf(files, 'second')).toEqual(['drive']);
|
|
});
|
|
|
|
// ─── #2729 review regressions ──────────────────────────────────────────
|
|
// Each of these failed against the pre-review build. They cover the two root
|
|
// causes: a receiver identified by TEXT with no scope lookup, and a shadow
|
|
// guard that reached only one of the export forms.
|
|
|
|
// F2: the canonical UMD wrapper takes the exports object as a PARAMETER. A
|
|
// text match called it a module export, invented a symbol, and deleted the
|
|
// factory's real call edges.
|
|
it('an `exports` parameter does not hijack the module (UMD factory)', async () => {
|
|
expect(
|
|
await callersOf(
|
|
[
|
|
{ path: 'src/other.js', content: 'exports.noop = function (v) { return v; };\n' },
|
|
{
|
|
path: 'src/umd.js',
|
|
content:
|
|
"const other = require('./other');\n" +
|
|
'(function (exports) {\n' +
|
|
' function publicApi(v) { return other.noop(v); }\n' +
|
|
' exports.publicApi = function (v) { return publicApi(v); };\n' +
|
|
'})(this);\n',
|
|
},
|
|
],
|
|
'noop',
|
|
),
|
|
).toEqual(['publicApi']);
|
|
});
|
|
|
|
// F1: `const helper = require('./helper'); exports.helper = fn` resolved an
|
|
// importer into the OTHER module's function — an edge in no source.
|
|
it('a wrapper export does not resolve into the required module', async () => {
|
|
expect(
|
|
await callersOf(
|
|
[
|
|
{ path: 'src/helper.js', content: 'exports.helper = function (v) { return v; };\n' },
|
|
{
|
|
path: 'src/wrap.js',
|
|
content:
|
|
"const helper = require('./helper');\nexports.helper = function (v) { return v; };\n",
|
|
},
|
|
{
|
|
path: 'src/wrapuse.js',
|
|
content:
|
|
"const { helper } = require('./wrap');\nfunction driveWrap(v) { return helper(v); }\n",
|
|
},
|
|
],
|
|
'helper',
|
|
'src/wrap.js',
|
|
),
|
|
).toEqual(['driveWrap']);
|
|
});
|
|
|
|
// F3 hole A: the alias receiver skipped the shadow guard, dropping a real edge.
|
|
it('an aliased export does not shadow a same-named declared function', async () => {
|
|
expect(
|
|
await callersOf(
|
|
[
|
|
{
|
|
path: 'src/aliasdup.js',
|
|
content:
|
|
'const e = exports;\nfunction dup(v) { return v; }\n' +
|
|
'e.dup = function (v) { return !v; };\nfunction callIt(v) { return dup(v); }\n',
|
|
},
|
|
],
|
|
'dup',
|
|
),
|
|
).toEqual(['callIt']);
|
|
});
|
|
|
|
// F3 hole B: module-level `this` never reached the shadow guard at all.
|
|
it('a module-level this export does not shadow a declared function', async () => {
|
|
expect(
|
|
await callersOf(
|
|
[
|
|
{
|
|
path: 'src/thisdup.js',
|
|
content:
|
|
"const dep = require('./other2');\nfunction dup2(v) { return v; }\n" +
|
|
'this.dup2 = function (v) { return !v; };\nfunction callIt2(v) { return dup2(v); }\n',
|
|
},
|
|
{ path: 'src/other2.js', content: 'exports.noop = function (v) { return v; };\n' },
|
|
],
|
|
'dup2',
|
|
),
|
|
).toEqual(['callIt2']);
|
|
});
|
|
|
|
// F8: the guard collected EVERY module-scope name, so an export colliding
|
|
// with a plain variable was deleted outright — node and edge both gone.
|
|
it('a non-callable variable of the same name does not delete the export', async () => {
|
|
expect(
|
|
await callersOf(
|
|
[
|
|
{
|
|
path: 'src/cache.js',
|
|
content:
|
|
'let cache = null;\nexports.cache = function (v) { cache = v; return cache; };\n',
|
|
},
|
|
{
|
|
path: 'src/cacheuse.js',
|
|
content:
|
|
"const { cache } = require('./cache');\nfunction driveCache(v) { return cache(v); }\n",
|
|
},
|
|
],
|
|
'cache',
|
|
),
|
|
).toEqual(['driveCache']);
|
|
});
|
|
|
|
it('cross-file destructured require() call resolves', async () => {
|
|
expect(
|
|
await callersOf(
|
|
[
|
|
{
|
|
path: 'src/validate.js',
|
|
content: 'exports.areVariablesValid = function (v) { return !!v; };\n',
|
|
},
|
|
{
|
|
path: 'src/handler.js',
|
|
content:
|
|
"const { areVariablesValid } = require('./validate');\n" +
|
|
'function handle(v) { return areVariablesValid(v); }\n',
|
|
},
|
|
],
|
|
'areVariablesValid',
|
|
),
|
|
).toEqual(['handle']);
|
|
});
|
|
});
|