From e344adc2542906cbacf323c82498c79ea76c6400 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Mon, 5 Oct 2026 20:08:02 +0900 Subject: [PATCH 1/6] feat(lexer): prototype versioned detection baselines Conditional export detection keeps the last matching assignment, even when its branch cannot run. This PoC makes final export outcomes conditional on control flow through an opt-in detection baseline. The third parse argument selects a versioned baseline that can combine detection rules. The default call retains its current behavior and result. This is a bounded syntax experiment. Unsupported syntax, opaque effects, and analysis limits produce unknown outcomes instead of speculative export lists. Synchronous ESM parsing includes the analyzer in the bundle, which adds about 18 KB. Refs: https://github.com/nodejs/cjs-module-lexer/issues/99 --- README.md | 46 +++ build.js | 3 +- lexer.d.ts | 24 +- lexer.js | 2 + package.json | 1 + src/export-analysis.js | 853 +++++++++++++++++++++++++++++++++++++++++ src/lexer.js | 2 + test/flow.js | 440 +++++++++++++++++++++ 8 files changed, 1369 insertions(+), 2 deletions(-) create mode 100644 src/export-analysis.js create mode 100644 test/flow.js diff --git a/README.md b/README.md index 7d3305a..eaf33de 100755 --- a/README.md +++ b/README.md @@ -76,6 +76,52 @@ The optional third argument to `parse(source, name, options)` controls detection in supported `Object.defineProperty` exports and star reexports. Whitespace and comments can separate `!` and `0`. This option does not change Node.js's built-in CommonJS named-export detection. +### Detection baselines (proof of concept) + +The optional third argument selects a detection baseline. +Omit it, or select `legacy`, to retain the existing return value and detection patterns. + +```js +const report = parse(` + if (production) module.exports = require('./production.cjs'); + else module.exports = require('./development.cjs'); +`, 'entry.cjs', { baseline: 'flow-v1' }); + +for (const outcome of report.outcomes) { + console.log(outcome.conditions, outcome.value); +} +``` + +The selected baseline returns a flow report instead of the legacy arrays. +Each outcome describes the final export value under its conditions. +Conditions identify source expressions with UTF-16 offsets and record truthiness or nullishness. +Ranges include the start offset and exclude the end offset. +They are analysis facts, not instructions to execute the source again. + +Use valid CommonJS source. The report describes successful synchronous completion under standard CommonJS bindings. +It excludes replaced export objects, detached aliases, deleted properties, and uncalled function bodies. +A module value identifies a direct re-export. +An object value contains confirmed property names, including a property whose value is a required module. + +This PoC supports `if`, conditional expressions, logical expressions and assignments, primitive constants, +local bindings, and owned object properties. +It does not execute the source or resolve dependencies. +Dependency spreads and unsupported syntax or effects produce an `unknown` value. +Its source offsets identify the expression when available. +An unknown outcome makes `complete` false. +A complete report can still contain runtime conditions. + +Opaque reads can invoke getters. They invalidate exposed export state and can return different values on each read. +The analyzer correlates stable local bindings. +It does not assume that identical source expressions have identical results. +Dependency execution also invalidates exposed state. +Dependency calls combined with local function declarations remain unknown because callbacks can change captured bindings. +Assignments to a fresh export value can establish a known result again. + +The parser limits tokens, source and AST nesting, branch expansion, and analysis work. +Reaching a limit produces an unknown outcome rather than a partial export list. + + ```js const { exports } = parse( "Object.defineProperty(exports, 'b', { enumerable: !0, value: 'b' });", diff --git a/build.js b/build.js index 843c46e..8745502 100755 --- a/build.js +++ b/build.js @@ -7,7 +7,8 @@ try { fs.mkdirSync('./dist'); } catch (e) {} const wasmBuffer = fs.readFileSync('./lib/lexer.wasm'); -const jsSource = fs.readFileSync('./src/lexer.js').toString(); +const analysisSource = fs.readFileSync('./src/export-analysis.js', 'utf8').replace('module.exports = analyzeExports;', ''); +const jsSource = analysisSource + '\n' + fs.readFileSync('./src/lexer.js').toString(); const pjson = JSON.parse(fs.readFileSync('./package.json').toString()); const jsSourceProcessed = jsSource.replace('WASM_BINARY', wasmBuffer.toString('base64')); diff --git a/lexer.d.ts b/lexer.d.ts index 616f952..e5d42f8 100755 --- a/lexer.d.ts +++ b/lexer.d.ts @@ -6,8 +6,30 @@ export interface Exports { export interface ParseOptions { allowMinifiedEnumerable?: boolean; mode?: 'default' | 'extended'; + baseline?: 'legacy' | 'flow-v1'; } -export declare function parse(source: string, name?: string, options?: ParseOptions): Exports; +export interface FlowCondition { + start: number; + end: number; + when: 'truthy' | 'falsy' | 'nullish' | 'non-nullish'; +} + +export type FlowExportValue = + | { kind: 'module'; specifier: string } + | { kind: 'object'; properties: string[] } + | { kind: 'literal'; value: string | number | boolean | null | undefined } + | { kind: 'unknown'; reason: string; start?: number; end?: number }; + +export interface ExportAnalysis { + baseline: 'flow-v1'; + complete: boolean; + outcomes: { conditions: FlowCondition[]; value: FlowExportValue }[]; +} + +export declare function parse(source: string, name: string | undefined, options: { baseline: 'flow-v1' }): ExportAnalysis; +export declare function parse(source: string, name: string | undefined, options: { baseline?: 'legacy' }): Exports; +export declare function parse(source: string, name: string | undefined, options: ParseOptions): Exports | ExportAnalysis; +export declare function parse(source: string, name?: string): Exports; export declare function init(): Promise; export declare function initSync(): void; diff --git a/lexer.js b/lexer.js index 2217554..639beac 100755 --- a/lexer.js +++ b/lexer.js @@ -66,6 +66,8 @@ function parseCJS (source, name = '@', options) { 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; + const analysis = options === undefined ? undefined : require('./src/export-analysis.js')(source, options, decode); + if (analysis !== undefined) return analysis; resetState(); allowMinifiedEnumerable = minifiedEnumerable; extended = mode === 'extended'; diff --git a/package.json b/package.json index 6db516c..4033371 100755 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ }, "files": [ "dist", + "src/export-analysis.js", "lexer.d.ts", "lexer.d.mts" ], diff --git a/src/export-analysis.js b/src/export-analysis.js new file mode 100644 index 0000000..e8d9687 --- /dev/null +++ b/src/export-analysis.js @@ -0,0 +1,853 @@ +/** + * @typedef {string | number | boolean | null | undefined} FlowPrimitive + * @typedef {{ text: string, start: number, end: number }} FlowToken + * @typedef {{ type: 'literal', value: FlowPrimitive, start: number, end: number }} FlowLiteral + * @typedef {{ type: 'identifier', name: string, start: number, end: number }} FlowIdentifier + * @typedef {{ type: 'member', object: FlowExpression, key: FlowExpression, start: number, end: number }} FlowMember + * @typedef {{ type: 'call', callee: FlowExpression, args: FlowExpression[], start: number, end: number }} FlowCall + * @typedef {{ type: 'unary', operator: string, argument: FlowExpression, start: number, end: number }} FlowUnary + * @typedef {{ type: 'binary', operator: string, left: FlowExpression, right: FlowExpression, + * start: number, end: number }} FlowBinary + * @typedef {{ type: 'conditional', test: FlowExpression, consequent: FlowExpression, alternate: FlowExpression, + * start: number, end: number }} FlowConditional + * @typedef {{ type: 'object', entries: { key?: string, value: FlowExpression }[], + * start: number, end: number }} FlowObject + * @typedef {FlowLiteral | FlowIdentifier | FlowMember | FlowCall | FlowUnary | FlowBinary | + * FlowConditional | FlowObject} FlowExpression + * @typedef {{ type: 'block', body: FlowStatement[], start: number, end: number } | + * { type: 'if', test: FlowExpression, consequent: FlowStatement, alternate?: FlowStatement, + * start: number, end: number } | + * { type: 'declaration', mode: string, declarations: { name: string, value?: FlowExpression }[], + * start: number, end: number } | + * { type: 'function', name: string, start: number, end: number } | + * { type: 'expression', expression: FlowExpression, start: number, end: number } | + * { type: 'return' | 'throw' | 'empty', start: number, end: number }} FlowStatement + * @typedef {{ kind: 'literal', value: FlowPrimitive } | + * { kind: 'object', id: number } | { kind: 'module', id: number, specifier: string } | + * { kind: 'unknown', id: number, reason?: string } | + * { kind: 'module-object' | 'require' | 'uninitialized' }} FlowValue + * @typedef {{ properties: Map, reason?: string, start?: number, end?: number }} FlowOwnedObject + * @typedef {{ bindings: Map, objects: Map, + * facts: Map, conditions: import('../lexer').FlowCondition[], value: FlowValue, + * declared: Set, constants: Set, prototypeUnknown: boolean, + * completion: 'normal' | 'return' | 'throw' }} FlowState + * @typedef {{ source: string, decode: (text: string) => string | undefined, tokens: FlowToken[], pos: number, + * depth: number, steps: number, branches: number, nextId: number, hasFunctions: boolean }} FlowContext + * @typedef {{ state: FlowState, value: FlowValue }} FlowEvaluation + * @typedef {{ kind: 'binding', name: string } | { kind: 'module' } | + * { kind: 'property', id: number, name: string }} FlowReference + */ + +let flowPowers; +let flowPatterns; +const flowLimits = { tokens: 8192, depth: 64, outcomes: 32, steps: 65536 }; + +class FlowFailure extends Error { + /** @param {string} reason @param {number} start @param {number} end */ + constructor (reason, start, end) { + super(reason); + this.reason = reason; + this.start = start; + this.end = end; + } +} + +/** + * @param {string} source + * @param {import('../lexer').ParseOptions} options + * @param {(text: string) => string | undefined} decode + */ +function analyzeExports (source, options, decode) { + const baseline = options.baseline; + if (baseline === undefined || baseline === 'legacy') return; + if (typeof baseline !== 'string') throw new TypeError('Detection baseline must be a string'); + if (baseline !== 'flow-v1') throw new RangeError('Unknown detection baseline: ' + baseline); + if (flowPowers === undefined) { + flowPatterns = { + whitespace: /\s/, lineBreak: /[\r\n]/, identifierStart: /[a-zA-Z_$]/, + identifierPart: /[a-zA-Z0-9_$]/, identifier: /^[a-zA-Z_$][a-zA-Z0-9_$]*$/, + digit: /[0-9]/, numericStart: /^\d/, legacyNumber: /^0\d/, + number: /^(?:0[xX][\da-fA-F]+|0[bB][01]+|0[oO][0-7]+|\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/, + operator: /^(?:\.\.\.|===|!==|&&=|\|\|=|\?\?=|&&|\|\||\?\?|==|!=|<=|>=|=>|\+\+|--)/ + }; + flowPowers = new Map([ + [',', 1], ['=', 2], ['&&=', 2], ['||=', 2], ['??=', 2], ['?', 3], + ['||', 4], ['??', 4], ['&&', 5], ['===', 6], ['!==', 6], ['==', 6], ['!=', 6], + ['<', 7], ['>', 7], ['<=', 7], ['>=', 7], ['+', 8], ['-', 8], ['*', 9], ['%', 9] + ]); + } + /** @type {FlowContext} */ + const context = { source, decode, tokens: [], pos: 0, depth: 0, steps: 0, branches: 0, nextId: 1, + hasFunctions: false }; + try { + tokenizeFlow(context); + const body = []; + while (context.pos < context.tokens.length) body.push(readFlowStatement(context)); + validateFlowDepth(context, body); + /** @type {FlowValue} */ + const initial = { kind: 'object', id: 0 }; + /** @type {FlowState} */ + const state = { + bindings: new Map([ + ['module', { kind: 'module-object' }], ['exports', initial], ['require', { kind: 'require' }] + ]), + objects: new Map([[0, { properties: new Map() }]]), facts: new Map(), conditions: [], value: initial, + declared: new Set(['module', 'exports', 'require']), constants: new Set(), prototypeUnknown: false, + completion: 'normal' + }; + hoistFlowVars(body, state); + const states = executeFlowBody(context, body, [state]); + const outcomes = []; + for (const result of states) { + if (result.completion === 'throw') continue; + outcomes.push({ conditions: result.conditions, value: reportFlowValue(result, result.value) }); + } + return { baseline, complete: outcomes.every( + /** @param {import('../lexer').ExportAnalysis['outcomes'][number]} outcome */ + outcome => outcome.value.kind !== 'unknown' + ), outcomes }; + } catch (error) { + if (!(error instanceof FlowFailure)) throw error; + return { baseline, complete: false, outcomes: [{ conditions: [], + value: { kind: 'unknown', reason: error.reason, start: error.start, end: error.end } }] }; + } +} + +/** @param {FlowContext} context @param {string} reason @param {number} [start] @param {number} [end] */ +function failFlow (context, reason, start, end) { + const token = context.tokens[context.pos]; + if (start === undefined) start = token ? token.start : context.source.length; + if (end === undefined) end = token ? token.end : start; + throw new FlowFailure(reason, start, end); +} + +/** @param {FlowContext} context */ +function stepFlow (context) { + if (++context.steps > flowLimits.steps) failFlow(context, 'work-limit'); +} + +/** @param {FlowContext} context */ +function tokenizeFlow (context) { + const source = context.source; + let pos = source.startsWith('#!') ? source.indexOf('\n') : 0; + if (pos === -1) return; + while (pos < source.length) { + stepFlow(context); + const start = pos; + const ch = source[pos]; + if (flowPatterns.whitespace.test(ch)) { pos++; continue; } + if (source.startsWith('//', pos)) { + const next = source.indexOf('\n', pos + 2); + pos = next === -1 ? source.length : next; + continue; + } + if (source.startsWith('/*', pos)) { + const next = source.indexOf('*/', pos + 2); + if (next === -1) failFlow(context, 'unterminated-comment', start, source.length); + pos = next + 2; + continue; + } + if (ch === '"' || ch === "'") { + pos++; + while (pos < source.length && source[pos] !== ch) { + if (source[pos] === '\\') pos++; + pos++; + } + if (pos === source.length) failFlow(context, 'unterminated-string', start, pos); + pos++; + } else if (flowPatterns.identifierStart.test(ch)) { + while (++pos < source.length && flowPatterns.identifierPart.test(source[pos])) {} + } else if (flowPatterns.digit.test(ch)) { + const number = flowPatterns.number.exec(source.slice(pos)); + pos += number[0].length; + if (flowPatterns.legacyNumber.test(number[0])) failFlow(context, 'unsupported-number', start, pos); + } else { + const operator = flowPatterns.operator.exec(source.slice(pos)); + if (operator) pos += operator[0].length; + else if ('{}()[];:,.?=+-!*%<>'.includes(ch)) pos++; + else failFlow(context, 'unsupported-token', start, start + 1); + } + context.tokens.push({ text: source.slice(start, pos), start, end: pos }); + if (context.tokens.length > flowLimits.tokens) failFlow(context, 'token-limit', start, pos); + } +} + +/** @param {FlowContext} context @param {string} text */ +function takeFlow (context, text) { + const token = context.tokens[context.pos]; + if (!token || token.text !== text) return false; + context.pos++; + return true; +} + +/** @param {FlowContext} context @param {string} text */ +function expectFlow (context, text) { + if (!takeFlow(context, text)) failFlow(context, 'unsupported-syntax'); +} + +/** @param {FlowContext} context */ +function flowSemicolon (context) { + if (takeFlow(context, ';') || context.pos === context.tokens.length || + context.tokens[context.pos].text === '}') return; + const previous = context.tokens[context.pos - 1]; + if (!flowPatterns.lineBreak.test(context.source.slice(previous.end, context.tokens[context.pos].start))) { + failFlow(context, 'unsupported-syntax'); + } +} + +/** @param {FlowContext} context @returns {FlowStatement} */ +function readFlowStatement (context) { + stepFlow(context); + if (++context.depth > flowLimits.depth) failFlow(context, 'depth-limit'); + const token = context.tokens[context.pos++]; + if (!token) failFlow(context, 'unsupported-syntax'); + const start = token.start; + let result; + if (token.text === ';') result = { type: 'empty', start, end: token.end }; + else if (token.text === '{') { + const body = []; + while (!context.tokens[context.pos] || context.tokens[context.pos].text !== '}') { + body.push(readFlowStatement(context)); + } + expectFlow(context, '}'); + result = { type: 'block', body, start, end: context.tokens[context.pos - 1].end }; + } else if (token.text === 'if') { + expectFlow(context, '('); + const test = readFlowExpression(context); + expectFlow(context, ')'); + const consequent = readFlowStatement(context); + const alternate = takeFlow(context, 'else') ? readFlowStatement(context) : undefined; + result = { type: 'if', test, consequent, alternate, start, end: alternate ? alternate.end : consequent.end }; + } else if (['const', 'let', 'var'].includes(token.text)) { + const declarations = []; + do { + const name = context.tokens[context.pos++]; + if (!name || !flowPatterns.identifier.test(name.text)) failFlow(context, 'unsupported-binding'); + const value = takeFlow(context, '=') ? readFlowExpression(context, 2) : undefined; + declarations.push({ name: name.text, value }); + } while (takeFlow(context, ',')); + flowSemicolon(context); + result = { type: 'declaration', mode: token.text, declarations, start, + end: context.tokens[context.pos - 1].end }; + } else if (token.text === 'function') { + context.hasFunctions = true; + const name = context.tokens[context.pos++]; + if (!name || !flowPatterns.identifier.test(name.text)) failFlow(context, 'unsupported-function'); + skipFlowGroup(context, '(', ')'); + skipFlowGroup(context, '{', '}'); + result = { type: 'function', name: name.text, start, end: context.tokens[context.pos - 1].end }; + } else if (token.text === 'return' || token.text === 'throw') { + if (context.tokens[context.pos] && ![';', '}'].includes(context.tokens[context.pos].text) && + !flowPatterns.lineBreak.test(context.source.slice(token.end, context.tokens[context.pos].start))) { + failFlow(context, 'unsupported-completion-value', start, token.end); + } + flowSemicolon(context); + result = { type: token.text, start, end: context.tokens[context.pos - 1].end }; + } else { + context.pos--; + const expression = readFlowExpression(context); + flowSemicolon(context); + result = { type: 'expression', expression, start, end: context.tokens[context.pos - 1].end }; + } + context.depth--; + return result; +} + +/** @param {FlowContext} context @param {string} open @param {string} close */ +function skipFlowGroup (context, open, close) { + expectFlow(context, open); + let depth = 1; + while (depth) { + stepFlow(context); + const token = context.tokens[context.pos++]; + if (!token) failFlow(context, 'unsupported-syntax'); + if (token.text === open) depth++; + else if (token.text === close) depth--; + } +} + +/** @param {FlowContext} context @param {number} [minimum] @returns {FlowExpression} */ +function readFlowExpression (context, minimum = 1) { + stepFlow(context); + if (++context.depth > flowLimits.depth) failFlow(context, 'depth-limit'); + const token = context.tokens[context.pos++]; + if (!token) failFlow(context, 'unsupported-expression'); + let left; + if (token.text === '(') { + left = readFlowExpression(context); + expectFlow(context, ')'); + } else if (token.text === '{') { + const entries = []; + while (!takeFlow(context, '}')) { + if (takeFlow(context, '...')) entries.push({ value: readFlowExpression(context, 2) }); + else { + const key = context.tokens[context.pos++]; + if (!key) failFlow(context, 'unsupported-object'); + const name = flowPatterns.numericStart.test(key.text) ? String(Number(key.text)) : + key.text[0] === '"' || key.text[0] === "'" ? context.decode(key.text) : key.text; + if (name === undefined || name === '__proto__') failFlow(context, 'unsupported-object', key.start, key.end); + const value = takeFlow(context, ':') ? readFlowExpression(context, 2) : + { type: 'identifier', name, start: key.start, end: key.end }; + entries.push({ key: name, value }); + } + if (!takeFlow(context, ',')) { expectFlow(context, '}'); break; } + } + left = { type: 'object', entries, start: token.start, end: context.tokens[context.pos - 1].end }; + } else if (['!', '+', '-', 'void', 'typeof', 'delete'].includes(token.text)) { + const argument = readFlowExpression(context, 10); + left = { type: 'unary', operator: token.text, argument, start: token.start, end: argument.end }; + } else if (token.text[0] === '"' || token.text[0] === "'" || flowPatterns.numericStart.test(token.text) || + ['true', 'false', 'null'].includes(token.text)) { + const value = token.text[0] === '"' || token.text[0] === "'" ? context.decode(token.text) : + token.text === 'true' ? true : token.text === 'false' ? false : token.text === 'null' ? null : Number(token.text); + if (value === undefined) failFlow(context, 'unsupported-string', token.start, token.end); + left = { type: 'literal', value, start: token.start, end: token.end }; + } else if (flowPatterns.identifier.test(token.text)) { + left = { type: 'identifier', name: token.text, start: token.start, end: token.end }; + } else failFlow(context, 'unsupported-expression', token.start, token.end); + + while (context.pos < context.tokens.length) { + stepFlow(context); + const next = context.tokens[context.pos]; + if (next.text === '.' || next.text === '[') { + context.pos++; + let key; + if (next.text === '.') { + const property = context.tokens[context.pos++]; + if (!property || !flowPatterns.identifier.test(property.text)) failFlow(context, 'unsupported-member'); + key = { type: 'literal', value: property.text, start: property.start, end: property.end }; + } else { key = readFlowExpression(context); expectFlow(context, ']'); } + left = { type: 'member', object: left, key, start: left.start, end: context.tokens[context.pos - 1].end }; + continue; + } + if (next.text === '(') { + context.pos++; + const args = []; + if (!takeFlow(context, ')')) { + do { args.push(readFlowExpression(context, 2)); } while (takeFlow(context, ',')); + expectFlow(context, ')'); + } + left = { type: 'call', callee: left, args, start: left.start, end: context.tokens[context.pos - 1].end }; + continue; + } + const power = flowPowers.get(next.text); + if (power === undefined || power < minimum) break; + context.pos++; + if (next.text === '?') { + const consequent = readFlowExpression(context, 2); + expectFlow(context, ':'); + const alternate = readFlowExpression(context, 2); + left = { type: 'conditional', test: left, consequent, alternate, start: left.start, end: alternate.end }; + } else { + const right = readFlowExpression(context, power === 2 ? power : power + 1); + left = { type: 'binary', operator: next.text, left, right, start: left.start, end: right.end }; + } + } + context.depth--; + return left; +} + +/** @param {FlowContext} context @param {FlowStatement[]} body */ +function validateFlowDepth (context, body) { + /** @type {{ node: FlowStatement | FlowExpression, depth: number }[]} */ + const pending = []; + for (const node of body) pending.push({ node, depth: 1 }); + while (pending.length) { + const frame = pending.pop(); + const node = frame.node; + stepFlow(context); + if (frame.depth > flowLimits.depth) failFlow(context, 'depth-limit', node.start, node.end); + const depth = frame.depth + 1; + if (node.type === 'block') { + for (const child of node.body) pending.push({ node: child, depth }); + } else if (node.type === 'if' || node.type === 'conditional') { + pending.push({ node: node.test, depth }, { node: node.consequent, depth }); + if (node.alternate) pending.push({ node: node.alternate, depth }); + } else if (node.type === 'declaration') { + for (const declaration of node.declarations) { + if (declaration.value) pending.push({ node: declaration.value, depth }); + } + } else if (node.type === 'expression') pending.push({ node: node.expression, depth }); + else if (node.type === 'member') { + pending.push({ node: node.object, depth }, { node: node.key, depth }); + } else if (node.type === 'call') { + pending.push({ node: node.callee, depth }); + for (const argument of node.args) pending.push({ node: argument, depth }); + } else if (node.type === 'unary') pending.push({ node: node.argument, depth }); + else if (node.type === 'binary') { + pending.push({ node: node.left, depth }, { node: node.right, depth }); + } else if (node.type === 'object') { + for (const entry of node.entries) pending.push({ node: entry.value, depth }); + } + } +} + +/** @param {FlowState} state */ +function cloneFlowState (state) { + const objects = new Map(); + for (const [id, object] of state.objects) { + objects.set(id, { + properties: new Map(object.properties), reason: object.reason, start: object.start, end: object.end + }); + } + return { bindings: new Map(state.bindings), objects, facts: new Map(state.facts), + conditions: state.conditions.slice(), value: state.value, declared: new Set(state.declared), + constants: new Set(state.constants), prototypeUnknown: state.prototypeUnknown, completion: state.completion }; +} + +/** @param {FlowStatement[]} body @param {FlowState} state */ +function hoistFlowVars (body, state) { + for (const statement of body) { + if (statement.type === 'declaration' && statement.mode === 'var') { + for (const declaration of statement.declarations) { + state.declared.add(declaration.name); + if (!state.bindings.has(declaration.name)) { + state.bindings.set(declaration.name, { kind: 'literal', value: undefined }); + } + } + } else if (statement.type === 'block') hoistFlowVars(statement.body, state); + else if (statement.type === 'if') { + hoistFlowVars([statement.consequent], state); + if (statement.alternate) hoistFlowVars([statement.alternate], state); + } + } +} + +/** @param {FlowContext} context @param {FlowStatement[]} body @param {FlowState[]} states */ +function executeFlowBody (context, body, states) { + for (const statement of body) { + for (const state of states) { + if (statement.type === 'function') { + state.declared.add(statement.name); + state.bindings.set(statement.name, { kind: 'unknown', id: context.nextId++ }); + } + if (statement.type === 'declaration') { + for (const declaration of statement.declarations) { + state.declared.add(declaration.name); + if (statement.mode !== 'var') { + state.constants.delete(declaration.name); + state.bindings.set(declaration.name, { kind: 'uninitialized' }); + } + else if (!state.bindings.has(declaration.name)) { + state.bindings.set(declaration.name, { kind: 'literal', value: undefined }); + } + } + } + } + } + for (const statement of body) { + const next = []; + for (const state of states) { + if (state.completion !== 'normal') next.push(state); + else next.push(...executeFlowStatement(context, statement, state)); + } + if (next.length > flowLimits.outcomes) failFlow(context, 'outcome-limit', statement.start, statement.end); + states = next; + } + return states; +} + +/** @param {FlowContext} context @param {FlowStatement} statement @param {FlowState} state */ +function executeFlowStatement (context, statement, state) { + stepFlow(context); + if (statement.type === 'empty' || statement.type === 'function') return [state]; + if (statement.type === 'return' || statement.type === 'throw') { state.completion = statement.type; return [state]; } + if (statement.type === 'block') { + const bindings = new Map(); + for (const child of statement.body) { + if (child.type === 'declaration' && child.mode !== 'var') { + for (const declaration of child.declarations) bindings.set(declaration.name, { + value: state.bindings.get(declaration.name), declared: state.declared.has(declaration.name), + constant: state.constants.has(declaration.name) + }); + } + if (child.type === 'function') failFlow(context, 'block-function', child.start, child.end); + } + const results = executeFlowBody(context, statement.body, [state]); + for (const result of results) { + for (const [name, binding] of bindings) { + const value = binding.value; + if (value === undefined) result.bindings.delete(name); + else result.bindings.set(name, value); + if (!binding.declared) result.declared.delete(name); + if (binding.constant) result.constants.add(name); + else result.constants.delete(name); + } + } + return results; + } + if (statement.type === 'declaration') { + let states = [state]; + for (const declaration of statement.declarations) { + const next = []; + for (const current of states) { + if (statement.mode === 'var' && !declaration.value) { next.push(current); continue; } + const results = declaration.value ? evaluateFlow(context, declaration.value, current) : + [{ state: current, value: { kind: 'literal', value: undefined } }]; + for (const result of results) { + result.state.bindings.set(declaration.name, result.value); + if (statement.mode === 'const') result.state.constants.add(declaration.name); + next.push(result.state); + } + } + states = next; + } + return states; + } + if (statement.type === 'expression') { + return evaluateFlow(context, statement.expression, state).map( + /** @param {FlowEvaluation} result */ + result => result.state + ); + } + const results = []; + for (const result of evaluateFlow(context, statement.test, state)) { + for (const branch of branchFlow(context, result, statement.test, 'truthy')) { + const child = branch.pass ? statement.consequent : statement.alternate; + if (child) results.push(...executeFlowStatement(context, child, branch.state)); + else results.push(branch.state); + } + } + return results; +} + +/** + * @param {FlowContext} context + * @param {FlowEvaluation} result + * @param {FlowExpression} test + * @param {string} predicate + */ +function branchFlow (context, result, test, predicate) { + const value = result.value; + if (value.kind === 'literal' || value.kind === 'object' || + value.kind === 'module-object' || value.kind === 'require') { + const pass = predicate === 'nullish' ? value.kind === 'literal' && value.value == null : + value.kind !== 'literal' || Boolean(value.value); + return [{ state: result.state, value, pass }]; + } + const fact = predicate + ':' + value.id; + if (predicate === 'truthy' && result.state.facts.get('nullish:' + value.id) === true) { + return [{ state: result.state, value, pass: false }]; + } + if (predicate === 'nullish' && result.state.facts.get('truthy:' + value.id) === true) { + return [{ state: result.state, value, pass: false }]; + } + if (result.state.facts.has(fact)) return [{ state: result.state, value, pass: result.state.facts.get(fact) }]; + const branches = []; + context.branches += 2; + if (context.branches > flowLimits.outcomes * 2) failFlow(context, 'outcome-limit', test.start, test.end); + for (const pass of [true, false]) { + const state = cloneFlowState(result.state); + state.facts.set(fact, pass); + state.conditions.push({ start: test.start, end: test.end, + when: predicate === 'nullish' ? pass ? 'nullish' : 'non-nullish' : pass ? 'truthy' : 'falsy' }); + branches.push({ state, value, pass }); + } + return branches; +} + +/** + * @param {FlowContext} context + * @param {FlowExpression} expression + * @param {FlowState} state + * @returns {FlowEvaluation[]} + */ +function evaluateFlow (context, expression, state) { + stepFlow(context); + if (expression.type === 'literal') return [{ state, value: { kind: 'literal', value: expression.value } }]; + if (expression.type === 'identifier') { + let value = state.bindings.get(expression.name); + if (!value) { + if (expression.name === 'undefined') value = { kind: 'literal', value: undefined }; + else { + invalidateFlowEffects(context, state, 'opaque-read-effects'); + value = { kind: 'unknown', id: context.nextId++ }; + } + } + if (value.kind === 'uninitialized') failFlow(context, 'uninitialized-binding', expression.start, expression.end); + return [{ state, value }]; + } + if (expression.type === 'object') { + const id = context.nextId++; + state.objects.set(id, { properties: new Map(), start: expression.start, end: expression.end }); + let states = [state]; + for (const entry of expression.entries) { + const next = []; + for (const current of states) { + for (const result of evaluateFlow(context, entry.value, current)) { + const object = result.state.objects.get(id); + if (entry.key !== undefined) object.properties.set(entry.key, result.value); + else if (result.value.kind === 'object' && !result.state.objects.get(result.value.id).reason) { + for (const [key, value] of result.state.objects.get(result.value.id).properties) { + object.properties.set(key, value); + } + } else { + if (result.value.kind !== 'literal') { + invalidateFlowEffects(context, result.state, 'opaque-spread-effects'); + } + object.reason = 'unresolved-spread'; + } + next.push(result.state); + } + } + states = next; + } + return states.map( + /** @param {FlowState} current */ + current => ({ state: current, value: { kind: 'object', id } }) + ); + } + if (expression.type === 'member') { + const results = []; + for (const receiver of evaluateFlow(context, expression.object, state)) { + for (const key of evaluateFlow(context, expression.key, receiver.state)) { + const name = flowPropertyName(context, key.value, expression); + let value; + if (receiver.value.kind === 'module-object' && name === 'exports') value = key.state.value; + else if (receiver.value.kind === 'object') { + const object = key.state.objects.get(receiver.value.id); + if (object.reason) failFlow(context, object.reason, expression.start, expression.end); + value = object.properties.get(name); + if (value === undefined) invalidateFlowEffects(context, key.state, 'opaque-read-effects'); + } else invalidateFlowEffects(context, key.state, 'opaque-read-effects'); + results.push({ state: key.state, value: value || { kind: 'unknown', id: context.nextId++ } }); + } + } + return results; + } + if (expression.type === 'call') { + const requireBinding = state.bindings.get('require'); + if (expression.callee.type !== 'identifier' || expression.callee.name !== 'require' || + !requireBinding || requireBinding.kind !== 'require' || expression.args.length !== 1) { + failFlow(context, 'unsupported-call', expression.start, expression.end); + } + const results = []; + for (const argument of evaluateFlow(context, expression.args[0], state)) { + if (argument.value.kind !== 'literal' || typeof argument.value.value !== 'string') { + failFlow(context, 'dynamic-require', expression.start, expression.end); + } + invalidateFlowEffects(context, argument.state, 'dependency-effects'); + results.push({ state: argument.state, + value: { kind: 'module', id: context.nextId++, specifier: argument.value.value } }); + } + return results; + } + if (expression.type === 'conditional') { + const results = []; + for (const test of evaluateFlow(context, expression.test, state)) { + for (const branch of branchFlow(context, test, expression.test, 'truthy')) { + const child = branch.pass ? expression.consequent : expression.alternate; + results.push(...evaluateFlow(context, child, branch.state)); + } + } + return results; + } + if (expression.type === 'unary') { + if (expression.operator === 'delete') { + if (expression.argument.type !== 'member') { + failFlow(context, 'unsupported-delete', expression.start, expression.end); + } + return writeFlow(context, expression.argument, undefined, state); + } + const results = []; + for (const result of evaluateFlow(context, expression.argument, state)) { + if (expression.operator === '!') { + for (const branch of branchFlow(context, result, expression.argument, 'truthy')) { + results.push({ state: branch.state, value: { kind: 'literal', value: !branch.pass } }); + } + } else if (expression.operator === 'void') { + results.push({ state: result.state, value: { kind: 'literal', value: undefined } }); + } + else if (result.value.kind === 'literal') { + const value = expression.operator === '+' ? +result.value.value : expression.operator === '-' ? + -result.value.value : typeof result.value.value; + results.push({ state: result.state, value: { kind: 'literal', value } }); + } else { + if (expression.operator === '+' || expression.operator === '-') { + invalidateFlowEffects(context, result.state, 'opaque-coercion-effects'); + } + results.push({ state: result.state, value: { kind: 'unknown', id: context.nextId++ } }); + } + } + return results; + } + if (expression.operator === '=') { + const results = []; + for (const target of referenceFlow(context, expression.left, state)) { + for (const right of evaluateFlow(context, expression.right, target.state)) { + assignFlowReference(context, target.reference, right.value, right.state); + results.push(right); + } + } + return results; + } + if (['&&=', '||=', '??='].includes(expression.operator)) { + const results = []; + for (const target of referenceFlow(context, expression.left, state)) { + const reference = target.reference; + const value = reference.kind === 'binding' ? target.state.bindings.get(reference.name) : + reference.kind === 'module' ? target.state.value : + target.state.objects.get(reference.id).properties.get(reference.name); + if (reference.kind === 'property' && value === undefined) { + invalidateFlowEffects(context, target.state, 'opaque-read-effects'); + } + const left = { state: target.state, value: value || { kind: 'unknown', id: context.nextId++ } }; + for (const branch of branchFlow(context, left, expression.left, + expression.operator === '??=' ? 'nullish' : 'truthy')) { + const evaluateRight = expression.operator === '||=' ? !branch.pass : branch.pass; + if (!evaluateRight) { results.push({ state: branch.state, value: left.value }); continue; } + for (const right of evaluateFlow(context, expression.right, branch.state)) { + assignFlowReference(context, reference, right.value, right.state); + results.push(right); + } + } + } + return results; + } + const results = []; + for (const left of evaluateFlow(context, expression.left, state)) { + const logical = ['&&', '||', '??'].includes(expression.operator); + if (logical) { + const predicate = expression.operator.startsWith('??') ? 'nullish' : 'truthy'; + for (const branch of branchFlow(context, left, expression.left, predicate)) { + const evaluateRight = expression.operator.startsWith('||') ? !branch.pass : branch.pass; + if (!evaluateRight) { results.push({ state: branch.state, value: left.value }); continue; } + for (const right of evaluateFlow(context, expression.right, branch.state)) { + results.push(right); + } + } + } else { + for (const right of evaluateFlow(context, expression.right, left.state)) { + if (expression.operator !== ',' && expression.operator !== '===' && expression.operator !== '!==' && + (left.value.kind !== 'literal' || right.value.kind !== 'literal')) { + invalidateFlowEffects(context, right.state, 'opaque-coercion-effects'); + } + const value = expression.operator === ',' ? right.value : + left.value.kind === 'literal' && right.value.kind === 'literal' ? + foldFlowBinary(expression.operator, left.value.value, right.value.value) : + { kind: 'unknown', id: context.nextId++ }; + results.push({ state: right.state, value }); + } + } + } + return results; +} + +/** @param {FlowContext} context @param {FlowValue} value @param {FlowExpression} expression */ +function flowPropertyName (context, value, expression) { + if (value.kind !== 'literal') failFlow(context, 'dynamic-property', expression.start, expression.end); + const name = String(value.value); + if (name === '__proto__') failFlow(context, 'prototype-mutation', expression.start, expression.end); + return name; +} + +/** + * @param {FlowContext} context + * @param {FlowExpression} target + * @param {FlowValue | undefined} value + * @param {FlowState} state + */ +function writeFlow (context, target, value, state) { + const results = []; + for (const result of referenceFlow(context, target, state)) { + assignFlowReference(context, result.reference, value, result.state); + results.push({ state: result.state, value: value || { kind: 'literal', value: true } }); + } + return results; +} + +/** @param {FlowContext} context @param {FlowExpression} target @param {FlowState} state */ +function referenceFlow (context, target, state) { + if (target.type === 'identifier') { + const binding = state.bindings.get(target.name); + if (!state.declared.has(target.name) || state.constants.has(target.name) || + binding && binding.kind === 'uninitialized') { + failFlow(context, 'unsupported-binding-write', target.start, target.end); + } + return [{ state, reference: { kind: 'binding', name: target.name } }]; + } + if (target.type !== 'member') failFlow(context, 'unsupported-assignment', target.start, target.end); + const results = []; + for (const receiver of evaluateFlow(context, target.object, state)) { + for (const key of evaluateFlow(context, target.key, receiver.state)) { + const name = flowPropertyName(context, key.value, target); + let reference; + if (receiver.value.kind === 'module-object' && name === 'exports') reference = { kind: 'module' }; + else if (receiver.value.kind === 'object') { + const object = key.state.objects.get(receiver.value.id); + if (object.reason) failFlow(context, object.reason, target.start, target.end); + reference = { kind: 'property', id: receiver.value.id, name }; + } + else failFlow(context, 'unknown-mutation', target.start, target.end); + results.push({ state: key.state, reference }); + } + } + return results; +} + +/** @param {FlowContext} context @param {FlowReference} reference + * @param {FlowValue | undefined} value @param {FlowState} state */ +function assignFlowReference (context, reference, value, state) { + if (reference.kind === 'binding') state.bindings.set(reference.name, value); + else if (reference.kind === 'module') state.value = value || { kind: 'literal', value: undefined }; + else { + const object = state.objects.get(reference.id); + if (object.reason) failFlow(context, object.reason); + if (value === undefined) object.properties.delete(reference.name); + else { + if (state.prototypeUnknown && !object.properties.has(reference.name)) failFlow(context, 'dependency-effects'); + object.properties.set(reference.name, value); + } + } +} + +/** @param {FlowContext} context @param {FlowState} state @param {string} reason */ +function invalidateFlowEffects (context, state, reason) { + if (context.hasFunctions) failFlow(context, 'unresolved-function-effects'); + const pending = [state.value]; + const visited = new Set(); + while (pending.length) { + const value = pending.pop(); + if (value.kind !== 'object' || visited.has(value.id)) continue; + visited.add(value.id); + const object = state.objects.get(value.id); + object.reason = reason; + for (const child of object.properties.values()) pending.push(child); + } + state.value = { kind: 'unknown', id: context.nextId++, reason }; + state.prototypeUnknown = true; +} + +/** @param {string} operator @param {FlowPrimitive} left @param {FlowPrimitive} right @returns {FlowValue} */ +function foldFlowBinary (operator, left, right) { + let value; + switch (operator) { + case '===': value = left === right; break; + case '!==': value = left !== right; break; + case '==': value = left == right; break; + case '!=': value = left != right; break; + case '<': value = left < right; break; + case '>': value = left > right; break; + case '<=': value = left <= right; break; + case '>=': value = left >= right; break; + case '+': value = left + right; break; + case '-': value = left - right; break; + case '*': value = left * right; break; + case '%': value = left % right; break; + } + return { kind: 'literal', value }; +} + +/** @param {FlowState} state @param {FlowValue} value @returns {import('../lexer').FlowExportValue} */ +function reportFlowValue (state, value) { + if (value.kind === 'module') return { kind: 'module', specifier: value.specifier }; + if (value.kind === 'object') { + const object = state.objects.get(value.id); + if (object.reason) return { kind: 'unknown', reason: object.reason, start: object.start, end: object.end }; + return { kind: 'object', properties: [...object.properties.keys()] }; + } + if (value.kind === 'literal') return { kind: 'literal', value: value.value }; + return { kind: 'unknown', reason: value.kind === 'unknown' && value.reason || 'unknown-export-value' }; +} + +module.exports = analyzeExports; diff --git a/src/lexer.js b/src/lexer.js index cb6509e..6863c29 100755 --- a/src/lexer.js +++ b/src/lexer.js @@ -14,6 +14,8 @@ export function parse (source, name = '@', options) { throw new TypeError('Expected options.mode to be "default" or "extended".'); const flags = (options && options.allowMinifiedEnumerable === true ? 1 : 0) | (mode === 'extended' ? 2 : 0); + const analysis = options === undefined ? undefined : analyzeExports(source, options, decode); + if (analysis !== undefined) return analysis; if (!wasm) initSync(); diff --git a/test/flow.js b/test/flow.js new file mode 100644 index 0000000..88d0bb3 --- /dev/null +++ b/test/flow.js @@ -0,0 +1,440 @@ +const assert = require('node:assert/strict'); +const { mkdtempSync, writeFileSync, rmSync } = require('node:fs'); +const { createRequire } = require('node:module'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); + +/** @typedef {import('../lexer').ExportAnalysis['outcomes'][number]} FlowOutcome */ + +let parse; + +/** @param {string} source */ +function analysis (source) { + return parse(source, 'fixture.cjs', { baseline: 'flow-v1' }); +} + +/** @param {string} source @param {(getExports: () => Record) => unknown} effect */ +function observeFlowSource (source, effect) { + const directory = mkdtempSync(join(tmpdir(), 'cjs-flow-effect-')); + const filename = join(directory, 'entry.cjs'); + writeFileSync(filename, source); + const requireFixture = createRequire(filename); + const cacheKey = requireFixture.resolve(filename); + const previous = Object.getOwnPropertyDescriptor(globalThis, 'flowPocCondition'); + const getExports = () => requireFixture.cache[cacheKey].exports; + let calls = 0; + Object.defineProperty(globalThis, 'flowPocCondition', { + configurable: true, + get () { + calls++; + return effect(getExports); + } + }); + try { + const report = analysis(source); + assert.equal(calls, 0); + const observed = requireFixture(filename); + assert.equal(calls, 1); + return { report, observed }; + } finally { + if (previous) Object.defineProperty(globalThis, 'flowPocCondition', previous); + else delete globalThis.flowPocCondition; + delete requireFixture.cache[cacheKey]; + rmSync(directory, { recursive: true, force: true }); + } +} + +suite('Flow detection baseline', () => { + suiteSetup(async () => { + if (process.env.WASM) { + const lexer = await import('../dist/lexer.mjs'); + await lexer.init(); + parse = lexer.parse; + } else if (process.env.WASM_SYNC) { + parse = require('../dist/lexer.js').parse; + } else { + parse = require('../lexer.js').parse; + } + }); + + test('default and explicit legacy results retain their exact shape', () => { + const source = "if (true) module.exports = require('./a'); else module.exports = require('./b');"; + const expected = { exports: [], reexports: ['./b'] }; + assert.deepStrictEqual(parse(source), expected); + assert.deepStrictEqual(parse(source, 'fixture.cjs', {}), expected); + assert.deepStrictEqual(parse(source, 'fixture.cjs', { baseline: 'legacy' }), expected); + }); + + test('rejects an unknown baseline', () => { + assert.throws(() => parse('', 'fixture.cjs', { baseline: 'future' }), { + name: 'RangeError', message: 'Unknown detection baseline: future' + }); + }); + + test('the issue reproduction reports the reachable export', () => { + const result = analysis("if (true) module.exports = require('./a'); else module.exports = require('./b');"); + assert.equal(result.baseline, 'flow-v1'); + assert.equal(result.complete, true); + assert.deepStrictEqual(result.outcomes, [{ conditions: [], value: { kind: 'module', specifier: './a' } }]); + }); + + test('unknown conditions preserve both final alternatives', () => { + const result = analysis("if (flag) module.exports = require('./a'); else module.exports = require('./b');"); + assert.equal(result.complete, true); + assert.deepStrictEqual(result.outcomes.map(/** @param {FlowOutcome} outcome */ outcome => outcome.value), [ + { kind: 'module', specifier: './a' }, { kind: 'module', specifier: './b' } + ]); + assert.deepStrictEqual(result.outcomes.map( + /** @param {FlowOutcome} outcome */ outcome => outcome.conditions[0].when + ), ['truthy', 'falsy']); + }); + + test('later replacements remove all earlier module sources', () => { + for (const prefix of ["module.exports = require('./a');", "if (flag) module.exports = require('./a');"]) { + const result = analysis(prefix + " module.exports = require('./b');"); + assert.equal(result.complete, true); + for (const outcome of result.outcomes) { + assert.deepStrictEqual(outcome.value, { kind: 'module', specifier: './b' }); + } + } + assert.deepStrictEqual(analysis("module.exports = require('./a'); module.exports = {};").outcomes[0].value, + { kind: 'object', properties: [] }); + }); + + test('retains the unchanged outcome when there is no else', () => { + const result = analysis("if (flag) module.exports = require('./a');"); + assert.deepStrictEqual(result.outcomes.map(/** @param {FlowOutcome} outcome */ outcome => outcome.value), [ + { kind: 'module', specifier: './a' }, { kind: 'unknown', reason: 'opaque-read-effects' } + ]); + }); + + test('ternaries and short-circuit assignments preserve alternatives', () => { + const conditional = analysis("module.exports = flag ? require('./a') : require('./b');"); + assert.deepStrictEqual(conditional.outcomes.map( + /** @param {FlowOutcome} outcome */ outcome => outcome.value.specifier + ), ['./a', './b']); + for (const [operator, conditions, active] of [['&&', ['truthy', 'falsy'], 0], ['||', ['truthy', 'falsy'], 1], + ['??', ['nullish', 'non-nullish'], 0]]) { + const result = analysis("flag " + operator + " (module.exports = require('./a'));"); + assert.deepStrictEqual(result.outcomes.map( + /** @param {FlowOutcome} outcome */ outcome => outcome.conditions[0].when + ), conditions); + assert.deepStrictEqual(result.outcomes[active].value, { kind: 'module', specifier: './a' }); + assert.deepStrictEqual(result.outcomes[1 - active].value, { kind: 'unknown', reason: 'opaque-read-effects' }); + } + }); + + test('folds constants with JavaScript truthiness and nullish semantics', () => { + for (const condition of ['false', '0', "''", 'null', 'void 0', '!true', '1 === 2']) { + assert.deepStrictEqual(analysis('if (' + condition + ") module.exports = require('./dead');").outcomes, + [{ conditions: [], value: { kind: 'object', properties: [] } }]); + } + for (const condition of ['true', '1', "'x'", '!false', '1 === 1']) { + assert.deepStrictEqual(analysis('if (' + condition + ") module.exports = require('./live');").outcomes[0].value, + { kind: 'module', specifier: './live' }); + } + assert.deepStrictEqual(analysis("0 ?? (module.exports = require('./dead'));").outcomes[0].value, + { kind: 'object', properties: [] }); + }); + + test('tracks local values, reassignment, and correlated tests', () => { + assert.deepStrictEqual(analysis("let flag = false; flag = true; if (flag) module.exports = require('./a');") + .outcomes[0].value, { kind: 'module', specifier: './a' }); + const result = analysis("const flag = require('./flag'); module.exports = {}; " + + "if (flag) { if (!flag) module.exports = require('./dead'); }"); + assert.equal(result.complete, true); + for (const outcome of result.outcomes) { + assert.deepStrictEqual(outcome.value, { kind: 'object', properties: [] }); + } + const shadowed = analysis("const flag = false; { let flag = false; flag = true; " + + "if (flag) exports.live = 1; } if (flag) exports.dead = 1;"); + assert.equal(shadowed.complete, true); + assert.deepStrictEqual(shadowed.outcomes[0].value, { kind: 'object', properties: ['live'] }); + }); + + test('tracks object identity and detached aliases', () => { + assert.deepStrictEqual(analysis("const target = exports; target.live = 1; exports = {}; exports.dead = 2;") + .outcomes[0].value, { kind: 'object', properties: ['live'] }); + assert.deepStrictEqual(analysis('module.exports = {live: 1}; exports.dead = 1;').outcomes[0].value, + { kind: 'object', properties: ['live'] }); + const effects = analysis("module.exports = require('./a'); exports.dead = 1;"); + assert.equal(effects.complete, false); + assert.equal(effects.outcomes[0].value.kind, 'unknown'); + assert.equal(effects.outcomes[0].value.reason, 'dependency-effects'); + assert.deepStrictEqual(analysis('module.exports = exports = {}; exports.live = 1;').outcomes[0].value, + { kind: 'object', properties: ['live'] }); + }); + + test('named require values do not become whole-module reexports', () => { + assert.deepStrictEqual(analysis("module.exports = { dep: require('./a') };").outcomes[0].value, + { kind: 'object', properties: ['dep'] }); + }); + + test('ignores overwritten, deleted, and uninvoked exports', () => { + assert.deepStrictEqual(analysis("exports.dead = 1; delete exports.dead; exports.live = 2;").outcomes[0].value, + { kind: 'object', properties: ['live'] }); + assert.deepStrictEqual(analysis("function unused(module) { module.exports = require('./dead'); }") + .outcomes[0].value, { kind: 'object', properties: [] }); + assert.deepStrictEqual(analysis("return; module.exports = require('./dead');").outcomes[0].value, + { kind: 'object', properties: [] }); + }); + + test('compound right-hand sides and comma expressions use their final value', () => { + assert.deepStrictEqual(analysis("module.exports = (require('./a'), {});").outcomes[0].value, + { kind: 'object', properties: [] }); + const result = analysis("module.exports = require('./a') && {};"); + assert.deepStrictEqual(result.outcomes.map( + /** @param {FlowOutcome} outcome */ outcome => outcome.value.kind + ), ['object', 'module']); + }); + + test('unknown spread shapes are explicit rather than confirmed module reexports', () => { + const result = analysis("module.exports = {...require('./a'), ...require('./b')};"); + assert.equal(result.complete, false); + assert.equal(result.outcomes[0].value.kind, 'unknown'); + assert.equal(result.outcomes[0].value.reason, 'unresolved-spread'); + }); + + test('dependency execution invalidates exposed export state', () => { + for (const source of ["exports.before = 1; require('./mutate');", "exports.dep = require('./a');", + "const target = {}; require('./mutate'); target.after = 1; module.exports = target;"]) { + const result = analysis(source); + assert.equal(result.complete, false); + assert.equal(result.outcomes[0].value.kind, 'unknown'); + assert.equal(result.outcomes[0].value.reason, 'dependency-effects'); + } + const local = analysis("const target = {safe: 1}; require('./mutate'); module.exports = target;"); + assert.deepStrictEqual(local.outcomes[0].value, { kind: 'object', properties: ['safe'] }); + const captured = analysis("const target = {dead: 1}; function mutate() { delete target.dead; } " + + "exports.mutate = mutate; require('./call'); module.exports = target;"); + assert.equal(captured.complete, false); + assert.equal(captured.outcomes[0].value.kind, 'unknown'); + }); + + test('opaque condition reads can have effects and are not correlated', () => { + const result = analysis("if (flag) { if (!flag) module.exports = require('./a'); }"); + assert.equal(result.complete, false); + assert.equal(result.outcomes.some( + /** @param {FlowOutcome} outcome */ outcome => outcome.value.kind === 'module' + ), true); + const unchanged = analysis('if (globalThis.flag) {}'); + assert.equal(unchanged.complete, false); + assert.equal(unchanged.outcomes[0].value.kind, 'unknown'); + }); + + test('opaque getter effects match a real CommonJS load without executing analysis input', () => { + const source = "if (flowPocCondition) module.exports = require('./a');"; + const { report, observed } = observeFlowSource(source, + /** @param {() => Record} getExports */ + getExports => { getExports().real = 1; return false; } + ); + assert.equal(report.complete, false); + assert.equal(report.outcomes[1].value.kind, 'unknown'); + assert.deepStrictEqual(observed, { real: 1 }); + }); + + test('invalidated objects cannot provide cached values or references', () => { + const prefix = 'const target = exports; target.enabled = false; flowPocCondition; '; + const cases = [ + [prefix + 'module.exports = target.enabled ? {live: 1} : {dead: 1};', + /** @param {() => Record} getExports */ + getExports => { getExports().enabled = true; return false; }], + [prefix + 'module.exports = (delete target.enabled) ? {dead: 1} : {live: 1};', + /** @param {() => Record} getExports */ + getExports => { Object.defineProperty(getExports(), 'enabled', { configurable: false }); return false; }], + [prefix + 'module.exports = {}; target.enabled = 1;', + /** @param {() => Record} getExports */ + getExports => { + Object.defineProperty(getExports(), 'enabled', { + configurable: true, + /** @param {number} value */ + set (value) { getExports().live = value; } + }); + return false; + }] + ]; + for (const [source, effect] of cases) { + const { report, observed } = observeFlowSource(source, effect); + assert.equal(report.complete, false, source); + assert.equal(report.outcomes[0].value.kind, 'unknown', source); + assert.deepStrictEqual(observed, { live: 1 }, source); + } + }); + + test('numeric object keys use runtime property names', () => { + const source = 'module.exports = {0x10: 1, 1e2: 1, 1.0: 1, 0b10: 1, 0o7: 1};'; + assert.deepStrictEqual(analysis(source).outcomes[0].value.properties, ['16', '100', '1', '2', '7']); + }); + + test('inherited accessors cannot leave confirmed exports after opaque effects', () => { + const previous = Object.getOwnPropertyDescriptor(Object.prototype, 'flowPocProperty'); + try { + for (const operator of ['=', '&&=', '||=', '??=']) { + const source = 'const target = {}; flowPocCondition; module.exports = {live: 1}; ' + + 'target.flowPocProperty ' + operator + ' 1;'; + const { report, observed } = observeFlowSource(source, + /** @param {() => Record} getExports */ + getExports => { + Object.defineProperty(Object.prototype, 'flowPocProperty', { + configurable: true, + get () { delete getExports().live; }, + /** @param {number} value */ + set (value) { delete getExports().live; } + }); + return false; + } + ); + assert.equal(report.complete, false, operator); + assert.equal(report.outcomes[0].value.kind, 'unknown', operator); + assert.deepStrictEqual(observed, {}, operator); + } + } finally { + if (previous) Object.defineProperty(Object.prototype, 'flowPocProperty', previous); + else delete Object.prototype.flowPocProperty; + } + }); + + test('shallow source cannot build an unbounded expression tree', () => { + const accepted = 'module.exports = ' + Array(62).fill('1').join('+') + ';'; + assert.deepStrictEqual(analysis(accepted).outcomes[0].value, { kind: 'literal', value: 62 }); + for (const source of ['module.exports = ' + Array(63).fill('1').join('+') + ';', + 'module.exports = ' + Array(1000).fill('1').join('+') + ';', + 'module.exports = exports' + '.a'.repeat(1000) + ';', + "module.exports = require('./a')" + '()'.repeat(1000) + ';']) { + const report = analysis(source); + assert.equal(report.complete, false); + assert.equal(report.outcomes[0].value.reason, 'depth-limit'); + } + }); + + test('implicit coercion and opaque spread can change exports', () => { + const prefix = 'const operand = flowPocCondition; module.exports = {}; '; + for (const suffix of ['+operand;', '-operand;', 'operand + 1;', 'operand - 1;', 'operand * 1;', + 'operand % 2;', 'operand == 1;', 'operand != 1;', 'operand < 1;', 'operand > 1;', + 'operand <= 1;', 'operand >= 1;', '({...operand});', 'const copy = {...operand}; ({...copy});']) { + const { report, observed } = observeFlowSource(prefix + suffix, + /** @param {() => Record} getExports */ + getExports => ({ + valueOf () { getExports().live = 1; return 1; }, + get spread () { getExports().live = 1; return 1; } + }) + ); + assert.equal(report.complete, false, suffix); + assert.equal(report.outcomes[0].value.kind, 'unknown', suffix); + assert.deepStrictEqual(observed, { live: 1 }, suffix); + } + for (const suffix of ['operand === 1;', 'operand !== 1;', 'typeof operand;', '!operand;', 'void operand;']) { + const { report, observed } = observeFlowSource(prefix + suffix, + /** @param {() => Record} getExports */ + getExports => ({ valueOf () { getExports().live = 1; return 1; } }) + ); + assert.equal(report.complete, true, suffix); + assert.deepStrictEqual(report.outcomes[0].value, { kind: 'object', properties: [] }, suffix); + assert.deepStrictEqual(observed, {}, suffix); + } + }); + + test('unsupported effects never produce speculative exports', () => { + for (const source of ["mutate(exports);", "while (flag) module.exports = require('./a');", + "try { module.exports = require('./a'); } catch (error) { module.exports = require('./b'); }", + "module.exports[key] = 1;", "const require = other; module.exports = require('./a');"]) { + const result = analysis(source); + assert.equal(result.complete, false); + assert.equal(result.outcomes[0].value.kind, 'unknown'); + assert.equal(JSON.stringify(result.outcomes).includes('specifier'), false); + } + }); + + test('source text is never executed', () => { + globalThis.flowPocExecuted = false; + const result = analysis('globalThis.flowPocExecuted = true;'); + assert.equal(globalThis.flowPocExecuted, false); + assert.equal(result.complete, false); + delete globalThis.flowPocExecuted; + }); + + test('captures an assignment target before evaluating its right-hand side', () => { + assert.deepStrictEqual(analysis('let target = {}; target.foo = (target = exports, 1);').outcomes[0].value, + { kind: 'object', properties: [] }); + assert.deepStrictEqual(analysis('let target = exports; target.foo = (target = {}, 1);').outcomes[0].value, + { kind: 'object', properties: ['foo'] }); + for (const [initial, operator] of [['0', '||='], ['true', '&&='], ['null', '??=']]) { + assert.deepStrictEqual(analysis('let target = {foo: ' + initial + '}; target.foo ' + operator + + ' (target = exports, 1);').outcomes[0].value, { kind: 'object', properties: [] }); + } + }); + + test('rejects unsupported binding changes and ambiguous number forms', () => { + for (const source of ['const flag = false; flag = true;', 'missing = 1;', + "if (010 === 8) module.exports = require('./a');"]) { + const result = analysis(source); + assert.equal(result.complete, false); + assert.equal(result.outcomes[0].value.kind, 'unknown'); + } + }); + + test('hoists var declarations without resetting wrapper parameters', () => { + assert.deepStrictEqual(analysis("var module; module.exports = require('./a');").outcomes[0].value, + { kind: 'module', specifier: './a' }); + assert.deepStrictEqual(analysis("if (flag) module.exports = require('./dead'); if (false) { var flag; }") + .outcomes[0].value, { kind: 'object', properties: [] }); + }); + + test('bounds branch expansion and nesting', () => { + assert.equal(analysis('('.repeat(62) + 'true' + ')'.repeat(62) + ';').complete, true); + const accepted = Array.from({ length: 5 }, + /** @param {undefined} entry @param {number} index */ + (entry, index) => 'if (flag' + index + ') module.exports = {a: 1}; else module.exports = {};' + ).join('\n'); + assert.equal(analysis(accepted).outcomes.length, 32); + assert.equal(analysis(';'.repeat(8192)).complete, true); + for (const source of ['('.repeat(63) + 'true' + ')'.repeat(63) + ';', ';'.repeat(8193), + accepted + '\nif (flag5) module.exports = {a: 1}; else module.exports = {};']) { + const result = analysis(source); + assert.equal(result.complete, false); + assert.equal(result.outcomes[0].value.kind, 'unknown'); + assert.match(result.outcomes[0].value.reason, /limit/); + } + }); + + test('confirmed outcomes match exports from real CommonJS modules', () => { + const directory = mkdtempSync(join(tmpdir(), 'cjs-flow-')); + writeFileSync(join(directory, 'a.cjs'), 'module.exports = {a: 1};'); + writeFileSync(join(directory, 'b.cjs'), 'module.exports = {b: 1};'); + const sources = [ + "if (true) module.exports = require('./a.cjs'); else module.exports = require('./b.cjs');", + "if (false) module.exports = require('./a.cjs'); else module.exports = require('./b.cjs');", + "module.exports = require('./a.cjs'); module.exports = require('./b.cjs');", + 'module.exports = {live: 1}; exports.dead = 1;', + "module.exports = {dep: require('./a.cjs')};", + 'let target = {}; target.foo = (target = exports, 1);', + 'let target = exports; target.foo = (target = {}, 1);', + 'let target = {foo: 0}; target.foo ||= (target = exports, 1);', + 'module.exports = {0x10: 1, 1e2: 1, 1.0: 1, 0b10: 1, 0o7: 1};', + "module.exports = (module = {}, require('./a.cjs'));", + 'const a = {x: 1}; const b = {x: 2, y: 3}; module.exports = {...a, ...b};', + 'exports.dead = 1; delete exports.dead; exports.live = 2;', + 'function unused(module) { module.exports = 1; }', + 'return; module.exports = 1;', + 'module.exports = exports = {}; exports.live = 1;' + ]; + try { + for (let index = 0; index < sources.length; index++) { + const source = sources[index]; + const filename = join(directory, 'entry-' + index + '.cjs'); + writeFileSync(filename, source); + const requireFixture = createRequire(filename); + const observed = requireFixture(filename); + const report = analysis(source); + assert.equal(report.complete, true, source); + assert.equal(report.outcomes.length, 1, source); + const value = report.outcomes[0].value; + if (value.kind === 'module') assert.equal(observed, requireFixture(value.specifier), source); + else assert.deepStrictEqual(Object.keys(observed).sort(), value.properties.slice().sort(), source); + delete requireFixture.cache[requireFixture.resolve(filename)]; + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); From 27d7ac8cd50edf851e100f9e2971983a981870c3 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Mon, 5 Oct 2026 21:46:49 +0900 Subject: [PATCH 2/6] refactor(lexer): gate export analysis on repeated replacements Flow reports distinguish sequential replacements from conditional alternatives. Default calls defer the report until its first read so legacy consumers avoid control-flow parsing. Unsupported effects remain unknown. Reusing scalar lexer stacks reduces empty JavaScript parses from 4.05 us to 177 ns and from 4.32 us to 187 ns. Two fresh Node 22.23.3 processes use V8 12.4.254.21-node.57, 50k warmup iterations, and seven alternating 30k-iteration trials. Each result excludes the fastest and slowest trials. The Wasm counter adds a small cost without an additional host/guest call. Conditional reports can be slower because the lexer must establish the trigger first. Refs: https://github.com/nodejs/cjs-module-lexer/issues/99 --- README.md | 57 ++-- lexer.d.ts | 2 + lexer.js | 24 +- src/export-analysis.js | 663 ++++++++++------------------------------- src/lexer.c | 16 + src/lexer.js | 16 +- test/flow.js | 573 +++++++++++------------------------ 7 files changed, 408 insertions(+), 943 deletions(-) diff --git a/README.md b/README.md index eaf33de..445d266 100755 --- a/README.md +++ b/README.md @@ -78,8 +78,18 @@ This option does not change Node.js's built-in CommonJS named-export detection. ### Detection baselines (proof of concept) -The optional third argument selects a detection baseline. -Omit it, or select `legacy`, to retain the existing return value and detection patterns. +Flow analysis runs only after the lexer detects more than one `module.exports` replacement. +The counter ignores comments, strings, property writes, and comparisons. +It includes direct assignments and logical assignments. +The default result retains the legacy `exports` and `reexports` arrays. +When the trigger applies, a non-enumerable `analysis` property provides the replacement report. +The first read computes and caches the report. Legacy consumers do not run control-flow analysis. +Object enumeration and JSON serialization retain their current output. +Reflection can observe the new property. + +The optional third argument selects a versioned detection baseline. +Select `legacy` to omit analysis, or `flow-v1` to return the report directly. +The baseline name can combine detection rules without adding an option for each rule. ```js const report = parse(` @@ -92,34 +102,33 @@ for (const outcome of report.outcomes) { } ``` -The selected baseline returns a flow report instead of the legacy arrays. +You can also read `parse(source).analysis` when the trigger applies. +With fewer than two detected replacements, explicit `flow-v1` reports `insufficient-replacements` as unknown. +This includes a single assignment whose right-hand side contains a conditional expression. Each outcome describes the final export value under its conditions. Conditions identify source expressions with UTF-16 offsets and record truthiness or nullishness. Ranges include the start offset and exclude the end offset. They are analysis facts, not instructions to execute the source again. -Use valid CommonJS source. The report describes successful synchronous completion under standard CommonJS bindings. -It excludes replaced export objects, detached aliases, deleted properties, and uncalled function bodies. +Use valid CommonJS source with standard CommonJS bindings and an ordinary writable `module.exports` property. +The report describes successful synchronous completion. +It reports final replacement values, rather than a union of every detected assignment. A module value identifies a direct re-export. -An object value contains confirmed property names, including a property whose value is a required module. - -This PoC supports `if`, conditional expressions, logical expressions and assignments, primitive constants, -local bindings, and owned object properties. -It does not execute the source or resolve dependencies. -Dependency spreads and unsupported syntax or effects produce an `unknown` value. -Its source offsets identify the expression when available. -An unknown outcome makes `complete` false. -A complete report can still contain runtime conditions. - -Opaque reads can invoke getters. They invalidate exposed export state and can return different values on each read. -The analyzer correlates stable local bindings. -It does not assume that identical source expressions have identical results. -Dependency execution also invalidates exposed state. -Dependency calls combined with local function declarations remain unknown because callbacks can change captured bindings. -Assignments to a fresh export value can establish a known result again. - -The parser limits tokens, source and AST nesting, branch expansion, and analysis work. -Reaching a limit produces an unknown outcome rather than a partial export list. +An object value contains its confirmed own literal property names. + +The bounded heuristic distinguishes sequential replacements from conditional alternatives. +Short sequences of bare re-export assignments identify the final module without parsing control flow. +The conditional path supports `if`, conditional and logical expressions, primitive guards, and fresh literal values. +It does not track local bindings or object aliases. +Loops, `switch`, `try`, function bodies, property mutation, spreads, and unsupported effects produce an unknown report. +Logical assignments with an unknown previous export value also remain unknown. + +The analyzer does not execute source or resolve dependencies. +Opaque reads and dependency calls can change exposed exports, so they invalidate earlier values. +A fresh replacement can establish a known value again. +Unknown outcomes make `complete` false. A complete report can still contain runtime conditions. +Token, nesting, branch, and work limits produce unknown outcomes rather than partial export lists. +When lexing is required, existing lexer errors still apply. ```js diff --git a/lexer.d.ts b/lexer.d.ts index e5d42f8..0e9aa38 100755 --- a/lexer.d.ts +++ b/lexer.d.ts @@ -1,6 +1,8 @@ export interface Exports { exports: string[]; reexports: string[]; + /** Present for multiple detected replacements unless the legacy baseline is selected. Non-enumerable. */ + readonly analysis?: ExportAnalysis; } export interface ParseOptions { diff --git a/lexer.js b/lexer.js index 639beac..4a13dd5 100755 --- a/lexer.js +++ b/lexer.js @@ -17,7 +17,8 @@ let openTokenDepth, lastStarExportSpecifier, _exports, unsafeGetters, - reexports; + reexports, + moduleExportCount; function resetState () { openTokenDepth = 0; @@ -33,6 +34,7 @@ function resetState () { _exports = new Set(); unsafeGetters = new Set(); reexports = new Set(); + moduleExportCount = 0; } // RequireType @@ -66,8 +68,11 @@ function parseCJS (source, name = '@', options) { 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; - const analysis = options === undefined ? undefined : require('./src/export-analysis.js')(source, options, decode); - if (analysis !== undefined) return analysis; + const baseline = options === undefined ? undefined : options.baseline; + if (baseline === 'flow-v1') { + const report = require('./src/export-analysis.js')(source, 'flow-v1', undefined, decode); + if (report !== undefined) return report; + } resetState(); allowMinifiedEnumerable = minifiedEnumerable; extended = mode === 'extended'; @@ -81,8 +86,10 @@ function parseCJS (source, name = '@', options) { throw error; } const result = { exports: [..._exports].filter(expt => expt !== undefined && !unsafeGetters.has(expt)), reexports: [...reexports].filter(reexpt => reexpt !== undefined) }; + const replacements = moduleExportCount; resetState(); - return result; + return baseline === 'legacy' || (replacements < 2 && baseline === undefined) ? result : + require('./src/export-analysis.js')(source, baseline, replacements, decode, result); } /** @param {string} str */ @@ -1001,6 +1008,8 @@ function tryParseExportsDotAssign (assign) { // module.exports = case 61/*=*/: { if (assign) { + if (moduleExportCount < 2 && source.charCodeAt(pos + 1) !== 61) + moduleExportCount++; if (!extended && reexports.size) reexports = new Set(); pos++; @@ -1022,6 +1031,13 @@ function tryParseExportsDotAssign (assign) { if (ch === 114/*r*/) tryParseRequire(ExportAssign); } + break; + } + case 38/*&*/: + case 124/*|*/: + case 63/*?*/: { + if (assign && moduleExportCount < 2 && source.charCodeAt(pos + 1) === ch && + source.charCodeAt(pos + 2) === 61) moduleExportCount++; } } pos = revertPos; diff --git a/src/export-analysis.js b/src/export-analysis.js index e8d9687..3de98f4 100644 --- a/src/export-analysis.js +++ b/src/export-analysis.js @@ -17,30 +17,19 @@ * @typedef {{ type: 'block', body: FlowStatement[], start: number, end: number } | * { type: 'if', test: FlowExpression, consequent: FlowStatement, alternate?: FlowStatement, * start: number, end: number } | - * { type: 'declaration', mode: string, declarations: { name: string, value?: FlowExpression }[], - * start: number, end: number } | - * { type: 'function', name: string, start: number, end: number } | * { type: 'expression', expression: FlowExpression, start: number, end: number } | - * { type: 'return' | 'throw' | 'empty', start: number, end: number }} FlowStatement - * @typedef {{ kind: 'literal', value: FlowPrimitive } | - * { kind: 'object', id: number } | { kind: 'module', id: number, specifier: string } | - * { kind: 'unknown', id: number, reason?: string } | - * { kind: 'module-object' | 'require' | 'uninitialized' }} FlowValue - * @typedef {{ properties: Map, reason?: string, start?: number, end?: number }} FlowOwnedObject - * @typedef {{ bindings: Map, objects: Map, - * facts: Map, conditions: import('../lexer').FlowCondition[], value: FlowValue, - * declared: Set, constants: Set, prototypeUnknown: boolean, - * completion: 'normal' | 'return' | 'throw' }} FlowState + * { type: 'empty', start: number, end: number }} FlowStatement + * @typedef {import('../lexer').FlowExportValue} FlowValue + * @typedef {{ value: FlowValue, conditions: import('../lexer').FlowCondition[] }} FlowState * @typedef {{ source: string, decode: (text: string) => string | undefined, tokens: FlowToken[], pos: number, - * depth: number, steps: number, branches: number, nextId: number, hasFunctions: boolean }} FlowContext + * depth: number, steps: number, branches: number }} FlowContext * @typedef {{ state: FlowState, value: FlowValue }} FlowEvaluation - * @typedef {{ kind: 'binding', name: string } | { kind: 'module' } | - * { kind: 'property', id: number, name: string }} FlowReference */ let flowPowers; let flowPatterns; const flowLimits = { tokens: 8192, depth: 64, outcomes: 32, steps: 65536 }; +const flowSequence = /^(?:\s*module\.exports\s*=\s*require\(((['"])(?:\\.|(?!\2)[^\\])*\2)\);){2,}\s*$/; class FlowFailure extends Error { /** @param {string} reason @param {number} start @param {number} end */ @@ -54,17 +43,45 @@ class FlowFailure extends Error { /** * @param {string} source - * @param {import('../lexer').ParseOptions} options + * @param {import('../lexer').ParseOptions['baseline']} baseline + * @param {number} [replacements] * @param {(text: string) => string | undefined} decode + * @param {import('../lexer').Exports} [legacy] */ -function analyzeExports (source, options, decode) { - const baseline = options.baseline; - if (baseline === undefined || baseline === 'legacy') return; - if (typeof baseline !== 'string') throw new TypeError('Detection baseline must be a string'); - if (baseline !== 'flow-v1') throw new RangeError('Unknown detection baseline: ' + baseline); +function analyzeExports (source, baseline, replacements, decode, legacy) { + if (baseline === 'legacy') return legacy; + if (baseline !== undefined && typeof baseline !== 'string') + throw new TypeError('Detection baseline must be a string'); + if (baseline !== undefined && baseline !== 'flow-v1') throw new RangeError('Unknown detection baseline: ' + baseline); + if (baseline === undefined) { + // Legacy consumers do not read the report, so defer analysis until the first read. + Object.defineProperty(legacy, 'analysis', { configurable: true, get () { + const report = analyzeExports(source, 'flow-v1', replacements, decode); + Object.defineProperty(legacy, 'analysis', { value: report, configurable: false }); + return report; + } }); + return legacy; + } + const sequence = source.length <= 4096 ? flowSequence.exec(source) : undefined; + if (replacements === undefined) { + if (!sequence && source.indexOf('module', source.indexOf('module') + 6) !== -1) return; + replacements = sequence ? 2 : 0; + } + const specifier = sequence ? decode(sequence[1]) : undefined; + const report = replacements < 2 ? { baseline: 'flow-v1', complete: false, + outcomes: [{ conditions: [], value: { kind: 'unknown', reason: 'insufficient-replacements' } }] } : + specifier !== undefined ? + { baseline: 'flow-v1', complete: true, + outcomes: [{ conditions: [], value: { kind: 'module', specifier } }] } : + analyzeReplacements(source, decode); + return report; +} + +/** @param {string} source @param {(text: string) => string | undefined} decode */ +function analyzeReplacements (source, decode) { if (flowPowers === undefined) { flowPatterns = { - whitespace: /\s/, lineBreak: /[\r\n]/, identifierStart: /[a-zA-Z_$]/, + whitespace: /\s/, lineBreak: /[\r\n\u2028\u2029]/, identifierStart: /[a-zA-Z_$]/, identifierPart: /[a-zA-Z0-9_$]/, identifier: /^[a-zA-Z_$][a-zA-Z0-9_$]*$/, digit: /[0-9]/, numericStart: /^\d/, legacyNumber: /^0\d/, number: /^(?:0[xX][\da-fA-F]+|0[bB][01]+|0[oO][0-7]+|\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/, @@ -77,38 +94,19 @@ function analyzeExports (source, options, decode) { ]); } /** @type {FlowContext} */ - const context = { source, decode, tokens: [], pos: 0, depth: 0, steps: 0, branches: 0, nextId: 1, - hasFunctions: false }; + const context = { source, decode, tokens: [], pos: 0, depth: 0, steps: 0, branches: 0 }; try { tokenizeFlow(context); const body = []; while (context.pos < context.tokens.length) body.push(readFlowStatement(context)); validateFlowDepth(context, body); - /** @type {FlowValue} */ - const initial = { kind: 'object', id: 0 }; - /** @type {FlowState} */ - const state = { - bindings: new Map([ - ['module', { kind: 'module-object' }], ['exports', initial], ['require', { kind: 'require' }] - ]), - objects: new Map([[0, { properties: new Map() }]]), facts: new Map(), conditions: [], value: initial, - declared: new Set(['module', 'exports', 'require']), constants: new Set(), prototypeUnknown: false, - completion: 'normal' - }; - hoistFlowVars(body, state); - const states = executeFlowBody(context, body, [state]); - const outcomes = []; - for (const result of states) { - if (result.completion === 'throw') continue; - outcomes.push({ conditions: result.conditions, value: reportFlowValue(result, result.value) }); - } - return { baseline, complete: outcomes.every( - /** @param {import('../lexer').ExportAnalysis['outcomes'][number]} outcome */ - outcome => outcome.value.kind !== 'unknown' + const outcomes = executeFlowBody(context, body, [{ conditions: [], value: { kind: 'object', properties: [] } }]); + return { baseline: 'flow-v1', complete: outcomes.every( + /** @param {FlowState} outcome */ outcome => outcome.value.kind !== 'unknown' ), outcomes }; } catch (error) { if (!(error instanceof FlowFailure)) throw error; - return { baseline, complete: false, outcomes: [{ conditions: [], + return { baseline: 'flow-v1', complete: false, outcomes: [{ conditions: [], value: { kind: 'unknown', reason: error.reason, start: error.start, end: error.end } }] }; } } @@ -126,19 +124,23 @@ function stepFlow (context) { if (++context.steps > flowLimits.steps) failFlow(context, 'work-limit'); } +/** @param {string} source @param {number} start */ +function flowLineEnd (source, start) { + while (start < source.length && !flowPatterns.lineBreak.test(source[start])) start++; + return start; +} + /** @param {FlowContext} context */ function tokenizeFlow (context) { const source = context.source; - let pos = source.startsWith('#!') ? source.indexOf('\n') : 0; - if (pos === -1) return; + let pos = source.startsWith('#!') ? flowLineEnd(source, 2) : 0; while (pos < source.length) { stepFlow(context); const start = pos; const ch = source[pos]; if (flowPatterns.whitespace.test(ch)) { pos++; continue; } if (source.startsWith('//', pos)) { - const next = source.indexOf('\n', pos + 2); - pos = next === -1 ? source.length : next; + pos = flowLineEnd(source, pos + 2); continue; } if (source.startsWith('/*', pos)) { @@ -218,31 +220,6 @@ function readFlowStatement (context) { const consequent = readFlowStatement(context); const alternate = takeFlow(context, 'else') ? readFlowStatement(context) : undefined; result = { type: 'if', test, consequent, alternate, start, end: alternate ? alternate.end : consequent.end }; - } else if (['const', 'let', 'var'].includes(token.text)) { - const declarations = []; - do { - const name = context.tokens[context.pos++]; - if (!name || !flowPatterns.identifier.test(name.text)) failFlow(context, 'unsupported-binding'); - const value = takeFlow(context, '=') ? readFlowExpression(context, 2) : undefined; - declarations.push({ name: name.text, value }); - } while (takeFlow(context, ',')); - flowSemicolon(context); - result = { type: 'declaration', mode: token.text, declarations, start, - end: context.tokens[context.pos - 1].end }; - } else if (token.text === 'function') { - context.hasFunctions = true; - const name = context.tokens[context.pos++]; - if (!name || !flowPatterns.identifier.test(name.text)) failFlow(context, 'unsupported-function'); - skipFlowGroup(context, '(', ')'); - skipFlowGroup(context, '{', '}'); - result = { type: 'function', name: name.text, start, end: context.tokens[context.pos - 1].end }; - } else if (token.text === 'return' || token.text === 'throw') { - if (context.tokens[context.pos] && ![';', '}'].includes(context.tokens[context.pos].text) && - !flowPatterns.lineBreak.test(context.source.slice(token.end, context.tokens[context.pos].start))) { - failFlow(context, 'unsupported-completion-value', start, token.end); - } - flowSemicolon(context); - result = { type: token.text, start, end: context.tokens[context.pos - 1].end }; } else { context.pos--; const expression = readFlowExpression(context); @@ -253,19 +230,6 @@ function readFlowStatement (context) { return result; } -/** @param {FlowContext} context @param {string} open @param {string} close */ -function skipFlowGroup (context, open, close) { - expectFlow(context, open); - let depth = 1; - while (depth) { - stepFlow(context); - const token = context.tokens[context.pos++]; - if (!token) failFlow(context, 'unsupported-syntax'); - if (token.text === open) depth++; - else if (token.text === close) depth--; - } -} - /** @param {FlowContext} context @param {number} [minimum] @returns {FlowExpression} */ function readFlowExpression (context, minimum = 1) { stepFlow(context); @@ -279,7 +243,7 @@ function readFlowExpression (context, minimum = 1) { } else if (token.text === '{') { const entries = []; while (!takeFlow(context, '}')) { - if (takeFlow(context, '...')) entries.push({ value: readFlowExpression(context, 2) }); + if (takeFlow(context, '...')) failFlow(context, 'unsupported-object'); else { const key = context.tokens[context.pos++]; if (!key) failFlow(context, 'unsupported-object'); @@ -293,7 +257,7 @@ function readFlowExpression (context, minimum = 1) { if (!takeFlow(context, ',')) { expectFlow(context, '}'); break; } } left = { type: 'object', entries, start: token.start, end: context.tokens[context.pos - 1].end }; - } else if (['!', '+', '-', 'void', 'typeof', 'delete'].includes(token.text)) { + } else if (['!', '+', '-', 'void', 'typeof'].includes(token.text)) { const argument = readFlowExpression(context, 10); left = { type: 'unary', operator: token.text, argument, start: token.start, end: argument.end }; } else if (token.text[0] === '"' || token.text[0] === "'" || flowPatterns.numericStart.test(token.text) || @@ -363,10 +327,6 @@ function validateFlowDepth (context, body) { } else if (node.type === 'if' || node.type === 'conditional') { pending.push({ node: node.test, depth }, { node: node.consequent, depth }); if (node.alternate) pending.push({ node: node.alternate, depth }); - } else if (node.type === 'declaration') { - for (const declaration of node.declarations) { - if (declaration.value) pending.push({ node: declaration.value, depth }); - } } else if (node.type === 'expression') pending.push({ node: node.expression, depth }); else if (node.type === 'member') { pending.push({ node: node.object, depth }, { node: node.key, depth }); @@ -382,67 +342,14 @@ function validateFlowDepth (context, body) { } } -/** @param {FlowState} state */ -function cloneFlowState (state) { - const objects = new Map(); - for (const [id, object] of state.objects) { - objects.set(id, { - properties: new Map(object.properties), reason: object.reason, start: object.start, end: object.end - }); - } - return { bindings: new Map(state.bindings), objects, facts: new Map(state.facts), - conditions: state.conditions.slice(), value: state.value, declared: new Set(state.declared), - constants: new Set(state.constants), prototypeUnknown: state.prototypeUnknown, completion: state.completion }; -} - -/** @param {FlowStatement[]} body @param {FlowState} state */ -function hoistFlowVars (body, state) { - for (const statement of body) { - if (statement.type === 'declaration' && statement.mode === 'var') { - for (const declaration of statement.declarations) { - state.declared.add(declaration.name); - if (!state.bindings.has(declaration.name)) { - state.bindings.set(declaration.name, { kind: 'literal', value: undefined }); - } - } - } else if (statement.type === 'block') hoistFlowVars(statement.body, state); - else if (statement.type === 'if') { - hoistFlowVars([statement.consequent], state); - if (statement.alternate) hoistFlowVars([statement.alternate], state); - } - } -} /** @param {FlowContext} context @param {FlowStatement[]} body @param {FlowState[]} states */ function executeFlowBody (context, body, states) { - for (const statement of body) { - for (const state of states) { - if (statement.type === 'function') { - state.declared.add(statement.name); - state.bindings.set(statement.name, { kind: 'unknown', id: context.nextId++ }); - } - if (statement.type === 'declaration') { - for (const declaration of statement.declarations) { - state.declared.add(declaration.name); - if (statement.mode !== 'var') { - state.constants.delete(declaration.name); - state.bindings.set(declaration.name, { kind: 'uninitialized' }); - } - else if (!state.bindings.has(declaration.name)) { - state.bindings.set(declaration.name, { kind: 'literal', value: undefined }); - } - } - } - } - } for (const statement of body) { const next = []; - for (const state of states) { - if (state.completion !== 'normal') next.push(state); - else next.push(...executeFlowStatement(context, statement, state)); - } - if (next.length > flowLimits.outcomes) failFlow(context, 'outcome-limit', statement.start, statement.end); + for (const state of states) next.push(...executeFlowStatement(context, statement, state)); states = next; + if (states.length > flowLimits.outcomes) failFlow(context, 'outcome-limit'); } return states; } @@ -450,404 +357,136 @@ function executeFlowBody (context, body, states) { /** @param {FlowContext} context @param {FlowStatement} statement @param {FlowState} state */ function executeFlowStatement (context, statement, state) { stepFlow(context); - if (statement.type === 'empty' || statement.type === 'function') return [state]; - if (statement.type === 'return' || statement.type === 'throw') { state.completion = statement.type; return [state]; } - if (statement.type === 'block') { - const bindings = new Map(); - for (const child of statement.body) { - if (child.type === 'declaration' && child.mode !== 'var') { - for (const declaration of child.declarations) bindings.set(declaration.name, { - value: state.bindings.get(declaration.name), declared: state.declared.has(declaration.name), - constant: state.constants.has(declaration.name) - }); - } - if (child.type === 'function') failFlow(context, 'block-function', child.start, child.end); - } - const results = executeFlowBody(context, statement.body, [state]); - for (const result of results) { - for (const [name, binding] of bindings) { - const value = binding.value; - if (value === undefined) result.bindings.delete(name); - else result.bindings.set(name, value); - if (!binding.declared) result.declared.delete(name); - if (binding.constant) result.constants.add(name); - else result.constants.delete(name); - } - } - return results; - } - if (statement.type === 'declaration') { - let states = [state]; - for (const declaration of statement.declarations) { - const next = []; - for (const current of states) { - if (statement.mode === 'var' && !declaration.value) { next.push(current); continue; } - const results = declaration.value ? evaluateFlow(context, declaration.value, current) : - [{ state: current, value: { kind: 'literal', value: undefined } }]; - for (const result of results) { - result.state.bindings.set(declaration.name, result.value); - if (statement.mode === 'const') result.state.constants.add(declaration.name); - next.push(result.state); - } - } - states = next; - } - return states; - } - if (statement.type === 'expression') { - return evaluateFlow(context, statement.expression, state).map( - /** @param {FlowEvaluation} result */ - result => result.state - ); - } + if (statement.type === 'empty') return [state]; + if (statement.type === 'block') return executeFlowBody(context, statement.body, [state]); const results = []; - for (const result of evaluateFlow(context, statement.test, state)) { - for (const branch of branchFlow(context, result, statement.test, 'truthy')) { - const child = branch.pass ? statement.consequent : statement.alternate; - if (child) results.push(...executeFlowStatement(context, child, branch.state)); - else results.push(branch.state); + if (statement.type === 'expression') { + for (const result of evaluateFlow(context, statement.expression, state)) results.push(result.state); + } else { + for (const test of evaluateFlow(context, statement.test, state)) { + for (const branch of branchFlow(context, test, statement.test, false)) { + const arm = branch.active ? statement.consequent : statement.alternate; + results.push(...(arm ? executeFlowStatement(context, arm, branch.state) : [branch.state])); + } } } return results; } -/** - * @param {FlowContext} context - * @param {FlowEvaluation} result - * @param {FlowExpression} test - * @param {string} predicate - */ -function branchFlow (context, result, test, predicate) { +/** @param {FlowExpression} node */ +function isFlowReplacement (node) { + return node.type === 'member' && node.object.type === 'identifier' && node.object.name === 'module' && + node.key.type === 'literal' && node.key.value === 'exports'; +} + +/** @param {FlowContext} context @param {FlowEvaluation} result @param {FlowExpression} test + * @param {boolean} nullish */ +function branchFlow (context, result, test, nullish) { const value = result.value; - if (value.kind === 'literal' || value.kind === 'object' || - value.kind === 'module-object' || value.kind === 'require') { - const pass = predicate === 'nullish' ? value.kind === 'literal' && value.value == null : - value.kind !== 'literal' || Boolean(value.value); - return [{ state: result.state, value, pass }]; - } - const fact = predicate + ':' + value.id; - if (predicate === 'truthy' && result.state.facts.get('nullish:' + value.id) === true) { - return [{ state: result.state, value, pass: false }]; - } - if (predicate === 'nullish' && result.state.facts.get('truthy:' + value.id) === true) { - return [{ state: result.state, value, pass: false }]; - } - if (result.state.facts.has(fact)) return [{ state: result.state, value, pass: result.state.facts.get(fact) }]; - const branches = []; - context.branches += 2; - if (context.branches > flowLimits.outcomes * 2) failFlow(context, 'outcome-limit', test.start, test.end); - for (const pass of [true, false]) { - const state = cloneFlowState(result.state); - state.facts.set(fact, pass); - state.conditions.push({ start: test.start, end: test.end, - when: predicate === 'nullish' ? pass ? 'nullish' : 'non-nullish' : pass ? 'truthy' : 'falsy' }); - branches.push({ state, value, pass }); - } - return branches; + if (value.kind === 'literal' || value.kind === 'object') { + const active = value.kind === 'object' ? !nullish : nullish ? value.value == null : !!value.value; + return [{ state: result.state, active }]; + } + if (++context.branches >= flowLimits.outcomes) failFlow(context, 'outcome-limit', test.start, test.end); + return [true, false].map(/** @param {boolean} active */ active => ({ active, state: { + value: result.state.value, + conditions: result.state.conditions.concat({ start: test.start, end: test.end, + when: nullish ? active ? 'nullish' : 'non-nullish' : active ? 'truthy' : 'falsy' }) + } })); } -/** - * @param {FlowContext} context - * @param {FlowExpression} expression - * @param {FlowState} state - * @returns {FlowEvaluation[]} - */ -function evaluateFlow (context, expression, state) { +/** @param {FlowContext} context @param {FlowExpression} node @param {FlowState} state + * @returns {FlowEvaluation[]} */ +function evaluateFlow (context, node, state) { stepFlow(context); - if (expression.type === 'literal') return [{ state, value: { kind: 'literal', value: expression.value } }]; - if (expression.type === 'identifier') { - let value = state.bindings.get(expression.name); - if (!value) { - if (expression.name === 'undefined') value = { kind: 'literal', value: undefined }; - else { - invalidateFlowEffects(context, state, 'opaque-read-effects'); - value = { kind: 'unknown', id: context.nextId++ }; - } - } - if (value.kind === 'uninitialized') failFlow(context, 'uninitialized-binding', expression.start, expression.end); - return [{ state, value }]; - } - if (expression.type === 'object') { - const id = context.nextId++; - state.objects.set(id, { properties: new Map(), start: expression.start, end: expression.end }); - let states = [state]; - for (const entry of expression.entries) { + if (node.type === 'literal') return [{ state, value: { kind: 'literal', value: node.value } }]; + if (node.type === 'identifier' || node.type === 'member') { + if (node.type === 'identifier' && + ['module', 'exports', 'require', 'function', 'return', 'throw'].includes(node.name)) + failFlow(context, 'unsupported-reference', node.start, node.end); + if (isFlowReplacement(node)) failFlow(context, 'unsupported-reference', node.start, node.end); + state.value = { kind: 'unknown', reason: 'opaque-read-effects' }; + return [{ state, value: state.value }]; + } + if (node.type === 'call') { + if (node.callee.type !== 'identifier' || node.callee.name !== 'require' || node.args.length !== 1 || + node.args[0].type !== 'literal' || typeof node.args[0].value !== 'string') + failFlow(context, 'unsupported-call', node.start, node.end); + state.value = { kind: 'unknown', reason: 'dependency-effects' }; + return [{ state, value: { kind: 'module', specifier: node.args[0].value } }]; + } + if (node.type === 'object') { + let results = [{ state, value: { kind: 'object', properties: [] } }]; + for (const entry of node.entries) { const next = []; - for (const current of states) { - for (const result of evaluateFlow(context, entry.value, current)) { - const object = result.state.objects.get(id); - if (entry.key !== undefined) object.properties.set(entry.key, result.value); - else if (result.value.kind === 'object' && !result.state.objects.get(result.value.id).reason) { - for (const [key, value] of result.state.objects.get(result.value.id).properties) { - object.properties.set(key, value); - } - } else { - if (result.value.kind !== 'literal') { - invalidateFlowEffects(context, result.state, 'opaque-spread-effects'); - } - object.reason = 'unresolved-spread'; - } - next.push(result.state); - } - } - states = next; - } - return states.map( - /** @param {FlowState} current */ - current => ({ state: current, value: { kind: 'object', id } }) - ); - } - if (expression.type === 'member') { - const results = []; - for (const receiver of evaluateFlow(context, expression.object, state)) { - for (const key of evaluateFlow(context, expression.key, receiver.state)) { - const name = flowPropertyName(context, key.value, expression); - let value; - if (receiver.value.kind === 'module-object' && name === 'exports') value = key.state.value; - else if (receiver.value.kind === 'object') { - const object = key.state.objects.get(receiver.value.id); - if (object.reason) failFlow(context, object.reason, expression.start, expression.end); - value = object.properties.get(name); - if (value === undefined) invalidateFlowEffects(context, key.state, 'opaque-read-effects'); - } else invalidateFlowEffects(context, key.state, 'opaque-read-effects'); - results.push({ state: key.state, value: value || { kind: 'unknown', id: context.nextId++ } }); - } - } - return results; - } - if (expression.type === 'call') { - const requireBinding = state.bindings.get('require'); - if (expression.callee.type !== 'identifier' || expression.callee.name !== 'require' || - !requireBinding || requireBinding.kind !== 'require' || expression.args.length !== 1) { - failFlow(context, 'unsupported-call', expression.start, expression.end); - } - const results = []; - for (const argument of evaluateFlow(context, expression.args[0], state)) { - if (argument.value.kind !== 'literal' || typeof argument.value.value !== 'string') { - failFlow(context, 'dynamic-require', expression.start, expression.end); - } - invalidateFlowEffects(context, argument.state, 'dependency-effects'); - results.push({ state: argument.state, - value: { kind: 'module', id: context.nextId++, specifier: argument.value.value } }); - } - return results; - } - if (expression.type === 'conditional') { - const results = []; - for (const test of evaluateFlow(context, expression.test, state)) { - for (const branch of branchFlow(context, test, expression.test, 'truthy')) { - const child = branch.pass ? expression.consequent : expression.alternate; - results.push(...evaluateFlow(context, child, branch.state)); - } - } - return results; - } - if (expression.type === 'unary') { - if (expression.operator === 'delete') { - if (expression.argument.type !== 'member') { - failFlow(context, 'unsupported-delete', expression.start, expression.end); - } - return writeFlow(context, expression.argument, undefined, state); - } - const results = []; - for (const result of evaluateFlow(context, expression.argument, state)) { - if (expression.operator === '!') { - for (const branch of branchFlow(context, result, expression.argument, 'truthy')) { - results.push({ state: branch.state, value: { kind: 'literal', value: !branch.pass } }); + for (const result of results) { + for (const value of evaluateFlow(context, entry.value, result.state)) { + const properties = result.value.properties.slice(); + if (!properties.includes(entry.key)) properties.push(entry.key); + next.push({ state: value.state, value: { kind: 'object', properties } }); } - } else if (expression.operator === 'void') { - results.push({ state: result.state, value: { kind: 'literal', value: undefined } }); - } - else if (result.value.kind === 'literal') { - const value = expression.operator === '+' ? +result.value.value : expression.operator === '-' ? - -result.value.value : typeof result.value.value; - results.push({ state: result.state, value: { kind: 'literal', value } }); - } else { - if (expression.operator === '+' || expression.operator === '-') { - invalidateFlowEffects(context, result.state, 'opaque-coercion-effects'); - } - results.push({ state: result.state, value: { kind: 'unknown', id: context.nextId++ } }); } + results = next; } return results; } - if (expression.operator === '=') { + if (node.type === 'conditional') { const results = []; - for (const target of referenceFlow(context, expression.left, state)) { - for (const right of evaluateFlow(context, expression.right, target.state)) { - assignFlowReference(context, target.reference, right.value, right.state); - results.push(right); + for (const test of evaluateFlow(context, node.test, state)) { + for (const branch of branchFlow(context, test, node.test, false)) { + results.push(...evaluateFlow(context, branch.active ? node.consequent : node.alternate, branch.state)); } } return results; } - if (['&&=', '||=', '??='].includes(expression.operator)) { + if (node.type === 'unary') { const results = []; - for (const target of referenceFlow(context, expression.left, state)) { - const reference = target.reference; - const value = reference.kind === 'binding' ? target.state.bindings.get(reference.name) : - reference.kind === 'module' ? target.state.value : - target.state.objects.get(reference.id).properties.get(reference.name); - if (reference.kind === 'property' && value === undefined) { - invalidateFlowEffects(context, target.state, 'opaque-read-effects'); - } - const left = { state: target.state, value: value || { kind: 'unknown', id: context.nextId++ } }; - for (const branch of branchFlow(context, left, expression.left, - expression.operator === '??=' ? 'nullish' : 'truthy')) { - const evaluateRight = expression.operator === '||=' ? !branch.pass : branch.pass; - if (!evaluateRight) { results.push({ state: branch.state, value: left.value }); continue; } - for (const right of evaluateFlow(context, expression.right, branch.state)) { - assignFlowReference(context, reference, right.value, right.state); - results.push(right); - } - } + for (const result of evaluateFlow(context, node.argument, state)) { + const primitive = result.value.kind === 'literal'; + const value = primitive ? result.value.value : undefined; + if (!primitive && (node.operator === '+' || node.operator === '-')) + failFlow(context, 'unsupported-coercion', node.start, node.end); + const computed = node.operator === 'void' ? undefined : node.operator === 'typeof' ? + primitive ? typeof value : undefined : node.operator === '!' ? !value : node.operator === '+' ? +value : -value; + results.push({ state: result.state, value: !primitive && node.operator !== 'void' ? + { kind: 'unknown', reason: 'unknown-condition' } : { kind: 'literal', value: computed } }); } return results; } + const assignment = ['=', '&&=', '||=', '??='].includes(node.operator); + if (assignment && !isFlowReplacement(node.left)) failFlow(context, 'unsupported-assignment', node.start, node.end); + const logical = ['&&', '||', '??', '&&=', '||=', '??='].includes(node.operator); + const leftResults = assignment ? [{ state, value: state.value }] : evaluateFlow(context, node.left, state); const results = []; - for (const left of evaluateFlow(context, expression.left, state)) { - const logical = ['&&', '||', '??'].includes(expression.operator); + for (const left of leftResults) { if (logical) { - const predicate = expression.operator.startsWith('??') ? 'nullish' : 'truthy'; - for (const branch of branchFlow(context, left, expression.left, predicate)) { - const evaluateRight = expression.operator.startsWith('||') ? !branch.pass : branch.pass; - if (!evaluateRight) { results.push({ state: branch.state, value: left.value }); continue; } - for (const right of evaluateFlow(context, expression.right, branch.state)) { + if (assignment && left.value.kind !== 'literal' && left.value.kind !== 'object') + failFlow(context, 'unknown-logical-assignment', node.start, node.end); + for (const branch of branchFlow(context, left, node.left, node.operator.startsWith('??'))) { + const active = node.operator.startsWith('||') ? !branch.active : branch.active; + if (!active) results.push({ state: branch.state, value: left.value }); + else for (const right of evaluateFlow(context, node.right, branch.state)) { + if (assignment) right.state.value = right.value; results.push(right); } } } else { - for (const right of evaluateFlow(context, expression.right, left.state)) { - if (expression.operator !== ',' && expression.operator !== '===' && expression.operator !== '!==' && - (left.value.kind !== 'literal' || right.value.kind !== 'literal')) { - invalidateFlowEffects(context, right.state, 'opaque-coercion-effects'); + for (const right of evaluateFlow(context, node.right, left.state)) { + if (assignment) right.state.value = right.value; + if (assignment || node.operator === ',') results.push(right); + else { + if (left.value.kind !== 'literal' || right.value.kind !== 'literal') { + failFlow(context, 'unsupported-comparison', node.start, node.end); + } + const value = node.operator === '===' ? left.value.value === right.value.value : + node.operator === '!==' ? left.value.value !== right.value.value : undefined; + if (value === undefined) failFlow(context, 'unsupported-operator', node.start, node.end); + results.push({ state: right.state, value: { kind: 'literal', value } }); } - const value = expression.operator === ',' ? right.value : - left.value.kind === 'literal' && right.value.kind === 'literal' ? - foldFlowBinary(expression.operator, left.value.value, right.value.value) : - { kind: 'unknown', id: context.nextId++ }; - results.push({ state: right.state, value }); } } } return results; } -/** @param {FlowContext} context @param {FlowValue} value @param {FlowExpression} expression */ -function flowPropertyName (context, value, expression) { - if (value.kind !== 'literal') failFlow(context, 'dynamic-property', expression.start, expression.end); - const name = String(value.value); - if (name === '__proto__') failFlow(context, 'prototype-mutation', expression.start, expression.end); - return name; -} - -/** - * @param {FlowContext} context - * @param {FlowExpression} target - * @param {FlowValue | undefined} value - * @param {FlowState} state - */ -function writeFlow (context, target, value, state) { - const results = []; - for (const result of referenceFlow(context, target, state)) { - assignFlowReference(context, result.reference, value, result.state); - results.push({ state: result.state, value: value || { kind: 'literal', value: true } }); - } - return results; -} - -/** @param {FlowContext} context @param {FlowExpression} target @param {FlowState} state */ -function referenceFlow (context, target, state) { - if (target.type === 'identifier') { - const binding = state.bindings.get(target.name); - if (!state.declared.has(target.name) || state.constants.has(target.name) || - binding && binding.kind === 'uninitialized') { - failFlow(context, 'unsupported-binding-write', target.start, target.end); - } - return [{ state, reference: { kind: 'binding', name: target.name } }]; - } - if (target.type !== 'member') failFlow(context, 'unsupported-assignment', target.start, target.end); - const results = []; - for (const receiver of evaluateFlow(context, target.object, state)) { - for (const key of evaluateFlow(context, target.key, receiver.state)) { - const name = flowPropertyName(context, key.value, target); - let reference; - if (receiver.value.kind === 'module-object' && name === 'exports') reference = { kind: 'module' }; - else if (receiver.value.kind === 'object') { - const object = key.state.objects.get(receiver.value.id); - if (object.reason) failFlow(context, object.reason, target.start, target.end); - reference = { kind: 'property', id: receiver.value.id, name }; - } - else failFlow(context, 'unknown-mutation', target.start, target.end); - results.push({ state: key.state, reference }); - } - } - return results; -} - -/** @param {FlowContext} context @param {FlowReference} reference - * @param {FlowValue | undefined} value @param {FlowState} state */ -function assignFlowReference (context, reference, value, state) { - if (reference.kind === 'binding') state.bindings.set(reference.name, value); - else if (reference.kind === 'module') state.value = value || { kind: 'literal', value: undefined }; - else { - const object = state.objects.get(reference.id); - if (object.reason) failFlow(context, object.reason); - if (value === undefined) object.properties.delete(reference.name); - else { - if (state.prototypeUnknown && !object.properties.has(reference.name)) failFlow(context, 'dependency-effects'); - object.properties.set(reference.name, value); - } - } -} - -/** @param {FlowContext} context @param {FlowState} state @param {string} reason */ -function invalidateFlowEffects (context, state, reason) { - if (context.hasFunctions) failFlow(context, 'unresolved-function-effects'); - const pending = [state.value]; - const visited = new Set(); - while (pending.length) { - const value = pending.pop(); - if (value.kind !== 'object' || visited.has(value.id)) continue; - visited.add(value.id); - const object = state.objects.get(value.id); - object.reason = reason; - for (const child of object.properties.values()) pending.push(child); - } - state.value = { kind: 'unknown', id: context.nextId++, reason }; - state.prototypeUnknown = true; -} - -/** @param {string} operator @param {FlowPrimitive} left @param {FlowPrimitive} right @returns {FlowValue} */ -function foldFlowBinary (operator, left, right) { - let value; - switch (operator) { - case '===': value = left === right; break; - case '!==': value = left !== right; break; - case '==': value = left == right; break; - case '!=': value = left != right; break; - case '<': value = left < right; break; - case '>': value = left > right; break; - case '<=': value = left <= right; break; - case '>=': value = left >= right; break; - case '+': value = left + right; break; - case '-': value = left - right; break; - case '*': value = left * right; break; - case '%': value = left % right; break; - } - return { kind: 'literal', value }; -} - -/** @param {FlowState} state @param {FlowValue} value @returns {import('../lexer').FlowExportValue} */ -function reportFlowValue (state, value) { - if (value.kind === 'module') return { kind: 'module', specifier: value.specifier }; - if (value.kind === 'object') { - const object = state.objects.get(value.id); - if (object.reason) return { kind: 'unknown', reason: object.reason, start: object.start, end: object.end }; - return { kind: 'object', properties: [...object.properties.keys()] }; - } - if (value.kind === 'literal') return { kind: 'literal', value: value.value }; - return { kind: 'unknown', reason: value.kind === 'unknown' && value.reason || 'unknown-export-value' }; -} - module.exports = analyzeExports; diff --git a/src/lexer.c b/src/lexer.c index dab3336..68c9ed9 100755 --- a/src/lexer.c +++ b/src/lexer.c @@ -33,6 +33,7 @@ uint16_t* lastReexportEnd; uint32_t parse_error; uint32_t error = 0; uint32_t sourceLen; +uint32_t moduleExportCount; uint16_t templateStack_[STACK_DEPTH]; uint16_t* openTokenPosStack_[STACK_DEPTH]; @@ -85,6 +86,7 @@ uint32_t parseCJSWithOptions (uint16_t* _source, uint32_t _sourceLen, void (*_ad sourceLen = _sourceLen; allowMinifiedEnumerable = (options & 1) != 0; extended = (options & 2) != 0; + moduleExportCount = 0; if (_addExport) addExport = _addExport; if (_addReexport) @@ -303,7 +305,12 @@ uint32_t parseCJSWithOptions (uint16_t* _source, uint32_t _sourceLen, void (*_ad } // success +#ifdef __wasm__ + // The low byte is reserved for parse errors in the host wrapper. + return moduleExportCount << 8; +#else return 0; +#endif } void tryBacktrackAddStarExportBinding (uint16_t* bPos) { @@ -1004,6 +1011,8 @@ void tryParseExportsDotAssign (bool assign) { // module.exports = case '=': { if (assign) { + if (moduleExportCount < 2 && pos[1] != '=') + moduleExportCount++; if (!extended) clearReexports(); pos++; ch = commentWhitespace(); @@ -1024,6 +1033,13 @@ void tryParseExportsDotAssign (bool assign) { if (ch == 'r') tryParseRequire(ExportAssign); } + break; + } + case '&': + case '|': + case '?': { + if (assign && moduleExportCount < 2 && end - pos >= 2 && pos[1] == ch && pos[2] == '=') + moduleExportCount++; } } pos = revertPos; diff --git a/src/lexer.js b/src/lexer.js index 6863c29..a6b4ec7 100755 --- a/src/lexer.js +++ b/src/lexer.js @@ -14,8 +14,11 @@ export function parse (source, name = '@', options) { throw new TypeError('Expected options.mode to be "default" or "extended".'); const flags = (options && options.allowMinifiedEnumerable === true ? 1 : 0) | (mode === 'extended' ? 2 : 0); - const analysis = options === undefined ? undefined : analyzeExports(source, options, decode); - if (analysis !== undefined) return analysis; + const baseline = options === undefined ? undefined : options.baseline; + if (baseline === 'flow-v1') { + const report = analyzeExports(source, 'flow-v1', undefined, decode); + if (report !== undefined) return report; + } if (!wasm) initSync(); @@ -36,7 +39,8 @@ export function parse (source, name = '@', options) { else (isLE ? copyLE : copyBE)(source, new Uint16Array(memory, addr, len)); - const err_code = wasm.parseCJSWithOptions(addr, len, 0, 0, 0, 0, flags); + const status = wasm.parseCJSWithOptions(addr, len, 0, 0, 0, 0, flags); + const err_code = status & 255; if (err_code) { const err = new Error(`Parse error ${name}${wasm.e()}:${source.slice(0, wasm.e()).split('\n').length}:${wasm.e() - source.lastIndexOf('\n', wasm.e() - 1)}`); @@ -63,7 +67,11 @@ export function parse (source, name = '@', options) { exports.add(exptStr); } - return { exports: [...exports], reexports: [...reexports] }; + const result = { exports: [...exports], reexports: [...reexports] }; + if (baseline === 'legacy') return result; + const replacements = status >>> 8; + return replacements < 2 && baseline === undefined ? result : + analyzeExports(source, baseline, replacements, decode, result); } function decode (str) { diff --git a/test/flow.js b/test/flow.js index 88d0bb3..e245362 100644 --- a/test/flow.js +++ b/test/flow.js @@ -5,436 +5,211 @@ const { tmpdir } = require('node:os'); const { join } = require('node:path'); /** @typedef {import('../lexer').ExportAnalysis['outcomes'][number]} FlowOutcome */ - let parse; - /** @param {string} source */ -function analysis (source) { - return parse(source, 'fixture.cjs', { baseline: 'flow-v1' }); -} - -/** @param {string} source @param {(getExports: () => Record) => unknown} effect */ -function observeFlowSource (source, effect) { - const directory = mkdtempSync(join(tmpdir(), 'cjs-flow-effect-')); - const filename = join(directory, 'entry.cjs'); - writeFileSync(filename, source); - const requireFixture = createRequire(filename); - const cacheKey = requireFixture.resolve(filename); - const previous = Object.getOwnPropertyDescriptor(globalThis, 'flowPocCondition'); - const getExports = () => requireFixture.cache[cacheKey].exports; - let calls = 0; - Object.defineProperty(globalThis, 'flowPocCondition', { - configurable: true, - get () { - calls++; - return effect(getExports); - } - }); - try { - const report = analysis(source); - assert.equal(calls, 0); - const observed = requireFixture(filename); - assert.equal(calls, 1); - return { report, observed }; - } finally { - if (previous) Object.defineProperty(globalThis, 'flowPocCondition', previous); - else delete globalThis.flowPocCondition; - delete requireFixture.cache[cacheKey]; - rmSync(directory, { recursive: true, force: true }); - } -} +function analysis (source) { return parse(source, 'fixture.cjs', { baseline: 'flow-v1' }); } +/** @param {import('../lexer').ExportAnalysis} report */ +function values (report) { return report.outcomes.map(/** @param {FlowOutcome} outcome */ outcome => outcome.value); } -suite('Flow detection baseline', () => { +suite('Replacement flow heuristic', () => { suiteSetup(async () => { if (process.env.WASM) { const lexer = await import('../dist/lexer.mjs'); await lexer.init(); parse = lexer.parse; - } else if (process.env.WASM_SYNC) { - parse = require('../dist/lexer.js').parse; - } else { - parse = require('../lexer.js').parse; - } - }); - - test('default and explicit legacy results retain their exact shape', () => { - const source = "if (true) module.exports = require('./a'); else module.exports = require('./b');"; - const expected = { exports: [], reexports: ['./b'] }; - assert.deepStrictEqual(parse(source), expected); - assert.deepStrictEqual(parse(source, 'fixture.cjs', {}), expected); - assert.deepStrictEqual(parse(source, 'fixture.cjs', { baseline: 'legacy' }), expected); - }); - - test('rejects an unknown baseline', () => { - assert.throws(() => parse('', 'fixture.cjs', { baseline: 'future' }), { - name: 'RangeError', message: 'Unknown detection baseline: future' - }); - }); - - test('the issue reproduction reports the reachable export', () => { - const result = analysis("if (true) module.exports = require('./a'); else module.exports = require('./b');"); - assert.equal(result.baseline, 'flow-v1'); - assert.equal(result.complete, true); - assert.deepStrictEqual(result.outcomes, [{ conditions: [], value: { kind: 'module', specifier: './a' } }]); - }); - - test('unknown conditions preserve both final alternatives', () => { - const result = analysis("if (flag) module.exports = require('./a'); else module.exports = require('./b');"); - assert.equal(result.complete, true); - assert.deepStrictEqual(result.outcomes.map(/** @param {FlowOutcome} outcome */ outcome => outcome.value), [ - { kind: 'module', specifier: './a' }, { kind: 'module', specifier: './b' } - ]); - assert.deepStrictEqual(result.outcomes.map( - /** @param {FlowOutcome} outcome */ outcome => outcome.conditions[0].when - ), ['truthy', 'falsy']); - }); - - test('later replacements remove all earlier module sources', () => { - for (const prefix of ["module.exports = require('./a');", "if (flag) module.exports = require('./a');"]) { - const result = analysis(prefix + " module.exports = require('./b');"); - assert.equal(result.complete, true); - for (const outcome of result.outcomes) { - assert.deepStrictEqual(outcome.value, { kind: 'module', specifier: './b' }); - } - } - assert.deepStrictEqual(analysis("module.exports = require('./a'); module.exports = {};").outcomes[0].value, - { kind: 'object', properties: [] }); + } else if (process.env.WASM_SYNC) parse = require('../dist/lexer.js').parse; + else parse = require('../lexer.js').parse; }); - test('retains the unchanged outcome when there is no else', () => { - const result = analysis("if (flag) module.exports = require('./a');"); - assert.deepStrictEqual(result.outcomes.map(/** @param {FlowOutcome} outcome */ outcome => outcome.value), [ - { kind: 'module', specifier: './a' }, { kind: 'unknown', reason: 'opaque-read-effects' } - ]); - }); - - test('ternaries and short-circuit assignments preserve alternatives', () => { - const conditional = analysis("module.exports = flag ? require('./a') : require('./b');"); - assert.deepStrictEqual(conditional.outcomes.map( - /** @param {FlowOutcome} outcome */ outcome => outcome.value.specifier - ), ['./a', './b']); - for (const [operator, conditions, active] of [['&&', ['truthy', 'falsy'], 0], ['||', ['truthy', 'falsy'], 1], - ['??', ['nullish', 'non-nullish'], 0]]) { - const result = analysis("flag " + operator + " (module.exports = require('./a'));"); - assert.deepStrictEqual(result.outcomes.map( - /** @param {FlowOutcome} outcome */ outcome => outcome.conditions[0].when - ), conditions); - assert.deepStrictEqual(result.outcomes[active].value, { kind: 'module', specifier: './a' }); - assert.deepStrictEqual(result.outcomes[1 - active].value, { kind: 'unknown', reason: 'opaque-read-effects' }); - } - }); - - test('folds constants with JavaScript truthiness and nullish semantics', () => { - for (const condition of ['false', '0', "''", 'null', 'void 0', '!true', '1 === 2']) { - assert.deepStrictEqual(analysis('if (' + condition + ") module.exports = require('./dead');").outcomes, - [{ conditions: [], value: { kind: 'object', properties: [] } }]); + test('does not activate for zero or one detected replacement', () => { + for (const source of ['', 'exports.name = 1;', "module.exports = require('./one');", + "if (false) module.exports = require('./one');", "module.exports = flag ? require('./a') : require('./b');"]) { + assert.equal(Object.hasOwn(parse(source), 'analysis'), false); + assert.deepStrictEqual(values(analysis(source)), [{ kind: 'unknown', reason: 'insufficient-replacements' }]); } - for (const condition of ['true', '1', "'x'", '!false', '1 === 1']) { - assert.deepStrictEqual(analysis('if (' + condition + ") module.exports = require('./live');").outcomes[0].value, - { kind: 'module', specifier: './live' }); - } - assert.deepStrictEqual(analysis("0 ?? (module.exports = require('./dead'));").outcomes[0].value, - { kind: 'object', properties: [] }); - }); - - test('tracks local values, reassignment, and correlated tests', () => { - assert.deepStrictEqual(analysis("let flag = false; flag = true; if (flag) module.exports = require('./a');") - .outcomes[0].value, { kind: 'module', specifier: './a' }); - const result = analysis("const flag = require('./flag'); module.exports = {}; " + - "if (flag) { if (!flag) module.exports = require('./dead'); }"); - assert.equal(result.complete, true); - for (const outcome of result.outcomes) { - assert.deepStrictEqual(outcome.value, { kind: 'object', properties: [] }); - } - const shadowed = analysis("const flag = false; { let flag = false; flag = true; " + - "if (flag) exports.live = 1; } if (flag) exports.dead = 1;"); - assert.equal(shadowed.complete, true); - assert.deepStrictEqual(shadowed.outcomes[0].value, { kind: 'object', properties: ['live'] }); - }); - - test('tracks object identity and detached aliases', () => { - assert.deepStrictEqual(analysis("const target = exports; target.live = 1; exports = {}; exports.dead = 2;") - .outcomes[0].value, { kind: 'object', properties: ['live'] }); - assert.deepStrictEqual(analysis('module.exports = {live: 1}; exports.dead = 1;').outcomes[0].value, - { kind: 'object', properties: ['live'] }); - const effects = analysis("module.exports = require('./a'); exports.dead = 1;"); - assert.equal(effects.complete, false); - assert.equal(effects.outcomes[0].value.kind, 'unknown'); - assert.equal(effects.outcomes[0].value.reason, 'dependency-effects'); - assert.deepStrictEqual(analysis('module.exports = exports = {}; exports.live = 1;').outcomes[0].value, - { kind: 'object', properties: ['live'] }); - }); - - test('named require values do not become whole-module reexports', () => { - assert.deepStrictEqual(analysis("module.exports = { dep: require('./a') };").outcomes[0].value, - { kind: 'object', properties: ['dep'] }); }); - - test('ignores overwritten, deleted, and uninvoked exports', () => { - assert.deepStrictEqual(analysis("exports.dead = 1; delete exports.dead; exports.live = 2;").outcomes[0].value, - { kind: 'object', properties: ['live'] }); - assert.deepStrictEqual(analysis("function unused(module) { module.exports = require('./dead'); }") - .outcomes[0].value, { kind: 'object', properties: [] }); - assert.deepStrictEqual(analysis("return; module.exports = require('./dead');").outcomes[0].value, - { kind: 'object', properties: [] }); - }); - - test('compound right-hand sides and comma expressions use their final value', () => { - assert.deepStrictEqual(analysis("module.exports = (require('./a'), {});").outcomes[0].value, - { kind: 'object', properties: [] }); - const result = analysis("module.exports = require('./a') && {};"); - assert.deepStrictEqual(result.outcomes.map( - /** @param {FlowOutcome} outcome */ outcome => outcome.value.kind - ), ['object', 'module']); - }); - - test('unknown spread shapes are explicit rather than confirmed module reexports', () => { - const result = analysis("module.exports = {...require('./a'), ...require('./b')};"); - assert.equal(result.complete, false); - assert.equal(result.outcomes[0].value.kind, 'unknown'); - assert.equal(result.outcomes[0].value.reason, 'unresolved-spread'); - }); - - test('dependency execution invalidates exposed export state', () => { - for (const source of ["exports.before = 1; require('./mutate');", "exports.dep = require('./a');", - "const target = {}; require('./mutate'); target.after = 1; module.exports = target;"]) { - const result = analysis(source); - assert.equal(result.complete, false); - assert.equal(result.outcomes[0].value.kind, 'unknown'); - assert.equal(result.outcomes[0].value.reason, 'dependency-effects'); + test('counts lexer detections, excluding comments, strings, properties, and comparisons', () => { + for (const extra of ['// module.exports = 2;', '/* module.exports = 2; */', "'module.exports = 2;'", + 'module.exports.name = 2;', 'module.exports["name"] = 2;', 'module.exports === 2;', 'module.exports == 2;']) { + assert.equal(Object.hasOwn(parse('module.exports = {}; ' + extra), 'analysis'), false); } - const local = analysis("const target = {safe: 1}; require('./mutate'); module.exports = target;"); - assert.deepStrictEqual(local.outcomes[0].value, { kind: 'object', properties: ['safe'] }); - const captured = analysis("const target = {dead: 1}; function mutate() { delete target.dead; } " + - "exports.mutate = mutate; require('./call'); module.exports = target;"); - assert.equal(captured.complete, false); - assert.equal(captured.outcomes[0].value.kind, 'unknown'); + assert.equal(Object.hasOwn(parse('module /* gap */ . exports = {}; module.exports = {};'), 'analysis'), true); }); - - test('opaque condition reads can have effects and are not correlated', () => { - const result = analysis("if (flag) { if (!flag) module.exports = require('./a'); }"); - assert.equal(result.complete, false); - assert.equal(result.outcomes.some( - /** @param {FlowOutcome} outcome */ outcome => outcome.value.kind === 'module' - ), true); - const unchanged = analysis('if (globalThis.flag) {}'); - assert.equal(unchanged.complete, false); - assert.equal(unchanged.outcomes[0].value.kind, 'unknown'); - }); - - test('opaque getter effects match a real CommonJS load without executing analysis input', () => { - const source = "if (flowPocCondition) module.exports = require('./a');"; - const { report, observed } = observeFlowSource(source, - /** @param {() => Record} getExports */ - getExports => { getExports().real = 1; return false; } - ); - assert.equal(report.complete, false); - assert.equal(report.outcomes[1].value.kind, 'unknown'); - assert.deepStrictEqual(observed, { real: 1 }); - }); - - test('invalidated objects cannot provide cached values or references', () => { - const prefix = 'const target = exports; target.enabled = false; flowPocCondition; '; - const cases = [ - [prefix + 'module.exports = target.enabled ? {live: 1} : {dead: 1};', - /** @param {() => Record} getExports */ - getExports => { getExports().enabled = true; return false; }], - [prefix + 'module.exports = (delete target.enabled) ? {dead: 1} : {live: 1};', - /** @param {() => Record} getExports */ - getExports => { Object.defineProperty(getExports(), 'enabled', { configurable: false }); return false; }], - [prefix + 'module.exports = {}; target.enabled = 1;', - /** @param {() => Record} getExports */ - getExports => { - Object.defineProperty(getExports(), 'enabled', { - configurable: true, - /** @param {number} value */ - set (value) { getExports().live = value; } - }); - return false; - }] - ]; - for (const [source, effect] of cases) { - const { report, observed } = observeFlowSource(source, effect); - assert.equal(report.complete, false, source); - assert.equal(report.outcomes[0].value.kind, 'unknown', source); - assert.deepStrictEqual(observed, { live: 1 }, source); - } - }); - - test('numeric object keys use runtime property names', () => { - const source = 'module.exports = {0x10: 1, 1e2: 1, 1.0: 1, 0b10: 1, 0o7: 1};'; - assert.deepStrictEqual(analysis(source).outcomes[0].value.properties, ['16', '100', '1', '2', '7']); - }); - - test('inherited accessors cannot leave confirmed exports after opaque effects', () => { - const previous = Object.getOwnPropertyDescriptor(Object.prototype, 'flowPocProperty'); + test('matches native line terminators in comments, hashbangs, and semicolon insertion', () => { + const directory = mkdtempSync(join(tmpdir(), 'cjs-flow-lines-')); + const filename = join(directory, 'entry.cjs'); + const requireFixture = createRequire(filename); try { - for (const operator of ['=', '&&=', '||=', '??=']) { - const source = 'const target = {}; flowPocCondition; module.exports = {live: 1}; ' + - 'target.flowPocProperty ' + operator + ' 1;'; - const { report, observed } = observeFlowSource(source, - /** @param {() => Record} getExports */ - getExports => { - Object.defineProperty(Object.prototype, 'flowPocProperty', { - configurable: true, - get () { delete getExports().live; }, - /** @param {number} value */ - set (value) { delete getExports().live; } - }); - return false; + for (const line of ['\n', '\r', '\r\n', '\u2028', '\u2029']) { + const before = 'module.exports = {}; module.exports = { before: 1 }'; + const after = 'module.exports = { after: 1 };'; + for (const source of [before + '; // comment' + line + after, before + line + after, + '#!/usr/bin/env node' + line + before + '; ' + after]) { + writeFileSync(filename, source); + const observed = requireFixture(filename); + delete requireFixture.cache[requireFixture.resolve(filename)]; + assert.deepStrictEqual(Object.keys(observed), ['after']); + const result = parse(source); + const report = analysis(source); + if (source.startsWith('#!') && (line === '\u2028' || line === '\u2029')) { + assert.equal(Object.hasOwn(result, 'analysis'), false); + assert.deepStrictEqual(values(report), [{ kind: 'unknown', reason: 'insufficient-replacements' }]); + } else { + assert.equal(report.complete, true, JSON.stringify(source)); + assert.deepStrictEqual(values(report), [{ kind: 'object', properties: Object.keys(observed) }]); + assert.deepStrictEqual(result.analysis, report); } - ); - assert.equal(report.complete, false, operator); - assert.equal(report.outcomes[0].value.kind, 'unknown', operator); - assert.deepStrictEqual(observed, {}, operator); + } } + const end = 'module.exports = {}; module.exports = { after: 1 }; // no terminator'; + assert.deepStrictEqual(values(analysis(end)), [{ kind: 'object', properties: ['after'] }]); } finally { - if (previous) Object.defineProperty(Object.prototype, 'flowPocProperty', previous); - else delete Object.prototype.flowPocProperty; - } - }); - - test('shallow source cannot build an unbounded expression tree', () => { - const accepted = 'module.exports = ' + Array(62).fill('1').join('+') + ';'; - assert.deepStrictEqual(analysis(accepted).outcomes[0].value, { kind: 'literal', value: 62 }); - for (const source of ['module.exports = ' + Array(63).fill('1').join('+') + ';', - 'module.exports = ' + Array(1000).fill('1').join('+') + ';', - 'module.exports = exports' + '.a'.repeat(1000) + ';', - "module.exports = require('./a')" + '()'.repeat(1000) + ';']) { - const report = analysis(source); - assert.equal(report.complete, false); - assert.equal(report.outcomes[0].value.reason, 'depth-limit'); + rmSync(directory, { recursive: true, force: true }); } }); - - test('implicit coercion and opaque spread can change exports', () => { - const prefix = 'const operand = flowPocCondition; module.exports = {}; '; - for (const suffix of ['+operand;', '-operand;', 'operand + 1;', 'operand - 1;', 'operand * 1;', - 'operand % 2;', 'operand == 1;', 'operand != 1;', 'operand < 1;', 'operand > 1;', - 'operand <= 1;', 'operand >= 1;', '({...operand});', 'const copy = {...operand}; ({...copy});']) { - const { report, observed } = observeFlowSource(prefix + suffix, - /** @param {() => Record} getExports */ - getExports => ({ - valueOf () { getExports().live = 1; return 1; }, - get spread () { getExports().live = 1; return 1; } - }) - ); - assert.equal(report.complete, false, suffix); - assert.equal(report.outcomes[0].value.kind, 'unknown', suffix); - assert.deepStrictEqual(observed, { live: 1 }, suffix); - } - for (const suffix of ['operand === 1;', 'operand !== 1;', 'typeof operand;', '!operand;', 'void operand;']) { - const { report, observed } = observeFlowSource(prefix + suffix, - /** @param {() => Record} getExports */ - getExports => ({ valueOf () { getExports().live = 1; return 1; } }) - ); - assert.equal(report.complete, true, suffix); - assert.deepStrictEqual(report.outcomes[0].value, { kind: 'object', properties: [] }, suffix); - assert.deepStrictEqual(observed, {}, suffix); - } + test('default analysis preserves legacy enumeration and serialization', () => { + const source = "if (true) module.exports = require('./a'); else module.exports = require('./b');"; + const expected = { exports: [], reexports: ['./b'] }; + const result = parse(source); + assert.equal(Object.getOwnPropertyDescriptor(result, 'analysis').enumerable, false); + assert.deepStrictEqual(result, expected); + assert.equal(JSON.stringify(result), JSON.stringify(expected)); + assert.deepStrictEqual(values(result.analysis), [{ kind: 'module', specifier: './a' }]); + assert.deepStrictEqual(parse(source, '@', {}), expected); + const legacy = parse(source, '@', { baseline: 'legacy' }); + assert.equal(Object.hasOwn(legacy, 'analysis'), false); + assert.deepStrictEqual(legacy, expected); + }); + test('defers and caches reports across interleaved parses and result mutation', () => { + const source = "module.exports = require('./old'); module.exports = require('./final');"; + const result = parse(source); + assert.equal(typeof Object.getOwnPropertyDescriptor(result, 'analysis').get, 'function'); + result.reexports[0] = './mutated'; + parse('module.exports = {}; module.exports = { other: 1 };'); + const report = result.analysis; + assert.equal(Object.getOwnPropertyDescriptor(result, 'analysis').get, undefined); + assert.equal(result.analysis, report); + assert.deepStrictEqual(values(report), [{ kind: 'module', specifier: './final' }]); + }); + test('validates the versioned third argument', () => { + assert.throws(() => parse('', '@', { baseline: 'future' }), { + name: 'RangeError', message: 'Unknown detection baseline: future' + }); + assert.throws(() => parse('', '@', { baseline: 1 }), { + name: 'TypeError', message: 'Detection baseline must be a string' + }); }); - - test('unsupported effects never produce speculative exports', () => { - for (const source of ["mutate(exports);", "while (flag) module.exports = require('./a');", - "try { module.exports = require('./a'); } catch (error) { module.exports = require('./b'); }", - "module.exports[key] = 1;", "const require = other; module.exports = require('./a');"]) { - const result = analysis(source); + test('reads the baseline once before lexing', () => { + const source = "if (flag) module.exports = require('./a'); else module.exports = require('./b');"; + for (const baseline of [undefined, 'legacy', 'flow-v1']) { + let reads = 0; + parse(source, '@', { get baseline () { reads++; return baseline; } }); + assert.equal(reads, 1); + } + }); + test('reports only the last sequential replacement', () => { + assert.deepStrictEqual(values(analysis("module.exports = require('./a'); module.exports = require('./b');")), + [{ kind: 'module', specifier: './b' }]); + assert.deepStrictEqual(values(analysis('module.exports = { removed: 1 }; module.exports = { final: 1 };')), + [{ kind: 'object', properties: ['final'] }]); + for (const source of ["module.exports = require('old'); module.exports = require(\"new'quote\");", + "module.exports = require('old'); module.exports = require('new\\'quote');"]) { + assert.deepStrictEqual(values(analysis(source)), [{ kind: 'module', specifier: "new'quote" }]); + } + for (const length of [4096, 4097]) { + const source = "module.exports = require('./a'); module.exports = require('./b');"; + assert.deepStrictEqual(values(analysis(source.padEnd(length))), [{ kind: 'module', specifier: './b' }]); + } + }); + test('preserves conditional alternatives with source ranges', () => { + const report = analysis("if (flag) module.exports = require('./a'); else module.exports = require('./b');"); + assert.equal(report.complete, true); + assert.deepStrictEqual(values(report), [{ kind: 'module', specifier: './a' }, { kind: 'module', specifier: './b' }]); + assert.deepStrictEqual(report.outcomes.map(/** @param {FlowOutcome} outcome */ outcome => outcome.conditions[0]), + [{ start: 4, end: 8, when: 'truthy' }, { start: 4, end: 8, when: 'falsy' }]); + const nested = "if (first) { if (second) module.exports = require('./a'); " + + "else module.exports = require('./b'); } else module.exports = require('./c');"; + assert.equal(analysis(nested).outcomes.length, 3); + for (const value of values(analysis(nested + " module.exports = require('./final');"))) { + assert.deepStrictEqual(value, { kind: 'module', specifier: './final' }); + } + }); + test('handles ternaries and logical conditions after the trigger', () => { + assert.deepStrictEqual(values(analysis("module.exports = {}; module.exports = flag ? require('./a') : require('./b');")), + [{ kind: 'module', specifier: './a' }, { kind: 'module', specifier: './b' }]); + for (const [operator, when] of [['&&', 'truthy'], ['||', 'falsy'], ['??', 'nullish']]) { + const result = analysis("module.exports = {}; flag " + operator + " (module.exports = require('./active'));"); assert.equal(result.complete, false); - assert.equal(result.outcomes[0].value.kind, 'unknown'); - assert.equal(JSON.stringify(result.outcomes).includes('specifier'), false); + const active = result.outcomes.find(/** @param {FlowOutcome} outcome */ outcome => outcome.value.kind === 'module'); + assert.equal(active.conditions[0].when, when); } }); - - test('source text is never executed', () => { - globalThis.flowPocExecuted = false; - const result = analysis('globalThis.flowPocExecuted = true;'); - assert.equal(globalThis.flowPocExecuted, false); - assert.equal(result.complete, false); - delete globalThis.flowPocExecuted; - }); - - test('captures an assignment target before evaluating its right-hand side', () => { - assert.deepStrictEqual(analysis('let target = {}; target.foo = (target = exports, 1);').outcomes[0].value, - { kind: 'object', properties: [] }); - assert.deepStrictEqual(analysis('let target = exports; target.foo = (target = {}, 1);').outcomes[0].value, - { kind: 'object', properties: ['foo'] }); - for (const [initial, operator] of [['0', '||='], ['true', '&&='], ['null', '??=']]) { - assert.deepStrictEqual(analysis('let target = {foo: ' + initial + '}; target.foo ' + operator + - ' (target = exports, 1);').outcomes[0].value, { kind: 'object', properties: [] }); + test('counts and handles logical replacement assignments', () => { + for (const [operator, initial] of [['&&=', 'true'], ['||=', 'false'], ['??=', 'null']]) { + const source = 'module.exports = ' + initial + '; module.exports ' + operator + " require('./active');"; + assert.equal(Object.hasOwn(parse(source), 'analysis'), true); + assert.deepStrictEqual(values(analysis(source)), [{ kind: 'module', specifier: './active' }]); } + assert.equal(analysis("module.exports = require('./a'); module.exports ||= require('./b');").complete, false); }); - - test('rejects unsupported binding changes and ambiguous number forms', () => { - for (const source of ['const flag = false; flag = true;', 'missing = 1;', - "if (010 === 8) module.exports = require('./a');"]) { - const result = analysis(source); - assert.equal(result.complete, false); - assert.equal(result.outcomes[0].value.kind, 'unknown'); - } - }); - - test('hoists var declarations without resetting wrapper parameters', () => { - assert.deepStrictEqual(analysis("var module; module.exports = require('./a');").outcomes[0].value, - { kind: 'module', specifier: './a' }); - assert.deepStrictEqual(analysis("if (flag) module.exports = require('./dead'); if (false) { var flag; }") - .outcomes[0].value, { kind: 'object', properties: [] }); - }); - - test('bounds branch expansion and nesting', () => { - assert.equal(analysis('('.repeat(62) + 'true' + ')'.repeat(62) + ';').complete, true); - const accepted = Array.from({ length: 5 }, - /** @param {undefined} entry @param {number} index */ - (entry, index) => 'if (flag' + index + ') module.exports = {a: 1}; else module.exports = {};' - ).join('\n'); - assert.equal(analysis(accepted).outcomes.length, 32); - assert.equal(analysis(';'.repeat(8192)).complete, true); - for (const source of ['('.repeat(63) + 'true' + ')'.repeat(63) + ';', ';'.repeat(8193), - accepted + '\nif (flag5) module.exports = {a: 1}; else module.exports = {};']) { - const result = analysis(source); - assert.equal(result.complete, false); - assert.equal(result.outcomes[0].value.kind, 'unknown'); - assert.match(result.outcomes[0].value.reason, /limit/); + test('folds pure primitive conditions and normalizes literal keys', () => { + for (const condition of ['false', '0', "''", 'null', 'void 0', '!true', '1 === 2']) { + assert.deepStrictEqual(values(analysis('if (' + condition + ") module.exports = require('./dead'); " + + "else module.exports = require('./live');")), [{ kind: 'module', specifier: './live' }]); + } + assert.deepStrictEqual(values(analysis('module.exports = {}; ' + + 'module.exports = { 0x10: 1, 1e2: 2, 1.5: 3, final: 0, final: 1 };')), + [{ kind: 'object', properties: ['16', '100', '1.5', 'final'] }]); + }); + test('does not guess exports across unsupported effects, bindings, or control flow', () => { + for (const middle of ['for (;;) { break; }', 'while (flag) {}', 'switch (flag) { case 1: break; }', + 'try {} catch (error) {}', 'const flag = true;', 'function unused() { module.exports = {}; }', + 'exports.detached = 1;', 'module.exports.detached = 1;', 'delete module.exports.name;', + 'module.exports = { ...require("./spread") };', 'mutate();', 'return;', 'throw new Error();']) { + const report = analysis('module.exports = {}; ' + middle + ' module.exports = {};'); + assert.equal(report.complete, false, middle); + for (const value of values(report)) assert.equal(value.kind, 'unknown', middle); + } + }); + test('invalidates earlier values after opaque reads and dependency execution', () => { + for (const expression of ['flag;', 'flag + 1;', 'flag.name;', "require('./effects');"]) { + const report = analysis('module.exports = {}; module.exports = { previous: 1 }; ' + expression); + assert.equal(report.complete, false); + for (const value of values(report)) assert.equal(value.kind, 'unknown'); } }); - - test('confirmed outcomes match exports from real CommonJS modules', () => { - const directory = mkdtempSync(join(tmpdir(), 'cjs-flow-')); - writeFileSync(join(directory, 'a.cjs'), 'module.exports = {a: 1};'); - writeFileSync(join(directory, 'b.cjs'), 'module.exports = {b: 1};'); - const sources = [ - "if (true) module.exports = require('./a.cjs'); else module.exports = require('./b.cjs');", - "if (false) module.exports = require('./a.cjs'); else module.exports = require('./b.cjs');", - "module.exports = require('./a.cjs'); module.exports = require('./b.cjs');", - 'module.exports = {live: 1}; exports.dead = 1;', - "module.exports = {dep: require('./a.cjs')};", - 'let target = {}; target.foo = (target = exports, 1);', - 'let target = exports; target.foo = (target = {}, 1);', - 'let target = {foo: 0}; target.foo ||= (target = exports, 1);', - 'module.exports = {0x10: 1, 1e2: 1, 1.0: 1, 0b10: 1, 0o7: 1};', - "module.exports = (module = {}, require('./a.cjs'));", - 'const a = {x: 1}; const b = {x: 2, y: 3}; module.exports = {...a, ...b};', - 'exports.dead = 1; delete exports.dead; exports.live = 2;', - 'function unused(module) { module.exports = 1; }', - 'return; module.exports = 1;', - 'module.exports = exports = {}; exports.live = 1;' - ]; + test('matches native exports without executing conditions during analysis', () => { + const directory = mkdtempSync(join(tmpdir(), 'cjs-replacement-flow-')); + const filename = join(directory, 'entry.cjs'); + const requireFixture = createRequire(filename); + const previous = Object.getOwnPropertyDescriptor(globalThis, 'flowPocCondition'); + let calls = 0; + Object.defineProperty(globalThis, 'flowPocCondition', { configurable: true, get () { calls++; return true; } }); try { - for (let index = 0; index < sources.length; index++) { - const source = sources[index]; - const filename = join(directory, 'entry-' + index + '.cjs'); - writeFileSync(filename, source); - const requireFixture = createRequire(filename); - const observed = requireFixture(filename); - const report = analysis(source); - assert.equal(report.complete, true, source); - assert.equal(report.outcomes.length, 1, source); - const value = report.outcomes[0].value; - if (value.kind === 'module') assert.equal(observed, requireFixture(value.specifier), source); - else assert.deepStrictEqual(Object.keys(observed).sort(), value.properties.slice().sort(), source); - delete requireFixture.cache[requireFixture.resolve(filename)]; - } + const source = 'if (flowPocCondition) module.exports = { actual: 1 }; else module.exports = { other: 1 };'; + writeFileSync(filename, source); + const report = analysis(source); + assert.equal(calls, 0); + const observed = requireFixture(filename); + assert.equal(calls, 1); + assert.deepStrictEqual(Object.keys(observed), report.outcomes[0].value.properties); + delete requireFixture.cache[requireFixture.resolve(filename)]; } finally { + if (previous) Object.defineProperty(globalThis, 'flowPocCondition', previous); + else delete globalThis.flowPocCondition; rmSync(directory, { recursive: true, force: true }); } }); + test('bounds tokens, outcomes, and AST depth', () => { + const prefix = 'module.exports = {}; module.exports = {};'; + assert.equal(analysis(prefix + ';'.repeat(8192 - 14)).complete, true); + assert.equal(values(analysis(prefix + ';'.repeat(8193 - 14)))[0].reason, 'token-limit'); + for (const expression of ['1' + '+1'.repeat(1000), 'flag' + '.name'.repeat(1000), + 'require("./a")' + '()'.repeat(1000)]) { + assert.equal(values(analysis(prefix + expression + ';'))[0].reason, 'depth-limit'); + } + const branch = 'if (flag) module.exports = {}; else module.exports = {};'; + assert.equal(analysis(branch.repeat(5)).outcomes.length, 32); + assert.equal(values(analysis(branch.repeat(6)))[0].reason, 'outcome-limit'); + }); }); From b8de8f6fe5bd0bb794d16cb68a4512785a4d0787 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Tue, 6 Oct 2026 12:07:07 +0900 Subject: [PATCH 3/6] feat(lexer)!: group final reexports in Wasm by default Conditional replacements lose live dependencies when only the last syntactic assignment is reported. Reexport groups separate alternative final values and combine dependencies spread into one value. The third argument retains an explicit legacy baseline. Flow analysis starts after two detected replacements and has one C owner for both parser entries. Unsupported effects produce incomplete results rather than guessed exports. Zero or one replacement retains the existing fast path. Against f580443, conditional analysis falls from 4.57 to 2.86 us in CommonJS and 4.23 to 1.90 us in Wasm. Straight replacements add about 0.90 us in CommonJS and 0.44 us in Wasm. The shared C owner keeps this bounded cost on the active path. Measurements use Node 22.23.3, V8 12.4.254.21-node.57, 50,000 warmups, 20,000 iterations per trial, seven alternating trials, and two fresh processes. Each run removes the best and worst trials. Refs: https://github.com/nodejs/cjs-module-lexer/issues/99 --- Makefile | 2 +- README.md | 106 ++--- build.js | 3 +- build/Makefile | 3 +- include-wasm/cjs-module-lexer.h | 2 + lexer.d.ts | 34 +- lexer.js | 24 +- package.json | 1 - src/export-analysis.js | 492 -------------------- src/lexer.c | 5 + src/lexer.js | 50 ++- src/reexport-analysis.h | 763 ++++++++++++++++++++++++++++++++ test/_unit.js | 5 +- test/flow.js | 215 --------- test/grouped.js | 256 +++++++++++ test/integration.js | 5 +- 16 files changed, 1139 insertions(+), 827 deletions(-) mode change 100755 => 100644 lexer.d.ts delete mode 100644 src/export-analysis.js create mode 100644 src/reexport-analysis.h delete mode 100644 test/flow.js create mode 100644 test/grouped.js diff --git a/Makefile b/Makefile index bf45b62..726d19b 100755 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ WASM_OPT ?= ../binaryen/bin/wasm-opt lib/lexer.wat: lib/lexer.wasm $(WASM2WAT) lib/lexer.wasm -o lib/lexer.wat -lib/lexer.wasm: include-wasm/cjs-module-lexer.h src/lexer.c | lib/ +lib/lexer.wasm: include-wasm/cjs-module-lexer.h src/lexer.c src/reexport-analysis.h | lib/ node build/wasm.js --docker lib/: diff --git a/README.md b/README.md index 445d266..a398454 100755 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This project is used in Node.js core for detecting the named exports available w PRs will be accepted and upstreamed for parser bugs, performance improvements or new syntax support only. -_Default detection patterns for this project are **frozen**_. This is because adding any new export detection patterns would result in fragmented backwards-compatibility. Specifically, it would be very difficult to figure out why an ES module named export for CommonJS might work in newer Node.js versions but not older versions. This problem would only be discovered downstream of module authors, with the fix for module authors being to then have to understand which patterns in this project provide full backwards-compatibily. Rather, by fully freezing the detected patterns, if it works in any Node.js version it will work in any other. Build tools can also reliably treat the supported syntax for this project as a part of their output target for ensuring syntax support. +_Detection patterns for the `legacy` baseline are **frozen**_. This is because adding any new export detection patterns would result in fragmented backwards-compatibility. Specifically, it would be very difficult to figure out why an ES module named export for CommonJS might work in newer Node.js versions but not older versions. This problem would only be discovered downstream of module authors, with the fix for module authors being to then have to understand which patterns in this project provide full backwards-compatibily. Rather, by fully freezing the detected patterns, if it works in any Node.js version it will work in any other. Build tools can also reliably treat the supported syntax for this project as a part of their output target for ensuring syntax support. ### Usage @@ -29,30 +29,16 @@ For use in CommonJS: ```js const { parse } = require('cjs-module-lexer'); -// `init` return a promise for parity with the ESM API, but you do not have to call it +// Parsing initializes Wasm on demand. Await init() to initialize it in advance. -const { exports, reexports } = parse(` - // named exports detection - module.exports.a = 'a'; - (function () { - exports.b = 'b'; - })(); - Object.defineProperty(exports, 'c', { value: 'c' }); - /* exports.d = 'not detected'; */ - - // reexports detection - if (maybe) module.exports = require('./dep1.js'); - if (another) module.exports = require('./dep2.js'); - - // literal exports assignments - module.exports = { a, b: c, d, 'e': f } - - // __esModule detection - Object.defineProperty(module.exports, '__esModule', { value: true }) +const { exports, reexports, complete } = parse(` + if (mode) module.exports = { own: 1, ...require('./dep1.js') }; + else module.exports = { own: 2, ...require('./dep2.js') }; `); -// exports === ['a', 'b', 'c', '__esModule'] -// reexports === ['./dep1.js', './dep2.js'] +// exports: ['own'] +// reexports: [['./dep1.js'], ['./dep2.js']] +// complete: true ``` When using the ESM version, Wasm is supported instead: @@ -78,57 +64,43 @@ This option does not change Node.js's built-in CommonJS named-export detection. ### Detection baselines (proof of concept) -Flow analysis runs only after the lexer detects more than one `module.exports` replacement. -The counter ignores comments, strings, property writes, and comparisons. -It includes direct assignments and logical assignments. -The default result retains the legacy `exports` and `reexports` arrays. -When the trigger applies, a non-enumerable `analysis` property provides the replacement report. -The first read computes and caches the report. Legacy consumers do not run control-flow analysis. -Object enumeration and JSON serialization retain their current output. -Reflection can observe the new property. - -The optional third argument selects a versioned detection baseline. -Select `legacy` to omit analysis, or `flow-v1` to return the report directly. -The baseline name can combine detection rules without adding an option for each rule. +The default `reexports` value is an array of arrays. +Each inner array contains dependencies combined in one final export value. +The outer array contains alternative final values. +This changes the return type and requires a major release. ```js -const report = parse(` - if (production) module.exports = require('./production.cjs'); - else module.exports = require('./development.cjs'); -`, 'entry.cjs', { baseline: 'flow-v1' }); - -for (const outcome of report.outcomes) { - console.log(outcome.conditions, outcome.value); -} +const { reexports, complete } = parse(` + if (production) module.exports = {...require('./a.cjs'), ...require('./b.cjs')}; + else module.exports = {...require('./c.cjs'), ...require('./d.cjs')}; +`); +// reexports: [['./a.cjs', './b.cjs'], ['./c.cjs', './d.cjs']] +// complete: true ``` -You can also read `parse(source).analysis` when the trigger applies. -With fewer than two detected replacements, explicit `flow-v1` reports `insufficient-replacements` as unknown. -This includes a single assignment whose right-hand side contains a conditional expression. -Each outcome describes the final export value under its conditions. -Conditions identify source expressions with UTF-16 offsets and record truthiness or nullishness. -Ranges include the start offset and exclude the end offset. -They are analysis facts, not instructions to execute the source again. +Sequential replacements retain only the final value. +Pure guards remove unreachable branches. Unknown guards retain their possible alternatives. +Object spreads of direct `require` calls combine dependencies in one group. +The `exports` array contains detected literal names from surviving values. + +Flow analysis runs only after the lexer detects more than one `module.exports` replacement. +The counter ignores comments, strings, property writes, and comparisons. +With fewer replacements, existing detection patterns run and their reexports form one group. +The optional third argument selects a versioned detection baseline. +Select `legacy` for the previous flat result, or `flow-v1` for the default grouped result. +A baseline can combine detection rules without an option for each rule. +The C lexer compiled to Wasm owns flow analysis. +The JavaScript entry point invokes it only for competing replacements. Use valid CommonJS source with standard CommonJS bindings and an ordinary writable `module.exports` property. -The report describes successful synchronous completion. -It reports final replacement values, rather than a union of every detected assignment. -A module value identifies a direct re-export. -An object value contains its confirmed own literal property names. - -The bounded heuristic distinguishes sequential replacements from conditional alternatives. -Short sequences of bare re-export assignments identify the final module without parsing control flow. -The conditional path supports `if`, conditional and logical expressions, primitive guards, and fresh literal values. -It does not track local bindings or object aliases. -Loops, `switch`, `try`, function bodies, property mutation, spreads, and unsupported effects produce an unknown report. -Logical assignments with an unknown previous export value also remain unknown. - -The analyzer does not execute source or resolve dependencies. -Opaque reads and dependency calls can change exposed exports, so they invalidate earlier values. -A fresh replacement can establish a known value again. -Unknown outcomes make `complete` false. A complete report can still contain runtime conditions. -Token, nesting, branch, and work limits produce unknown outcomes rather than partial export lists. -When lexing is required, existing lexer errors still apply. +Analysis describes successful synchronous completion and does not execute source or resolve dependencies. +Opaque reads and dependency execution invalidate earlier exposed values. A fresh replacement can establish a known value again. + +Results include `complete` only when flow analysis runs. +Unsupported syntax, effects, bindings, filtered string literals, and analysis limits make it false. +An incomplete result contains confirmed alternatives and omits unknown values. +Token, nesting, outcome, and work limits bound the analyzer. +Existing lexer errors still apply. ```js diff --git a/build.js b/build.js index 8745502..843c46e 100755 --- a/build.js +++ b/build.js @@ -7,8 +7,7 @@ try { fs.mkdirSync('./dist'); } catch (e) {} const wasmBuffer = fs.readFileSync('./lib/lexer.wasm'); -const analysisSource = fs.readFileSync('./src/export-analysis.js', 'utf8').replace('module.exports = analyzeExports;', ''); -const jsSource = analysisSource + '\n' + fs.readFileSync('./src/lexer.js').toString(); +const jsSource = fs.readFileSync('./src/lexer.js').toString(); const pjson = JSON.parse(fs.readFileSync('./package.json').toString()); const jsSourceProcessed = jsSource.replace('WASM_BINARY', wasmBuffer.toString('base64')); diff --git a/build/Makefile b/build/Makefile index 3a29c0c..199fe9a 100755 --- a/build/Makefile +++ b/build/Makefile @@ -11,10 +11,11 @@ WASM_EXTRA_LDFLAGS := -Wl,-z,stack-size=13312,--no-entry,--compress-relocations, WASM_EXTRA_LDFLAGS += -Wl,--mllvm=-inline-threshold=150 WASM_EXTRA_LDFLAGS += -Wl,--export=__heap_base,--export=parseCJS,--export=sa WASM_EXTRA_LDFLAGS += -Wl,--export=parseCJSWithOptions +WASM_EXTRA_LDFLAGS += -Wl,--export=parseCJSGrouped WASM_EXTRA_LDFLAGS += -Wl,--export=e,--export=re,--export=es,--export=ee WASM_EXTRA_LDFLAGS += -Wl,--export=rre,--export=ree,--export=res,--export=ru,--export=us,--export=ue -lib/lexer.wasm: include-wasm/cjs-module-lexer.h src/lexer.c +lib/lexer.wasm: include-wasm/cjs-module-lexer.h src/lexer.c src/reexport-analysis.h @mkdir -p lib $(WASM_CC) $(WASM_CFLAGS) $(WASM_TARGET) $(WASM_EXTRA_CFLAGS) \ src/lexer.c -o lib/lexer.wasm \ diff --git a/include-wasm/cjs-module-lexer.h b/include-wasm/cjs-module-lexer.h index 77911fe..a0c63ed 100755 --- a/include-wasm/cjs-module-lexer.h +++ b/include-wasm/cjs-module-lexer.h @@ -77,6 +77,8 @@ uint32_t ee () { } // getReexportStart uint32_t res () { + // A null slice starts a group; Wasm exposes this sentinel as signed -1. + if (reexport_read_head->start == NULL) return UINT32_MAX; return reexport_read_head->start - source; } // getReexportEnd diff --git a/lexer.d.ts b/lexer.d.ts old mode 100755 new mode 100644 index 0e9aa38..acb3da8 --- a/lexer.d.ts +++ b/lexer.d.ts @@ -1,8 +1,14 @@ export interface Exports { + exports: string[]; + /** Alternative final values; each group contains dependencies combined in one value. */ + reexports: string[][]; + /** Present when competing replacements are analyzed. False for unsupported effects, filtered names, or limits. */ + complete?: boolean; +} + +export interface LegacyExports { exports: string[]; reexports: string[]; - /** Present for multiple detected replacements unless the legacy baseline is selected. Non-enumerable. */ - readonly analysis?: ExportAnalysis; } export interface ParseOptions { @@ -11,27 +17,9 @@ export interface ParseOptions { baseline?: 'legacy' | 'flow-v1'; } -export interface FlowCondition { - start: number; - end: number; - when: 'truthy' | 'falsy' | 'nullish' | 'non-nullish'; -} - -export type FlowExportValue = - | { kind: 'module'; specifier: string } - | { kind: 'object'; properties: string[] } - | { kind: 'literal'; value: string | number | boolean | null | undefined } - | { kind: 'unknown'; reason: string; start?: number; end?: number }; - -export interface ExportAnalysis { - baseline: 'flow-v1'; - complete: boolean; - outcomes: { conditions: FlowCondition[]; value: FlowExportValue }[]; -} - -export declare function parse(source: string, name: string | undefined, options: { baseline: 'flow-v1' }): ExportAnalysis; -export declare function parse(source: string, name: string | undefined, options: { baseline?: 'legacy' }): Exports; -export declare function parse(source: string, name: string | undefined, options: ParseOptions): Exports | ExportAnalysis; +export declare function parse(source: string, name: string | undefined, options: { baseline: 'legacy' }): LegacyExports; +export declare function parse(source: string, name: string | undefined, options: { baseline?: 'flow-v1' }): Exports; +export declare function parse(source: string, name: string | undefined, options: ParseOptions): Exports | LegacyExports; export declare function parse(source: string, name?: string): Exports; export declare function init(): Promise; export declare function initSync(): void; diff --git a/lexer.js b/lexer.js index 4a13dd5..d11cd64 100755 --- a/lexer.js +++ b/lexer.js @@ -69,10 +69,10 @@ function parseCJS (source, name = '@', options) { throw new TypeError('Expected options.mode to be "default" or "extended".'); const minifiedEnumerable = options ? options.allowMinifiedEnumerable === true : false; const baseline = options === undefined ? undefined : options.baseline; - if (baseline === 'flow-v1') { - const report = require('./src/export-analysis.js')(source, 'flow-v1', undefined, decode); - if (report !== undefined) return report; - } + if (baseline !== undefined && typeof baseline !== 'string') + throw new TypeError('Detection baseline must be a string'); + if (baseline !== undefined && baseline !== 'legacy' && baseline !== 'flow-v1') + throw new RangeError('Unknown detection baseline: ' + baseline); resetState(); allowMinifiedEnumerable = minifiedEnumerable; extended = mode === 'extended'; @@ -85,11 +85,15 @@ function parseCJS (source, name = '@', options) { error.loc = pos; throw error; } + if (baseline !== 'legacy' && moduleExportCount > 1) { + resetState(); + return require('./dist/lexer.js').parse(source, name, baseline === undefined ? undefined : { baseline }); + } const result = { exports: [..._exports].filter(expt => expt !== undefined && !unsafeGetters.has(expt)), reexports: [...reexports].filter(reexpt => reexpt !== undefined) }; - const replacements = moduleExportCount; resetState(); - return baseline === 'legacy' || (replacements < 2 && baseline === undefined) ? result : - require('./src/export-analysis.js')(source, baseline, replacements, decode, result); + if (baseline === 'legacy') return result; + result.reexports = result.reexports.length ? [result.reexports] : []; + return result; } /** @param {string} str */ @@ -1908,8 +1912,6 @@ function isExpressionTerminator (curPos) { return false; } -const initPromise = Promise.resolve(); - -module.exports.init = () => initPromise; -module.exports.initSync = () => {}; +module.exports.init = () => require('./dist/lexer.js').init(); +module.exports.initSync = () => require('./dist/lexer.js').initSync(); module.exports.parse = parseCJS; diff --git a/package.json b/package.json index 4033371..6db516c 100755 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ }, "files": [ "dist", - "src/export-analysis.js", "lexer.d.ts", "lexer.d.mts" ], diff --git a/src/export-analysis.js b/src/export-analysis.js deleted file mode 100644 index 3de98f4..0000000 --- a/src/export-analysis.js +++ /dev/null @@ -1,492 +0,0 @@ -/** - * @typedef {string | number | boolean | null | undefined} FlowPrimitive - * @typedef {{ text: string, start: number, end: number }} FlowToken - * @typedef {{ type: 'literal', value: FlowPrimitive, start: number, end: number }} FlowLiteral - * @typedef {{ type: 'identifier', name: string, start: number, end: number }} FlowIdentifier - * @typedef {{ type: 'member', object: FlowExpression, key: FlowExpression, start: number, end: number }} FlowMember - * @typedef {{ type: 'call', callee: FlowExpression, args: FlowExpression[], start: number, end: number }} FlowCall - * @typedef {{ type: 'unary', operator: string, argument: FlowExpression, start: number, end: number }} FlowUnary - * @typedef {{ type: 'binary', operator: string, left: FlowExpression, right: FlowExpression, - * start: number, end: number }} FlowBinary - * @typedef {{ type: 'conditional', test: FlowExpression, consequent: FlowExpression, alternate: FlowExpression, - * start: number, end: number }} FlowConditional - * @typedef {{ type: 'object', entries: { key?: string, value: FlowExpression }[], - * start: number, end: number }} FlowObject - * @typedef {FlowLiteral | FlowIdentifier | FlowMember | FlowCall | FlowUnary | FlowBinary | - * FlowConditional | FlowObject} FlowExpression - * @typedef {{ type: 'block', body: FlowStatement[], start: number, end: number } | - * { type: 'if', test: FlowExpression, consequent: FlowStatement, alternate?: FlowStatement, - * start: number, end: number } | - * { type: 'expression', expression: FlowExpression, start: number, end: number } | - * { type: 'empty', start: number, end: number }} FlowStatement - * @typedef {import('../lexer').FlowExportValue} FlowValue - * @typedef {{ value: FlowValue, conditions: import('../lexer').FlowCondition[] }} FlowState - * @typedef {{ source: string, decode: (text: string) => string | undefined, tokens: FlowToken[], pos: number, - * depth: number, steps: number, branches: number }} FlowContext - * @typedef {{ state: FlowState, value: FlowValue }} FlowEvaluation - */ - -let flowPowers; -let flowPatterns; -const flowLimits = { tokens: 8192, depth: 64, outcomes: 32, steps: 65536 }; -const flowSequence = /^(?:\s*module\.exports\s*=\s*require\(((['"])(?:\\.|(?!\2)[^\\])*\2)\);){2,}\s*$/; - -class FlowFailure extends Error { - /** @param {string} reason @param {number} start @param {number} end */ - constructor (reason, start, end) { - super(reason); - this.reason = reason; - this.start = start; - this.end = end; - } -} - -/** - * @param {string} source - * @param {import('../lexer').ParseOptions['baseline']} baseline - * @param {number} [replacements] - * @param {(text: string) => string | undefined} decode - * @param {import('../lexer').Exports} [legacy] - */ -function analyzeExports (source, baseline, replacements, decode, legacy) { - if (baseline === 'legacy') return legacy; - if (baseline !== undefined && typeof baseline !== 'string') - throw new TypeError('Detection baseline must be a string'); - if (baseline !== undefined && baseline !== 'flow-v1') throw new RangeError('Unknown detection baseline: ' + baseline); - if (baseline === undefined) { - // Legacy consumers do not read the report, so defer analysis until the first read. - Object.defineProperty(legacy, 'analysis', { configurable: true, get () { - const report = analyzeExports(source, 'flow-v1', replacements, decode); - Object.defineProperty(legacy, 'analysis', { value: report, configurable: false }); - return report; - } }); - return legacy; - } - const sequence = source.length <= 4096 ? flowSequence.exec(source) : undefined; - if (replacements === undefined) { - if (!sequence && source.indexOf('module', source.indexOf('module') + 6) !== -1) return; - replacements = sequence ? 2 : 0; - } - const specifier = sequence ? decode(sequence[1]) : undefined; - const report = replacements < 2 ? { baseline: 'flow-v1', complete: false, - outcomes: [{ conditions: [], value: { kind: 'unknown', reason: 'insufficient-replacements' } }] } : - specifier !== undefined ? - { baseline: 'flow-v1', complete: true, - outcomes: [{ conditions: [], value: { kind: 'module', specifier } }] } : - analyzeReplacements(source, decode); - return report; -} - -/** @param {string} source @param {(text: string) => string | undefined} decode */ -function analyzeReplacements (source, decode) { - if (flowPowers === undefined) { - flowPatterns = { - whitespace: /\s/, lineBreak: /[\r\n\u2028\u2029]/, identifierStart: /[a-zA-Z_$]/, - identifierPart: /[a-zA-Z0-9_$]/, identifier: /^[a-zA-Z_$][a-zA-Z0-9_$]*$/, - digit: /[0-9]/, numericStart: /^\d/, legacyNumber: /^0\d/, - number: /^(?:0[xX][\da-fA-F]+|0[bB][01]+|0[oO][0-7]+|\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/, - operator: /^(?:\.\.\.|===|!==|&&=|\|\|=|\?\?=|&&|\|\||\?\?|==|!=|<=|>=|=>|\+\+|--)/ - }; - flowPowers = new Map([ - [',', 1], ['=', 2], ['&&=', 2], ['||=', 2], ['??=', 2], ['?', 3], - ['||', 4], ['??', 4], ['&&', 5], ['===', 6], ['!==', 6], ['==', 6], ['!=', 6], - ['<', 7], ['>', 7], ['<=', 7], ['>=', 7], ['+', 8], ['-', 8], ['*', 9], ['%', 9] - ]); - } - /** @type {FlowContext} */ - const context = { source, decode, tokens: [], pos: 0, depth: 0, steps: 0, branches: 0 }; - try { - tokenizeFlow(context); - const body = []; - while (context.pos < context.tokens.length) body.push(readFlowStatement(context)); - validateFlowDepth(context, body); - const outcomes = executeFlowBody(context, body, [{ conditions: [], value: { kind: 'object', properties: [] } }]); - return { baseline: 'flow-v1', complete: outcomes.every( - /** @param {FlowState} outcome */ outcome => outcome.value.kind !== 'unknown' - ), outcomes }; - } catch (error) { - if (!(error instanceof FlowFailure)) throw error; - return { baseline: 'flow-v1', complete: false, outcomes: [{ conditions: [], - value: { kind: 'unknown', reason: error.reason, start: error.start, end: error.end } }] }; - } -} - -/** @param {FlowContext} context @param {string} reason @param {number} [start] @param {number} [end] */ -function failFlow (context, reason, start, end) { - const token = context.tokens[context.pos]; - if (start === undefined) start = token ? token.start : context.source.length; - if (end === undefined) end = token ? token.end : start; - throw new FlowFailure(reason, start, end); -} - -/** @param {FlowContext} context */ -function stepFlow (context) { - if (++context.steps > flowLimits.steps) failFlow(context, 'work-limit'); -} - -/** @param {string} source @param {number} start */ -function flowLineEnd (source, start) { - while (start < source.length && !flowPatterns.lineBreak.test(source[start])) start++; - return start; -} - -/** @param {FlowContext} context */ -function tokenizeFlow (context) { - const source = context.source; - let pos = source.startsWith('#!') ? flowLineEnd(source, 2) : 0; - while (pos < source.length) { - stepFlow(context); - const start = pos; - const ch = source[pos]; - if (flowPatterns.whitespace.test(ch)) { pos++; continue; } - if (source.startsWith('//', pos)) { - pos = flowLineEnd(source, pos + 2); - continue; - } - if (source.startsWith('/*', pos)) { - const next = source.indexOf('*/', pos + 2); - if (next === -1) failFlow(context, 'unterminated-comment', start, source.length); - pos = next + 2; - continue; - } - if (ch === '"' || ch === "'") { - pos++; - while (pos < source.length && source[pos] !== ch) { - if (source[pos] === '\\') pos++; - pos++; - } - if (pos === source.length) failFlow(context, 'unterminated-string', start, pos); - pos++; - } else if (flowPatterns.identifierStart.test(ch)) { - while (++pos < source.length && flowPatterns.identifierPart.test(source[pos])) {} - } else if (flowPatterns.digit.test(ch)) { - const number = flowPatterns.number.exec(source.slice(pos)); - pos += number[0].length; - if (flowPatterns.legacyNumber.test(number[0])) failFlow(context, 'unsupported-number', start, pos); - } else { - const operator = flowPatterns.operator.exec(source.slice(pos)); - if (operator) pos += operator[0].length; - else if ('{}()[];:,.?=+-!*%<>'.includes(ch)) pos++; - else failFlow(context, 'unsupported-token', start, start + 1); - } - context.tokens.push({ text: source.slice(start, pos), start, end: pos }); - if (context.tokens.length > flowLimits.tokens) failFlow(context, 'token-limit', start, pos); - } -} - -/** @param {FlowContext} context @param {string} text */ -function takeFlow (context, text) { - const token = context.tokens[context.pos]; - if (!token || token.text !== text) return false; - context.pos++; - return true; -} - -/** @param {FlowContext} context @param {string} text */ -function expectFlow (context, text) { - if (!takeFlow(context, text)) failFlow(context, 'unsupported-syntax'); -} - -/** @param {FlowContext} context */ -function flowSemicolon (context) { - if (takeFlow(context, ';') || context.pos === context.tokens.length || - context.tokens[context.pos].text === '}') return; - const previous = context.tokens[context.pos - 1]; - if (!flowPatterns.lineBreak.test(context.source.slice(previous.end, context.tokens[context.pos].start))) { - failFlow(context, 'unsupported-syntax'); - } -} - -/** @param {FlowContext} context @returns {FlowStatement} */ -function readFlowStatement (context) { - stepFlow(context); - if (++context.depth > flowLimits.depth) failFlow(context, 'depth-limit'); - const token = context.tokens[context.pos++]; - if (!token) failFlow(context, 'unsupported-syntax'); - const start = token.start; - let result; - if (token.text === ';') result = { type: 'empty', start, end: token.end }; - else if (token.text === '{') { - const body = []; - while (!context.tokens[context.pos] || context.tokens[context.pos].text !== '}') { - body.push(readFlowStatement(context)); - } - expectFlow(context, '}'); - result = { type: 'block', body, start, end: context.tokens[context.pos - 1].end }; - } else if (token.text === 'if') { - expectFlow(context, '('); - const test = readFlowExpression(context); - expectFlow(context, ')'); - const consequent = readFlowStatement(context); - const alternate = takeFlow(context, 'else') ? readFlowStatement(context) : undefined; - result = { type: 'if', test, consequent, alternate, start, end: alternate ? alternate.end : consequent.end }; - } else { - context.pos--; - const expression = readFlowExpression(context); - flowSemicolon(context); - result = { type: 'expression', expression, start, end: context.tokens[context.pos - 1].end }; - } - context.depth--; - return result; -} - -/** @param {FlowContext} context @param {number} [minimum] @returns {FlowExpression} */ -function readFlowExpression (context, minimum = 1) { - stepFlow(context); - if (++context.depth > flowLimits.depth) failFlow(context, 'depth-limit'); - const token = context.tokens[context.pos++]; - if (!token) failFlow(context, 'unsupported-expression'); - let left; - if (token.text === '(') { - left = readFlowExpression(context); - expectFlow(context, ')'); - } else if (token.text === '{') { - const entries = []; - while (!takeFlow(context, '}')) { - if (takeFlow(context, '...')) failFlow(context, 'unsupported-object'); - else { - const key = context.tokens[context.pos++]; - if (!key) failFlow(context, 'unsupported-object'); - const name = flowPatterns.numericStart.test(key.text) ? String(Number(key.text)) : - key.text[0] === '"' || key.text[0] === "'" ? context.decode(key.text) : key.text; - if (name === undefined || name === '__proto__') failFlow(context, 'unsupported-object', key.start, key.end); - const value = takeFlow(context, ':') ? readFlowExpression(context, 2) : - { type: 'identifier', name, start: key.start, end: key.end }; - entries.push({ key: name, value }); - } - if (!takeFlow(context, ',')) { expectFlow(context, '}'); break; } - } - left = { type: 'object', entries, start: token.start, end: context.tokens[context.pos - 1].end }; - } else if (['!', '+', '-', 'void', 'typeof'].includes(token.text)) { - const argument = readFlowExpression(context, 10); - left = { type: 'unary', operator: token.text, argument, start: token.start, end: argument.end }; - } else if (token.text[0] === '"' || token.text[0] === "'" || flowPatterns.numericStart.test(token.text) || - ['true', 'false', 'null'].includes(token.text)) { - const value = token.text[0] === '"' || token.text[0] === "'" ? context.decode(token.text) : - token.text === 'true' ? true : token.text === 'false' ? false : token.text === 'null' ? null : Number(token.text); - if (value === undefined) failFlow(context, 'unsupported-string', token.start, token.end); - left = { type: 'literal', value, start: token.start, end: token.end }; - } else if (flowPatterns.identifier.test(token.text)) { - left = { type: 'identifier', name: token.text, start: token.start, end: token.end }; - } else failFlow(context, 'unsupported-expression', token.start, token.end); - - while (context.pos < context.tokens.length) { - stepFlow(context); - const next = context.tokens[context.pos]; - if (next.text === '.' || next.text === '[') { - context.pos++; - let key; - if (next.text === '.') { - const property = context.tokens[context.pos++]; - if (!property || !flowPatterns.identifier.test(property.text)) failFlow(context, 'unsupported-member'); - key = { type: 'literal', value: property.text, start: property.start, end: property.end }; - } else { key = readFlowExpression(context); expectFlow(context, ']'); } - left = { type: 'member', object: left, key, start: left.start, end: context.tokens[context.pos - 1].end }; - continue; - } - if (next.text === '(') { - context.pos++; - const args = []; - if (!takeFlow(context, ')')) { - do { args.push(readFlowExpression(context, 2)); } while (takeFlow(context, ',')); - expectFlow(context, ')'); - } - left = { type: 'call', callee: left, args, start: left.start, end: context.tokens[context.pos - 1].end }; - continue; - } - const power = flowPowers.get(next.text); - if (power === undefined || power < minimum) break; - context.pos++; - if (next.text === '?') { - const consequent = readFlowExpression(context, 2); - expectFlow(context, ':'); - const alternate = readFlowExpression(context, 2); - left = { type: 'conditional', test: left, consequent, alternate, start: left.start, end: alternate.end }; - } else { - const right = readFlowExpression(context, power === 2 ? power : power + 1); - left = { type: 'binary', operator: next.text, left, right, start: left.start, end: right.end }; - } - } - context.depth--; - return left; -} - -/** @param {FlowContext} context @param {FlowStatement[]} body */ -function validateFlowDepth (context, body) { - /** @type {{ node: FlowStatement | FlowExpression, depth: number }[]} */ - const pending = []; - for (const node of body) pending.push({ node, depth: 1 }); - while (pending.length) { - const frame = pending.pop(); - const node = frame.node; - stepFlow(context); - if (frame.depth > flowLimits.depth) failFlow(context, 'depth-limit', node.start, node.end); - const depth = frame.depth + 1; - if (node.type === 'block') { - for (const child of node.body) pending.push({ node: child, depth }); - } else if (node.type === 'if' || node.type === 'conditional') { - pending.push({ node: node.test, depth }, { node: node.consequent, depth }); - if (node.alternate) pending.push({ node: node.alternate, depth }); - } else if (node.type === 'expression') pending.push({ node: node.expression, depth }); - else if (node.type === 'member') { - pending.push({ node: node.object, depth }, { node: node.key, depth }); - } else if (node.type === 'call') { - pending.push({ node: node.callee, depth }); - for (const argument of node.args) pending.push({ node: argument, depth }); - } else if (node.type === 'unary') pending.push({ node: node.argument, depth }); - else if (node.type === 'binary') { - pending.push({ node: node.left, depth }, { node: node.right, depth }); - } else if (node.type === 'object') { - for (const entry of node.entries) pending.push({ node: entry.value, depth }); - } - } -} - - -/** @param {FlowContext} context @param {FlowStatement[]} body @param {FlowState[]} states */ -function executeFlowBody (context, body, states) { - for (const statement of body) { - const next = []; - for (const state of states) next.push(...executeFlowStatement(context, statement, state)); - states = next; - if (states.length > flowLimits.outcomes) failFlow(context, 'outcome-limit'); - } - return states; -} - -/** @param {FlowContext} context @param {FlowStatement} statement @param {FlowState} state */ -function executeFlowStatement (context, statement, state) { - stepFlow(context); - if (statement.type === 'empty') return [state]; - if (statement.type === 'block') return executeFlowBody(context, statement.body, [state]); - const results = []; - if (statement.type === 'expression') { - for (const result of evaluateFlow(context, statement.expression, state)) results.push(result.state); - } else { - for (const test of evaluateFlow(context, statement.test, state)) { - for (const branch of branchFlow(context, test, statement.test, false)) { - const arm = branch.active ? statement.consequent : statement.alternate; - results.push(...(arm ? executeFlowStatement(context, arm, branch.state) : [branch.state])); - } - } - } - return results; -} - -/** @param {FlowExpression} node */ -function isFlowReplacement (node) { - return node.type === 'member' && node.object.type === 'identifier' && node.object.name === 'module' && - node.key.type === 'literal' && node.key.value === 'exports'; -} - -/** @param {FlowContext} context @param {FlowEvaluation} result @param {FlowExpression} test - * @param {boolean} nullish */ -function branchFlow (context, result, test, nullish) { - const value = result.value; - if (value.kind === 'literal' || value.kind === 'object') { - const active = value.kind === 'object' ? !nullish : nullish ? value.value == null : !!value.value; - return [{ state: result.state, active }]; - } - if (++context.branches >= flowLimits.outcomes) failFlow(context, 'outcome-limit', test.start, test.end); - return [true, false].map(/** @param {boolean} active */ active => ({ active, state: { - value: result.state.value, - conditions: result.state.conditions.concat({ start: test.start, end: test.end, - when: nullish ? active ? 'nullish' : 'non-nullish' : active ? 'truthy' : 'falsy' }) - } })); -} - -/** @param {FlowContext} context @param {FlowExpression} node @param {FlowState} state - * @returns {FlowEvaluation[]} */ -function evaluateFlow (context, node, state) { - stepFlow(context); - if (node.type === 'literal') return [{ state, value: { kind: 'literal', value: node.value } }]; - if (node.type === 'identifier' || node.type === 'member') { - if (node.type === 'identifier' && - ['module', 'exports', 'require', 'function', 'return', 'throw'].includes(node.name)) - failFlow(context, 'unsupported-reference', node.start, node.end); - if (isFlowReplacement(node)) failFlow(context, 'unsupported-reference', node.start, node.end); - state.value = { kind: 'unknown', reason: 'opaque-read-effects' }; - return [{ state, value: state.value }]; - } - if (node.type === 'call') { - if (node.callee.type !== 'identifier' || node.callee.name !== 'require' || node.args.length !== 1 || - node.args[0].type !== 'literal' || typeof node.args[0].value !== 'string') - failFlow(context, 'unsupported-call', node.start, node.end); - state.value = { kind: 'unknown', reason: 'dependency-effects' }; - return [{ state, value: { kind: 'module', specifier: node.args[0].value } }]; - } - if (node.type === 'object') { - let results = [{ state, value: { kind: 'object', properties: [] } }]; - for (const entry of node.entries) { - const next = []; - for (const result of results) { - for (const value of evaluateFlow(context, entry.value, result.state)) { - const properties = result.value.properties.slice(); - if (!properties.includes(entry.key)) properties.push(entry.key); - next.push({ state: value.state, value: { kind: 'object', properties } }); - } - } - results = next; - } - return results; - } - if (node.type === 'conditional') { - const results = []; - for (const test of evaluateFlow(context, node.test, state)) { - for (const branch of branchFlow(context, test, node.test, false)) { - results.push(...evaluateFlow(context, branch.active ? node.consequent : node.alternate, branch.state)); - } - } - return results; - } - if (node.type === 'unary') { - const results = []; - for (const result of evaluateFlow(context, node.argument, state)) { - const primitive = result.value.kind === 'literal'; - const value = primitive ? result.value.value : undefined; - if (!primitive && (node.operator === '+' || node.operator === '-')) - failFlow(context, 'unsupported-coercion', node.start, node.end); - const computed = node.operator === 'void' ? undefined : node.operator === 'typeof' ? - primitive ? typeof value : undefined : node.operator === '!' ? !value : node.operator === '+' ? +value : -value; - results.push({ state: result.state, value: !primitive && node.operator !== 'void' ? - { kind: 'unknown', reason: 'unknown-condition' } : { kind: 'literal', value: computed } }); - } - return results; - } - const assignment = ['=', '&&=', '||=', '??='].includes(node.operator); - if (assignment && !isFlowReplacement(node.left)) failFlow(context, 'unsupported-assignment', node.start, node.end); - const logical = ['&&', '||', '??', '&&=', '||=', '??='].includes(node.operator); - const leftResults = assignment ? [{ state, value: state.value }] : evaluateFlow(context, node.left, state); - const results = []; - for (const left of leftResults) { - if (logical) { - if (assignment && left.value.kind !== 'literal' && left.value.kind !== 'object') - failFlow(context, 'unknown-logical-assignment', node.start, node.end); - for (const branch of branchFlow(context, left, node.left, node.operator.startsWith('??'))) { - const active = node.operator.startsWith('||') ? !branch.active : branch.active; - if (!active) results.push({ state: branch.state, value: left.value }); - else for (const right of evaluateFlow(context, node.right, branch.state)) { - if (assignment) right.state.value = right.value; - results.push(right); - } - } - } else { - for (const right of evaluateFlow(context, node.right, left.state)) { - if (assignment) right.state.value = right.value; - if (assignment || node.operator === ',') results.push(right); - else { - if (left.value.kind !== 'literal' || right.value.kind !== 'literal') { - failFlow(context, 'unsupported-comparison', node.start, node.end); - } - const value = node.operator === '===' ? left.value.value === right.value.value : - node.operator === '!==' ? left.value.value !== right.value.value : undefined; - if (value === undefined) failFlow(context, 'unsupported-operator', node.start, node.end); - results.push({ state: right.state, value: { kind: 'literal', value } }); - } - } - } - } - return results; -} - -module.exports = analyzeExports; diff --git a/src/lexer.c b/src/lexer.c index 68c9ed9..319df18 100755 --- a/src/lexer.c +++ b/src/lexer.c @@ -9,6 +9,7 @@ const uint16_t* EMPTY_CHAR = &__empty_char; enum TokenType { ClassToken = 1, ExportObjectToken = 2 }; const uint16_t* source; + bool lastSlashWasDivision; uint16_t templateStackDepth; uint16_t openTokenDepth; @@ -35,6 +36,10 @@ uint32_t error = 0; uint32_t sourceLen; uint32_t moduleExportCount; +#ifdef __wasm__ +#include "reexport-analysis.h" +#endif + uint16_t templateStack_[STACK_DEPTH]; uint16_t* openTokenPosStack_[STACK_DEPTH]; uint8_t openTokenTypeStack[STACK_DEPTH]; diff --git a/src/lexer.js b/src/lexer.js index a6b4ec7..c563b39 100755 --- a/src/lexer.js +++ b/src/lexer.js @@ -15,10 +15,10 @@ export function parse (source, name = '@', options) { const flags = (options && options.allowMinifiedEnumerable === true ? 1 : 0) | (mode === 'extended' ? 2 : 0); const baseline = options === undefined ? undefined : options.baseline; - if (baseline === 'flow-v1') { - const report = analyzeExports(source, 'flow-v1', undefined, decode); - if (report !== undefined) return report; - } + if (baseline !== undefined && typeof baseline !== 'string') + throw new TypeError('Detection baseline must be a string'); + if (baseline !== undefined && baseline !== 'legacy' && baseline !== 'flow-v1') + throw new RangeError('Unknown detection baseline: ' + baseline); if (!wasm) initSync(); @@ -39,7 +39,8 @@ export function parse (source, name = '@', options) { else (isLE ? copyLE : copyBE)(source, new Uint16Array(memory, addr, len)); - const status = wasm.parseCJSWithOptions(addr, len, 0, 0, 0, 0, flags); + const status = baseline === 'legacy' ? wasm.parseCJSWithOptions(addr, len, 0, 0, 0, 0, flags) : + wasm.parseCJSGrouped(addr, len, flags); const err_code = status & 255; if (err_code) { @@ -51,27 +52,52 @@ export function parse (source, name = '@', options) { } let exports = new Set(), reexports = new Set(), unsafeGetters; + const groups = status & 1024 ? [] : undefined; + let complete = !(status & 2048); while (wasm.rre()) { - const reexptStr = decode(source.slice(wasm.res(), wasm.ree())); + const start = wasm.res(); + if (start === -1) { + reexports = new Set(); + groups.push(reexports); + continue; + } + const reexptStr = decode(source.slice(start, wasm.ree())); if (reexptStr !== undefined) reexports.add(reexptStr); + else if (reexptStr === undefined) + complete = false; } while (wasm.ru()) { if (!unsafeGetters) unsafeGetters = new Set(); unsafeGetters.add(decode(source.slice(wasm.us(), wasm.ue()))); } while (wasm.re()) { - let exptStr = decode(source.slice(wasm.es(), wasm.ee())); + const text = source.slice(wasm.es(), wasm.ee()); + let exptStr = status & 1024 && text.charCodeAt(0) >= 48 && text.charCodeAt(0) <= 57 ? + String(Number(text)) : decode(text); if (exptStr !== undefined && (!unsafeGetters || !unsafeGetters.has(exptStr))) exports.add(exptStr); + else if (exptStr === undefined) + complete = false; } - const result = { exports: [...exports], reexports: [...reexports] }; - if (baseline === 'legacy') return result; - const replacements = status >>> 8; - return replacements < 2 && baseline === undefined ? result : - analyzeExports(source, baseline, replacements, decode, result); + if (baseline === 'legacy') return { exports: [...exports], reexports: [...reexports] }; + if (!(status & 1024)) return { exports: [...exports], reexports: reexports.size ? [[...reexports]] : [] }; + if (groups.length === 1) + return { exports: [...exports], reexports: reexports.size ? [[...reexports]] : [], complete }; + const alternatives = []; + const seen = new Set(); + let hasDependencies = false; + for (const group of groups) { + const values = [...group]; + const key = JSON.stringify(values); + if (seen.has(key)) continue; + seen.add(key); + alternatives.push(values); + if (values.length) hasDependencies = true; + } + return { exports: [...exports], reexports: hasDependencies ? alternatives : [], complete }; } function decode (str) { diff --git a/src/reexport-analysis.h b/src/reexport-analysis.h new file mode 100644 index 0000000..21296a6 --- /dev/null +++ b/src/reexport-analysis.h @@ -0,0 +1,763 @@ +#define FLOW_TOKENS 8192 +#define FLOW_DEPTH 64 +#define FLOW_OUTCOMES 32 +#define FLOW_STEPS 65536 +#define FLOW_ANALYZED 1024 +#define FLOW_INCOMPLETE 2048 + +enum FlowKind { + FT_IDENTIFIER = 256, FT_STRING, FT_NUMBER, FT_EQUAL, FT_NOT_EQUAL, + FT_AND, FT_OR, FT_NULLISH, FT_AND_ASSIGN, FT_OR_ASSIGN, FT_NULLISH_ASSIGN, FT_SPREAD, + FN_NUMBER, FN_STRING, FN_BOOLEAN, FN_NULL, FN_UNDEFINED, FN_IDENTIFIER, + FN_MEMBER, FN_CALL, FN_UNARY, FN_BINARY, FN_CONDITIONAL, FN_OBJECT, FN_PROPERTY, FN_SPREAD, + FN_EXPRESSION, FN_BLOCK, FN_IF, FN_EMPTY +}; + +enum FlowValueKind { + FV_UNKNOWN, FV_NUMBER, FV_STRING, FV_BOOLEAN, FV_NULL, FV_UNDEFINED, + FV_MODULE, FV_OBJECT, FV_OPAQUE_OBJECT, FV_FUNCTION, FV_UNKNOWN_STRING, + FV_TYPE_NUMBER, FV_TYPE_STRING, FV_TYPE_BOOLEAN, FV_TYPE_OBJECT, FV_TYPE_FUNCTION, FV_TYPE_UNDEFINED +}; + +typedef struct { + uint32_t start, end; + uint16_t kind; +} FlowToken; + +typedef struct { + uint16_t kind, op, left, right, third, next; + uint32_t start, end; + double number; +} FlowNode; + +typedef struct { + uint16_t kind, node; + int8_t truth; + double number; +} FlowValue; + +typedef struct { FlowValue root, value; } FlowEvaluation; +typedef struct { uint32_t count; FlowEvaluation values[FLOW_OUTCOMES]; } FlowResult; + +typedef struct { + FlowToken* tokens; + FlowNode* nodes; + FlowResult* results; + uint32_t tokenCount, nodeCount, cursor, depth, steps; + bool failed; +} FlowContext; + +static FlowContext flow; + +static bool flowStep () { + if (++flow.steps > FLOW_STEPS) flow.failed = true; + return !flow.failed; +} + +static bool flowReserve (uint32_t bytes) { + uint64_t required = (uint64_t)(uintptr_t)analysis_head + bytes; + if (required > UINT32_MAX) return false; + uint64_t capacity = (uint64_t)__builtin_wasm_memory_size(0) * 65536; + return required <= capacity || + __builtin_wasm_memory_grow(0, (required - capacity + 65535) / 65536) != (size_t)-1; +} + +static bool flowLineBreak (uint16_t ch) { + return ch == '\r' || ch == '\n' || ch == 0x2028 || ch == 0x2029; +} + +static bool flowSpace (uint16_t ch) { + return ch == 32 || ch >= 9 && ch <= 13 || ch == 0xA0 || ch == 0x1680 || + ch >= 0x2000 && ch <= 0x200A || ch == 0x2028 || ch == 0x2029 || + ch == 0x202F || ch == 0x205F || ch == 0x3000 || ch == 0xFEFF; +} + +static bool flowIdentifier (uint16_t ch) { + return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch == '_' || ch == '$' || + ch > 127 && !flowSpace(ch); +} + +static bool flowText (uint32_t start, uint32_t end, const char* text) { + while (start < end && *text && source[start] == (uint8_t)*text) { start++; text++; } + return start == end && !*text; +} + +static bool flowName (uint16_t node, const char* name) { + return flow.nodes[node].kind == FN_IDENTIFIER && + flowText(flow.nodes[node].start, flow.nodes[node].end, name); +} + +static int flowDigit (uint16_t ch) { + if (ch >= '0' && ch <= '9') return ch - '0'; + if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10; + if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10; + return -1; +} + +static int32_t flowStringNext (FlowNode* node, uint32_t* cursor, uint16_t* pending) { + if (*pending) { uint16_t ch = *pending; *pending = 0; return ch; } + while (*cursor < node->end - 1 && flowStep()) { + uint16_t ch = source[(*cursor)++]; + if (ch != '\\') return ch; + ch = source[(*cursor)++]; + if (flowLineBreak(ch)) { + if (ch == '\r' && source[*cursor] == '\n') (*cursor)++; + continue; + } + switch (ch) { + case 'b': return '\b'; + case 'f': return '\f'; + case 'n': return '\n'; + case 'r': return '\r'; + case 't': return '\t'; + case 'v': return '\v'; + case 'x': + case 'u': { + uint32_t code = 0, digits = ch == 'x' ? 2 : 4; + bool brace = ch == 'u' && source[*cursor] == '{'; + if (brace) { (*cursor)++; digits = 6; } + uint32_t read = 0; + while (read < digits && *cursor < node->end - 1) { + int digit = flowDigit(source[*cursor]); + if (digit < 0) break; + code = code * 16 + digit; + (*cursor)++; read++; + } + if (brace ? !read || source[(*cursor)++] != '}' : read != digits) { + flow.failed = true; return -1; + } + if (code > 0x10FFFF) { flow.failed = true; return -1; } + if (code > 0xFFFF) { + *pending = 0xDC00 + ((code - 0x10000) & 1023); + return 0xD800 + ((code - 0x10000) >> 10); + } + return code; + } + default: { + if (ch >= '0' && ch <= '7') { + uint32_t code = ch - '0', count = ch <= '3' ? 2 : 1; + while (count-- && source[*cursor] >= '0' && source[*cursor] <= '7') + code = code * 8 + source[(*cursor)++] - '0'; + return code; + } + return ch; + } + } + } + return -1; +} + +static bool flowStringText (uint16_t index, const char* text) { + FlowNode* node = &flow.nodes[index]; + uint32_t cursor = node->start + 1; + uint16_t pending = 0; + int32_t ch; + while ((ch = flowStringNext(node, &cursor, &pending)) >= 0) { + if (!*text || ch != (uint8_t)*text++) return false; + } + return !*text; +} + +static double flowNumber (FlowToken token) { + uint32_t cursor = token.start, radix = 10, fraction = 0; + int exponent = 0; + uint64_t value = 0; + bool point = false; + if (token.end - cursor > 128) { flow.failed = true; return 0; } + if (source[cursor] == '0' && cursor + 1 < token.end) { + uint16_t prefix = source[cursor + 1] | 32; + if (prefix == 'x' || prefix == 'b' || prefix == 'o') { + radix = prefix == 'x' ? 16 : prefix == 'b' ? 2 : 8; + cursor += 2; + } else if (source[cursor + 1] >= '0' && source[cursor + 1] <= '9') { + flow.failed = true; return 0; + } + } + while (cursor < token.end) { + uint16_t ch = source[cursor++]; + if (radix == 10 && ch == '.') { point = true; continue; } + if (radix == 10 && (ch == 'e' || ch == 'E')) { + bool negative = source[cursor] == '-'; + if (negative || source[cursor] == '+') cursor++; + while (cursor < token.end && exponent <= 128) exponent = exponent * 10 + source[cursor++] - '0'; + if (negative) exponent = -exponent; + break; + } + int digit = flowDigit(ch); + if (digit < 0 || digit >= radix || value > (9007199254740991ULL - digit) / radix) { + flow.failed = true; return 0; + } + value = value * radix + digit; + if (point) fraction++; + } + if (!value) return 0; + exponent -= fraction; + if (exponent < -15 || exponent > 15) { flow.failed = true; return 0; } + uint64_t scale = 1; + for (uint32_t i = 0; i < (uint32_t)(exponent < 0 ? -exponent : exponent); i++) scale *= 10; + if (exponent > 0 && value > 9007199254740991ULL / scale) { flow.failed = true; return 0; } + return exponent > 0 ? (double)(value * scale) : (double)value / scale; +} + +static void flowTokenize () { + uint32_t cursor = 0; + if (sourceLen > 1 && source[0] == '#' && source[1] == '!') { + while (cursor < sourceLen && !flowLineBreak(source[cursor]) && flowStep()) cursor++; + } + while (cursor < sourceLen && flowStep()) { + uint32_t start = cursor; + uint16_t ch = source[cursor++], kind = ch; + if (flowSpace(ch)) continue; + if (ch == '/' && cursor < sourceLen && source[cursor] == '/') { + while (cursor < sourceLen && !flowLineBreak(source[cursor]) && flowStep()) cursor++; + continue; + } + if (ch == '/' && cursor < sourceLen && source[cursor] == '*') { + cursor++; + while (cursor + 1 < sourceLen && !(source[cursor] == '*' && source[cursor + 1] == '/') && flowStep()) + cursor++; + if (cursor + 1 >= sourceLen) { flow.failed = true; return; } + cursor += 2; continue; + } + if (ch == '\'' || ch == '"') { + kind = FT_STRING; + while (cursor < sourceLen && source[cursor] != ch && flowStep()) { + if (source[cursor] == '\\') cursor++; + cursor++; + } + if (cursor >= sourceLen) { flow.failed = true; return; } + cursor++; + } else if (flowIdentifier(ch)) { + kind = FT_IDENTIFIER; + while (cursor < sourceLen && (flowIdentifier(source[cursor]) || + source[cursor] >= '0' && source[cursor] <= '9') && flowStep()) cursor++; + } else if (ch >= '0' && ch <= '9') { + kind = FT_NUMBER; + bool radix = ch == '0' && cursor < sourceLen && + ((source[cursor] | 32) == 'x' || (source[cursor] | 32) == 'b' || (source[cursor] | 32) == 'o'); + if (radix) cursor++; + while (cursor < sourceLen && flowStep()) { + ch = source[cursor]; + if (radix ? flowDigit(ch) >= 0 : ch >= '0' && ch <= '9' || ch == '.') { cursor++; continue; } + if (!radix && (ch == 'e' || ch == 'E')) { + cursor++; + if (source[cursor] == '+' || source[cursor] == '-') cursor++; + while (cursor < sourceLen && source[cursor] >= '0' && source[cursor] <= '9') cursor++; + } + break; + } + } else if ((ch == '+' || ch == '-') && cursor < sourceLen && source[cursor] == ch) { + flow.failed = true; return; + } else if ((ch == '=' || ch == '!') && cursor + 1 < sourceLen && + source[cursor] == '=' && source[cursor + 1] == '=') { + kind = ch == '=' ? FT_EQUAL : FT_NOT_EQUAL; cursor += 2; + } else if ((ch == '&' || ch == '|' || ch == '?') && cursor < sourceLen && source[cursor] == ch) { + cursor++; + bool assign = cursor < sourceLen && source[cursor] == '='; + kind = ch == '&' ? (assign ? FT_AND_ASSIGN : FT_AND) : + ch == '|' ? (assign ? FT_OR_ASSIGN : FT_OR) : (assign ? FT_NULLISH_ASSIGN : FT_NULLISH); + if (assign) cursor++; + } else if (ch == '.' && cursor + 1 < sourceLen && source[cursor] == '.' && source[cursor + 1] == '.') { + kind = FT_SPREAD; cursor += 2; + } + if (flow.tokenCount == FLOW_TOKENS) { flow.failed = true; return; } + flow.tokens[flow.tokenCount++] = (FlowToken){ start, cursor, kind }; + } +} + +static uint16_t flowPeek () { + return flow.cursor < flow.tokenCount ? flow.tokens[flow.cursor].kind : 0; +} + +static bool flowTake (uint16_t kind) { + if (flowPeek() != kind) return false; + flow.cursor++; return true; +} + +static void flowExpect (uint16_t kind) { + if (!flowTake(kind)) flow.failed = true; +} + +static uint16_t flowNode (uint16_t kind, uint16_t left, uint16_t right, FlowToken token) { + if (flow.nodeCount == FLOW_TOKENS || !flowStep()) { flow.failed = true; return 0; } + uint16_t index = ++flow.nodeCount; + FlowNode* node = &flow.nodes[index]; + node->kind = kind; node->op = 0; + node->left = left; node->right = right; node->third = 0; node->next = 0; + node->start = token.start; node->end = token.end; node->number = 0; + return index; +} + +static uint16_t flowLiteral (FlowToken token) { + uint16_t kind = token.kind == FT_STRING ? FN_STRING : token.kind == FT_NUMBER ? FN_NUMBER : FN_IDENTIFIER; + if (token.kind == FT_IDENTIFIER) { + if (flowText(token.start, token.end, "true") || flowText(token.start, token.end, "false")) kind = FN_BOOLEAN; + else if (flowText(token.start, token.end, "null")) kind = FN_NULL; + else if (flowText(token.start, token.end, "undefined")) kind = FN_UNDEFINED; + } + uint16_t node = flowNode(kind, 0, 0, token); + if (kind == FN_NUMBER) flow.nodes[node].number = flowNumber(token); + if (kind == FN_BOOLEAN) flow.nodes[node].number = source[token.start] == 't'; + return node; +} + +static uint32_t flowPower (uint16_t kind) { + switch (kind) { + case ',': return 1; + case '=': case FT_AND_ASSIGN: case FT_OR_ASSIGN: case FT_NULLISH_ASSIGN: return 2; + case '?': return 3; + case FT_OR: case FT_NULLISH: return 4; + case FT_AND: return 5; + case FT_EQUAL: case FT_NOT_EQUAL: return 6; + default: return 0; + } +} + +static uint16_t flowExpression (uint32_t minimum); + +static uint16_t flowObject (FlowToken token) { + uint16_t object = flowNode(FN_OBJECT, 0, 0, token), tail = 0; + while (flowPeek() != '}' && !flow.failed) { + if (!flowPeek()) { flow.failed = true; break; } + uint16_t entry, key = 0; + if (flowTake(FT_SPREAD)) { + entry = flowNode(FN_SPREAD, flowExpression(2), 0, token); + } else { + FlowToken name = flow.tokens[flow.cursor++]; + if (name.kind != FT_IDENTIFIER && name.kind != FT_STRING && name.kind != FT_NUMBER) { + flow.failed = true; break; + } + key = name.kind == FT_IDENTIFIER ? flowNode(FN_IDENTIFIER, 0, 0, name) : flowLiteral(name); + uint16_t value; + if (flowTake(':')) { + if (flowName(key, "__proto__") || name.kind == FT_STRING && flowStringText(key, "__proto__")) + flow.failed = true; + value = flowExpression(2); + } else { + if (name.kind != FT_IDENTIFIER) flow.failed = true; + value = flowLiteral(name); + } + entry = flowNode(FN_PROPERTY, key, value, name); + } + if (tail) flow.nodes[tail].next = entry; + else flow.nodes[object].left = entry; + tail = entry; + if (!flowTake(',')) break; + } + flowExpect('}'); + return object; +} + +static uint16_t flowExpression (uint32_t minimum) { + if (++flow.depth > FLOW_DEPTH || !flowPeek() || !flowStep()) { flow.failed = true; flow.depth--; return 0; } + FlowToken token = flow.tokens[flow.cursor++]; + uint16_t left; + if (token.kind == '(') { + left = flowExpression(1); flowExpect(')'); + } else if (token.kind == '{') left = flowObject(token); + else if (token.kind == '!' || token.kind == '+' || token.kind == '-' || + token.kind == FT_IDENTIFIER && (flowText(token.start, token.end, "void") || + flowText(token.start, token.end, "typeof"))) { + left = flowNode(FN_UNARY, flowExpression(12), 0, token); + flow.nodes[left].op = token.kind == FT_IDENTIFIER ? (source[token.start] == 'v' ? 'v' : 't') : token.kind; + } else if (token.kind == FT_IDENTIFIER || token.kind == FT_STRING || token.kind == FT_NUMBER) + left = flowLiteral(token); + else { flow.failed = true; left = 0; } + while (!flow.failed && flowStep()) { + if (flowPeek() == '.' || flowPeek() == '[') { + uint16_t op = flow.tokens[flow.cursor++].kind, key; + if (op == '.') { + if (flowPeek() != FT_IDENTIFIER) { flow.failed = true; break; } + key = flowNode(FN_IDENTIFIER, 0, 0, flow.tokens[flow.cursor++]); + } else { key = flowExpression(1); flowExpect(']'); } + left = flowNode(FN_MEMBER, left, key, token); flow.nodes[left].op = op; + } else if (flowTake('(')) { + uint16_t call = flowNode(FN_CALL, left, 0, token), tail = 0; + while (flowPeek() != ')' && !flow.failed) { + uint16_t argument = flowExpression(2); + if (tail) flow.nodes[tail].next = argument; + else flow.nodes[call].right = argument; + tail = argument; + if (!flowTake(',')) break; + } + flowExpect(')'); left = call; + } else { + uint32_t power = flowPower(flowPeek()); + if (!power || power < minimum) break; + FlowToken operator = flow.tokens[flow.cursor++]; + if (operator.kind == '?') { + uint16_t consequent = flowExpression(2); + flowExpect(':'); + uint16_t alternate = flowExpression(2); + left = flowNode(FN_CONDITIONAL, left, consequent, operator); flow.nodes[left].third = alternate; + } else { + uint16_t right = flowExpression(power == 2 ? power : power + 1); + left = flowNode(FN_BINARY, left, right, operator); flow.nodes[left].op = operator.kind; + } + } + } + flow.depth--; return left; +} + +static uint16_t flowStatement () { + if (++flow.depth > FLOW_DEPTH || !flowPeek() || !flowStep()) { flow.failed = true; flow.depth--; return 0; } + FlowToken token = flow.tokens[flow.cursor++]; + if (token.kind == FT_IDENTIFIER) { + static const char* const unsupported[] = { + "return", "throw", "break", "continue", "function", "class", "var", "let", "const", + "for", "while", "do", "switch", "case", "try", "catch", "finally", "with", "debugger", "in", "instanceof" + }; + for (uint32_t i = 0; i < sizeof(unsupported) / sizeof(unsupported[0]); i++) + if (flowText(token.start, token.end, unsupported[i])) { flow.failed = true; flow.depth--; return 0; } + } + uint16_t node; + if (token.kind == ';') node = flowNode(FN_EMPTY, 0, 0, token); + else if (token.kind == '{') { + node = flowNode(FN_BLOCK, 0, 0, token); + uint16_t tail = 0; + while (flowPeek() != '}' && !flow.failed) { + uint16_t statement = flowStatement(); + if (tail) flow.nodes[tail].next = statement; + else flow.nodes[node].left = statement; + tail = statement; + } + flowExpect('}'); + } else if (token.kind == FT_IDENTIFIER && flowText(token.start, token.end, "if")) { + flowExpect('('); uint16_t condition = flowExpression(1); flowExpect(')'); + node = flowNode(FN_IF, condition, flowStatement(), token); + if (flowPeek() == FT_IDENTIFIER && + flowText(flow.tokens[flow.cursor].start, flow.tokens[flow.cursor].end, "else")) { + flow.cursor++; flow.nodes[node].third = flowStatement(); + } + } else { + flow.cursor--; + node = flowNode(FN_EXPRESSION, flowExpression(1), 0, token); + if (!flowTake(';') && flowPeek() && flowPeek() != '}') { + uint32_t start = flow.tokens[flow.cursor - 1].end, next = flow.tokens[flow.cursor].start; + while (start < next && !flowLineBreak(source[start])) start++; + if (start == next || flowPeek() == '+' || flowPeek() == '-') flow.failed = true; + } + } + flow.depth--; return node; +} + +static FlowValue flowValue (uint16_t kind, uint16_t node, int8_t truth, double number) { + return (FlowValue){ kind, node, truth, number }; +} + +static bool flowSame (FlowValue first, FlowValue second) { + return first.kind == second.kind && first.node == second.node && + first.truth == second.truth && first.number == second.number; +} + +static void flowAppend (FlowResult* result, FlowValue root, FlowValue value) { + for (uint32_t i = 0; i < result->count && flowStep(); i++) + if (flowSame(result->values[i].root, root) && flowSame(result->values[i].value, value)) return; + if (result->count == FLOW_OUTCOMES) { flow.failed = true; return; } + result->values[result->count++] = (FlowEvaluation){ root, value }; +} + +static void flowMerge (FlowResult* target, FlowResult* values) { + for (uint32_t i = 0; i < values->count && flowStep(); i++) + flowAppend(target, values->values[i].root, values->values[i].value); +} + +static bool flowRoot (uint16_t index) { + FlowNode* node = &flow.nodes[index]; + return node->kind == FN_MEMBER && flowName(node->left, "module") && + (node->op == '.' ? flowName(node->right, "exports") : + flow.nodes[node->right].kind == FN_STRING && flowStringText(node->right, "exports")); +} + +static bool flowRequire (uint16_t index) { + FlowNode* node = &flow.nodes[index]; + return node->kind == FN_CALL && flowName(node->left, "require") && node->right && + !flow.nodes[node->right].next && flow.nodes[node->right].kind == FN_STRING; +} + +static bool flowStringsEqual (uint16_t first, uint16_t second) { + uint32_t firstCursor = flow.nodes[first].start + 1, secondCursor = flow.nodes[second].start + 1; + uint16_t firstPending = 0, secondPending = 0; + int32_t ch; + do { + ch = flowStringNext(&flow.nodes[first], &firstCursor, &firstPending); + if (ch != flowStringNext(&flow.nodes[second], &secondCursor, &secondPending)) return false; + } while (ch >= 0 && !flow.failed); + return true; +} + +static const char* flowTypeText (uint16_t kind) { + switch (kind) { + case FV_TYPE_NUMBER: return "number"; + case FV_TYPE_STRING: return "string"; + case FV_TYPE_BOOLEAN: return "boolean"; + case FV_TYPE_OBJECT: return "object"; + case FV_TYPE_FUNCTION: return "function"; + case FV_TYPE_UNDEFINED: return "undefined"; + default: return NULL; + } +} + +static int8_t flowEqual (FlowValue first, FlowValue second) { + if (first.kind == FV_UNKNOWN || second.kind == FV_UNKNOWN || + first.kind == FV_MODULE || second.kind == FV_MODULE || + first.kind == FV_UNKNOWN_STRING || second.kind == FV_UNKNOWN_STRING) return -1; + if ((first.kind == FV_OPAQUE_OBJECT && (second.kind == FV_OBJECT || second.kind == FV_OPAQUE_OBJECT)) || + (second.kind == FV_OPAQUE_OBJECT && first.kind == FV_OBJECT)) return -1; + const char* firstType = flowTypeText(first.kind), *secondType = flowTypeText(second.kind); + if (firstType || secondType) { + if (firstType && secondType) return first.kind == second.kind; + FlowValue string = firstType ? second : first; + return string.kind == FV_STRING && flowStringText(string.node, firstType ? firstType : secondType); + } + if (first.kind != second.kind) return 0; + if (first.kind == FV_STRING) return flowStringsEqual(first.node, second.node); + if (first.kind == FV_NUMBER || first.kind == FV_BOOLEAN) { + if (first.kind == FV_BOOLEAN && (first.truth < 0 || second.truth < 0)) return -1; + return first.number == second.number; + } + if (first.kind == FV_OBJECT) return first.node == second.node; + if (first.kind == FV_FUNCTION) return 1; + if (first.kind == FV_OPAQUE_OBJECT) return -1; + return 1; +} + +static FlowValue flowTypeof (FlowValue value) { + uint16_t kind; + switch (value.kind) { + case FV_NUMBER: kind = FV_TYPE_NUMBER; break; + case FV_BOOLEAN: kind = FV_TYPE_BOOLEAN; break; + case FV_NULL: case FV_OBJECT: case FV_OPAQUE_OBJECT: kind = FV_TYPE_OBJECT; break; + case FV_FUNCTION: kind = FV_TYPE_FUNCTION; break; + case FV_UNDEFINED: kind = FV_TYPE_UNDEFINED; break; + case FV_UNKNOWN: case FV_MODULE: return flowValue(FV_UNKNOWN_STRING, 0, 1, 0); + default: kind = FV_TYPE_STRING; + } + return flowValue(kind, 0, 1, 0); +} + +static int8_t flowRoute (uint16_t op, FlowValue value) { + if (op == FT_NULLISH || op == FT_NULLISH_ASSIGN) + return value.kind == FV_UNKNOWN || value.kind == FV_MODULE ? -1 : + value.kind == FV_NULL || value.kind == FV_UNDEFINED; + return value.truth < 0 ? -1 : (op == FT_OR || op == FT_OR_ASSIGN ? !value.truth : value.truth); +} + +static void flowEvaluate (uint16_t index, FlowValue root, FlowResult* result, uint32_t depth); + +static void flowEvaluate (uint16_t index, FlowValue root, FlowResult* result, uint32_t depth) { + result->count = 0; + if (depth == FLOW_DEPTH || !flowStep()) { flow.failed = true; return; } + FlowNode* node = &flow.nodes[index]; + FlowResult* first = &flow.results[depth * 3], *second = first + 1; + FlowValue unknown = flowValue(FV_UNKNOWN, 0, -1, 0); + switch (node->kind) { + case FN_NUMBER: flowAppend(result, root, flowValue(FV_NUMBER, 0, node->number != 0, node->number)); return; + case FN_BOOLEAN: flowAppend(result, root, flowValue(FV_BOOLEAN, 0, node->number != 0, node->number)); return; + case FN_NULL: flowAppend(result, root, flowValue(FV_NULL, 0, 0, 0)); return; + case FN_UNDEFINED: flowAppend(result, root, flowValue(FV_UNDEFINED, 0, 0, 0)); return; + case FN_STRING: { + uint32_t cursor = node->start + 1; uint16_t pending = 0; + flowAppend(result, root, flowValue(FV_STRING, index, flowStringNext(node, &cursor, &pending) >= 0, 0)); return; + } + case FN_IDENTIFIER: { + if (flowName(index, "require")) flowAppend(result, root, flowValue(FV_FUNCTION, 0, 1, 0)); + else if (flowName(index, "module") || flowName(index, "exports")) + flowAppend(result, root, flowValue(FV_OPAQUE_OBJECT, 0, 1, 0)); + else flowAppend(result, unknown, unknown); + return; + } + case FN_MEMBER: + if (flowRoot(index)) flowAppend(result, root, root); + else flowAppend(result, unknown, unknown); + return; + case FN_CALL: + if (flowRequire(index)) flowAppend(result, unknown, flowValue(FV_MODULE, index, -1, 0)); + else { flow.failed = true; } + return; + case FN_OBJECT: { + first->count = 0; flowAppend(first, root, root); + for (uint16_t entry = node->left; entry && !flow.failed; entry = flow.nodes[entry].next) { + FlowNode* property = &flow.nodes[entry]; + uint16_t expression = property->kind == FN_SPREAD ? property->left : property->right; + if (property->kind == FN_SPREAD && !flowRequire(expression) && + flow.nodes[expression].kind != FN_OBJECT && + flow.nodes[expression].kind != FN_NULL && flow.nodes[expression].kind != FN_UNDEFINED) { + flow.failed = true; break; + } + second->count = 0; + for (uint32_t i = 0; i < first->count && !flow.failed; i++) { + FlowResult* child = &flow.results[depth * 3 + 2]; + flowEvaluate(expression, first->values[i].root, child, depth + 1); + for (uint32_t j = 0; j < child->count && !flow.failed; j++) + flowAppend(second, child->values[j].root, child->values[j].root); + } + FlowResult* swap = first; first = second; second = swap; + } + for (uint32_t i = 0; i < first->count && !flow.failed; i++) + flowAppend(result, first->values[i].root, flowValue(FV_OBJECT, index, 1, 0)); + return; + } + case FN_UNARY: + flowEvaluate(node->left, root, first, depth + 1); + for (uint32_t i = 0; i < first->count && !flow.failed; i++) { + FlowEvaluation item = first->values[i]; FlowValue value; + if (node->op == '!') value = flowValue(FV_BOOLEAN, 0, + item.value.truth < 0 ? -1 : !item.value.truth, item.value.truth < 0 ? 0 : !item.value.truth); + else if (node->op == 'v') value = flowValue(FV_UNDEFINED, 0, 0, 0); + else if (node->op == 't') value = flowTypeof(item.value); + else if (item.value.kind == FV_NUMBER) value = flowValue(FV_NUMBER, 0, + item.value.number != 0, node->op == '-' ? -item.value.number : item.value.number); + else { flow.failed = true; break; } + flowAppend(result, item.root, value); + } + return; + case FN_CONDITIONAL: + flowEvaluate(node->left, root, first, depth + 1); + for (uint32_t i = 0; i < first->count && !flow.failed; i++) { + FlowEvaluation item = first->values[i]; + if (item.value.truth != 0) { + flowEvaluate(node->right, item.root, second, depth + 1); flowMerge(result, second); + } + if (item.value.truth != 1) { + flowEvaluate(node->third, item.root, second, depth + 1); flowMerge(result, second); + } + } + return; + case FN_BINARY: { + bool assign = node->op == '=' || node->op == FT_AND_ASSIGN || + node->op == FT_OR_ASSIGN || node->op == FT_NULLISH_ASSIGN; + if (assign && !flowRoot(node->left)) { flow.failed = true; return; } + if (node->op == '=') { + flowEvaluate(node->right, root, first, depth + 1); + for (uint32_t i = 0; i < first->count && !flow.failed; i++) + flowAppend(result, first->values[i].value, first->values[i].value); + return; + } + if (assign) { first->count = 0; flowAppend(first, root, root); } + else flowEvaluate(node->left, root, first, depth + 1); + for (uint32_t i = 0; i < first->count && !flow.failed; i++) { + FlowEvaluation item = first->values[i]; + if (node->op == FT_AND || node->op == FT_OR || node->op == FT_NULLISH || assign) { + int8_t route = flowRoute(node->op, item.value); + if (route != 1) flowAppend(result, item.root, item.value); + if (route != 0) { + flowEvaluate(node->right, item.root, second, depth + 1); + for (uint32_t j = 0; j < second->count && !flow.failed; j++) + flowAppend(result, assign ? second->values[j].value : second->values[j].root, second->values[j].value); + } + } else { + flowEvaluate(node->right, item.root, second, depth + 1); + for (uint32_t j = 0; j < second->count && !flow.failed; j++) { + FlowEvaluation right = second->values[j]; + if (node->op == ',') flowAppend(result, right.root, right.value); + else { + int8_t equal = flowEqual(item.value, right.value); + if (equal >= 0 && node->op == FT_NOT_EQUAL) equal = !equal; + flowAppend(result, right.root, flowValue(FV_BOOLEAN, 0, equal, equal < 0 ? 0 : equal)); + } + } + } + } + return; + } + default: flow.failed = true; + } +} + +static void flowExecute (uint16_t index, FlowValue root, FlowResult* result, uint32_t depth) { + result->count = 0; + if (depth == FLOW_DEPTH || !flowStep()) { flow.failed = true; return; } + FlowNode* node = &flow.nodes[index]; + FlowResult* first = &flow.results[depth * 3], *second = first + 1, *child = first + 2; + if (node->kind == FN_EMPTY) { flowAppend(result, root, root); return; } + if (node->kind == FN_EXPRESSION) { + flowEvaluate(node->left, root, first, depth + 1); + for (uint32_t i = 0; i < first->count && !flow.failed; i++) + flowAppend(result, first->values[i].root, first->values[i].root); + } else if (node->kind == FN_BLOCK) { + first->count = 0; flowAppend(first, root, root); + for (uint16_t statement = node->left; statement && !flow.failed; statement = flow.nodes[statement].next) { + second->count = 0; + for (uint32_t i = 0; i < first->count && !flow.failed; i++) { + flowExecute(statement, first->values[i].root, child, depth + 1); flowMerge(second, child); + } + FlowResult* swap = first; first = second; second = swap; + } + flowMerge(result, first); + } else if (node->kind == FN_IF) { + flowEvaluate(node->left, root, first, depth + 1); + for (uint32_t i = 0; i < first->count && !flow.failed; i++) { + FlowEvaluation item = first->values[i]; + if (item.value.truth != 0) { + flowExecute(node->right, item.root, second, depth + 1); flowMerge(result, second); + } + if (item.value.truth != 1) { + if (node->third) { + flowExecute(node->third, item.root, second, depth + 1); flowMerge(result, second); + } else flowAppend(result, item.root, item.root); + } + } + } else flow.failed = true; +} + +static void flowEmitObject (uint16_t index, uint32_t depth) { + if (depth == FLOW_DEPTH) { flow.failed = true; return; } + for (uint16_t entry = flow.nodes[index].left; entry && flowStep(); entry = flow.nodes[entry].next) { + FlowNode* property = &flow.nodes[entry], *value = &flow.nodes[property->left]; + if (property->kind == FN_PROPERTY) _addExport(source + value->start, source + value->end); + else if (flowRequire(property->left)) { + FlowNode* specifier = &flow.nodes[value->right]; + _addReexport(source + specifier->start, source + specifier->end); + } else if (value->kind == FN_OBJECT) flowEmitObject(property->left, depth + 1); + } +} + +static void flowClear () { + first_export = export_read_head = export_write_head = NULL; + first_reexport = reexport_read_head = reexport_write_head = NULL; + first_unsafe_getter = unsafe_getter_read_head = unsafe_getter_write_head = NULL; +} + +uint32_t parseCJSGrouped (uint16_t* input, uint32_t length, uint32_t options) { + uint32_t status = parseCJSWithOptions(input, length, NULL, NULL, NULL, NULL, options); + if ((status & 255) || moduleExportCount < 2) return status; + flowClear(); + flow.tokenCount = 0; flow.nodeCount = 0; flow.cursor = 0; flow.depth = 0; flow.steps = 0; flow.failed = false; + uint64_t aligned = ((uint64_t)(uintptr_t)analysis_head + 7) & ~7ULL; + uint32_t bytes = sizeof(FlowToken) * FLOW_TOKENS + sizeof(FlowNode) * (FLOW_TOKENS + 1) + + sizeof(FlowResult) * (FLOW_DEPTH * 3 + 1); + if (aligned > UINT32_MAX || aligned - (uintptr_t)analysis_head > UINT32_MAX - bytes || + !flowReserve(bytes + aligned - (uintptr_t)analysis_head)) return status | FLOW_ANALYZED | FLOW_INCOMPLETE; + flow.tokens = (FlowToken*)(uintptr_t)aligned; + flow.nodes = (FlowNode*)(flow.tokens + FLOW_TOKENS); + flow.results = (FlowResult*)(flow.nodes + FLOW_TOKENS + 1); + analysis_head = (void*)(flow.results + FLOW_DEPTH * 3 + 1); + flow.nodes[0].kind = FN_OBJECT; flow.nodes[0].left = 0; + flowTokenize(); + uint16_t block = flowNode(FN_BLOCK, 0, 0, (FlowToken){0}), tail = 0; + while (flowPeek() && !flow.failed) { + uint16_t statement = flowStatement(); + if (tail) flow.nodes[tail].next = statement; + else flow.nodes[block].left = statement; + tail = statement; + } + FlowResult* result = &flow.results[FLOW_DEPTH * 3]; + if (!flow.failed) flowExecute(block, flowValue(FV_OBJECT, 0, 1, 0), result, 0); + if (flow.failed || !flowReserve((flow.nodeCount + FLOW_OUTCOMES) * sizeof(Slice))) + return status | FLOW_ANALYZED | FLOW_INCOMPLETE; + bool complete = true; + for (uint32_t i = 0; i < result->count && flowStep(); i++) { + FlowValue value = result->values[i].root; + if (value.kind == FV_UNKNOWN || value.kind == FV_OPAQUE_OBJECT || value.kind == FV_FUNCTION) { + complete = false; continue; + } + _addReexport(NULL, NULL); + if (value.kind == FV_MODULE) { + FlowNode* specifier = &flow.nodes[flow.nodes[value.node].right]; + _addReexport(source + specifier->start, source + specifier->end); + } else if (value.kind == FV_OBJECT) flowEmitObject(value.node, 0); + } + if (flow.failed) { flowClear(); complete = false; } + return status | FLOW_ANALYZED | (complete ? 0 : FLOW_INCOMPLETE); +} diff --git a/test/_unit.js b/test/_unit.js index 3eccde1..dc7fd56 100755 --- a/test/_unit.js +++ b/test/_unit.js @@ -15,6 +15,9 @@ async function loadParser () { else { parse = require('../lexer.js').parse; } + const parseSource = parse; + parse = /** @param {string} source @param {string} [name] */ (source, name) => + parseSource(source, name, { baseline: 'legacy' }); } /** @@ -47,7 +50,7 @@ function createRequireDeclarations (count) { return source; } -suite('Lexer', () => { +suite('Legacy lexer', () => { suiteSetup(async () => await loadParser()); test('Repeated parsing preserves scanner state and earlier results', () => { diff --git a/test/flow.js b/test/flow.js deleted file mode 100644 index e245362..0000000 --- a/test/flow.js +++ /dev/null @@ -1,215 +0,0 @@ -const assert = require('node:assert/strict'); -const { mkdtempSync, writeFileSync, rmSync } = require('node:fs'); -const { createRequire } = require('node:module'); -const { tmpdir } = require('node:os'); -const { join } = require('node:path'); - -/** @typedef {import('../lexer').ExportAnalysis['outcomes'][number]} FlowOutcome */ -let parse; -/** @param {string} source */ -function analysis (source) { return parse(source, 'fixture.cjs', { baseline: 'flow-v1' }); } -/** @param {import('../lexer').ExportAnalysis} report */ -function values (report) { return report.outcomes.map(/** @param {FlowOutcome} outcome */ outcome => outcome.value); } - -suite('Replacement flow heuristic', () => { - suiteSetup(async () => { - if (process.env.WASM) { - const lexer = await import('../dist/lexer.mjs'); - await lexer.init(); - parse = lexer.parse; - } else if (process.env.WASM_SYNC) parse = require('../dist/lexer.js').parse; - else parse = require('../lexer.js').parse; - }); - - test('does not activate for zero or one detected replacement', () => { - for (const source of ['', 'exports.name = 1;', "module.exports = require('./one');", - "if (false) module.exports = require('./one');", "module.exports = flag ? require('./a') : require('./b');"]) { - assert.equal(Object.hasOwn(parse(source), 'analysis'), false); - assert.deepStrictEqual(values(analysis(source)), [{ kind: 'unknown', reason: 'insufficient-replacements' }]); - } - }); - test('counts lexer detections, excluding comments, strings, properties, and comparisons', () => { - for (const extra of ['// module.exports = 2;', '/* module.exports = 2; */', "'module.exports = 2;'", - 'module.exports.name = 2;', 'module.exports["name"] = 2;', 'module.exports === 2;', 'module.exports == 2;']) { - assert.equal(Object.hasOwn(parse('module.exports = {}; ' + extra), 'analysis'), false); - } - assert.equal(Object.hasOwn(parse('module /* gap */ . exports = {}; module.exports = {};'), 'analysis'), true); - }); - test('matches native line terminators in comments, hashbangs, and semicolon insertion', () => { - const directory = mkdtempSync(join(tmpdir(), 'cjs-flow-lines-')); - const filename = join(directory, 'entry.cjs'); - const requireFixture = createRequire(filename); - try { - for (const line of ['\n', '\r', '\r\n', '\u2028', '\u2029']) { - const before = 'module.exports = {}; module.exports = { before: 1 }'; - const after = 'module.exports = { after: 1 };'; - for (const source of [before + '; // comment' + line + after, before + line + after, - '#!/usr/bin/env node' + line + before + '; ' + after]) { - writeFileSync(filename, source); - const observed = requireFixture(filename); - delete requireFixture.cache[requireFixture.resolve(filename)]; - assert.deepStrictEqual(Object.keys(observed), ['after']); - const result = parse(source); - const report = analysis(source); - if (source.startsWith('#!') && (line === '\u2028' || line === '\u2029')) { - assert.equal(Object.hasOwn(result, 'analysis'), false); - assert.deepStrictEqual(values(report), [{ kind: 'unknown', reason: 'insufficient-replacements' }]); - } else { - assert.equal(report.complete, true, JSON.stringify(source)); - assert.deepStrictEqual(values(report), [{ kind: 'object', properties: Object.keys(observed) }]); - assert.deepStrictEqual(result.analysis, report); - } - } - } - const end = 'module.exports = {}; module.exports = { after: 1 }; // no terminator'; - assert.deepStrictEqual(values(analysis(end)), [{ kind: 'object', properties: ['after'] }]); - } finally { - rmSync(directory, { recursive: true, force: true }); - } - }); - test('default analysis preserves legacy enumeration and serialization', () => { - const source = "if (true) module.exports = require('./a'); else module.exports = require('./b');"; - const expected = { exports: [], reexports: ['./b'] }; - const result = parse(source); - assert.equal(Object.getOwnPropertyDescriptor(result, 'analysis').enumerable, false); - assert.deepStrictEqual(result, expected); - assert.equal(JSON.stringify(result), JSON.stringify(expected)); - assert.deepStrictEqual(values(result.analysis), [{ kind: 'module', specifier: './a' }]); - assert.deepStrictEqual(parse(source, '@', {}), expected); - const legacy = parse(source, '@', { baseline: 'legacy' }); - assert.equal(Object.hasOwn(legacy, 'analysis'), false); - assert.deepStrictEqual(legacy, expected); - }); - test('defers and caches reports across interleaved parses and result mutation', () => { - const source = "module.exports = require('./old'); module.exports = require('./final');"; - const result = parse(source); - assert.equal(typeof Object.getOwnPropertyDescriptor(result, 'analysis').get, 'function'); - result.reexports[0] = './mutated'; - parse('module.exports = {}; module.exports = { other: 1 };'); - const report = result.analysis; - assert.equal(Object.getOwnPropertyDescriptor(result, 'analysis').get, undefined); - assert.equal(result.analysis, report); - assert.deepStrictEqual(values(report), [{ kind: 'module', specifier: './final' }]); - }); - test('validates the versioned third argument', () => { - assert.throws(() => parse('', '@', { baseline: 'future' }), { - name: 'RangeError', message: 'Unknown detection baseline: future' - }); - assert.throws(() => parse('', '@', { baseline: 1 }), { - name: 'TypeError', message: 'Detection baseline must be a string' - }); - }); - test('reads the baseline once before lexing', () => { - const source = "if (flag) module.exports = require('./a'); else module.exports = require('./b');"; - for (const baseline of [undefined, 'legacy', 'flow-v1']) { - let reads = 0; - parse(source, '@', { get baseline () { reads++; return baseline; } }); - assert.equal(reads, 1); - } - }); - test('reports only the last sequential replacement', () => { - assert.deepStrictEqual(values(analysis("module.exports = require('./a'); module.exports = require('./b');")), - [{ kind: 'module', specifier: './b' }]); - assert.deepStrictEqual(values(analysis('module.exports = { removed: 1 }; module.exports = { final: 1 };')), - [{ kind: 'object', properties: ['final'] }]); - for (const source of ["module.exports = require('old'); module.exports = require(\"new'quote\");", - "module.exports = require('old'); module.exports = require('new\\'quote');"]) { - assert.deepStrictEqual(values(analysis(source)), [{ kind: 'module', specifier: "new'quote" }]); - } - for (const length of [4096, 4097]) { - const source = "module.exports = require('./a'); module.exports = require('./b');"; - assert.deepStrictEqual(values(analysis(source.padEnd(length))), [{ kind: 'module', specifier: './b' }]); - } - }); - test('preserves conditional alternatives with source ranges', () => { - const report = analysis("if (flag) module.exports = require('./a'); else module.exports = require('./b');"); - assert.equal(report.complete, true); - assert.deepStrictEqual(values(report), [{ kind: 'module', specifier: './a' }, { kind: 'module', specifier: './b' }]); - assert.deepStrictEqual(report.outcomes.map(/** @param {FlowOutcome} outcome */ outcome => outcome.conditions[0]), - [{ start: 4, end: 8, when: 'truthy' }, { start: 4, end: 8, when: 'falsy' }]); - const nested = "if (first) { if (second) module.exports = require('./a'); " + - "else module.exports = require('./b'); } else module.exports = require('./c');"; - assert.equal(analysis(nested).outcomes.length, 3); - for (const value of values(analysis(nested + " module.exports = require('./final');"))) { - assert.deepStrictEqual(value, { kind: 'module', specifier: './final' }); - } - }); - test('handles ternaries and logical conditions after the trigger', () => { - assert.deepStrictEqual(values(analysis("module.exports = {}; module.exports = flag ? require('./a') : require('./b');")), - [{ kind: 'module', specifier: './a' }, { kind: 'module', specifier: './b' }]); - for (const [operator, when] of [['&&', 'truthy'], ['||', 'falsy'], ['??', 'nullish']]) { - const result = analysis("module.exports = {}; flag " + operator + " (module.exports = require('./active'));"); - assert.equal(result.complete, false); - const active = result.outcomes.find(/** @param {FlowOutcome} outcome */ outcome => outcome.value.kind === 'module'); - assert.equal(active.conditions[0].when, when); - } - }); - test('counts and handles logical replacement assignments', () => { - for (const [operator, initial] of [['&&=', 'true'], ['||=', 'false'], ['??=', 'null']]) { - const source = 'module.exports = ' + initial + '; module.exports ' + operator + " require('./active');"; - assert.equal(Object.hasOwn(parse(source), 'analysis'), true); - assert.deepStrictEqual(values(analysis(source)), [{ kind: 'module', specifier: './active' }]); - } - assert.equal(analysis("module.exports = require('./a'); module.exports ||= require('./b');").complete, false); - }); - test('folds pure primitive conditions and normalizes literal keys', () => { - for (const condition of ['false', '0', "''", 'null', 'void 0', '!true', '1 === 2']) { - assert.deepStrictEqual(values(analysis('if (' + condition + ") module.exports = require('./dead'); " + - "else module.exports = require('./live');")), [{ kind: 'module', specifier: './live' }]); - } - assert.deepStrictEqual(values(analysis('module.exports = {}; ' + - 'module.exports = { 0x10: 1, 1e2: 2, 1.5: 3, final: 0, final: 1 };')), - [{ kind: 'object', properties: ['16', '100', '1.5', 'final'] }]); - }); - test('does not guess exports across unsupported effects, bindings, or control flow', () => { - for (const middle of ['for (;;) { break; }', 'while (flag) {}', 'switch (flag) { case 1: break; }', - 'try {} catch (error) {}', 'const flag = true;', 'function unused() { module.exports = {}; }', - 'exports.detached = 1;', 'module.exports.detached = 1;', 'delete module.exports.name;', - 'module.exports = { ...require("./spread") };', 'mutate();', 'return;', 'throw new Error();']) { - const report = analysis('module.exports = {}; ' + middle + ' module.exports = {};'); - assert.equal(report.complete, false, middle); - for (const value of values(report)) assert.equal(value.kind, 'unknown', middle); - } - }); - test('invalidates earlier values after opaque reads and dependency execution', () => { - for (const expression of ['flag;', 'flag + 1;', 'flag.name;', "require('./effects');"]) { - const report = analysis('module.exports = {}; module.exports = { previous: 1 }; ' + expression); - assert.equal(report.complete, false); - for (const value of values(report)) assert.equal(value.kind, 'unknown'); - } - }); - test('matches native exports without executing conditions during analysis', () => { - const directory = mkdtempSync(join(tmpdir(), 'cjs-replacement-flow-')); - const filename = join(directory, 'entry.cjs'); - const requireFixture = createRequire(filename); - const previous = Object.getOwnPropertyDescriptor(globalThis, 'flowPocCondition'); - let calls = 0; - Object.defineProperty(globalThis, 'flowPocCondition', { configurable: true, get () { calls++; return true; } }); - try { - const source = 'if (flowPocCondition) module.exports = { actual: 1 }; else module.exports = { other: 1 };'; - writeFileSync(filename, source); - const report = analysis(source); - assert.equal(calls, 0); - const observed = requireFixture(filename); - assert.equal(calls, 1); - assert.deepStrictEqual(Object.keys(observed), report.outcomes[0].value.properties); - delete requireFixture.cache[requireFixture.resolve(filename)]; - } finally { - if (previous) Object.defineProperty(globalThis, 'flowPocCondition', previous); - else delete globalThis.flowPocCondition; - rmSync(directory, { recursive: true, force: true }); - } - }); - test('bounds tokens, outcomes, and AST depth', () => { - const prefix = 'module.exports = {}; module.exports = {};'; - assert.equal(analysis(prefix + ';'.repeat(8192 - 14)).complete, true); - assert.equal(values(analysis(prefix + ';'.repeat(8193 - 14)))[0].reason, 'token-limit'); - for (const expression of ['1' + '+1'.repeat(1000), 'flag' + '.name'.repeat(1000), - 'require("./a")' + '()'.repeat(1000)]) { - assert.equal(values(analysis(prefix + expression + ';'))[0].reason, 'depth-limit'); - } - const branch = 'if (flag) module.exports = {}; else module.exports = {};'; - assert.equal(analysis(branch.repeat(5)).outcomes.length, 32); - assert.equal(values(analysis(branch.repeat(6)))[0].reason, 'outcome-limit'); - }); -}); diff --git a/test/grouped.js b/test/grouped.js new file mode 100644 index 0000000..e080060 --- /dev/null +++ b/test/grouped.js @@ -0,0 +1,256 @@ +const assert = require('node:assert').strict; +const { mkdtempSync, rmSync, writeFileSync } = require('node:fs'); +const { createRequire } = require('node:module'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); + +let parse; + +suite('Grouped reexports', () => { + suiteSetup(async () => { + if (process.env.WASM) { + const lexer = await import('../dist/lexer.mjs'); + await lexer.init(); + parse = lexer.parse; + } else if (process.env.WASM_SYNC) parse = require('../dist/lexer.js').parse; + else parse = require('../lexer.js').parse; + }); + + test('groups conditional alternatives and drops overwritten dependencies by default', () => { + assert.deepStrictEqual(parse("if (flag) module.exports = require('./a'); else module.exports = require('./b');"), + { exports: [], reexports: [['./a'], ['./b']], complete: true }); + assert.deepStrictEqual(parse("module.exports = require('./a'); module.exports = require('./b');"), + { exports: [], reexports: [['./b']], complete: true }); + }); + test('groups dependencies combined in a final object', () => { + const source = "if (flag) module.exports = {...require('./a'), ...require('./b')}; " + + "else module.exports = {...require('./c'), ...require('./d')};"; + assert.deepStrictEqual(parse(source), { exports: [], reexports: [['./a', './b'], ['./c', './d']], complete: true }); + assert.deepStrictEqual(parse(source.replace('flag', 'true')), + { exports: [], reexports: [['./a', './b']], complete: true }); + }); + test('keeps the count gate and the explicit legacy baseline', () => { + for (const source of ['', 'exports.name = 1;', "module.exports = require('./one');"]) { + const result = parse(source); + assert.equal(Object.prototype.hasOwnProperty.call(result, 'complete'), false); + assert.equal(Object.prototype.hasOwnProperty.call(result, 'analysis'), false); + } + assert.deepStrictEqual(parse("module.exports = {...require('./a'), ...require('./b')};").reexports, + [['./a', './b']]); + for (const extra of ['// module.exports = 2;', '/* module.exports = 2; */', "'module.exports = 2;'", + 'module.exports.name = 2;', 'module.exports === 2;', 'module.exports == 2;']) { + assert.equal(Object.prototype.hasOwnProperty.call(parse('module.exports = {}; ' + extra), 'complete'), false); + } + const source = "if (true) module.exports = require('./a'); else module.exports = require('./b');"; + assert.deepStrictEqual(parse(source, '@', { baseline: 'legacy' }), { exports: [], reexports: ['./b'] }); + assert.deepStrictEqual(parse(source, '@', { baseline: 'flow-v1' }), parse(source)); + }); + test('reads the baseline once and returns plain snapshot values', () => { + const source = "module.exports = require('./old'); module.exports = require('./final');"; + let reads = 0; + const result = parse(source, '@', { get baseline () { reads++; return 'flow-v1'; } }); + assert.equal(reads, 1); + assert.equal(Object.prototype.hasOwnProperty.call(result, 'analysis'), false); + assert.deepStrictEqual(Object.freeze(result).reexports, [['./final']]); + result.reexports[0][0] = './mutated'; + assert.deepStrictEqual(parse(source).reexports, [['./final']]); + assert.throws(() => parse('', '@', { baseline: 'future' }), { name: 'RangeError' }); + assert.throws(() => parse('', '@', { baseline: 1 }), { name: 'TypeError' }); + }); + test('marks filtered UTF-16 names and dependencies incomplete', () => { + assert.deepStrictEqual(parse("module.exports = {}; module.exports = { '\\uD800': 1, valid: 1 };"), + { exports: ['valid'], reexports: [], complete: false }); + assert.deepStrictEqual(parse("module.exports = {}; module.exports = require('./\\uD800');"), + { exports: [], reexports: [], complete: false }); + }); + test('initializes the shared Wasm engine through CommonJS init', async () => { + const rootPath = require.resolve('../lexer.js'), wasmPath = require.resolve('../dist/lexer.js'); + const rootModule = require.cache[rootPath], wasmModule = require.cache[wasmPath]; + const constructor = WebAssembly.Module; + let calls = 0; + delete require.cache[rootPath]; delete require.cache[wasmPath]; + try { + const lexer = require('../lexer.js'); + WebAssembly.Module = function forbiddenSyncCompile () { calls++; throw new Error('Synchronous compilation'); }; + await lexer.init(); + assert.equal(calls, 0); + assert.deepStrictEqual(lexer.parse("module.exports = require('./old'); module.exports = require('./final');") + .reexports, [['./final']]); + assert.equal(calls, 0); + } finally { + WebAssembly.Module = constructor; + if (rootModule) require.cache[rootPath] = rootModule; + else delete require.cache[rootPath]; + if (wasmModule) require.cache[wasmPath] = wasmModule; + else delete require.cache[wasmPath]; + } + }); + test('folds pure primitive guards, including escaped strings and typeof', () => { + for (const condition of ['false', '0', "''", 'null', 'void 0', '!true', '1 === 2', "'a' === 'b'", + "'\\x61' !== 'a'", "'\\u{61}' !== 'a'", "'\\141' !== 'a'", "'\\\r\n'", '0x0', '0b0', '0o0']) { + const source = 'if (' + condition + ") module.exports = require('./dead'); else module.exports = require('./live');"; + assert.deepStrictEqual(parse(source), { exports: [], reexports: [['./live']], complete: true }, condition); + } + for (const condition of ['true', '1e2 === 100', '0.1 === 0.1', "typeof 1 === 'number'", + "typeof typeof flag === 'string'", 'require', 'module', 'exports']) { + const source = 'if (' + condition + ") module.exports = require('./live'); else module.exports = require('./dead');"; + assert.deepStrictEqual(parse(source).reexports, [['./live']], condition); + } + assert.deepStrictEqual(parse("module.exports = {}; module.exports = typeof flag ?? require('./dead');").reexports, []); + }); + test('handles nested branches, ternaries, logical guards and logical assignments', () => { + assert.deepStrictEqual(parse("if (first) { if (second) module.exports = require('./a'); " + + "else module.exports = require('./b'); } else module.exports = require('./c');").reexports, + [['./a'], ['./b'], ['./c']]); + assert.deepStrictEqual(parse("module.exports = {}; module.exports = flag ? require('./a') : require('./b');").reexports, + [['./a'], ['./b']]); + for (const operator of ['&&', '||', '??']) { + const result = parse("module.exports = {}; flag " + operator + " (module.exports = require('./active'));"); + assert.equal(result.complete, false); + assert.deepStrictEqual(result.reexports, [['./active']]); + } + for (const [operator, initial] of [['&&=', 'true'], ['||=', 'false'], ['??=', 'null']]) { + assert.deepStrictEqual(parse('module.exports = ' + initial + '; module.exports ' + operator + + " require('./active');").reexports, [['./active']]); + } + assert.deepStrictEqual(parse("module.exports = require('./a'); module.exports ||= require('./b');").reexports, + [['./a'], ['./b']]); + assert.deepStrictEqual(parse("if (flag) module.exports = require('./a'); else module.exports = require('./b'); " + + "module.exports = require('./final');").reexports, [['./final']]); + }); + test('reports only surviving literal names and combined dependency spreads', () => { + assert.deepStrictEqual(parse('module.exports = { removed: 1 }; module.exports = { final: 1 };'), + { exports: ['final'], reexports: [], complete: true }); + assert.deepStrictEqual(parse('module.exports = {}; module.exports = { 0x10: 1, 1e2: 2, 1.5: 3, final: 0, final: 1 };'), + { exports: ['16', '100', '1.5', 'final'], reexports: [], complete: true }); + assert.deepStrictEqual(parse("module.exports = {}; module.exports = { own: require('./value'), " + + "...{ nested: 1, ...require('./spread') }, ...require('./spread') };"), + { exports: ['own', 'nested'], reexports: [['./spread']], complete: true }); + assert.deepStrictEqual(parse("if (flag) module.exports = require('./same'); else module.exports = require(\"./same\");"), + { exports: [], reexports: [['./same']], complete: true }); + assert.deepStrictEqual(parse("module.exports = {}; module.exports = { own: 1 }; " + + "if (module.exports === module.exports) module.exports = require('./live'); else module.exports = require('./dead');") + .reexports, [['./live']]); + }); + test('does not guess across unsupported scopes, mutation, or dynamic spreads', () => { + for (const middle of ['for (;;) { break; }', 'while (flag) {}', 'switch (flag) { case 1: break; }', + 'try {} catch (error) {}', 'const flag = true;', 'function unused() { module.exports = {}; }', + 'exports.detached = 1;', 'module.exports.detached = 1;', 'delete module.exports.name;', + 'module.exports = { ...foreign };', 'module.exports = { "__pro\\u0074o__": 1 };', + 'mutate();', 'return;', 'throw new Error();']) { + assert.deepStrictEqual(parse('module.exports = {}; ' + middle + ' module.exports = {};'), + { exports: [], reexports: [], complete: false }, middle); + } + }); + test('does not report a dependency after a CommonJS early return', () => { + assert.deepStrictEqual(parse("module.exports = require('./before'); return; module.exports = require('./dead');"), + { exports: [], reexports: [], complete: false }); + }); + test('invalidates exposed values after opaque reads or dependency execution', () => { + for (const effect of ['flag;', 'flag.name;', "require('./effects');"]) { + assert.deepStrictEqual(parse('module.exports = {}; module.exports = { previous: 1 }; ' + effect), + { exports: [], reexports: [], complete: false }); + } + assert.deepStrictEqual(parse('module.exports = { previous: 1 }; flag; module.exports = { fresh: 1 };'), + { exports: ['fresh'], reexports: [], complete: true }); + }); + test('handles ECMAScript line terminators and semicolon insertion', () => { + for (const line of ['\n', '\r', '\r\n', '\u2028', '\u2029']) { + const before = 'module.exports = {}; module.exports = { before: 1 }'; + const after = 'module.exports = { after: 1 };'; + for (const source of [before + '; // comment' + line + after, before + line + after]) { + assert.deepStrictEqual(parse(source), { exports: ['after'], reexports: [], complete: true }); + } + if (line === '\n' || line === '\r' || line === '\r\n') + assert.deepStrictEqual(parse('#!/usr/bin/env node' + line + before + '; ' + after), + { exports: ['after'], reexports: [], complete: true }); + } + assert.deepStrictEqual(parse('module.exports = {}; module.exports = { after: 1 }; // no line end'), + { exports: ['after'], reexports: [], complete: true }); + }); + test('matches native dependency groups without executing conditions during analysis', () => { + const directory = mkdtempSync(join(tmpdir(), 'cjs-grouped-')); + const filename = join(directory, 'entry.cjs'); + const requireFixture = createRequire(filename); + const previous = Object.getOwnPropertyDescriptor(globalThis, 'groupedFlowFlag'); + let calls = 0, enabled = true; + Object.defineProperty(globalThis, 'groupedFlowFlag', { + configurable: true, get () { calls++; return enabled; } + }); + try { + for (const name of ['a', 'b', 'c', 'd']) writeFileSync(join(directory, name + '.cjs'), 'exports.' + name + ' = 1;'); + const source = "if (groupedFlowFlag) module.exports = {...require('./a.cjs'), ...require('./b.cjs')}; " + + "else module.exports = {...require('./c.cjs'), ...require('./d.cjs')};"; + writeFileSync(filename, source); + const result = parse(source); + assert.equal(calls, 0); + assert.deepStrictEqual(result.reexports, [['./a.cjs', './b.cjs'], ['./c.cjs', './d.cjs']]); + for (const flag of [true, false]) { + enabled = flag; + const observed = requireFixture(filename); + delete requireFixture.cache[requireFixture.resolve(filename)]; + const group = result.reexports[flag ? 0 : 1]; + const keys = new Set(); + for (const dependency of group) for (const key of Object.keys(requireFixture(dependency))) keys.add(key); + assert.deepStrictEqual(Object.keys(observed), [...keys]); + } + assert.equal(calls, 2); + assert.equal(result.complete, true); + } finally { + for (const name of ['a', 'b', 'c', 'd']) { + const dependency = join(directory, name + '.cjs'); + delete requireFixture.cache[requireFixture.resolve(dependency)]; + } + if (previous) Object.defineProperty(globalThis, 'groupedFlowFlag', previous); + else delete globalThis.groupedFlowFlag; + rmSync(directory, { recursive: true, force: true }); + } + }); + test('rejects updates rather than certifying repeated unary operators', () => { + for (const update of ['++module.exports', '--module.exports', 'module.exports++', 'module.exports--']) { + const source = 'module.exports = 0; module.exports = 1; if (' + update + + ' === 2) module.exports = { good: 1 }; else module.exports = { bad: 1 };'; + assert.deepStrictEqual(parse(source), { exports: [], reexports: [], complete: false }, update); + } + assert.deepStrictEqual(parse('module.exports = {}; module.exports = { good: + +1, other: - -1 };'), + { exports: ['good', 'other'], reexports: [], complete: true }); + }); + test('rejects unsupported binary continuations across a line terminator', () => { + for (const separator of ['\n', '\r', '\r\n', '\u2028', '\u2029']) { + for (const operator of ['+', '-']) { + const prefix = "module.exports = {}; module.exports = require('./n')"; + assert.deepStrictEqual(parse(prefix + separator + operator + '1;'), + { exports: [], reexports: [], complete: false }); + assert.deepStrictEqual(parse(prefix + ';' + separator + operator + '1;'), + { exports: [], reexports: [['./n']], complete: true }); + } + } + assert.deepStrictEqual(parse('module.exports = {}; module.exports = 1\n+ 1; ' + + 'if (module.exports === 2) module.exports = { good: 1 }; else module.exports = { bad: 1 };'), + { exports: [], reexports: [], complete: false }); + for (const expression of ['1\nin\n{}', '1\ninstanceof\nObject']) { + assert.deepStrictEqual(parse('module.exports = {}; module.exports = ' + expression + '; ' + + 'if (module.exports) module.exports = { good: 1 }; else module.exports = { bad: 1 };'), + { exports: [], reexports: [], complete: false }); + } + }); + test('bounds tokens, depth, work, and alternative count', () => { + const prefix = 'module.exports = {}; module.exports = {};'; + assert.equal(parse(prefix + ';'.repeat(8192 - 14)).complete, true); + assert.deepStrictEqual(parse(prefix + ';'.repeat(8193 - 14)), + { exports: [], reexports: [], complete: false }); + assert.equal(parse(prefix + '('.repeat(62) + '1' + ')'.repeat(62) + ';').complete, true); + assert.equal(parse(prefix + '('.repeat(63) + '1' + ')'.repeat(63) + ';').complete, false); + assert.equal(parse(prefix + '// ' + 'x'.repeat(65454)).complete, true); + assert.equal(parse(prefix + '// ' + 'x'.repeat(65455)).complete, false); + /** @param {number} start @param {number} end */ + function alternatives (start, end) { + if (end - start === 1) return "require('./" + start + "')"; + const middle = Math.floor((start + end) / 2); + return '(flag ? ' + alternatives(start, middle) + ' : ' + alternatives(middle, end) + ')'; + } + assert.equal(parse('module.exports = {}; module.exports = ' + alternatives(0, 32) + ';').reexports.length, 32); + assert.deepStrictEqual(parse('module.exports = {}; module.exports = ' + alternatives(0, 33) + ';'), + { exports: [], reexports: [], complete: false }); + }); +}); diff --git a/test/integration.js b/test/integration.js index 1a99de9..2eb7f1c 100755 --- a/test/integration.js +++ b/test/integration.js @@ -16,6 +16,9 @@ async function loadParser () { else { parse = require('../lexer.js').parse; } + const parseSource = parse; + parse = /** @param {string} source @param {string} [name] */ (source, name) => + parseSource(source, name, { baseline: 'legacy' }); } const files = fs.readdirSync('test/samples') @@ -29,7 +32,7 @@ const files = fs.readdirSync('test/samples') }; }); -suite('Samples', () => { +suite('Legacy samples', () => { suiteSetup(async () => await loadParser()); const selfSource = fs.readFileSync(process.cwd() + '/lexer.js').toString(); From 87bdc67381a1e7c3a479f14bb6c21c9e47d002ce Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Tue, 6 Oct 2026 12:13:54 +0900 Subject: [PATCH 4/6] chore(types): preserve declaration file permissions --- lexer.d.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 lexer.d.ts diff --git a/lexer.d.ts b/lexer.d.ts old mode 100644 new mode 100755 From cfde53d4fbbcce5ec9fef51d7dc3498a867130ce Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Tue, 6 Oct 2026 15:09:42 +0900 Subject: [PATCH 5/6] fix(lexer): nest only distinct conditional reexports Wrapping a single outcome changes the dependency shape for deterministic modules. Preserve their flat lists and nest only distinct conditional lists. The overloads reject undefined options although the runtime accepts them, which prevents callers from forwarding an optional third argument. Refs: https://github.com/nodejs/cjs-module-lexer/issues/99 --- README.md | 10 ++++++---- lexer.d.ts | 8 ++++---- lexer.js | 2 -- src/lexer.js | 7 +++---- test/grouped.js | 50 +++++++++++++++++++++++++++++++++---------------- 5 files changed, 47 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index a398454..0065e0c 100755 --- a/README.md +++ b/README.md @@ -64,10 +64,12 @@ This option does not change Node.js's built-in CommonJS named-export detection. ### Detection baselines (proof of concept) -The default `reexports` value is an array of arrays. +The default `reexports` value stays flat for one distinct final dependency list. +Different conditional lists produce an array of arrays. Each inner array contains dependencies combined in one final export value. The outer array contains alternative final values. -This changes the return type and requires a major release. +Identical lists are deduplicated before choosing the return shape. +Conditional nesting changes the return type and requires a major release. ```js const { reexports, complete } = parse(` @@ -85,9 +87,9 @@ The `exports` array contains detected literal names from surviving values. Flow analysis runs only after the lexer detects more than one `module.exports` replacement. The counter ignores comments, strings, property writes, and comparisons. -With fewer replacements, existing detection patterns run and their reexports form one group. +With fewer replacements, existing detection patterns run and their reexports stay flat. The optional third argument selects a versioned detection baseline. -Select `legacy` for the previous flat result, or `flow-v1` for the default grouped result. +Select `legacy` for frozen detection, or `flow-v1` for the default conditional analysis. A baseline can combine detection rules without an option for each rule. The C lexer compiled to Wasm owns flow analysis. diff --git a/lexer.d.ts b/lexer.d.ts index acb3da8..a6885d4 100755 --- a/lexer.d.ts +++ b/lexer.d.ts @@ -1,7 +1,7 @@ export interface Exports { exports: string[]; - /** Alternative final values; each group contains dependencies combined in one value. */ - reexports: string[][]; + /** Flat dependencies, or alternative lists when conditional final dependencies differ. */ + reexports: string[] | string[][]; /** Present when competing replacements are analyzed. False for unsupported effects, filtered names, or limits. */ complete?: boolean; } @@ -18,8 +18,8 @@ export interface ParseOptions { } export declare function parse(source: string, name: string | undefined, options: { baseline: 'legacy' }): LegacyExports; -export declare function parse(source: string, name: string | undefined, options: { baseline?: 'flow-v1' }): Exports; -export declare function parse(source: string, name: string | undefined, options: ParseOptions): Exports | LegacyExports; +export declare function parse(source: string, name: string | undefined, options?: { baseline?: 'flow-v1' }): Exports; +export declare function parse(source: string, name: string | undefined, options?: ParseOptions): Exports | LegacyExports; export declare function parse(source: string, name?: string): Exports; export declare function init(): Promise; export declare function initSync(): void; diff --git a/lexer.js b/lexer.js index d11cd64..082e126 100755 --- a/lexer.js +++ b/lexer.js @@ -91,8 +91,6 @@ function parseCJS (source, name = '@', options) { } const result = { exports: [..._exports].filter(expt => expt !== undefined && !unsafeGetters.has(expt)), reexports: [...reexports].filter(reexpt => reexpt !== undefined) }; resetState(); - if (baseline === 'legacy') return result; - result.reexports = result.reexports.length ? [result.reexports] : []; return result; } diff --git a/src/lexer.js b/src/lexer.js index c563b39..0a21e56 100755 --- a/src/lexer.js +++ b/src/lexer.js @@ -82,10 +82,9 @@ export function parse (source, name = '@', options) { complete = false; } - if (baseline === 'legacy') return { exports: [...exports], reexports: [...reexports] }; - if (!(status & 1024)) return { exports: [...exports], reexports: reexports.size ? [[...reexports]] : [] }; + if (!(status & 1024)) return { exports: [...exports], reexports: [...reexports] }; if (groups.length === 1) - return { exports: [...exports], reexports: reexports.size ? [[...reexports]] : [], complete }; + return { exports: [...exports], reexports: [...reexports], complete }; const alternatives = []; const seen = new Set(); let hasDependencies = false; @@ -97,7 +96,7 @@ export function parse (source, name = '@', options) { alternatives.push(values); if (values.length) hasDependencies = true; } - return { exports: [...exports], reexports: hasDependencies ? alternatives : [], complete }; + return { exports: [...exports], reexports: hasDependencies ? alternatives.length === 1 ? alternatives[0] : alternatives : [], complete }; } function decode (str) { diff --git a/test/grouped.js b/test/grouped.js index e080060..6483258 100644 --- a/test/grouped.js +++ b/test/grouped.js @@ -16,18 +16,36 @@ suite('Grouped reexports', () => { else parse = require('../lexer.js').parse; }); + test('keeps flat results unless final dependency lists differ', () => { + for (const source of ['', 'exports.name = 1;', "module.exports = require('./one');", + "module.exports = {...require('./a'), ...require('./b')};"]) { + assert.deepStrictEqual(parse(source), parse(source, '@', { baseline: 'legacy' })); + } + assert.deepStrictEqual(parse("module.exports = require('./a'); module.exports = require('./b');").reexports, + ['./b']); + }); + test('deduplicates identical alternatives before choosing the result shape', () => { + assert.deepStrictEqual(parse("if (flag) module.exports = require('./same'); " + + 'else module.exports = require("./same");').reexports, ['./same']); + assert.deepStrictEqual(parse("if (flag) module.exports = {...require('./a'), ...require('./b'), ...require('./a')}; " + + "else module.exports = {...require('./a'), ...require('./b')};").reexports, ['./a', './b']); + assert.deepStrictEqual(parse("if (flag) module.exports = {}; else module.exports = require('./optional');").reexports, + [[], ['./optional']]); + assert.deepStrictEqual(parse('if (flag) module.exports = { first: 1 }; else module.exports = { second: 1 };').reexports, + []); + }); test('groups conditional alternatives and drops overwritten dependencies by default', () => { assert.deepStrictEqual(parse("if (flag) module.exports = require('./a'); else module.exports = require('./b');"), { exports: [], reexports: [['./a'], ['./b']], complete: true }); assert.deepStrictEqual(parse("module.exports = require('./a'); module.exports = require('./b');"), - { exports: [], reexports: [['./b']], complete: true }); + { exports: [], reexports: ['./b'], complete: true }); }); test('groups dependencies combined in a final object', () => { const source = "if (flag) module.exports = {...require('./a'), ...require('./b')}; " + "else module.exports = {...require('./c'), ...require('./d')};"; assert.deepStrictEqual(parse(source), { exports: [], reexports: [['./a', './b'], ['./c', './d']], complete: true }); assert.deepStrictEqual(parse(source.replace('flag', 'true')), - { exports: [], reexports: [['./a', './b']], complete: true }); + { exports: [], reexports: ['./a', './b'], complete: true }); }); test('keeps the count gate and the explicit legacy baseline', () => { for (const source of ['', 'exports.name = 1;', "module.exports = require('./one');"]) { @@ -36,7 +54,7 @@ suite('Grouped reexports', () => { assert.equal(Object.prototype.hasOwnProperty.call(result, 'analysis'), false); } assert.deepStrictEqual(parse("module.exports = {...require('./a'), ...require('./b')};").reexports, - [['./a', './b']]); + ['./a', './b']); for (const extra of ['// module.exports = 2;', '/* module.exports = 2; */', "'module.exports = 2;'", 'module.exports.name = 2;', 'module.exports === 2;', 'module.exports == 2;']) { assert.equal(Object.prototype.hasOwnProperty.call(parse('module.exports = {}; ' + extra), 'complete'), false); @@ -51,9 +69,9 @@ suite('Grouped reexports', () => { const result = parse(source, '@', { get baseline () { reads++; return 'flow-v1'; } }); assert.equal(reads, 1); assert.equal(Object.prototype.hasOwnProperty.call(result, 'analysis'), false); - assert.deepStrictEqual(Object.freeze(result).reexports, [['./final']]); - result.reexports[0][0] = './mutated'; - assert.deepStrictEqual(parse(source).reexports, [['./final']]); + assert.deepStrictEqual(Object.freeze(result).reexports, ['./final']); + result.reexports[0] = './mutated'; + assert.deepStrictEqual(parse(source).reexports, ['./final']); assert.throws(() => parse('', '@', { baseline: 'future' }), { name: 'RangeError' }); assert.throws(() => parse('', '@', { baseline: 1 }), { name: 'TypeError' }); }); @@ -75,7 +93,7 @@ suite('Grouped reexports', () => { await lexer.init(); assert.equal(calls, 0); assert.deepStrictEqual(lexer.parse("module.exports = require('./old'); module.exports = require('./final');") - .reexports, [['./final']]); + .reexports, ['./final']); assert.equal(calls, 0); } finally { WebAssembly.Module = constructor; @@ -89,12 +107,12 @@ suite('Grouped reexports', () => { for (const condition of ['false', '0', "''", 'null', 'void 0', '!true', '1 === 2', "'a' === 'b'", "'\\x61' !== 'a'", "'\\u{61}' !== 'a'", "'\\141' !== 'a'", "'\\\r\n'", '0x0', '0b0', '0o0']) { const source = 'if (' + condition + ") module.exports = require('./dead'); else module.exports = require('./live');"; - assert.deepStrictEqual(parse(source), { exports: [], reexports: [['./live']], complete: true }, condition); + assert.deepStrictEqual(parse(source), { exports: [], reexports: ['./live'], complete: true }, condition); } for (const condition of ['true', '1e2 === 100', '0.1 === 0.1', "typeof 1 === 'number'", "typeof typeof flag === 'string'", 'require', 'module', 'exports']) { const source = 'if (' + condition + ") module.exports = require('./live'); else module.exports = require('./dead');"; - assert.deepStrictEqual(parse(source).reexports, [['./live']], condition); + assert.deepStrictEqual(parse(source).reexports, ['./live'], condition); } assert.deepStrictEqual(parse("module.exports = {}; module.exports = typeof flag ?? require('./dead');").reexports, []); }); @@ -107,16 +125,16 @@ suite('Grouped reexports', () => { for (const operator of ['&&', '||', '??']) { const result = parse("module.exports = {}; flag " + operator + " (module.exports = require('./active'));"); assert.equal(result.complete, false); - assert.deepStrictEqual(result.reexports, [['./active']]); + assert.deepStrictEqual(result.reexports, ['./active']); } for (const [operator, initial] of [['&&=', 'true'], ['||=', 'false'], ['??=', 'null']]) { assert.deepStrictEqual(parse('module.exports = ' + initial + '; module.exports ' + operator + - " require('./active');").reexports, [['./active']]); + " require('./active');").reexports, ['./active']); } assert.deepStrictEqual(parse("module.exports = require('./a'); module.exports ||= require('./b');").reexports, [['./a'], ['./b']]); assert.deepStrictEqual(parse("if (flag) module.exports = require('./a'); else module.exports = require('./b'); " + - "module.exports = require('./final');").reexports, [['./final']]); + "module.exports = require('./final');").reexports, ['./final']); }); test('reports only surviving literal names and combined dependency spreads', () => { assert.deepStrictEqual(parse('module.exports = { removed: 1 }; module.exports = { final: 1 };'), @@ -125,12 +143,12 @@ suite('Grouped reexports', () => { { exports: ['16', '100', '1.5', 'final'], reexports: [], complete: true }); assert.deepStrictEqual(parse("module.exports = {}; module.exports = { own: require('./value'), " + "...{ nested: 1, ...require('./spread') }, ...require('./spread') };"), - { exports: ['own', 'nested'], reexports: [['./spread']], complete: true }); + { exports: ['own', 'nested'], reexports: ['./spread'], complete: true }); assert.deepStrictEqual(parse("if (flag) module.exports = require('./same'); else module.exports = require(\"./same\");"), - { exports: [], reexports: [['./same']], complete: true }); + { exports: [], reexports: ['./same'], complete: true }); assert.deepStrictEqual(parse("module.exports = {}; module.exports = { own: 1 }; " + "if (module.exports === module.exports) module.exports = require('./live'); else module.exports = require('./dead');") - .reexports, [['./live']]); + .reexports, ['./live']); }); test('does not guess across unsupported scopes, mutation, or dynamic spreads', () => { for (const middle of ['for (;;) { break; }', 'while (flag) {}', 'switch (flag) { case 1: break; }', @@ -222,7 +240,7 @@ suite('Grouped reexports', () => { assert.deepStrictEqual(parse(prefix + separator + operator + '1;'), { exports: [], reexports: [], complete: false }); assert.deepStrictEqual(parse(prefix + ';' + separator + operator + '1;'), - { exports: [], reexports: [['./n']], complete: true }); + { exports: [], reexports: ['./n'], complete: true }); } } assert.deepStrictEqual(parse('module.exports = {}; module.exports = 1\n+ 1; ' + From 51110a0d4dafd3061c1962e2f02f7bd989d41fcc Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Tue, 27 Oct 2026 16:52:43 +0100 Subject: [PATCH 6/6] refactor(lexer): reuse option handling for flow analysis Extended mode must retain its flat dependency union unless flow analysis is selected explicitly. Snapshot detection options before the baseline getter and preserve their values through CommonJS delegation so both entry points make the same selection. --- README.md | 22 ++++++++------- build/Makefile | 1 - lexer.d.ts | 7 +++-- lexer.js | 7 ++--- lib/lexer.wasm | Bin 28959 -> 50492 bytes src/lexer.c | 16 ++++++----- src/lexer.js | 13 ++++----- src/reexport-analysis.h | 25 +++++------------ test/_unit.js | 7 +++-- test/grouped.js | 58 +++++++++++++++++++++++++++++++++++++++- 10 files changed, 105 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 0065e0c..935305d 100755 --- a/README.md +++ b/README.md @@ -62,6 +62,15 @@ The optional third argument to `parse(source, name, options)` controls detection in supported `Object.defineProperty` exports and star reexports. Whitespace and comments can separate `!` and `0`. This option does not change Node.js's built-in CommonJS named-export detection. +```js +const { exports } = parse( + "Object.defineProperty(exports, 'b', { enumerable: !0, value: 'b' });", + undefined, + { allowMinifiedEnumerable: true } +); +// exports === ['b'] +``` + ### Detection baselines (proof of concept) The default `reexports` value stays flat for one distinct final dependency list. @@ -89,7 +98,9 @@ Flow analysis runs only after the lexer detects more than one `module.exports` r The counter ignores comments, strings, property writes, and comparisons. With fewer replacements, existing detection patterns run and their reexports stay flat. The optional third argument selects a versioned detection baseline. -Select `legacy` for frozen detection, or `flow-v1` for the default conditional analysis. +Select `baseline: 'legacy'` to disable flow analysis, or `baseline: 'flow-v1'` to enable it. +These baselines preserve the selected detection extensions when flow analysis does not run. +Extended mode disables flow analysis unless `baseline: 'flow-v1'` is explicit. A baseline can combine detection rules without an option for each rule. The C lexer compiled to Wasm owns flow analysis. @@ -105,15 +116,6 @@ Token, nesting, outcome, and work limits bound the analyzer. Existing lexer errors still apply. -```js -const { exports } = parse( - "Object.defineProperty(exports, 'b', { enumerable: !0, value: 'b' });", - undefined, - { allowMinifiedEnumerable: true } -); -// exports === ['b'] -``` - ### Extended detection The third argument selects an opt-in detection mode: diff --git a/build/Makefile b/build/Makefile index 199fe9a..310402d 100755 --- a/build/Makefile +++ b/build/Makefile @@ -11,7 +11,6 @@ WASM_EXTRA_LDFLAGS := -Wl,-z,stack-size=13312,--no-entry,--compress-relocations, WASM_EXTRA_LDFLAGS += -Wl,--mllvm=-inline-threshold=150 WASM_EXTRA_LDFLAGS += -Wl,--export=__heap_base,--export=parseCJS,--export=sa WASM_EXTRA_LDFLAGS += -Wl,--export=parseCJSWithOptions -WASM_EXTRA_LDFLAGS += -Wl,--export=parseCJSGrouped WASM_EXTRA_LDFLAGS += -Wl,--export=e,--export=re,--export=es,--export=ee WASM_EXTRA_LDFLAGS += -Wl,--export=rre,--export=ree,--export=res,--export=ru,--export=us,--export=ue diff --git a/lexer.d.ts b/lexer.d.ts index a6885d4..bdf86bc 100755 --- a/lexer.d.ts +++ b/lexer.d.ts @@ -17,9 +17,8 @@ export interface ParseOptions { baseline?: 'legacy' | 'flow-v1'; } -export declare function parse(source: string, name: string | undefined, options: { baseline: 'legacy' }): LegacyExports; -export declare function parse(source: string, name: string | undefined, options?: { baseline?: 'flow-v1' }): Exports; -export declare function parse(source: string, name: string | undefined, options?: ParseOptions): Exports | LegacyExports; -export declare function parse(source: string, name?: string): Exports; +export declare function parse(source: string, name: string | undefined, options: ParseOptions & { baseline: 'legacy' }): LegacyExports; +export declare function parse(source: string, name: string | undefined, options: ParseOptions & { mode: 'extended'; baseline?: 'legacy' }): LegacyExports; +export declare function parse(source: string, name?: string, options?: ParseOptions): Exports; export declare function init(): Promise; export declare function initSync(): void; diff --git a/lexer.js b/lexer.js index 082e126..8522eae 100755 --- a/lexer.js +++ b/lexer.js @@ -68,7 +68,7 @@ function parseCJS (source, name = '@', options) { 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; - const baseline = options === undefined ? undefined : options.baseline; + const baseline = options?.baseline; if (baseline !== undefined && typeof baseline !== 'string') throw new TypeError('Detection baseline must be a string'); if (baseline !== undefined && baseline !== 'legacy' && baseline !== 'flow-v1') @@ -85,9 +85,10 @@ function parseCJS (source, name = '@', options) { error.loc = pos; throw error; } - if (baseline !== 'legacy' && moduleExportCount > 1) { + if (moduleExportCount > 1 && baseline !== 'legacy' && (mode !== 'extended' || baseline === 'flow-v1')) { resetState(); - return require('./dist/lexer.js').parse(source, name, baseline === undefined ? undefined : { baseline }); + return require('./dist/lexer.js').parse(source, name, + { baseline, mode, allowMinifiedEnumerable: minifiedEnumerable }); } const result = { exports: [..._exports].filter(expt => expt !== undefined && !unsafeGetters.has(expt)), reexports: [...reexports].filter(reexpt => reexpt !== undefined) }; resetState(); diff --git a/lib/lexer.wasm b/lib/lexer.wasm index fd7050af5d28867e4b123c01bb484ff15fc7f724..21fc033ff9f9cd1aadf0bda2c324a1f6be0e975f 100755 GIT binary patch literal 50492 zcmd_Td%Rsob?3Ps=XKBH>Ref}Wl1*s99g!EpAf)cAZR~X`L!=flO#!JJI~1u97vv%9!L*7C*i6c z;4gVj&c8f0^kgYY@toeEqW(I0PSfA=B0vAUTGC9CdYWdfG)o(){<1tt)2TeIDP8^5 z+j*l_Pp?U9wR)Z=X=6=xAT19)oOTW*c5Cii(|FPD7hS#YceAAP>}S7l_vP0-`?;52 zyE{*_YcEeuPj@Ge%64Cy6xr_G$spfHa>&2ClP%eG*CuCV*X>TWX8U$0XXg9(_pEH+ zb;-8YHJ9(ZcK2^QEtmG7BAfw4_wkJqxbVp9PVGW>){4&eT)JHXa z+Mb)PrEq=H0iRK8(8#wN3bi3f>@g(N&dUGF+qY!!!{mQOQsg^f1ywxE%Bi_wTAuRE z5|${^{pFiRquSn0y>*jc94`%-R7bj_78-z|zL3mKpVZ#g*_NHUZChhoecQHeDOU}+ zlR*t%BJ^8O{j9m^R>r_aLVz1RIGp95pC+^WdZ~RW2h3dWJ2ZEd-Y8lt(@Q)+!5srtoL?tfjTr zA8GA@R`mRr5+R+I{}AcZD8C((tw0}0(k}l#=61&foF0?4KPL9t@)NP_y76Zxm*0)K z8_Iu*$&KTNH;?N*ef(Kbem0gJj?11=elzBtS$-`hx0PRw$w!x8j>&V!^`7r#v+ZLJ zO02_&X}`(fOw04(ve@w#s$2B>!g3od71-gi)>*6fje0lsjq~c61iIF%nBC*!Mof@QSeJ$eUlZoa59@dR}_vA6P zHaN8cvKu1_HV$fKZ;&!eNS5vuLAI{Kpn)%qQAG@Kv`JOk^D(?N)a*Q;^&1Z>HYey0yJ^D{{cZf<#JZ-PWnCTTkA)HQ&kr8cU1b)_#;0TZ6R7xl0C|5#)hJ z{uUwJu-><1%Q(=IZDS=%cCACUfTIbRNojCKr029K(r<}TP7*4*7~VdH$=5Kk7i11o zVXdA$sUTUip0#Adde)LHBe*5o)+3hemfyFE(*9I;OF%>wAtQu{1e+yYuvyXtnW1b03L; zCXjkHfjnmKrhS^{G!yV5f2KI#(!G|2fOq!EclEX`6_kV zp-0WSaNwlTxVL1>NNCCSIN2R0VanT8rqzVEV_)y|?xR9wU!jJ=EBnWo^p3UMwn-UF zI-QFM*|0H5=fM(|fgs&IgUmT+^D3OfY+yoV2PKhc`!rz$+NTBdI<3fkc0hF-Hp}Uq zz0=AA1GE+?4)he`Q~fzTXmxFv-qhMNXbX#k3SE=Xmn}YNpPMkNrX7u=X4$`t&#IcJ zXN}cU_KQ~8+qH>-PZM1f!&>

