fix(extraction): index CommonJS export assignments as functions (#1675) (#1771)

`exports.getItems = async (req, res) => {…}` and `module.exports.x =
function () {…}` — the Express controller style — produced no symbol: the
arrow's parent is an assignment, not a declarator, so it stayed anonymous,
its calls attributed to the file, and `node`/`callers` answered "Symbol
not found" for a route-wired handler. Resolve the name from the export
property, mark it exported, in both the wasm walker and the kernel.

Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
This commit is contained in:
Colby Mchenry
2026-09-08 09:37:20 -05:00
committed by GitHub
co-authored by danusha2345 Colby McHenry
parent 3adf06772b
commit 43271f3cd3
5 changed files with 153 additions and 3 deletions
+71
View File
@@ -0,0 +1,71 @@
/**
* CommonJS export assignments name the function they hold (#1675).
*
* `exports.getItems = async (req, res) => {…}` and `module.exports.x =
* function () {…}` are how Express controllers are commonly written. The
* arrow is anonymous only syntactically — the export property is the name
* every `router.get('/items', getItems)` resolves — so it gets the same
* treatment `const getItems = () => {}` already has: a function node, exported,
* with its calls attributed to it rather than to the file.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
});
const refsFrom = (result: ReturnType<typeof extractFromSource>, id: string) =>
result.unresolvedReferences.filter((r) => r.fromNodeId === id).map((r) => r.referenceName);
describe('CommonJS export assignments', () => {
it('indexes exports.X / module.exports.X functions as exported function nodes', () => {
const code = `
const { findItems, removeItem } = require('./db');
exports.getItems = async (req, res) => {
res.json(await findItems());
};
module.exports.deleteItem = function (req, res) {
removeItem(req.params.id);
res.end();
};
exports.plain = 42;
module.exports = { legacy: 1 };
`;
const result = extractFromSource('src/controller.js', code);
const fns = result.nodes.filter((n) => n.kind === 'function');
expect(fns.map((n) => n.name).sort()).toEqual(['deleteItem', 'getItems']);
const getItems = fns.find((n) => n.name === 'getItems')!;
const deleteItem = fns.find((n) => n.name === 'deleteItem')!;
expect(getItems.startLine).toBe(4);
expect(getItems.isExported).toBe(true);
expect(deleteItem.isExported).toBe(true);
expect(getItems.isAsync).toBe(true);
// The handlers' calls are their own, not the file's.
expect(refsFrom(result, getItems.id)).toContain('findItems');
expect(refsFrom(result, deleteItem.id)).toContain('removeItem');
const file = result.nodes.find((n) => n.kind === 'file')!;
expect(refsFrom(result, file.id)).not.toContain('findItems');
expect(refsFrom(result, file.id)).not.toContain('removeItem');
// A non-function export is not a function, and nothing is left anonymous.
expect(result.nodes.map((n) => n.name)).not.toContain('<anonymous>');
});
it('leaves other member assignments alone', () => {
const code = `
const handlers = {};
handlers.onSave = () => { persist(); };
app.locals.format = function () { return 1; };
`;
const result = extractFromSource('src/other.js', code);
expect(result.nodes.filter((n) => n.kind === 'function')).toEqual([]);
});
});
@@ -74,6 +74,11 @@ export default {
},
};
// --- CommonJS export assignments (#1675) -----------------------------------
exports.getItems = async (req, res) => { res.json(await findItems()); };
module.exports.deleteItem = function (req, res) { removeItem(req.params.id); res.end(); };
exports.plain = 42;
handlers.onSave = () => { persist(); };
// --- call-expression receivers (#1683) ----------------------------------------
function bucketChains(d, k, v) {
d.setdefault(k, []).append(v);