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>
This commit is contained in:
Gergo Magyar 2026-07-28 09:35:57 +00:00
parent ff86ccf1e7
commit f302916c70
5 changed files with 382 additions and 5 deletions

View file

@ -34,11 +34,17 @@
* resolved.
* 3. **Dynamic require** `require(computedPath)` is skipped (non-literal
* argument cannot statically resolve the target).
* 4. **`module.exports` / `exports.X`** CJS export forms are not yet
* modeled as re-exports. The finalize algorithm treats the exporting
* module as a namespace; importers that do `const X = require('./m')`
* bind the module namespace, and member-call resolution walks the
* class graph from there.
* 4. **`module.exports` / `exports.X`** CJS export forms are not modeled
* as re-exports. The finalize algorithm treats the exporting module as a
* namespace; importers that do `const X = require('./m')` bind the module
* namespace, and member-call resolution walks the class graph from there.
*
* `exports.foo = function () {}` / `module.exports.foo = (a) => a` DO
* declare `foo` in the module scope (#2723) the query block in
* `query.ts` so importers resolve to them by name. What is still
* unmodeled is the re-export EDGE: `exports.foo = someImported` does not
* forward to the original declaration, and `module.exports = fn`
* (anonymous default) has no name to bind.
*/
export { emitJsScopeCaptures } from './captures.js';

View file

@ -152,6 +152,75 @@ export const JAVASCRIPT_SCOPE_QUERY = `
name: (identifier) @declaration.name
value: (function_expression) @declaration.function))
;; CJS property-assignment exports (#2723): \`exports.foo = function () {}\`,
;; \`module.exports.foo = (a) => a\`. The graph node for these comes from
;; TYPESCRIPT/JAVASCRIPT_QUERIES; this block is the other half without a
;; scope-resolution declaration the node exists but nothing resolves TO it,
;; so \`impact\` answered "found, zero callers" on a whole CommonJS API.
;;
;; The declaration binds the BARE property name into the enclosing (module)
;; scope, which is what importers see: \`const { foo } = require('./m')\`
;; matches by name, and a namespace \`m.foo()\` walks the module's defs.
;;
;; Same anchor discipline as the blocks above \`@declaration.function\` sits
;; on the INNER arrow / function_expression so its range matches the
;; \`@scope.function\` range.
(assignment_expression
left: (member_expression
object: (identifier) @_cjs.exports
property: (property_identifier) @declaration.name)
right: (arrow_function) @declaration.function
(#eq? @_cjs.exports "exports"))
(assignment_expression
left: (member_expression
object: (identifier) @_cjs.exports
property: (property_identifier) @declaration.name)
right: (function_expression) @declaration.function
(#eq? @_cjs.exports "exports"))
;; Generator parity. The graph-node rules in \`tree-sitter-queries.ts\` accept
;; \`generator_function\` for this form, so without a matching declaration the
;; node existed with nothing resolving to it the same half-fixed state the
;; block above exists to prevent. \`(generator_function) @scope.function\` is
;; declared near the top of this query, so the anchor still aligns.
(assignment_expression
left: (member_expression
object: (identifier) @_cjs.exports
property: (property_identifier) @declaration.name)
right: (generator_function) @declaration.function
(#eq? @_cjs.exports "exports"))
(assignment_expression
left: (member_expression
object: (member_expression
object: (identifier) @_cjs.module
property: (property_identifier) @_cjs.exports)
property: (property_identifier) @declaration.name)
right: (arrow_function) @declaration.function
(#eq? @_cjs.module "module")
(#eq? @_cjs.exports "exports"))
(assignment_expression
left: (member_expression
object: (member_expression
object: (identifier) @_cjs.module
property: (property_identifier) @_cjs.exports)
property: (property_identifier) @declaration.name)
right: (function_expression) @declaration.function
(#eq? @_cjs.module "module")
(#eq? @_cjs.exports "exports"))
(assignment_expression
left: (member_expression
object: (member_expression
object: (identifier) @_cjs.module
property: (property_identifier) @_cjs.exports)
property: (property_identifier) @declaration.name)
right: (generator_function) @declaration.function
(#eq? @_cjs.module "module")
(#eq? @_cjs.exports "exports"))
;; Object-property arrows / function expressions named by their pair key.
;; Same anchor discipline as the lexical_declaration block above: the
;; @declaration.function capture must sit on the INNER arrow/fn-expression.

View file

@ -215,6 +215,62 @@ export const TYPESCRIPT_SCOPE_QUERY = `
name: (identifier) @declaration.name
value: (function_expression) @declaration.function))
;; CJS property-assignment exports (#2723) see the matching block in
;; \`languages/javascript/query.ts\` for the rationale. Mirrored here because
;; \`.ts\` files in a CommonJS package use the same form, and because the JS
;; provider delegates several hooks to these TypeScript counterparts.
(assignment_expression
left: (member_expression
object: (identifier) @_cjs.exports
property: (property_identifier) @declaration.name)
right: (arrow_function) @declaration.function
(#eq? @_cjs.exports "exports"))
(assignment_expression
left: (member_expression
object: (identifier) @_cjs.exports
property: (property_identifier) @declaration.name)
right: (function_expression) @declaration.function
(#eq? @_cjs.exports "exports"))
;; Generator parity see the matching note in \`languages/javascript/query.ts\`.
(assignment_expression
left: (member_expression
object: (identifier) @_cjs.exports
property: (property_identifier) @declaration.name)
right: (generator_function) @declaration.function
(#eq? @_cjs.exports "exports"))
(assignment_expression
left: (member_expression
object: (member_expression
object: (identifier) @_cjs.module
property: (property_identifier) @_cjs.exports)
property: (property_identifier) @declaration.name)
right: (arrow_function) @declaration.function
(#eq? @_cjs.module "module")
(#eq? @_cjs.exports "exports"))
(assignment_expression
left: (member_expression
object: (member_expression
object: (identifier) @_cjs.module
property: (property_identifier) @_cjs.exports)
property: (property_identifier) @declaration.name)
right: (function_expression) @declaration.function
(#eq? @_cjs.module "module")
(#eq? @_cjs.exports "exports"))
(assignment_expression
left: (member_expression
object: (member_expression
object: (identifier) @_cjs.module
property: (property_identifier) @_cjs.exports)
property: (property_identifier) @declaration.name)
right: (generator_function) @declaration.function
(#eq? @_cjs.module "module")
(#eq? @_cjs.exports "exports"))
;; Object-property arrows / function expressions named by their pair key:
;; \`{ addItem: (item) => ..., removeItem: (item) => ... }\`. The legacy
;; TYPESCRIPT_QUERIES emits the same shape; mirroring it here keeps

View file

@ -390,6 +390,26 @@ export const TYPESCRIPT_QUERIES = `
name: (property_identifier) @name
value: (function_expression)) @definition.method
; CJS property-assignment exports (#2723) see JAVASCRIPT_QUERIES for the
; rationale and for why the receiver is pinned to \`exports\`/\`module.exports\`.
; Mirrored here because \`.ts\` files in a CommonJS package use the same form.
(assignment_expression
left: (member_expression
object: (identifier) @_cjs.exports
property: (property_identifier) @name)
right: [(function_expression) (arrow_function) (generator_function)]
(#eq? @_cjs.exports "exports")) @definition.function
(assignment_expression
left: (member_expression
object: (member_expression
object: (identifier) @_cjs.module
property: (property_identifier) @_cjs.exports)
property: (property_identifier) @name)
right: [(function_expression) (arrow_function) (generator_function)]
(#eq? @_cjs.module "module")
(#eq? @_cjs.exports "exports")) @definition.function
; Constructor parameter properties: constructor(public address: Address)
(required_parameter
(accessibility_modifier)
@ -540,6 +560,35 @@ export const JAVASCRIPT_QUERIES = `
name: (identifier) @name
value: (generator_function)))) @definition.function
; CJS property-assignment exports (#2723): \`exports.foo = function () {}\`,
; \`module.exports.foo = (a) => a\`. This is the dominant export style in
; pre-ESM Node (Express, Firebase Functions), and without these rules a
; CommonJS codebase indexed its internals while every symbol on its public
; API was missing \`impact\`/\`context\`/\`rename\` all answered "not found".
;
; Scoped to the \`exports\` / \`module.exports\` receivers on purpose. The
; general \`X.foo = function () {}\` shape also covers \`Foo.prototype.bar\` and
; \`this.handler\`, which are member constructs with their own ownership
; questions (an owning Class, a function-local binding) a broader rule
; would emit ownerless top-level Functions for them. Same rationale as the
; other closure-binding rules above: the label means "is a call target".
(assignment_expression
left: (member_expression
object: (identifier) @_cjs.exports
property: (property_identifier) @name)
right: [(function_expression) (arrow_function) (generator_function)]
(#eq? @_cjs.exports "exports")) @definition.function
(assignment_expression
left: (member_expression
object: (member_expression
object: (identifier) @_cjs.module
property: (property_identifier) @_cjs.exports)
property: (property_identifier) @name)
right: [(function_expression) (arrow_function) (generator_function)]
(#eq? @_cjs.module "module")
(#eq? @_cjs.exports "exports")) @definition.function
; Object-property arrows / function expressions: \`{ addItem: () => ... }\`.
; See TYPESCRIPT_QUERIES for rationale (issue #1166).
(pair

View file

@ -0,0 +1,197 @@
/**
* #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']);
});
});
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,
): 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');
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']);
});
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']);
});
});