diff --git a/scripts/javascript-runtime-authority.mjs b/scripts/javascript-runtime-authority.mjs index 8ee68338..d7759a02 100644 --- a/scripts/javascript-runtime-authority.mjs +++ b/scripts/javascript-runtime-authority.mjs @@ -5,11 +5,11 @@ import ts from 'typescript'; * * Parsing the artifact instead of scanning raw text deliberately ignores comments, * string literals, and template text that merely mention `require()` or `import()`. - * Actual static imports/re-exports, bare CommonJS `require()` calls, and dynamic - * `import()` calls remain fail-closed findings. Literal module specifiers are - * preserved in diagnostics so a verifier failure identifies the dependency edge - * that escaped bundling; computed specifiers remain `undefined` and therefore do - * not acquire an invented interpretation. + * Actual static imports/re-exports, statically recognizable CommonJS loader calls, + * and dynamic `import()` calls remain fail-closed findings. Literal module specifiers + * are preserved in diagnostics so a verifier failure identifies the dependency edge + * that escaped bundling; computed specifiers remain `undefined` and therefore do not + * acquire an invented interpretation. * * @param {string} source JavaScript source emitted into the packed artifact. * @param {string} [filename='bundle.js'] Diagnostic filename for parse failures. @@ -42,6 +42,73 @@ export function findRuntimeModuleAuthority(source, filename = 'bundle.js') { : undefined; } + /** Remove syntax-only parentheses before classifying an executable reference. */ + function unwrapParentheses(expression) { + let current = expression; + while (ts.isParenthesizedExpression(current)) { + current = current.expression; + } + return current; + } + + /** + * Return whether an expression statically denotes a CommonJS loader function. + * + * Only intrinsic `require`, exact `module.require`, and comma expressions whose + * final value is one of those references are accepted. Arbitrary object methods + * named `require` remain outside this authority classifier. + */ + function isCommonJsLoaderReference(expression) { + const current = unwrapParentheses(expression); + if (ts.isIdentifier(current) && current.text === 'require') { + return true; + } + if ( + ts.isBinaryExpression(current) && + current.operatorToken.kind === ts.SyntaxKind.CommaToken + ) { + return isCommonJsLoaderReference(current.right); + } + if ( + ts.isPropertyAccessExpression(current) && + ts.isIdentifier(unwrapParentheses(current.expression)) && + unwrapParentheses(current.expression).text === 'module' && + current.name.text === 'require' + ) { + return true; + } + if ( + ts.isElementAccessExpression(current) && + ts.isIdentifier(unwrapParentheses(current.expression)) && + unwrapParentheses(current.expression).text === 'module' && + literalSpecifier(current.argumentExpression) === 'require' + ) { + return true; + } + return false; + } + + /** + * Resolve the specifier-bearing argument for a recognized CommonJS call. + * + * A matched call may legitimately have no literal argument, so the result uses + * an explicit object instead of overloading `undefined` as the no-match signal. + */ + function commonJsCall(node) { + const callee = unwrapParentheses(node.expression); + if (isCommonJsLoaderReference(callee)) { + return { specifierExpression: node.arguments[0] }; + } + if ( + ts.isPropertyAccessExpression(callee) && + callee.name.text === 'call' && + isCommonJsLoaderReference(callee.expression) + ) { + return { specifierExpression: node.arguments[1] }; + } + return null; + } + /** @param {import('typescript').Node} node Parsed JavaScript node. */ function visit(node) { if (ts.isImportDeclaration(node) && node.moduleSpecifier) { @@ -63,15 +130,15 @@ export function findRuntimeModuleAuthority(source, filename = 'bundle.js') { offset: node.getStart(sourceFile), specifier: literalSpecifier(node.arguments[0]), }); - } else if ( - ts.isIdentifier(node.expression) && - node.expression.text === 'require' - ) { - findings.push({ - kind: 'commonjs-require', - offset: node.getStart(sourceFile), - specifier: literalSpecifier(node.arguments[0]), - }); + } else { + const commonJs = commonJsCall(node); + if (commonJs) { + findings.push({ + kind: 'commonjs-require', + offset: node.getStart(sourceFile), + specifier: literalSpecifier(commonJs.specifierExpression), + }); + } } } @@ -80,4 +147,4 @@ export function findRuntimeModuleAuthority(source, filename = 'bundle.js') { visit(sourceFile); return Object.freeze(findings.map((finding) => Object.freeze(finding))); -} +} \ No newline at end of file diff --git a/scripts/javascript-runtime-authority.test.mjs b/scripts/javascript-runtime-authority.test.mjs index 7ca3e2a3..568cc376 100644 --- a/scripts/javascript-runtime-authority.test.mjs +++ b/scripts/javascript-runtime-authority.test.mjs @@ -41,9 +41,35 @@ test('reports executable module authority with actionable specifiers', () => { ); }); +test('reports statically recognizable indirect CommonJS loader calls', () => { + const source = [ + "const first = (0, require)('comma-package');", + "const second = require.call(undefined, 'call-package');", + "const third = module.require('module-package');", + "const fourth = module['require']('element-package');", + 'const fifth = (0, require)(runtimePackageName);', + 'const ordinary = { require() { return "ordinary-method"; } };', + "ordinary.require('not-commonjs-authority');", + 'void [first, second, third, fourth, fifth];', + ].join('\n'); + + assert.deepEqual( + findRuntimeModuleAuthority(source, 'indirect-commonjs.js').map( + ({ kind, specifier }) => ({ kind, specifier }), + ), + [ + { kind: 'commonjs-require', specifier: 'comma-package' }, + { kind: 'commonjs-require', specifier: 'call-package' }, + { kind: 'commonjs-require', specifier: 'module-package' }, + { kind: 'commonjs-require', specifier: 'element-package' }, + { kind: 'commonjs-require', specifier: undefined }, + ], + ); +}); + test('fails closed when emitted JavaScript is syntactically invalid', () => { assert.throws( () => findRuntimeModuleAuthority('const = ;', 'broken.js'), /broken\.js is not valid JavaScript/u, ); -}); +}); \ No newline at end of file