TIs8bBa2fRwrFJ9X=d3`Lt*jQ{|dn!-QE8`T|`I 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 1) + return analyzeReexports(); #endif + return 0; } void tryBacktrackAddStarExportBinding (uint16_t* bPos) { @@ -1016,8 +1016,10 @@ void tryParseExportsDotAssign (bool assign) { // module.exports = case '=': { if (assign) { +#ifdef __wasm__ if (moduleExportCount < 2 && pos[1] != '=') moduleExportCount++; +#endif if (!extended) clearReexports(); pos++; ch = commentWhitespace(); @@ -1043,8 +1045,10 @@ void tryParseExportsDotAssign (bool assign) { case '&': case '|': case '?': { +#ifdef __wasm__ if (assign && moduleExportCount < 2 && end - pos >= 2 && pos[1] == ch && pos[2] == '=') moduleExportCount++; +#endif } } pos = revertPos; diff --git a/src/lexer.js b/src/lexer.js index 0a21e56..4c6044c 100755 --- a/src/lexer.js +++ b/src/lexer.js @@ -12,13 +12,15 @@ export function parse (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 flags = (options && options.allowMinifiedEnumerable === true ? 1 : 0) | - (mode === 'extended' ? 2 : 0); - const baseline = options === undefined ? undefined : options.baseline; + const minifiedEnumerable = options ? options.allowMinifiedEnumerable === true : false; + const baseline = options?.baseline; if (baseline !== undefined && typeof baseline !== 'string') throw new TypeError('Detection baseline must be a string'); if (baseline !== undefined && baseline !== 'legacy' && baseline !== 'flow-v1') throw new RangeError('Unknown detection baseline: ' + baseline); + const flags = (minifiedEnumerable ? 1 : 0) | + (mode === 'extended' ? 2 : 0) | + (baseline !== 'legacy' && (mode !== 'extended' || baseline === 'flow-v1') ? 4 : 0); if (!wasm) initSync(); @@ -39,8 +41,7 @@ export function parse (source, name = '@', options) { else (isLE ? copyLE : copyBE)(source, new Uint16Array(memory, addr, len)); - const status = baseline === 'legacy' ? wasm.parseCJSWithOptions(addr, len, 0, 0, 0, 0, flags) : - wasm.parseCJSGrouped(addr, len, flags); + const status = wasm.parseCJSWithOptions(addr, len, 0, 0, 0, 0, flags); const err_code = status & 255; if (err_code) { @@ -65,7 +66,7 @@ export function parse (source, name = '@', options) { const reexptStr = decode(source.slice(start, wasm.ree())); if (reexptStr !== undefined) reexports.add(reexptStr); - else if (reexptStr === undefined) + else complete = false; } while (wasm.ru()) { diff --git a/src/reexport-analysis.h b/src/reexport-analysis.h index 21296a6..b6189b3 100644 --- a/src/reexport-analysis.h +++ b/src/reexport-analysis.h @@ -54,14 +54,6 @@ static bool flowStep () { return !flow.failed; } -static bool flowReserve (uint32_t bytes) { - uint64_t required = (uint64_t)(uintptr_t)analysis_head + bytes; - if (required > UINT32_MAX) return false; - uint64_t capacity = (uint64_t)__builtin_wasm_memory_size(0) * 65536; - return required <= capacity || - __builtin_wasm_memory_grow(0, (required - capacity + 65535) / 65536) != (size_t)-1; -} - static bool flowLineBreak (uint16_t ch) { return ch == '\r' || ch == '\n' || ch == 0x2028 || ch == 0x2029; } @@ -719,20 +711,17 @@ static void flowClear () { first_unsafe_getter = unsafe_getter_read_head = unsafe_getter_write_head = NULL; } -uint32_t parseCJSGrouped (uint16_t* input, uint32_t length, uint32_t options) { - uint32_t status = parseCJSWithOptions(input, length, NULL, NULL, NULL, NULL, options); - if ((status & 255) || moduleExportCount < 2) return status; +static uint32_t analyzeReexports () { flowClear(); flow.tokenCount = 0; flow.nodeCount = 0; flow.cursor = 0; flow.depth = 0; flow.steps = 0; flow.failed = false; - uint64_t aligned = ((uint64_t)(uintptr_t)analysis_head + 7) & ~7ULL; + uint64_t aligned = (analysis_head + 7) & ~7ULL; uint32_t bytes = sizeof(FlowToken) * FLOW_TOKENS + sizeof(FlowNode) * (FLOW_TOKENS + 1) + sizeof(FlowResult) * (FLOW_DEPTH * 3 + 1); - if (aligned > UINT32_MAX || aligned - (uintptr_t)analysis_head > UINT32_MAX - bytes || - !flowReserve(bytes + aligned - (uintptr_t)analysis_head)) return status | FLOW_ANALYZED | FLOW_INCOMPLETE; + ensureAnalysisCapacity(bytes + aligned - analysis_head); flow.tokens = (FlowToken*)(uintptr_t)aligned; flow.nodes = (FlowNode*)(flow.tokens + FLOW_TOKENS); flow.results = (FlowResult*)(flow.nodes + FLOW_TOKENS + 1); - analysis_head = (void*)(flow.results + FLOW_DEPTH * 3 + 1); + analysis_head = aligned + bytes; flow.nodes[0].kind = FN_OBJECT; flow.nodes[0].left = 0; flowTokenize(); uint16_t block = flowNode(FN_BLOCK, 0, 0, (FlowToken){0}), tail = 0; @@ -744,8 +733,8 @@ uint32_t parseCJSGrouped (uint16_t* input, uint32_t length, uint32_t options) { } FlowResult* result = &flow.results[FLOW_DEPTH * 3]; if (!flow.failed) flowExecute(block, flowValue(FV_OBJECT, 0, 1, 0), result, 0); - if (flow.failed || !flowReserve((flow.nodeCount + FLOW_OUTCOMES) * sizeof(Slice))) - return status | FLOW_ANALYZED | FLOW_INCOMPLETE; + if (flow.failed) + return FLOW_ANALYZED | FLOW_INCOMPLETE; bool complete = true; for (uint32_t i = 0; i < result->count && flowStep(); i++) { FlowValue value = result->values[i].root; @@ -759,5 +748,5 @@ uint32_t parseCJSGrouped (uint16_t* input, uint32_t length, uint32_t options) { } else if (value.kind == FV_OBJECT) flowEmitObject(value.node, 0); } if (flow.failed) { flowClear(); complete = false; } - return status | FLOW_ANALYZED | (complete ? 0 : FLOW_INCOMPLETE); + return FLOW_ANALYZED | (complete ? 0 : FLOW_INCOMPLETE); } diff --git a/test/_unit.js b/test/_unit.js index dc7fd56..5813112 100755 --- a/test/_unit.js +++ b/test/_unit.js @@ -16,8 +16,11 @@ async function loadParser () { parse = require('../lexer.js').parse; } const parseSource = parse; - parse = /** @param {string} source @param {string} [name] */ (source, name) => - parseSource(source, name, { baseline: 'legacy' }); + parse = /** + * @param {string} source + * @param {string} [name] + * @param {import('../lexer').ParseOptions} [options] + */ (source, name, options) => parseSource(source, name, { ...options, baseline: 'legacy' }); } /** diff --git a/test/grouped.js b/test/grouped.js index 6483258..3d9c506 100644 --- a/test/grouped.js +++ b/test/grouped.js @@ -1,4 +1,4 @@ -const assert = require('node:assert').strict; +const assert = require('node:assert/strict'); const { mkdtempSync, rmSync, writeFileSync } = require('node:fs'); const { createRequire } = require('node:module'); const { tmpdir } = require('node:os'); @@ -81,6 +81,62 @@ suite('Grouped reexports', () => { assert.deepStrictEqual(parse("module.exports = {}; module.exports = require('./\\uD800');"), { exports: [], reexports: [], complete: false }); }); + test('preserves extended detection unless flow analysis is explicitly selected', () => { + const source = "if (flag) module.exports = require('./a'); else module.exports = require('./b');"; + for (const baseline of [undefined, 'legacy']) { + assert.deepStrictEqual(parse(source, '@', { mode: 'extended', baseline }), + { exports: [], reexports: ['./a', './b'] }); + } + assert.deepStrictEqual(parse(source, '@', { mode: 'extended', baseline: 'flow-v1' }), + { exports: [], reexports: [['./a'], ['./b']], complete: true }); + }); + test('combines detection options with each baseline', () => { + const source = "Object.defineProperty(exports, 'name', { enumerable: !0, value: 1 });"; + for (const baseline of [undefined, 'legacy', 'flow-v1']) { + for (const mode of [undefined, 'default', 'extended']) { + for (const allowMinifiedEnumerable of [false, true]) { + assert.deepStrictEqual(parse(source, '@', { baseline, mode, allowMinifiedEnumerable }), + { exports: mode === 'extended' || allowMinifiedEnumerable ? ['name'] : [], reexports: [] }); + } + } + } + }); + test('keeps empty dependency specifiers in each analyzed alternative', () => { + assert.deepStrictEqual(parse("if (flag) module.exports = require(''); else module.exports = require('./b');"), + { exports: [], reexports: [[''], ['./b']], complete: true }); + }); + test('snapshots options before the shared engine is entered', () => { + const source = "module.exports = {}; module.exports = { final };"; + for (const baseline of [undefined, 'legacy', 'flow-v1']) { + for (const mode of ['default', 'extended']) { + let modeReads = 0, enumerableReads = 0, baselineReads = 0; + const result = parse(source, '@', { + get mode () { modeReads++; parse('exports.nested = 1;'); return mode; }, + get allowMinifiedEnumerable () { enumerableReads++; parse('exports.nested = 1;'); return true; }, + get baseline () { baselineReads++; parse('exports.nested = 1;'); return baseline; } + }); + assert.equal(modeReads, 1); + assert.equal(enumerableReads, 1); + assert.equal(baselineReads, 1); + assert.deepStrictEqual(result, baseline === 'legacy' || baseline === undefined && mode === 'extended' ? + { exports: ['final'], reexports: [] } : { exports: ['final'], reexports: [], complete: true }); + } + } + }); + test('reads detection options before a baseline getter can change them', () => { + const source = "Object.defineProperty(exports, 'name', { enumerable: !0, value: 1 });"; + for (const allowMinifiedEnumerable of [false, true]) { + const options = { + allowMinifiedEnumerable, + get baseline () { + this.allowMinifiedEnumerable = !allowMinifiedEnumerable; + return 'legacy'; + } + }; + assert.deepStrictEqual(parse(source, '@', options), + { exports: allowMinifiedEnumerable ? ['name'] : [], reexports: [] }); + } + }); test('initializes the shared Wasm engine through CommonJS init', async () => { const rootPath = require.resolve('../lexer.js'), wasmPath = require.resolve('../dist/lexer.js'); const rootModule = require.cache[rootPath], wasmModule = require.cache[wasmPath];