fix(js,ts): a function exported as exports["name"] is a symbol named after its export (#3032)

`exports.getItems = async (req, res) => {…}` names its function after the
export property, in both engines (since the CommonJS controller fix), but
the same export written with the name in quotes, `exports["getItems"] =
…` or `module.exports['deleteItem'] = function () {…}`, left the function
anonymous: no node, so nothing could find it or link to it, and its calls
counted as the file's. commonJsExportName (tree-sitter.ts) and its kernel
twin common_js_export_name (tsjs/extractors.rs) accepted only a
member_expression on the left of the assignment.

Both now also accept a subscript_expression on `exports` or
`module.exports` whose index is a string literal spelling a plain
identifier. The test for that string, which `this['svc']` receivers
already used in both engines, is now one helper per engine
(quotedIdentifier / quoted_identifier) shared by the two readers. A key
that no identifier can spell (`exports["not-a-name"]`), a computed key
and a template string still name nothing.

Validation: the new commonjs-exports cases fail on main and pass in both
engines, and a parity case compares the kernel's and wasm's nodes and
refs for the JS and TS forms.

Reimplements the bug part of PR #2896 by @johnhldiver-beep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-29 21:40:52 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent a38a1ec0c0
commit 8e854d11e5
4 changed files with 156 additions and 31 deletions
+21 -8
View File
@@ -704,6 +704,17 @@ function tsJsCalleeName(func: SyntaxNode, source: string): { name: string; at: S
return /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(chain) ? { name: chain, at: cur } : null;
}
/**
* The name a JS/TS string literal spells when it is a plain identifier: `'svc'`
* or `"getItems"` → the text between the quotes. Null for anything else,
* including a string with an escape or a character no identifier can hold.
* Mirrored in the kernel's quoted_identifier.
*/
function quotedIdentifier(node: SyntaxNode | null, source: string): string | null {
if (node?.type !== 'string') return null;
return /^(['"])([A-Za-z_$][\w$]*)\1$/.exec(getNodeText(node, source))?.[2] ?? null;
}
/**
* A TS/JS receiver that subscripts the enclosing class's object: an element
* of a field, `this.<field>[k]` → `this.<field>[]`, or a field named by a
@@ -716,8 +727,7 @@ function tsThisSubscriptReceiver(node: SyntaxNode, source: string): string | nul
if (!objectNode) return null;
const object = peelTsJsReceiver(objectNode);
if (object.type === 'this') {
const index = getChildByField(node, 'index');
const key = index?.type === 'string' ? /^(['"])([A-Za-z_$][\w$]*)\1$/.exec(getNodeText(index, source))?.[2] : undefined;
const key = quotedIdentifier(getChildByField(node, 'index'), source);
return key ? `this.${key}` : null;
}
if (object.type !== 'member_expression' || getChildByField(object, 'object')?.type !== 'this') return null;
@@ -7305,9 +7315,10 @@ export class TreeSitterExtractor {
/**
* The property a CommonJS export assignment binds a function to —
* `exports.NAME = <node>` or `module.exports.NAME = <node>` — or null for
* any other assignment. JS-family only; the node must be the assignment's
* whole right-hand side.
* `exports.NAME = <node>` or `module.exports.NAME = <node>`, or the same
* with the name in quotes, `exports["NAME"] = <node>` — or null for any
* other assignment. JS-family only; the node must be the assignment's whole
* right-hand side.
*/
private commonJsExportName(assignment: SyntaxNode, value: SyntaxNode): string | null {
if (
@@ -7321,12 +7332,14 @@ export class TreeSitterExtractor {
const right = getChildByField(assignment, 'right');
if (!right || right.startIndex !== value.startIndex || right.endIndex !== value.endIndex) return null;
const left = getChildByField(assignment, 'left');
if (!left || left.type !== 'member_expression') return null;
if (!left || (left.type !== 'member_expression' && left.type !== 'subscript_expression')) return null;
const object = getChildByField(left, 'object');
const property = getChildByField(left, 'property');
if (!object || !property || property.type !== 'property_identifier') return null;
if (!object) return null;
const objectText = getNodeText(object, this.source);
if (objectText !== 'exports' && objectText !== 'module.exports') return null;
if (left.type === 'subscript_expression') return quotedIdentifier(getChildByField(left, 'index'), this.source);
const property = getChildByField(left, 'property');
if (!property || property.type !== 'property_identifier') return null;
return getNodeText(property, this.source);
}