Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,42 @@ const { exports } = parse(
// exports === ['b']
```

### Grammar
### Extended detection

The third argument selects an opt-in detection mode:

```js
parse(source, filename, { mode: 'extended' });
```

Omitting the options, using `{}`, or selecting `mode: 'default'` preserves default detection.
An invalid mode throws a `TypeError`.
`allowMinifiedEnumerable` can be used with either mode.
Both modes use the existing lexer and assume valid JavaScript input.
They do not execute the source or validate the complete JavaScript grammar.

Extended mode detects literal names in `Object.defineProperty` calls regardless of the descriptor expression or getter body.
It also detects names in object literals passed to `Object.defineProperties(exports, ...)` or assigned to `module.exports`.
Object values can contain arbitrary expressions.
Methods, accessors, and computed string literal keys contribute their names.
Parentheses around these object literals are supported.
Parenthesized comma expressions can skip leading identifiers, integer literals, string literals, and empty object literals.
Other comma expressions are not supported.
For `module.exports = ({ a: 1 }, { b: 2 })`, extended mode detects `a`, although the expression evaluates to `{ b: 2 }`.
The same limitation applies to `Object.defineProperties`.
Direct reexport assignments contribute all detected dependencies instead of only the last assignment.
Comment thread
BridgeAR marked this conversation as resolved.
Object spreads of `require('literal')` contribute reexports, but named properties with require-valued expressions do not.

The result contains syntactic candidates, which can include non-enumerable properties, throwing getters, and unreachable assignments.
Extended mode does not apply the default getter side-effect veto.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping the unsafe-getter veto is a semantic change rather than broader syntax: the veto exists because non-matching getters can have side effects when names are snapshotted. Object.defineProperty(exports, 'x', { get: () => load() }) is now reported. The README documents it, but it should be called out in the PR description as a deliberate tradeoff.

It does not resolve bindings or aliases, evaluate computed names, or determine runtime control flow.
Names created by dynamic expressions can therefore remain undetected, and shadowed identifiers can produce false positives.

Division and regular-expression detection remains heuristic.
Ambiguous cases can cause extended mode's bracket tracking to report parse errors on valid JavaScript.
Selecting extended mode in this package does not change Node.js's built-in CommonJS detection.

### Grammar (default mode)

CommonJS exports matches are run against the source token stream. The grammar below describes default detection.

Expand Down
2 changes: 2 additions & 0 deletions include-wasm/cjs-module-lexer.h
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ enum RequireType {
void tryBacktrackAddStarExportBinding (uint16_t* pos);
bool tryParseRequire (enum RequireType requireType);
void tryParseLiteralExports ();
void readExportDefinition ();
void readExportProperty ();
bool readExportsOrModuleDotExports (uint16_t ch);
void tryParseModuleExportsDotAssign ();
void tryParseExportsDotAssign (bool assign);
Expand Down
1 change: 1 addition & 0 deletions lexer.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface Exports {

export interface ParseOptions {
allowMinifiedEnumerable?: boolean;
mode?: 'default' | 'extended';
}

export declare function parse(source: string, name?: string, options?: ParseOptions): Exports;
Expand Down
233 changes: 229 additions & 4 deletions lexer.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
let source, pos, end;
let extended, exportObjectPos;
const STACK_DEPTH = 2048;
const ClassToken = 1;
const ExportObjectToken = 2;
const templateStack = new Array(1024);
const openTokenPosStack = new Array(1024);
const openClassPosStack = new Array(1024);
const openTokenTypeStack = new Array(1024);
let openTokenDepth,
templateDepth,
lastTokenPos,
Expand Down Expand Up @@ -41,13 +44,15 @@ const ExportStar = 2;
function pushOpenToken (tokenPos) {
if (openTokenDepth === STACK_DEPTH)
throw new Error('Maximum nesting depth exceeded.');
if (extended) openTokenTypeStack[openTokenDepth] = 0;
openTokenPosStack[openTokenDepth++] = tokenPos;
}

function pushTemplate () {
if (templateStackDepth === STACK_DEPTH || openTokenDepth === STACK_DEPTH)
throw new Error('Maximum nesting depth exceeded.');
templateStack[templateStackDepth++] = templateDepth;
if (extended) openTokenTypeStack[openTokenDepth] = 0;
templateDepth = ++openTokenDepth;
}

Expand All @@ -57,9 +62,14 @@ function pushTemplate () {
* @param {import('./lexer').ParseOptions} [options]
*/
function parseCJS (source, name = '@', options) {
const mode = options ? options.mode : undefined;
if (mode !== undefined && mode !== 'default' && mode !== 'extended')
throw new TypeError('Expected options.mode to be "default" or "extended".');
const minifiedEnumerable = options ? options.allowMinifiedEnumerable === true : false;
resetState();
allowMinifiedEnumerable = minifiedEnumerable;
extended = mode === 'extended';
exportObjectPos = -1;
try {
parseSource(source);
}
Expand All @@ -73,6 +83,7 @@ function parseCJS (source, name = '@', options) {
return result;
}

/** @param {string} str */
function decode (str) {
if (str[0] === '"' || str[0] === '\'') {
try {
Expand Down Expand Up @@ -103,6 +114,7 @@ function decode (str) {
}
}

/** @param {string} cjsSource */
function parseSource (cjsSource) {
source = cjsSource;
pos = -1;
Expand Down Expand Up @@ -201,15 +213,35 @@ function parseSource (cjsSource) {
case 40/*(*/:
pushOpenToken(lastTokenPos);
break;
case 91/*[*/:
if (extended) {
pushOpenToken(lastTokenPos);
}
break;
case 93/*]*/:
if (extended) {
if (openTokenDepth === 0) throw new Error('Unexpected closing bracket.');
openTokenDepth--;
}
break;
case 44/*,*/:
Comment thread
BridgeAR marked this conversation as resolved.
if (extended && openTokenDepth && (openTokenTypeStack[openTokenDepth - 1] & ExportObjectToken))
readExportProperty();
break;
case 41/*)*/:
if (openTokenDepth === 0)
throw new Error('Unexpected closing bracket.');
openTokenDepth--;
break;
case 123/*{*/:
pushOpenToken(lastTokenPos);
openClassPosStack[openTokenDepth - 1] = nextBraceIsClass;
openTokenTypeStack[openTokenDepth - 1] = nextBraceIsClass ? ClassToken : 0;
nextBraceIsClass = false;
if (extended && pos === exportObjectPos) {
openTokenTypeStack[openTokenDepth - 1] |= ExportObjectToken;
exportObjectPos = -1;
readExportProperty();
}
break;
case 125/*}*/:
if (openTokenDepth === 0)
Expand Down Expand Up @@ -252,7 +284,9 @@ function parseSource (cjsSource) {
!(lastToken === 46/*.*/ && (source.charCodeAt(lastTokenPos - 1) >= 48/*0*/ && source.charCodeAt(lastTokenPos - 1) <= 57/*9*/)) &&
!(lastToken === 43/*+*/ && source.charCodeAt(lastTokenPos - 1) === 43/*+*/) && !(lastToken === 45/*-*/ && source.charCodeAt(lastTokenPos - 1) === 45/*-*/) ||
lastToken === 41/*)*/ && isParenKeyword(openTokenPosStack[openTokenDepth]) ||
lastToken === 125/*}*/ && (isExpressionTerminator(openTokenPosStack[openTokenDepth]) || openClassPosStack[openTokenDepth]) ||
lastToken === 125/*}*/ && (isExpressionTerminator(openTokenPosStack[openTokenDepth]) ||
(openTokenTypeStack[openTokenDepth] & ClassToken) ||
extended && isStatementBrace(openTokenPosStack[openTokenDepth], openTokenDepth)) ||
lastToken === 47/*/*/ && lastSlashWasDivision ||
isExpressionKeyword(lastTokenPos) ||
!lastToken) {
Expand Down Expand Up @@ -379,6 +413,11 @@ function tryParseObjectDefineOrKeys (keys) {
if (ch === 46/*.*/) {
pos++;
ch = commentWhitespace();
if (extended && ch === 100/*d*/) {
readExportDefinition();
pos = revertPos;
return;
}
if (ch === 100/*d*/ && source.startsWith('efineProperty', pos + 1)) {
let expt;
while (true) {
Expand Down Expand Up @@ -919,6 +958,7 @@ function tryParseModuleExportsDotAssign () {
pos = revertPos;
}

/** @param {boolean} assign */
function tryParseExportsDotAssign (assign) {
pos += 7;
const revertPos = pos - 1;
Expand Down Expand Up @@ -959,10 +999,17 @@ function tryParseExportsDotAssign (assign) {
// module.exports =
case 61/*=*/: {
if (assign) {
if (reexports.size)
if (!extended && reexports.size)
reexports = new Set();
pos++;
ch = commentWhitespace();
if (extended) {
if (ch === 40/*(*/) ch = readExportExpressionStart();
if (ch === 123/*{*/) exportObjectPos = pos;
Comment thread
BridgeAR marked this conversation as resolved.
else if (ch === 114/*r*/) tryParseRequire(ExportAssign);
pos = revertPos;
return;
}
// { ... }
if (ch === 123/*{*/) {
tryParseLiteralExports();
Expand Down Expand Up @@ -1012,6 +1059,128 @@ function tryParseRequire (requireType) {
return false;
}

function readExportExpressionStart () {
while (true) {
pos++;
const ch = commentWhitespace();
if (ch === 40/*(*/) continue;
const start = pos;
if (ch === 39/*'*/ || ch === 34/*"*/) {
stringLiteral(ch);
pos++;
}
else if (ch === 123/*{*/) {
pos++;
if (commentWhitespace() === 125/*}*/) pos++;
else {
pos = start;
return ch;
}
}
else {
while (pos <= end && isIdentifierChar(source.codePointAt(pos), true))
pos += codePointLen(source.codePointAt(pos));
}
if (pos === start || commentWhitespace() !== 44/*,*/) {
pos = start;
return ch;
}
}
}

function readExportDefinition () {
const start = pos;
const property = source.startsWith('defineProperty', pos);
const properties = source.startsWith('defineProperties', pos);
if (!property && !properties) return;
pos += property ? 14 : 16;
let ch = commentWhitespace();
if (ch !== 40/*(*/) return;
pos++;
ch = commentWhitespace();
if (!readExportsOrModuleDotExports(ch)) return;
if (commentWhitespace() !== 44/*,*/) return;
pos++;
ch = commentWhitespace();
if (properties) {
if (ch === 40/*(*/) ch = readExportExpressionStart();
if (ch === 123/*{*/) exportObjectPos = pos;
}
else if (ch === 39/*'*/ || ch === 34/*"*/) {
const nameStart = pos;
stringLiteral(ch);
const nameEnd = ++pos;
if (commentWhitespace() === 44/*,*/)
_exports.add(decode(source.slice(nameStart, nameEnd)));
}
pos = start;
}

function readExportProperty () {
const revertPos = pos;
pos++;
let ch = commentWhitespace();
if (ch === 46/*.*/ && source.startsWith('..', pos + 1)) {
pos += 3;
ch = commentWhitespace();
if (ch === 114/*r*/) tryParseRequire(ExportAssign);
pos = revertPos;
return;
}
if (ch === 42/***/) {
pos++;
ch = commentWhitespace();
}
let computed = ch === 91/*[*/;
if (computed) {
pos++;
ch = commentWhitespace();
}
let nameStart = pos;
let nameEnd;
if (ch === 39/*'*/ || ch === 34/*"*/) {
stringLiteral(ch);
nameEnd = ++pos;
}
else if (!computed && identifier()) {
nameEnd = pos;
ch = commentWhitespace();
if ((source.startsWith('get', nameStart) && nameEnd - nameStart === 3 ||
source.startsWith('set', nameStart) && nameEnd - nameStart === 3 ||
source.startsWith('async', nameStart) && nameEnd - nameStart === 5) &&
ch !== 58/*:*/ && ch !== 40/*(*/ && ch !== 44/*,*/ && ch !== 125/*}*/) {
if (ch === 42/***/) {
pos++;
ch = commentWhitespace();
}
computed = ch === 91/*[*/;
if (computed) {
pos++;
ch = commentWhitespace();
}
nameStart = pos;
if (ch === 39/*'*/ || ch === 34/*"*/) {
stringLiteral(ch);
nameEnd = ++pos;
}
else if (!computed && identifier()) nameEnd = pos;
else nameEnd = undefined;
}
}
ch = commentWhitespace();
if (computed) {
if (ch !== 93/*]*/) nameEnd = undefined;
else {
pos++;
ch = commentWhitespace();
}
}
if (nameEnd !== undefined && (ch === 58/*:*/ || ch === 40/*(*/ ||
!computed && (ch === 44/*,*/ || ch === 125/*}*/)))
_exports.add(decode(source.slice(nameStart, nameEnd)));
pos = revertPos;
}

function tryParseLiteralExports () {
const revertPos = pos - 1;
while (pos++ < end) {
Expand Down Expand Up @@ -1645,6 +1814,62 @@ function isExpressionPunctuator (ch) {
ch === 91/*[*/ || ch === 94/*^*/ || ch > 122 && ch < 127 && ch !== 125/*}*/;
}

/** @param {number} curPos */
function precedingToken (curPos) {
while (curPos >= 0) {
while (isBrOrWs(source.charCodeAt(curPos))) curPos--;
if (source.charCodeAt(curPos) !== 47/*/*/ || source.charCodeAt(curPos - 1) !== 42/***/)
return curPos;
curPos = source.lastIndexOf('/*', curPos - 2) - 1;
}
return -1;
}

/**
* @param {number} curPos
* @param {number} depth
*/
function isStatementBrace (curPos, depth) {
while (curPos >= 0) {
const ch = source.charCodeAt(curPos);
if (ch === 123/*{*/ && depth) {
curPos = openTokenPosStack[--depth];
continue;
}
if (ch !== 58/*:*/)
return ch === 125/*}*/ || isExpressionTerminator(curPos);
curPos = precedingToken(curPos - 1);
let start = curPos;
let identifierStart = false;
const quote = source.charCodeAt(start);
if (quote === 39/*'*/ || quote === 34/*"*/) {
while (--start >= 0) {
if (source.charCodeAt(start) !== quote) continue;
let before = start;
while (source.charCodeAt(before - 1) === 92/*\*/) before--;
if ((start - before) % 2 === 0) break;
}
start--;
}
else {
while (start >= 0 && isIdentifierChar(codePointAtLast(start), true)) {
const codePoint = codePointAtLast(start);
identifierStart = isIdentifierStart(codePoint, true);
start -= codePointLen(codePoint);
}
}
curPos = precedingToken(start);
if (source.charCodeAt(curPos) === 43/*+*/ || source.charCodeAt(curPos) === 45/*-*/)
curPos = precedingToken(curPos - 1);
if (curPos >= 3 && source.startsWith('case', curPos - 3) &&
(curPos === 3 || !isIdentifierChar(codePointAtLast(curPos - 4), true) && source.charCodeAt(curPos - 4) !== 46/*.*/))
return true;
if (!identifierStart)
return false;
}
return true;
}

function isExpressionTerminator (curPos) {
// detects:
// => ; ) finally catch else
Expand Down
Binary file modified lib/lexer.wasm
Binary file not shown.
Loading
Loading