From 75c85afec066aec16fec2abc345dc33590cd6d03 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Mon, 5 Oct 2026 20:08:02 +0900 Subject: [PATCH 1/5] 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 | 45 +++ build.js | 3 +- lexer.d.ts | 25 ++ lexer.js | 5 +- package.json | 1 + src/export-analysis.js | 853 +++++++++++++++++++++++++++++++++++++++++ src/lexer.js | 5 +- test/flow.js | 440 +++++++++++++++++++++ 8 files changed, 1374 insertions(+), 3 deletions(-) create mode 100644 src/export-analysis.js create mode 100644 test/flow.js diff --git a/README.md b/README.md index 18bfa08..47d1a7e 100755 --- a/README.md +++ b/README.md @@ -71,6 +71,51 @@ Browser main threads can restrict synchronous Wasm compilation, so await The Wasm build is around 1.5x faster and without a cold start. +### 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. + ### Grammar CommonJS exports matches are run against the source token stream. diff --git a/build.js b/build.js index da024d1..bc757dc 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 cda07d8..9ec8356 100755 --- a/lexer.d.ts +++ b/lexer.d.ts @@ -3,6 +3,31 @@ export interface Exports { reexports: string[]; } +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): Exports; export declare function init(): Promise; export declare function initSync(): void; diff --git a/lexer.js b/lexer.js index 2983aef..de96a5f 100755 --- a/lexer.js +++ b/lexer.js @@ -52,7 +52,10 @@ function pushTemplate () { templateDepth = ++openTokenDepth; } -function parseCJS (source, name = '@') { +/** @param {string} source @param {string} [name] @param {import('./lexer').ParseOptions} [options] */ +function parseCJS (source, name = '@', options) { + const analysis = options === undefined ? undefined : require('./src/export-analysis.js')(source, options, decode); + if (analysis !== undefined) return analysis; resetState(); try { parseSource(source); diff --git a/package.json b/package.json index de3b039..b698ed6 100755 --- a/package.json +++ b/package.json @@ -36,6 +36,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 9989658..2c82474 100755 --- a/src/lexer.js +++ b/src/lexer.js @@ -3,7 +3,10 @@ let wasm; const isLE = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1; const hasBuffer = typeof Buffer !== 'undefined'; -export function parse (source, name = '@') { +/** @param {string} source @param {string} [name] @param {import('../lexer').ParseOptions} [options] */ +export function parse (source, name = '@', options) { + 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 f58044370bf41b9f46288aef50d0d22778d13bc7 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Mon, 5 Oct 2026 21:46:49 +0900 Subject: [PATCH 2/5] 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 | 31 +- lib/lexer.wasm | Bin 22369 -> 22566 bytes src/export-analysis.js | 663 ++++++++++------------------------------- src/lexer.c | 16 + src/lexer.js | 16 +- test/flow.js | 573 +++++++++++------------------------ 8 files changed, 412 insertions(+), 946 deletions(-) diff --git a/README.md b/README.md index 47d1a7e..487a149 100755 --- a/README.md +++ b/README.md @@ -73,8 +73,18 @@ The Wasm build is around 1.5x faster and without a cold start. ### 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(` @@ -87,34 +97,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. ### Grammar diff --git a/lexer.d.ts b/lexer.d.ts index 9ec8356..2161ed2 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 de96a5f..f554add 100755 --- a/lexer.js +++ b/lexer.js @@ -13,17 +13,18 @@ let openTokenDepth, lastStarExportSpecifier, _exports, unsafeGetters, - reexports; + reexports, + moduleExportCount; function resetState () { openTokenDepth = 0; templateDepth = -1; lastTokenPos = -1; lastSlashWasDivision = false; - templateStack = new Array(1024); + if (templateStack === undefined) templateStack = new Array(1024); templateStackDepth = 0; - openTokenPosStack = new Array(1024); - openClassPosStack = new Array(1024); + if (openTokenPosStack === undefined) openTokenPosStack = new Array(1024); + if (openClassPosStack === undefined) openClassPosStack = new Array(1024); nextBraceIsClass = false; starExportMap = Object.create(null); lastStarExportSpecifier = null; @@ -31,6 +32,7 @@ function resetState () { _exports = new Set(); unsafeGetters = new Set(); reexports = new Set(); + moduleExportCount = 0; } // RequireType @@ -54,8 +56,11 @@ function pushTemplate () { /** @param {string} source @param {string} [name] @param {import('./lexer').ParseOptions} [options] */ function parseCJS (source, name = '@', options) { - 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(); try { parseSource(source); @@ -66,8 +71,10 @@ function parseCJS (source, name = '@', options) { throw e; } 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); } function decode (str) { @@ -900,6 +907,7 @@ function tryParseModuleExportsDotAssign () { pos = revertPos; } +/** @param {boolean} assign */ function tryParseExportsDotAssign (assign) { pos += 7; const revertPos = pos - 1; @@ -940,6 +948,8 @@ function tryParseExportsDotAssign (assign) { // module.exports = case 61/*=*/: { if (assign) { + if (moduleExportCount < 2 && source.charCodeAt(pos + 1) !== 61) + moduleExportCount++; if (reexports.size) reexports = new Set(); pos++; @@ -954,6 +964,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/lib/lexer.wasm b/lib/lexer.wasm index 08fbd540e65e1f1e87e704db63f3121c91814525..8e6e833e3369f6af2b138e4f0cbfadae114597e3 100755 GIT binary patch delta 1610 zcmYLJVQ3sh6rMM;yLXq_O*VH8Ns}fww@tz|xtJ1ai!F&a#at3177?+w1uck%EJX|j zMU*S3AlD#)2}Mva`C$wGC|4FLXn#aOe@KO*P$;%gE0z3dX;JV`X?$;Y)8n|AH?!~i z-ZyXFn?EnY`6e{`fZsf4`r$n^}6&RS@Y5IlRDN>pe z^L;%z;|X(6JcXgpTD>kqTHL+GW;|}-neHlAOn+I#pq;>6E)|>hfLHa$8;pc74f7sL z`No0k$r>*4BcgyLO7oo<1s?q^&cSgPVtnjCXpx{d;zMnpjfGmwdPsnD3T6_^BAp2_ zf?{IwS6sXCqGBd8L(xV1Ww+hZpOf6coomPjMp*EHz~~;g=2<#WqKUV zwS}qwHGc=bFwKItttw{GtdAAUR}*$5cjN&qGthOcgY+OWK9;5PX0xE83tc{#Bi%g* zePxXM>Y2_A8LOtD)j!$IhNv$UH#F2yY2(ocL%1-VDSYqmx>eS>*Dj= z1M{$WgeI+e%?b5ZCsw}D*QRPn_2E1E##GJxuHGPr1Er_UsnTOI!{C(?`Z0a!HddWI zht@h-xw$1YKOs}hnZJS)y@jVSU0ZnP>-XhrN6-)6YX9ix1qs9=H%eJ zZp_=fjy|JEF?f%@T^kC_KSRG{u_DG8R;c-DxR}EPhjB4R_lC<*yj}yl&g=RN_7DU% zwh*ROQCm`5X){``z_K}79)T6}QF*k`z)f}tnErIzST#SCe}jfu-E+T)U-25IJx*oy zq$ERRbA{9mvp&)Vjp*Bv1?VIqAfjf{jw@zw<$_#^`<$XTqC1tw4&*(h`*A$0dr`r8 zy>Bl9F)I)TF?uy+Vb1n9BNIXEVdM4P=0z*KT+sfQa_;9FM&=ewur(ZY+Q^>Xk!x5MQ2Z& zTQdvC6Kw(0XMAej#E}*rNbO=|Tatc-^5e*Bw+T&4jGP1^L1=AbVKF&oBU+pNfMbgK z=jZ1wOdxg>O?tD46%)T&IODx$vBh9Nw{W6cmI1vipwf3fyQmP^M-Lj0Nuo>hP5D0} COjAez delta 1389 zcmYLJU2Gdg5Z>AQ@z2`H)lFj8!SVVMr%9YNp@bq7CGrN@i3wFHt%N{`-#&z#L{$qA z;?EH(R)ky#8d3THBD{e5f($3ohZZSM2#J>p2?T;lfv3JesRTR$W%hi=I^FKv+{`yK z-_GnuZTR^UXb%H__Z`f^ao~6wMreH#roq6=P^R^7$OwLfGoe_&kOt=Y=ePS77!7r0 z2lP9^nafq*2HEg3FFTG4>PNO>mL;ggY7%HWgg0l0MndBPID?fW?|w1IQHv`{j>nBd z0Jv!s@yWx3X;Oh?>b*R{?*oRivSJD9O*JARRB)1`bl%4C}1TC*&t_1L~&(GhL7(5 z1umc(P;gp7&1h!0*4-MM6IWE~tp=O{9g_Z)fXXy}$ZnOTP$_(kRlzI0#~ zFCO?aPPE=kdCIBP{fpTzIiAVo{ByY*fUoA4@N)hbCgfpUlt-~8k3k);$t?aQ*Bxo7 zt4q2=UOA(FSgO|Xbm0_!TeuTr?k$XuK3J&Jzfbp=r>q)DA8IL9)5_PiXw>TX+UO&= zH~J8PE{tXI?F!rY%;Y!F zL^bsrt#hSZnrx_Psqr@*O=H4|Ck&yQ{;ASB?9%|C0ouX5suo_G{#dkj8MUGg+VtO= zd1HW(vdg*K$e5-IHrya&$Ac97*$whLu{=K)8hCC1xfKTP9G}jRUP?s)jiHXYg_o8e z>)+mG`uOS5P0;d-_gn|q_WLT+#!v@B{d>!xi$Ix_dW=%X`&TpW|2Za1QU$3sGwU!1YB4`oyD12W~AdApzC?+o#3LOohXaa#K946(xwTd!MK|=R2h=n8uNzP9)eTL~w! 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 b6576d4..8aae199 100755 --- a/src/lexer.c +++ b/src/lexer.c @@ -32,6 +32,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]; @@ -72,6 +73,7 @@ static inline __attribute__((always_inline)) bool pushTemplate () { uint32_t parseCJS (uint16_t* _source, uint32_t _sourceLen, void (*_addExport)(const uint16_t*, const uint16_t*), void (*_addReexport)(const uint16_t*, const uint16_t*), void (*_addUnsafeGetter)(const uint16_t*, const uint16_t*), void (*_clearReexports)()) { source = _source; sourceLen = _sourceLen; + moduleExportCount = 0; if (_addExport) addExport = _addExport; if (_addReexport) @@ -263,7 +265,12 @@ uint32_t parseCJS (uint16_t* _source, uint32_t _sourceLen, void (*_addExport)(co return error; // 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) { @@ -939,6 +946,8 @@ void tryParseExportsDotAssign (bool assign) { // module.exports = case '=': { if (assign) { + if (moduleExportCount < 2 && pos[1] != '=') + moduleExportCount++; clearReexports(); pos++; ch = commentWhitespace(); @@ -952,6 +961,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 2c82474..b4a745a 100755 --- a/src/lexer.js +++ b/src/lexer.js @@ -5,8 +5,11 @@ const hasBuffer = typeof Buffer !== 'undefined'; /** @param {string} source @param {string} [name] @param {import('../lexer').ParseOptions} [options] */ export function parse (source, name = '@', options) { - 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(); @@ -24,7 +27,8 @@ export function parse (source, name = '@', options) { else (isLE ? copyLE : copyBE)(source, new Uint16Array(wasm.memory.buffer, addr, len)); - const err_code = wasm.parseCJS(addr, source.length, 0, 0, 0); + const status = wasm.parseCJS(addr, source.length, 0, 0, 0); + 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)}`); @@ -49,7 +53,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 b997b32d648d461d0c664c22937a55f1986938e6 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Tue, 6 Oct 2026 12:07:07 +0900 Subject: [PATCH 3/5] 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 | 110 ++--- build.js | 3 +- build/Makefile | 4 +- include-wasm/cjs-module-lexer.h | 2 + lexer.d.ts | 34 +- lexer.js | 24 +- lib/lexer.wasm | Bin 22566 -> 37051 bytes 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 +- 17 files changed, 1141 insertions(+), 830 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 487a149..7eb112f 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. -_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: @@ -69,64 +55,50 @@ Call `initSync()` directly to choose when the blocking initialization runs. Browser main threads can restrict synchronous Wasm compilation, so await `init()` before parsing in browsers. -The Wasm build is around 1.5x faster and without a cold start. - ### 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. ### Grammar +The grammar below describes the `legacy` baseline. + CommonJS exports matches are run against the source token stream. The token grammar is: diff --git a/build.js b/build.js index bc757dc..da024d1 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 b8e96d8..7d78168 100755 --- a/build/Makefile +++ b/build/Makefile @@ -7,11 +7,11 @@ WASM_LDFLAGS := -nostartfiles WASM_TARGET := -target wasm32-unknown-wasi WASM_EXTRA_CFLAGS := -I include-wasm/ -Wno-logical-op-parentheses -Wno-parentheses -Oz WASM_EXTRA_LDFLAGS := -Wl,-z,stack-size=13312,--no-entry,--compress-relocations,--strip-all -WASM_EXTRA_LDFLAGS += -Wl,--export=__heap_base,--export=parseCJS,--export=sa +WASM_EXTRA_LDFLAGS += -Wl,--export=__heap_base,--export=parseCJS,--export=parseCJSGrouped,--export=sa 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 ab2adca..2e88907 100755 --- a/include-wasm/cjs-module-lexer.h +++ b/include-wasm/cjs-module-lexer.h @@ -72,6 +72,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 2161ed2..3deb718 --- a/lexer.d.ts +++ b/lexer.d.ts @@ -1,35 +1,23 @@ 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 { 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 f554add..5f0d083 100755 --- a/lexer.js +++ b/lexer.js @@ -57,10 +57,10 @@ function pushTemplate () { /** @param {string} source @param {string} [name] @param {import('./lexer').ParseOptions} [options] */ function parseCJS (source, name = '@', options) { 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(); try { parseSource(source); @@ -70,11 +70,15 @@ function parseCJS (source, name = '@', options) { e.loc = pos; throw e; } + 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; } function decode (str) { @@ -1659,8 +1663,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/lib/lexer.wasm b/lib/lexer.wasm index 8e6e833e3369f6af2b138e4f0cbfadae114597e3..cbc6a444222e58eb4653991a160591c55192eb20 100755 GIT binary patch literal 37051 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 delta 9563 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 b6YY7f9Zh}US*5g6%HS`+Ux+_`+-d(We?^Hy diff --git a/package.json b/package.json index b698ed6..de3b039 100755 --- a/package.json +++ b/package.json @@ -36,7 +36,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 8aae199..e7ed7f8 100755 --- a/src/lexer.c +++ b/src/lexer.c @@ -10,6 +10,7 @@ const uint16_t* EMPTY_CHAR = &__empty_char; #define MAX_STAR_EXPORTS 256 const uint16_t* source; + bool lastSlashWasDivision; uint16_t templateStackDepth; uint16_t openTokenDepth; @@ -34,6 +35,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]; bool openClassPosStack[STACK_DEPTH]; diff --git a/src/lexer.js b/src/lexer.js index b4a745a..2305b13 100755 --- a/src/lexer.js +++ b/src/lexer.js @@ -6,10 +6,10 @@ const hasBuffer = typeof Buffer !== 'undefined'; /** @param {string} source @param {string} [name] @param {import('../lexer').ParseOptions} [options] */ export function parse (source, name = '@', options) { 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(); @@ -27,7 +27,8 @@ export function parse (source, name = '@', options) { else (isLE ? copyLE : copyBE)(source, new Uint16Array(wasm.memory.buffer, addr, len)); - const status = wasm.parseCJS(addr, source.length, 0, 0, 0); + const status = baseline === 'legacy' ? wasm.parseCJS(addr, source.length, 0, 0, 0) : + wasm.parseCJSGrouped(addr, source.length); const err_code = status & 255; if (err_code) { @@ -39,25 +40,50 @@ export function parse (source, name = '@', options) { } let exports = new Set(), reexports = new Set(), unsafeGetters = new Set(); + 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) reexports.add(reexptStr); + else if (reexptStr === undefined) + complete = false; } while (wasm.ru()) 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.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..93e72b7 --- /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 status = parseCJS(input, length, NULL, NULL, NULL, NULL); + 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 59e5e6c..d42fa3c 100755 --- a/test/_unit.js +++ b/test/_unit.js @@ -15,9 +15,12 @@ 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' }); } -suite('Lexer', () => { +suite('Legacy lexer', () => { suiteSetup(async () => await loadParser()); test('automatic Wasm initialization', async () => { 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 24371f2adba3dfe2b3d5d972e6a2f80bb795a271 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Tue, 6 Oct 2026 12:13:54 +0900 Subject: [PATCH 4/5] 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 af3f5ff8937e9419012053311890fc325b3bf4be Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Tue, 6 Oct 2026 15:09:42 +0900 Subject: [PATCH 5/5] 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 7eb112f..d61803b 100755 --- a/README.md +++ b/README.md @@ -57,10 +57,12 @@ Browser main threads can restrict synchronous Wasm compilation, so await ### 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(` @@ -78,9 +80,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 3deb718..c8f3318 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; } @@ -16,8 +16,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 5f0d083..2cbfa61 100755 --- a/lexer.js +++ b/lexer.js @@ -76,8 +76,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 2305b13..8d4dfc1 100755 --- a/src/lexer.js +++ b/src/lexer.js @@ -68,10 +68,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; @@ -83,7 +82,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; ' +