diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index db80dae951..8852e339eb 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -363,6 +363,29 @@ conditions retain both return branches, including fallthrough. Short-circuit selection distinguishes truthiness from nullishness; uncertain `??` paths retain a nullish guard rather than a falsy guard. +Recognized module factories retain a separate initialization boundary for each +reachable return and for fallthrough. A conditional early return does not hide +later exports on the continuing path; exports and captured initializers after a +definite exit remain unavailable. Fallthrough candidates retain their branch +predicates. Exports shared by the complete set of proven exits also retain +unconditional facts. Their shared view excludes later overwrites and bounds +captured initializers by the earliest completion; branch-dependent facts keep +their predicates. Incomplete or additionally guarded exit sets cannot use that +proof. Completed factory control flow is reused within that analysis only. +Guarded main-process registrations remain candidate paths even with empty +callbacks; they do not become unconditional IPC summary facts. +Factory guards read export writes at their source position. Destructured and +spread snapshots retain their capture position rather than reading later writes. +An unproven namespace read boundary remains unknown. +Completed factory environments preserve their `exports` and `module.exports` +alias identity when a namespace escapes through an export or returned closure. +Synchronous helper calls retain the caller's factory activation and read point; +lexical captures and executing callers remain separate facts. +Async calls retain their truthy Promise identity until an explicit `await`; +generator calls produce iterators without evaluating their bodies. Iterator +advancement and Promise/iterator members remain explicitly unresolved. Awaited +function values retain their source origins and captured initialization bounds. + These paths appear as contextual `javascript-expression` nodes. An exact `api` seed such as `desktop.copy` can trace an exposed member through a unique IPC pairing to an external API call. `addListener` is normalized to `on`. Semantic diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index 2c953d0a69..3bf5e87554 100644 --- a/src/domain/javascript/electronWrappedCallPaths.test.ts +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -771,3 +771,24 @@ describe("wrapped Electron API identity", () => { ); }); }); + +it.each([false, true])( + "preserves a guarded bridge entry sharing a registration with an unconditional member (reverse=%s)", + (reverse) => { + const members = ["always:register", "conditional:flag?register:()=>{}"]; + if (reverse) members.reverse(); + const result = analyzeJavaScriptStaticSource(`const e=require("electron"); + function register(){e.ipcMain.on("bridge:register",()=>{});} + e.contextBridge.exposeInMainWorld("api",{${members.join(",")}});`); + expect(result.electron.call_paths).toContainEqual( + expect.objectContaining({ + entry_kind: "bridge-member", + entry: "api.conditional", + terminal: "electron.ipcMain.on", + conditions: expect.arrayContaining([ + expect.objectContaining({ expression: "flag" }), + ]), + }), + ); + }, +); diff --git a/src/domain/javascript/electronWrappedCallPaths.ts b/src/domain/javascript/electronWrappedCallPaths.ts index e41fafe78d..c32bca04b8 100644 --- a/src/domain/javascript/electronWrappedCallPaths.ts +++ b/src/domain/javascript/electronWrappedCallPaths.ts @@ -97,6 +97,7 @@ export const collectElectronWrappedCallPaths = ( const resolver = new JavaScriptSymbolicBindings(state, program, identity); const paths: ElectronCallPath[] = []; const seen = new Set(); + const unconditionalRegistrations = new Set(); const step = ( node: t.Node, kind: ElectronCallPathStep["kind"], @@ -286,8 +287,10 @@ export const collectElectronWrappedCallPaths = ( args: readonly SymbolicValue[], context: PathContext, invocationNode: t.Node, + caller?: SymbolicEnvironment, ): void => { - if (context.active.has(fn.node)) return; + const body = resolver.callBody(fn); + if (body === undefined || context.active.has(fn.node)) return; if (fn.conditionUnresolved === true) { accumulator.unknownFindings += 1; return; @@ -297,7 +300,7 @@ export const collectElectronWrappedCallPaths = ( fn.node.id ? fn.node.id.name : "closure"; - walk(fn.node.body, resolver.bind(fn, args, invocationNode), { + walk(body, resolver.bind(fn, args, invocationNode, caller), { ...context, steps: [ ...context.steps, @@ -397,6 +400,23 @@ export const collectElectronWrappedCallPaths = ( recorded.invocations.has(ipcInvocationKey(invocationLocation))); const findingGuarded = context.conditions.length > 0 || hasConditions(first) || guardedDispatch; + // Registry facts do not establish every bridge entry's reachability. Only + // this exact entry and provenance trace can discharge its guarded copy. + const registrationKey = + descriptor.side === "main" + ? JSON.stringify([ + endpoint, + channel, + location, + range(context.entryNode), + context.entryKind, + context.entry, + context.entryPath, + context.steps, + ]) + : undefined; + if (!findingGuarded && registrationKey !== undefined) + unconditionalRegistrations.add(registrationKey); if (!duplicate && !findingGuarded) { const value: ElectronIpcFinding = { ...finding, @@ -406,7 +426,27 @@ export const collectElectronWrappedCallPaths = ( indexIpcFinding(ipcAtEndpoint(), value); if (descriptor.mode !== "send" && descriptor.mode !== "invoke" && !fn) accumulator.unknownFindings += 1; - } else if (findingGuarded) accumulator.unknownFindings += 1; + } else if ( + findingGuarded && + (registrationKey === undefined || + !unconditionalRegistrations.has(registrationKey)) + ) { + accumulator.unknownFindings += 1; + // A guarded registration is a path candidate even when its callback has + // no observable body. Keep its predicate instead of promoting it to an + // unconditional IPC fact or dropping the registration entirely. + if (descriptor.side === "main") + emit( + { + ...channelContext, + entryKind: channelContext.entryKind ?? "ipc-handler", + entry: channelContext.entry || channel, + }, + node, + endpoint, + channel, + ); + } if (descriptor.mode === "send" || descriptor.mode === "invoke") { emit(channelContext, node, endpoint, channel); return; @@ -555,6 +595,7 @@ export const collectElectronWrappedCallPaths = ( ], }, node, + env, ); } else if (callee.kind === "external") { const path = callee.path; diff --git a/src/domain/javascript/javascriptSymbolicBindings.test.ts b/src/domain/javascript/javascriptSymbolicBindings.test.ts index b78bd543a1..bf9a4a4003 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.test.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.test.ts @@ -1,4 +1,4 @@ -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import * as t from "@babel/types"; import { parseJavaScriptSource } from "./javascriptSourceParser.js"; import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js"; @@ -316,6 +316,126 @@ it("does not treat statements after a returned module factory as initialized", ( expect(result).toMatchObject({ kind: "unknown" }); }); +it("retains the guard on a factory's completed fallthrough activation", () => { + const value = inspectOne(`(() => { + const modules = { + 5(module, exports) { + { if (flag) return; } + const electron = require("electron"); + exports.read = () => electron; + }, + 9(module, exports, load) { inspect(load(5).read()); } + }; + function load(id) { const m={exports:{}}; modules[id](m,m.exports,load); return m.exports; } + load(9); + })();`); + const electron = symbolicVariants(value).filter( + (variant) => variant.kind === "external", + ); + expect(electron).toHaveLength(1); + expect(electron[0]).toMatchObject({ + path: ["electron"], + conditions: expect.arrayContaining([ + { + node: expect.objectContaining({ type: "Identifier", name: "flag" }), + positive: false, + }, + ]), + }); + expect( + symbolicVariants(value).some((variant) => variant.kind === "unknown"), + ).toBe(true); +}); + +it("shares completed factory control flow across repeated loads within one analysis", () => { + const { parsed, resolver } = resolverFor(`(() => { + const modules = { + 5(module, exports) { if (flag) return; exports.value = 42; }, + 9(module, exports, load) { ${"inspect(load(5));".repeat(64)} } + }; + function load(id) { const m={exports:{}}; modules[id](m,m.exports,load); return m.exports; } + load(9); + })();`); + const evaluated = vi.spyOn(resolver, "evaluate"); + const results: SymbolicValue[] = []; + traverseJavaScriptAst(parsed.program, { + enter: (node) => { + if ( + t.isCallExpression(node) && + t.isIdentifier(node.callee, { name: "inspect" }) + ) + results.push(resolver.evaluate(node.arguments[0])); + }, + }); + expect(results).toHaveLength(64); + expect( + results.every((result) => + symbolicVariants(result).every( + (variant, index) => variant === symbolicVariants(results[0]!)[index], + ), + ), + ).toBe(true); + expect( + evaluated.mock.calls.filter(([node]) => + t.isIdentifier(node, { name: "flag" }), + ), + ).toHaveLength(1); + expect(resolver.truncated).toBe(false); +}); + +it("bounds predicate expansion across many conditional factory exits", () => { + const { resolver, values } = inspectFixture(`(() => { + const modules = { + 5(module, exports) { ${"if (flag) return;".repeat(1_600)} exports.value = 42; }, + 9(module, exports, load) { inspect(load(5)); } + }; + function load(id) { const m={exports:{}}; modules[id](m,m.exports,load); return m.exports; } + load(9); + })();`); + expect(resolver.truncated).toBe(true); + expect(values.flatMap(symbolicVariants)).toContainEqual( + expect.objectContaining({ + kind: "unknown", + reason: expect.stringContaining("resource limit"), + }), + ); +}); + +it("does not borrow an unrelated activation's namespace read boundary", () => { + const { parsed, resolver, values } = inspectFixture(`(() => { + const modules = {5(module,exports){exports.stop=true;inspect(exports);if(exports.stop)return;}}; + function load(id){const m={exports:{}};modules[id](m,m.exports,load);return m.exports;} + load(5); + })();`); + const namespace = values[0]; + if (namespace?.kind !== "namespace") + throw new Error("namespace fixture missing"); + let read: t.MemberExpression | undefined; + traverseJavaScriptAst(parsed.program, { + enter: (node) => { + if (t.isIfStatement(node) && t.isMemberExpression(node.test)) + read = node.test; + }, + }); + if (read === undefined) throw new Error("namespace read fixture missing"); + expect( + resolver.property(namespace, "stop", { + node: read, + environment: namespace.environment, + }), + ).toMatchObject({ kind: "literal", value: true }); + expect( + resolver.property(namespace, "stop", { + node: read, + environment: { ...namespace.environment, activation: {} }, + }), + ).toMatchObject({ + kind: "unknown", + reason: "module export read boundary is unavailable", + }); + expect(resolver.truncated).toBe(false); +}); + it("treats known symbolic records and loaders as truthy", () => { expect(symbolicTruth({ kind: "record", values: new Map() })).toBe(true); expect(symbolicTruth({ kind: "loader", modules: new Map() })).toBe(true); @@ -574,3 +694,66 @@ it("completes a loaded module past a very large array without stack overflow", ( throw new Error("loaded module namespace missing"); expect(namespace.environment.completedAt).toBe(earliestReturn); }); + +it("keeps async call objects truthy and unwraps only awaited results", () => { + const values = inspectedValues(`async function flag(){return false;} + inspect(flag()); inspect(!flag()); inspect(flag() ?? true); + inspect(await flag()); inspect(flag() || false);`); + expect(values[0]).toMatchObject({ + kind: "promise", + value: { kind: "literal", value: false }, + }); + expect(values[1]).toMatchObject({ kind: "literal", value: false }); + expect(values[2]?.kind).toBe("promise"); + expect(values[3]).toMatchObject({ kind: "literal", value: false }); + expect(values[4]?.kind).toBe("promise"); +}); + +it("does not execute generator bodies to resolve initial calls", () => { + const { resolver, values } = inspectFixture(`function* flag(){return false;} + async function* asyncFlag(){return false;} + inspect(flag()); inspect(!flag()); inspect(asyncFlag()); inspect(flag() ?? false);`); + expect(values.map((value) => value.kind)).toEqual([ + "iterator", + "literal", + "iterator", + "iterator", + ]); + expect(values[1]).toMatchObject({ kind: "literal", value: false }); + expect(resolver.unresolved).toBe(false); +}); + +it("preserves captured initialization through async and forwarding returns", () => { + const { resolver, values } = inspectFixture(`function outer(){ + const electron=require("electron"); + async function capture(){return ()=>electron.ipcMain;} + return capture(); + } + async function forward(){return outer();} + inspect(await forward());`); + const fn = values[0]; + expect(fn?.kind).toBe("function"); + if (fn?.kind !== "function") throw new Error("awaited closure missing"); + expect(resolver.evaluate(fn.node.body, resolver.bind(fn, []))).toMatchObject({ + kind: "external", + path: ["electron", "ipcMain"], + }); + expect(fn.origins.length).toBeGreaterThan(0); +}); + +it.each([ + "function* value(){return false;}", + "async function value(){return false;}", +])( + "keeps opaque call-result member access explicitly unresolved: %s", + (declaration) => { + const { resolver, values } = inspectFixture( + `${declaration}inspect(value().next);`, + ); + expect(values[0]).toMatchObject({ + kind: "unknown", + reason: expect.stringContaining("members are not modeled"), + }); + expect(resolver.unresolved).toBe(true); + }, +); diff --git a/src/domain/javascript/javascriptSymbolicBindings.ts b/src/domain/javascript/javascriptSymbolicBindings.ts index 1bc1d26373..3d100260f0 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.ts @@ -32,9 +32,9 @@ import { symbolicUnion, symbolicUnknown, symbolicVariants, - symbolicConditionsUnresolved, symbolicTruth, type SymbolicCondition, + type SymbolicMetadata, type SymbolicValue, type SymbolicEnvironment, type SymbolicFunction, @@ -45,10 +45,20 @@ interface PropertyWrite { readonly value: t.Node; readonly site: t.Node; } -interface SymbolicReturnFlow { +interface SymbolicReturnFlow extends SymbolicMetadata { readonly values: readonly SymbolicValue[]; readonly fallsThrough: boolean; } +interface SymbolicPropertyRead { + readonly node: t.Node; + readonly environment: SymbolicEnvironment; +} +interface SymbolicReadBoundary { + readonly node: t.Node; + readonly edge: "start" | "end"; +} +type SymbolicNamespace = Extract; + const emptyEnvironment: SymbolicEnvironment = { values: new Map() }; const isObjectPrototypeSetter = ( property: t.ObjectMethod | t.ObjectProperty, @@ -82,7 +92,17 @@ export class JavaScriptSymbolicBindings { readonly #activeFunctions = new Set(); readonly #exportAliases = new Map(); readonly #moduleBindings = new Set(); - readonly #replacements = new Map(); + readonly #completedModules = new WeakMap< + SymbolicEnvironment, + SymbolicValue + >(); + readonly #completionGroups = new WeakMap< + SymbolicValue, + { + readonly members: ReadonlySet; + readonly common: SymbolicNamespace; + } + >(); readonly #propertyActive = new WeakMap>(); readonly #literalPropertyIndex = new WeakMap< t.ObjectExpression, @@ -189,33 +209,99 @@ export class JavaScriptSymbolicBindings { } } - property(value: SymbolicValue, key: string): SymbolicValue { - let result: SymbolicValue; - if (value.kind === "union") - result = symbolicUnion( - value.values.map((variant) => this.property(variant, key)), - ); - else result = this.#property(value, key); - for (const condition of value.conditions ?? []) - result = symbolicCondition(result, condition); - if (value.conditionUnresolved) - result = symbolicConditionsUnresolved(result); - return result; + #withConditions( + value: SymbolicValue, + source: SymbolicMetadata, + ): SymbolicValue { + const conditions = source.conditions ?? []; + if (conditions.length === 0 && !source.conditionUnresolved) return value; + const variants = symbolicVariants(value); + // Charge the representation expansion before allocating copies. A long + // sequence of early exits otherwise builds unbounded predicate prefixes. + for (const variant of variants) { + const cost = + 1 + + (conditions.length + ? conditions.length + (variant.conditions?.length ?? 0) + : 0); + if (cost > this.#remainingEvaluations) { + this.#remainingEvaluations = 0; + this.truncated = true; + return symbolicUnknown("symbolic condition resource limit"); + } + this.#remainingEvaluations -= cost; + } + return symbolicUnion( + variants.map((variant) => ({ + ...variant, + ...(conditions.length + ? { conditions: [...(variant.conditions ?? []), ...conditions] } + : {}), + ...(source.conditionUnresolved + ? { conditionUnresolved: true as const } + : {}), + })), + ); } - #property(value: SymbolicValue, key: string): SymbolicValue { + property( + value: SymbolicValue, + key: string, + read?: SymbolicPropertyRead, + ): SymbolicValue { + let result: SymbolicValue; + if (value.kind === "union") + result = symbolicUnion([ + ...this.#commonProperty(value.values, key, read), + ...value.values.map((variant) => this.property(variant, key, read)), + ]); + else result = this.#property(value, key, read); + return this.#withConditions(result, value); + } + + #commonProperty( + values: readonly SymbolicValue[], + key: string, + read?: SymbolicPropertyRead, + ): readonly SymbolicValue[] { + const first = values[0]; + const group = + first === undefined ? undefined : this.#completionGroups.get(first); + if (group === undefined || group.members.size !== values.length) return []; + if (values.length > this.#remainingEvaluations) { + this.truncated = true; + this.#remainingEvaluations = 0; + return []; + } + this.#remainingEvaluations -= values.length; + // Only the complete, original partition proves an unconditional fact. + // Added predicates or a subset of exits cannot borrow this proof. + if (!values.every((value) => group.members.has(value))) return []; + const common = this.property(group.common, key, read); + return common.kind === "unknown" ? [] : [common]; + } + + #property( + value: SymbolicValue, + key: string, + read?: SymbolicPropertyRead, + ): SymbolicValue { const active = this.#propertyActive.get(value) ?? new Set(); if (active.has(key)) return symbolicUnknown("cyclic property"); active.add(key); this.#propertyActive.set(value, active); try { - return this.#propertyValue(value, key); + return this.#propertyValue(value, key, read); } finally { active.delete(key); } } - #propertyValue(value: SymbolicValue, key: string): SymbolicValue { + #propertyValue( + value: SymbolicValue, + key: string, + read?: SymbolicPropertyRead, + ): SymbolicValue { if (value.kind === "external") { const path = [...value.path, key]; if ( @@ -234,13 +320,21 @@ export class JavaScriptSymbolicBindings { this.#unsafeExports.has(`${value.bindingId}:${key}`) ) return symbolicUnknown("ambiguous export writes"); - const replacement = this.#replacements.get(value.bindingId); + const available = this.#namespaceWrites(value, read, key); + if (available === undefined) { + this.unresolved = true; + return symbolicUnknown("module export read boundary is unavailable"); + } + const replacement = available.findLast( + (write) => write.path.length === 0, + ); if (replacement) return this.property( - this.evaluate(replacement, value.environment), + this.evaluate(replacement.value, value.environment), key, + read, ); - const writes = (this.#writes.get(value.bindingId) ?? []).filter( + const writes = available.filter( (w) => w.path.length === 1 && w.path[0] === key, ); const write = writes.at(-1); @@ -289,7 +383,10 @@ export class JavaScriptSymbolicBindings { for (const p of [...value.node.properties].reverse()) { if (t.isSpreadElement(p)) { const spread = this.evaluate(p.argument, value.environment); - const found = this.property(spread, key); + const found = this.property(spread, key, { + node: p.argument, + environment: value.environment, + }); if (found.kind !== "unknown") return found; if ( this.unknownMembers(spread) > 0 || @@ -307,6 +404,10 @@ export class JavaScriptSymbolicBindings { if (index !== null) return value.values[index] ?? symbolicUnknown("missing argument"); } + if (value.kind === "iterator" || value.kind === "promise") { + this.unresolved = true; + return symbolicUnknown(`${value.kind} members are not modeled`); + } return symbolicUnknown("unresolved property"); } @@ -332,14 +433,19 @@ export class JavaScriptSymbolicBindings { return [...new Set(value.values.flatMap((v) => this.members(v, active)))]; if (value.kind === "record") return [...value.values.keys()]; if (value.kind === "namespace") { - const replacement = this.#replacements.get(value.bindingId); + const available = this.#namespaceWrites(value); + if (available === undefined) return []; + const replacement = available.findLast( + (write) => write.path.length === 0, + ); return replacement - ? this.members(this.evaluate(replacement, value.environment), active) + ? this.members( + this.evaluate(replacement.value, value.environment), + active, + ) : [ ...new Set( - (this.#writes.get(value.bindingId) ?? []).flatMap((w) => - w.path.length === 1 ? w.path : [], - ), + available.flatMap((w) => (w.path.length === 1 ? w.path : [])), ), ]; } @@ -372,10 +478,14 @@ export class JavaScriptSymbolicBindings { if (value.kind === "record") return 0; if (value.kind === "namespace") { if (this.#mutated.has(value.bindingId)) return 1; - const replacement = this.#replacements.get(value.bindingId); + const available = this.#namespaceWrites(value); + if (available === undefined) return 1; + const replacement = available.findLast( + (write) => write.path.length === 0, + ); return replacement ? this.unknownMembers( - this.evaluate(replacement, value.environment), + this.evaluate(replacement.value, value.environment), active, ) : 0; @@ -394,10 +504,16 @@ export class JavaScriptSymbolicBindings { ); } + /** Calling a generator creates an iterator; advancing it is not modeled. */ + callBody(fn: SymbolicFunction): t.Function["body"] | undefined { + return fn.node.generator ? undefined : fn.node.body; + } + bind( fn: SymbolicFunction, args: readonly SymbolicValue[], invocationNode?: t.Node, + caller?: SymbolicEnvironment, ): SymbolicEnvironment { const values = new Map(); runtimeFunctionParameters(fn.node).forEach((param, index) => { @@ -421,7 +537,9 @@ export class JavaScriptSymbolicBindings { this.state.scopeByNode.get(fn.node.body)?.scopeId; return { values, + activation: {}, parent: fn.environment, + ...(caller === undefined ? {} : { caller }), point: invocationNode ?? fn.node, scopeNode: fn.node, ...(scopeId === undefined ? {} : { scopeId }), @@ -458,12 +576,16 @@ export class JavaScriptSymbolicBindings { const key = semanticStaticPropertyKey(node.property, node.computed); return key === null ? symbolicUnknown("dynamic member") - : this.property(this.evaluate(node.object, env), key); + : this.property(this.evaluate(node.object, env), key, { + node, + environment: env, + }); } if (t.isSequenceExpression(node)) return this.evaluate(node.expressions.at(-1), env); if (t.isAssignmentExpression(node)) return this.evaluate(node.right, env); - if (t.isAwaitExpression(node)) return this.evaluate(node.argument, env); + if (t.isAwaitExpression(node)) + return this.#awaited(this.evaluate(node.argument, env)); if (t.isUnaryExpression(node)) { const value = this.evaluate(node.argument, env); if (node.operator === "void") @@ -550,7 +672,9 @@ export class JavaScriptSymbolicBindings { binding.initializers.length === 1 ? binding.initializers[0] : undefined; if (!initializer) return symbolicUnknown("missing or ambiguous initializer"); - const point = this.#initializerReadPoint(binding.scopeId, env, node); + const boundary = this.#readBoundary(binding.scopeId, env, node); + const point = + boundary === undefined ? undefined : boundary.node[boundary.edge]; if ( !initializer.entryBody && !binding.definitions.every(({ kind }) => kind === "function") && @@ -566,26 +690,29 @@ export class JavaScriptSymbolicBindings { let value = this.evaluate(initializer.node, env); for (const key of initializer.projection) { if (key === null) return symbolicUnknown("dynamic destructuring"); - value = this.property(value, String(key)); + value = this.property(value, String(key), { + node: initializer.node, + environment: env, + }); } return value; } - #initializerReadPoint( + #readBoundary( bindingScopeId: string, environment: SymbolicEnvironment, readNode: t.Node, - ): number | undefined { + ): SymbolicReadBoundary | undefined { let current: SymbolicEnvironment | undefined = environment; let child: SymbolicEnvironment | undefined; while (current) { if (this.#scopeContains(bindingScopeId, current.scopeId)) { const point = child?.point ?? readNode; if (current.completedAt !== undefined) - return current.completedAt.end ?? undefined; + return { node: current.completedAt, edge: "end" }; if (current.scopeNode && !this.#nodeWithin(point, current.scopeNode)) return undefined; - return point.start ?? undefined; + return { node: point, edge: "start" }; } child = current; current = current.parent; @@ -593,7 +720,7 @@ export class JavaScriptSymbolicBindings { let outermost = environment; while (outermost.parent) outermost = outermost.parent; const point = outermost.point ?? environment.point ?? readNode; - return point.start ?? undefined; + return { node: point, edge: "start" }; } #scopeContains(bindingScopeId: string, environmentScopeId?: string): boolean { @@ -631,37 +758,285 @@ export class JavaScriptSymbolicBindings { return parent === undefined ? undefined : { ...environment, parent }; } - #completeReturnedFunction( - value: SymbolicFunction, - activation: SymbolicEnvironment, - returnPoint: t.Node, - ): SymbolicFunction { - const environment = this.#completedEnvironment( - value.environment, - activation, - returnPoint, + #awaited(value: SymbolicValue): SymbolicValue { + return symbolicUnion( + symbolicVariants(value).map((variant) => + variant.kind === "promise" + ? this.#withConditions(this.#awaited(variant.value), variant) + : variant, + ), ); - if (environment === undefined) return value; - const { returnPoint: _returnPoint, ...returned } = value; - return { ...returned, environment }; } - #moduleCompletionPoint(factory: t.Function): t.Node { - let earliestReturn: t.ReturnStatement | undefined; - // Use the shared traversal so source-sized child lists are pushed one at - // a time. Spreading array elements or call arguments overflows the stack. - traverseJavaScriptAst(factory.body, { - skip: (node) => t.isFunction(node) || t.isClass(node), - enter: (node) => { + #completeReturnedValue( + value: SymbolicValue, + activation: SymbolicEnvironment, + fallbackPoint: t.Node, + origins: readonly t.Node[], + ): SymbolicValue { + if (value.kind === "union") + return symbolicUnion( + value.values.map((variant) => + this.#completeReturnedValue( + variant, + activation, + fallbackPoint, + origins, + ), + ), + ); + if (value.kind !== "function" && value.kind !== "promise") return value; + const { returnPoint, ...returned } = value; + const point = returnPoint ?? fallbackPoint; + if (returned.kind === "promise") + return { + ...returned, + value: this.#completeReturnedValue( + returned.value, + activation, + point, + origins, + ), + }; + const environment = this.#completedEnvironment( + returned.environment, + activation, + point, + ); + return { + ...returned, + environment: environment ?? returned.environment, + origins: [...origins, ...returned.origins], + }; + } + + #namespaceReadBoundary( + value: SymbolicNamespace, + read: SymbolicPropertyRead, + ): SymbolicReadBoundary | undefined { + const scopeId = value.environment.scopeId; + const activation = value.environment.activation; + if (scopeId === undefined || activation === undefined) return undefined; + const captured = this.#hasActivation(read.environment, activation); + // Replaying a factory-owned initializer/spread reads at capture, even + // when a later closure observes it after the activation completed. + if ( + captured && + this.#owningFunction(read.node) === value.environment.scopeNode + ) + return { node: read.node, edge: "start" }; + const caller = this.#namespaceCallerBoundary( + value, + read.environment, + activation, + ); + if (caller !== undefined) return caller; + if (!captured) return undefined; + const boundary = this.#readBoundary(scopeId, read.environment, read.node); + return boundary !== undefined && + this.#owningFunction(boundary.node) === value.environment.scopeNode + ? boundary + : undefined; + } + + #hasActivation( + environment: SymbolicEnvironment, + activation: object, + ): boolean { + for ( + let current: SymbolicEnvironment | undefined = environment; + current; + current = current.parent + ) { + if (--this.#remainingEvaluations < 0) { + this.truncated = true; + return false; + } + if (current.activation === activation) return true; + } + return false; + } + + #namespaceCallerBoundary( + value: SymbolicNamespace, + environment: SymbolicEnvironment, + activation: object, + ): SymbolicReadBoundary | undefined { + for ( + let current = environment; + current.caller !== undefined; + current = current.caller + ) { + if (--this.#remainingEvaluations < 0) { + this.truncated = true; + return undefined; + } + const point = current.point; + if ( + point !== undefined && + this.#owningFunction(point) === value.environment.scopeNode && + this.#hasActivation(current.caller, activation) + ) + return { node: point, edge: "start" }; + } + return undefined; + } + + #owningFunction(node: t.Node): t.Function | undefined { + let current: t.Node | undefined = node; + while (current !== undefined && !t.isFunction(current)) + current = this.state.parentsByNode.get(current); + return current; + } + + #namespaceWrites( + value: SymbolicNamespace, + read?: SymbolicPropertyRead, + selectedKey?: string, + ): readonly PropertyWrite[] | undefined { + const writes = this.#writes.get(value.bindingId) ?? []; + const completedAt = value.environment.completedAt; + const boundary = + read === undefined ? undefined : this.#namespaceReadBoundary(value, read); + if (this.#remainingEvaluations <= 0) { + this.truncated = true; + return undefined; + } + const sourcePoint = boundary?.node[boundary.edge]; + if (boundary !== undefined && sourcePoint == null) return undefined; + if (completedAt !== undefined && completedAt.end == null) return undefined; + if (sourcePoint == null && completedAt === undefined) + return read === undefined ? writes : undefined; + // An owned snapshot cannot read beyond either its capture or this exit. + const point = Math.min( + sourcePoint ?? Infinity, + completedAt?.end ?? Infinity, + ); + const commonThrough = value.environment.commonThrough?.end; + const changed = new Set(); + if (commonThrough != null) { + if (writes.length > this.#remainingEvaluations) { + this.#remainingEvaluations = 0; + this.truncated = true; + return undefined; + } + this.#remainingEvaluations -= writes.length; + for (const write of writes) { + if (write.site.end == null) return undefined; if ( - t.isReturnStatement(node) && - (earliestReturn === undefined || - (node.start ?? Infinity) < (earliestReturn.start ?? Infinity)) + write.site.end > completedAt!.end! && + write.site.end <= commonThrough + ) { + if (write.path.length === 0) return []; + changed.add(write.path[0]!); + } + } + } + return writes.filter( + (write) => + write.site.end != null && + write.site.end <= point && + (changed.size === 0 || + (write.path.length > 0 + ? !changed.has(write.path[0]!) + : selectedKey !== undefined && !changed.has(selectedKey))), + ); + } + + #completeNamespace( + value: SymbolicNamespace, + point: t.Node, + commonThrough?: t.Node, + ): SymbolicNamespace { + const values = new Map(value.environment.values); + const environment: SymbolicEnvironment = { + ...value.environment, + values, + completedAt: point, + ...(commonThrough === undefined ? {} : { commonThrough }), + }; + const completed: SymbolicNamespace = { ...value, environment }; + // A completed activation owns coherent views of both parameter aliases. + // Merely copying completedAt loses that proof when `exports` escapes. + // This map has only the recognized factory's module/exports/loader slots. + for (const [bindingId, slot] of value.environment.values) { + if (slot === value) values.set(bindingId, completed); + else if ( + this.#moduleBindings.has(bindingId) && + this.#exportAliases.get(bindingId) === value.bindingId && + slot.kind === "record" && + slot.values.get("exports") === value + ) { + values.set(bindingId, { + ...slot, + values: new Map(slot.values).set("exports", completed), + }); + } + } + return completed; + } + + #moduleExports(value: SymbolicNamespace): SymbolicValue { + const factory = this.#namespaceOwners.get(value.bindingId); + if (factory === undefined) return value; + const cached = this.#completedModules.get(value.environment); + if (cached !== undefined) return cached; + if (this.#activeFunctions.has(factory)) + return symbolicUnknown("recursive module completion"); + this.#activeFunctions.add(factory); + try { + // Factory return values are ignored by the loader. Each reachable exit + // still owns a distinct initialization boundary and branch predicates. + const flow = this.#returnFlow( + t.isBlockStatement(factory.body) ? factory.body.body : [], + value.environment, + (statement) => this.#completeNamespace(value, statement), + ); + const completed = symbolicUnion([ + ...flow.values, + ...(flow.fallsThrough + ? [ + this.#withConditions( + this.#completeNamespace(value, factory.body), + flow, + ), + ] + : []), + ]); + const variants = symbolicVariants(completed); + if ( + variants.length > 1 && + variants.every( + (variant) => + variant.kind === "namespace" && + variant.environment.completedAt?.end != null && + !variant.conditionUnresolved, ) - earliestReturn = node; - }, - }); - return earliestReturn ?? factory.body; + ) { + if (variants.length > this.#remainingEvaluations) { + this.#remainingEvaluations = 0; + this.truncated = true; + } else { + this.#remainingEvaluations -= variants.length; + const exits = variants as readonly SymbolicNamespace[]; + let first = exits[0]!.environment.completedAt!; + let last = first; + for (const exit of exits) { + const point = exit.environment.completedAt!; + if (point.end! < first.end!) first = point; + if (point.end! > last.end!) last = point; + } + const common = this.#completeNamespace(value, first, last); + const group = { members: new Set(variants), common }; + for (const variant of variants) + this.#completionGroups.set(variant, group); + } + } + this.#completedModules.set(value.environment, completed); + return completed; + } finally { + this.#activeFunctions.delete(factory); + } } #call( @@ -680,19 +1055,8 @@ export class JavaScriptSymbolicBindings { ? (value.modules.get(String(key.value)) ?? symbolicUnknown("missing module")) : symbolicUnknown("dynamic module"); - if (loaded.kind === "namespace") { - const factory = this.#namespaceOwners.get(loaded.bindingId); - result = - factory === undefined - ? loaded - : { - ...loaded, - environment: { - ...loaded.environment, - completedAt: this.#moduleCompletionPoint(factory), - }, - }; - } else result = loaded; + result = + loaded.kind === "namespace" ? this.#moduleExports(loaded) : loaded; } else if ( value.kind === "external" && value.path.join(".") === "require" @@ -707,36 +1071,31 @@ export class JavaScriptSymbolicBindings { this.#activeFunctions.has(value.node) ) { result = symbolicUnknown("unresolved or recursive call"); + } else if (this.callBody(value) === undefined) { + // Calling a generator creates an iterator without running its body. + result = { kind: "iterator" }; } else { this.#activeFunctions.add(value.node); try { - const bound = this.bind(value, args, node); + const bound = this.bind(value, args, node, env); const returned = t.isBlockStatement(value.node.body) ? this.#returns(value.node.body.body, bound) : this.evaluate(value.node.body, bound); - result = symbolicUnion( - symbolicVariants(returned).map((v) => - v.kind === "function" - ? { - ...this.#completeReturnedFunction( - v, - bound, - v.returnPoint ?? value.node.body, - ), - origins: [...value.origins, node, ...v.origins], - } - : v, - ), + const completed = this.#completeReturnedValue( + returned, + bound, + value.node.body, + [...value.origins, node], ); + // Async calls return a truthy Promise; only await selects its value. + result = value.node.async + ? { kind: "promise", value: this.#awaited(completed) } + : completed; } finally { this.#activeFunctions.delete(value.node); } } - for (const condition of value.conditions ?? []) - result = symbolicCondition(result, condition); - if (value.conditionUnresolved) - result = symbolicConditionsUnresolved(result); - return result; + return this.#withConditions(result, value); }), ); } @@ -745,11 +1104,20 @@ export class JavaScriptSymbolicBindings { statements: readonly t.Statement[], env: SymbolicEnvironment, ): SymbolicValue { - const flow = this.#returnFlow(statements, env); + const flow = this.#returnFlow(statements, env, (statement) => { + const value = this.evaluate(statement.argument, env); + return symbolicUnion( + symbolicVariants(value).map((variant) => + variant.kind === "function" || variant.kind === "promise" + ? { ...variant, returnPoint: statement } + : variant, + ), + ); + }); return symbolicUnion([ ...flow.values, ...(flow.fallsThrough - ? [{ kind: "literal" as const, value: undefined }] + ? [this.#withConditions({ kind: "literal", value: undefined }, flow)] : []), ]); } @@ -757,19 +1125,17 @@ export class JavaScriptSymbolicBindings { #returnFlow( statements: readonly t.Statement[], env: SymbolicEnvironment, + returnedValue: (statement: t.ReturnStatement) => SymbolicValue, depth = 0, ): SymbolicReturnFlow { const possible: SymbolicValue[] = []; let unresolvedFallthrough = false; const continuationConditions: SymbolicCondition[] = []; - const guardCurrentPath = (value: SymbolicValue): SymbolicValue => { - let guarded = value; - for (const condition of continuationConditions) - guarded = symbolicCondition(guarded, condition); - return unresolvedFallthrough - ? symbolicConditionsUnresolved(guarded) - : guarded; - }; + const guardCurrentPath = (value: SymbolicValue): SymbolicValue => + this.#withConditions(value, { + conditions: continuationConditions, + ...(unresolvedFallthrough ? { conditionUnresolved: true } : {}), + }); for (const statement of statements) { if (--this.#remainingEvaluations < 0 || depth >= 128) { this.truncated = true; @@ -784,24 +1150,23 @@ export class JavaScriptSymbolicBindings { fallsThrough: false, }; if (t.isReturnStatement(statement)) { - const value = this.evaluate(statement.argument, env); - const returned = symbolicUnion( - symbolicVariants(value).map((variant) => - variant.kind === "function" - ? { ...variant, returnPoint: statement } - : variant, - ), - ); return { - values: [...possible, guardCurrentPath(returned)], + values: [...possible, guardCurrentPath(returnedValue(statement))], fallsThrough: false, }; } if (t.isBlockStatement(statement)) { - const flow = this.#returnFlow(statement.body, env, depth + 1); + const flow = this.#returnFlow( + statement.body, + env, + returnedValue, + depth + 1, + ); possible.push(...flow.values.map(guardCurrentPath)); if (!flow.fallsThrough) return { values: possible, fallsThrough: false }; + continuationConditions.push(...(flow.conditions ?? [])); + unresolvedFallthrough ||= flow.conditionUnresolved === true; } if (t.isIfStatement(statement)) { const truth = symbolicTruth(this.evaluate(statement.test, env)); @@ -810,7 +1175,12 @@ export class JavaScriptSymbolicBindings { ? [statement.consequent, statement.alternate] : [truth ? statement.consequent : statement.alternate]; const flows = branches.map((branch) => - this.#returnFlow(branch ? [branch] : [], env, depth + 1), + this.#returnFlow( + branch ? [branch] : [], + env, + returnedValue, + depth + 1, + ), ); possible.push( ...flows.flatMap((flow, index) => @@ -827,7 +1197,10 @@ export class JavaScriptSymbolicBindings { ); if (flows.every((flow) => !flow.fallsThrough)) return { values: possible, fallsThrough: false }; - if (truth === undefined) { + if (truth !== undefined) { + continuationConditions.push(...(flows[0]?.conditions ?? [])); + unresolvedFallthrough ||= flows[0]?.conditionUnresolved === true; + } else { const fallthrough = flows.flatMap((flow, index) => flow.fallsThrough ? [{ flow, index }] : [], ); @@ -848,13 +1221,11 @@ export class JavaScriptSymbolicBindings { } } if ( - t.isWhileStatement(statement) || - t.isDoWhileStatement(statement) || - t.isForStatement(statement) || - t.isForInStatement(statement) || - t.isForOfStatement(statement) || + t.isLoop(statement) || t.isSwitchStatement(statement) || - t.isTryStatement(statement) + t.isTryStatement(statement) || + t.isLabeledStatement(statement) || + t.isWithStatement(statement) ) { if (containsOwnedReturn(statement)) { possible.push( @@ -866,7 +1237,14 @@ export class JavaScriptSymbolicBindings { } } } - return { values: possible, fallsThrough: true }; + return { + values: possible, + fallsThrough: true, + ...(continuationConditions.length + ? { conditions: continuationConditions } + : {}), + ...(unresolvedFallthrough ? { conditionUnresolved: true } : {}), + }; } #indexBootstrap(call: t.CallExpression): void { @@ -890,6 +1268,7 @@ export class JavaScriptSymbolicBindings { const values = new Map(); const environment: SymbolicEnvironment = { values, + activation: {}, point: factory.body, scopeNode: factory, ...(this.state.scopeByNode.get(factory)?.scopeId === undefined @@ -1048,10 +1427,6 @@ export class JavaScriptSymbolicBindings { } } } - if (alias && selected.length === 0) { - this.#replacements.set(key, value); - return; - } const writes = this.#writes.get(key) ?? []; writes.push({ path: selected, value, site }); this.#writes.set(key, writes); diff --git a/src/domain/javascript/javascriptSymbolicControlFlow.ts b/src/domain/javascript/javascriptSymbolicControlFlow.ts index 79bccd0a42..dfed9ec872 100644 --- a/src/domain/javascript/javascriptSymbolicControlFlow.ts +++ b/src/domain/javascript/javascriptSymbolicControlFlow.ts @@ -28,6 +28,8 @@ export const symbolicLogicalRight = ( "array", "record", "loader", + "promise", + "iterator", ].includes(left.kind) ? false : undefined; diff --git a/src/domain/javascript/javascriptSymbolicValues.ts b/src/domain/javascript/javascriptSymbolicValues.ts index f267c22093..2a29a8a550 100644 --- a/src/domain/javascript/javascriptSymbolicValues.ts +++ b/src/domain/javascript/javascriptSymbolicValues.ts @@ -4,6 +4,10 @@ import type * as t from "@babel/types"; export interface SymbolicEnvironment { readonly values: ReadonlyMap; readonly parent?: SymbolicEnvironment; + /** Stable activation identity, shared by its completed views. */ + readonly activation?: object; + /** Known synchronous caller, distinct from lexical capture. */ + readonly caller?: SymbolicEnvironment; /** Source point at which the current function is being symbolically executed. */ readonly point?: t.Node; /** Lexical function scope for point-sensitive local initializer checks. */ @@ -12,6 +16,8 @@ export interface SymbolicEnvironment { readonly scopeNode?: t.Function; /** Completion boundary proven for this captured activation. */ readonly completedAt?: t.Node; + /** Shared completion facts must remain unchanged through this later exit. */ + readonly commonThrough?: t.Node; } export interface SymbolicCondition { @@ -62,6 +68,12 @@ export type SymbolicValue = ( readonly values: ReadonlyMap; } | { readonly kind: "union"; readonly values: readonly SymbolicValue[] } + | { readonly kind: "iterator" } + | { + readonly kind: "promise"; + readonly value: SymbolicValue; + readonly returnPoint?: t.Node; + } | SymbolicFunction ) & SymbolicMetadata; @@ -90,9 +102,16 @@ export const symbolicTruth = (value: SymbolicValue): boolean | undefined => { } return value.kind === "literal" ? Boolean(value.value) - : ["function", "object", "namespace", "array", "record", "loader"].includes( - value.kind, - ) + : [ + "function", + "object", + "namespace", + "array", + "record", + "loader", + "promise", + "iterator", + ].includes(value.kind) ? true : undefined; }; diff --git a/tests/acceptance/applications/javascriptFactoryCompletion.test.ts b/tests/acceptance/applications/javascriptFactoryCompletion.test.ts new file mode 100644 index 0000000000..80b46ccc9f --- /dev/null +++ b/tests/acceptance/applications/javascriptFactoryCompletion.test.ts @@ -0,0 +1,497 @@ +import { writeFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { runInNewContext } from "node:vm"; +import { isNativeError } from "node:util/types"; +import { expect } from "vitest"; + +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const scenarios = [ + { + name: "pre-exit-root-export", + guard: "if(flag)return;exports.unrelated=1;", + eventsFirst: true, + bindingFirst: true, + rootEvents: true, + candidate: true, + }, + { + name: "pre-exit-export-replaced", + guard: "if(flag)return;exports.events=null;", + eventsFirst: true, + bindingFirst: true, + candidate: true, + guarded: true, + guardCondition: "flag", + }, + + { + name: "pre-exit-export", + guard: "if(flag)return;", + eventsFirst: true, + bindingFirst: true, + candidate: true, + }, + { + name: "pre-exit-export-later-local", + guard: "if(flag)return;const unrelated=1;", + eventsFirst: true, + bindingFirst: true, + candidate: true, + }, + { + name: "pre-exit-export-late-capture", + guard: "if(flag)return;", + eventsFirst: true, + candidate: true, + guarded: true, + }, + + { name: "control", guard: "", candidate: true }, + { name: "false", guard: "if (false) return;", candidate: true }, + { + name: "unknown", + guard: "if (flag) return;", + candidate: true, + guarded: true, + }, + { + name: "block", + guard: "{ if (flag) return; }", + candidate: true, + guarded: true, + }, + { + name: "nested-function", + guard: "function unused() { return; }", + candidate: true, + }, + { + name: "async-guard", + guard: "async function stop(){return false;}if(stop())return;", + candidate: false, + }, + { + name: "generator-guard", + guard: "function* stop(){return false;}if(stop())return;", + candidate: false, + }, + { + name: "async-generator-guard", + guard: "async function* stop(){return false;}if(stop())return;", + candidate: false, + }, + { name: "true", guard: "if (true) return;", candidate: false }, + { name: "return", guard: "return;", candidate: false }, + { name: "both", guard: "if (flag) return; else return;", candidate: false }, + { name: "throw", guard: "throw new Error('stop');", candidate: false }, + { name: "labeled-exit", guard: "exit: { return; }", candidate: false }, + { name: "with-exit", guard: "with ({}) { return; }", candidate: false }, + { + name: "export-guard", + guard: "exports.stop=true;if(exports.stop)return;exports.stop=false;", + candidate: false, + }, + { + name: "module-guard", + guard: + "module.exports.stop=true;if(module.exports.stop)return;module.exports.stop=false;", + candidate: false, + }, + { + name: "snapshot-guard", + guard: + "exports.stop=true;const {stop}=exports;exports.stop=false;if(stop)return;", + candidate: false, + }, + { + name: "spread-guard", + guard: + "exports.stop=true;const saved={...exports};exports.stop=false;if(saved.stop)return;", + candidate: false, + }, + { + name: "local-guard", + guard: + "exports.stop=true;function stop(){return exports.stop;}if(stop())return;exports.stop=false;", + candidate: false, + }, + { + name: "argument-guard", + guard: "exports.stop=true;if(readStop(exports))return;exports.stop=false;", + candidate: false, + }, + { + name: "false-export-guard", + guard: "exports.stop=false;if(exports.stop)return;exports.stop=true;", + candidate: true, + }, + { + name: "loaded-spread-snapshot", + guard: + "exports.stop=true;const saved={...exports};exports.stop=false;exports.check=()=>saved.stop;", + callerGuard: "if(helper.check())return;", + candidate: false, + }, + { + name: "loaded-destructured-snapshot", + guard: + "exports.stop=true;const {stop}=exports;exports.stop=false;exports.check=()=>stop;", + callerGuard: "if(helper.check())return;", + candidate: false, + }, + { + name: "self-alias", + guard: "exports.self=exports;", + listenerPath: "self.self.events", + candidate: true, + }, + { + name: "module-alias", + guard: "exports.owner=module;", + listenerPath: "owner.exports.events", + candidate: true, + }, + { + name: "returned-alias", + guard: "exports.getSelf=()=>exports;", + listenerPath: "getSelf().events", + candidate: true, + }, + { + name: "self-alias-after-exit", + guard: "exports.self=exports;return;", + listenerPath: "self.events", + candidate: false, + }, + { + name: "imported-guard", + guard: + "exports.stop=true;if(load(1).read(exports))return;exports.stop=false;", + candidate: false, + }, + { + name: "imported-false-guard", + guard: + "exports.stop=false;if(load(1).read(exports))return;exports.stop=true;", + candidate: true, + }, + { + name: "imported-nested-guard", + guard: + "exports.stop=true;if(load(1).read2(exports))return;exports.stop=false;", + candidate: false, + }, + { + name: "imported-snapshot-guard", + guard: + "exports.stop=true;const saved=load(1).read(exports);exports.stop=false;if(saved)return;", + candidate: false, + }, + { + name: "loaded-imported-snapshot", + guard: + "exports.stop=true;const saved=load(1).read(exports);exports.stop=false;exports.check=()=>saved;", + callerGuard: "if(helper.check())return;", + candidate: false, + }, + { + name: "inline-self-alias", + guard: "exports.self=exports;", + listenerPath: "self.events", + inlineEvents: true, + candidate: true, + }, + { + name: "export-after-exit", + guard: "return;", + candidate: false, + bindingFirst: true, + }, +] as const; + +const factorySource = (scenario: (typeof scenarios)[number]) => { + const { guard } = scenario; + const bindingFirst = "bindingFirst" in scenario && scenario.bindingFirst; + const callerGuard = "callerGuard" in scenario ? scenario.callerGuard : ""; + const listenerPath = + "listenerPath" in scenario ? scenario.listenerPath : "events"; + const events = + "rootEvents" in scenario && scenario.rootEvents + ? `module.exports={events:{addListener(channel,handler){return electron.ipcMain.addListener(channel,handler);}}};` + : "inlineEvents" in scenario && scenario.inlineEvents + ? 'exports.events = require("electron").ipcMain;' + : `exports.events = { addListener(channel,handler) { + return electron.ipcMain.addListener(channel,handler); + }};`; + const binding = "const electron = load(2);"; + return `function readStop(value) { return value.stop; } + (() => { + const modules = { + 1(module,exports) { + exports.read = value => value.stop; + exports.read2 = value => readStop(value); + }, + 2(module) { module.exports = require("electron"); }, + 5(module,exports,load) { + ${bindingFirst ? binding : ""} + ${"eventsFirst" in scenario && scenario.eventsFirst ? events : ""} + ${guard} + ${bindingFirst ? "" : binding} + ${"eventsFirst" in scenario && scenario.eventsFirst ? "" : events} + }, + 9(module,exports,load) { + const helper = load(5); + ${callerGuard} + helper.${listenerPath}.addListener("test:copy", () => {}); + } + }; + function load(id) { const m={exports:{}}; modules[id].call(m.exports,m,m.exports,load); return m.exports; } + load(9); + })();`; +}; + +const runtimeChannels = (source: string, flag: boolean) => { + const channels: string[] = []; + try { + // Only this authored fixture executes. REA reads the same bytes as inert + // source; the stub does not establish real Electron runtime behavior. + runInNewContext( + source, + { + flag, + require: (name: string) => { + if (name !== "electron") throw new Error(`Unexpected module ${name}`); + return { + ipcMain: { + addListener: (channel: string) => channels.push(channel), + }, + }; + }, + }, + { timeout: 1_000 }, + ); + } catch (error) { + // A skipped export makes the caller throw before it registers a handler. + if ( + !isNativeError(error) || + channels.length !== 0 || + !( + error.message === "stop" || + (error.name === "TypeError" && error.message.includes("addListener")) || + (error.name === "ReferenceError" && + error.message === "Cannot access 'electron' before initialization") + ) + ) + throw error; + } + return channels; +}; + +type Application = ReturnType< + typeof javascriptApplicationAnalysisResultSchema.parse +>; + +const verifyApplication = ( + result: Application, + candidate: boolean, + guarded: string | false, + coverage: "partial" | "complete", +) => { + expect(result.statistics.parse_failures).toBe(0); + expect(result.summary.ipc.literal_channels).toBe( + candidate && !guarded ? 1 : 0, + ); + const handlers = result.graph.nodes.filter( + (node) => + node.identity.strategy === "artifact-local-key" && + node.identity.namespace === "electron-ipc-handler" && + node.identity.key.includes("main:on:test:copy:"), + ); + expect(handlers).toHaveLength(candidate && !guarded ? 1 : 0); + for (const handler of handlers) { + expect(handler.observations[0]?.evidence).toMatchObject({ + authority: "static-relationship-inference", + state: "inferred", + location: { available: true, value: { source: "main.js" } }, + coverage: { status: coverage }, + }); + } + expect(result.graph.coverage).toMatchObject({ + status: coverage, + truncated: false, + }); + const paths = result.graph.nodes + .flatMap((node) => node.observations) + .filter( + (observation) => + observation.properties.terminal === "electron.ipcMain.addListener" && + observation.properties.channel === "test:copy", + ); + if (!candidate || !guarded) expect(paths).toEqual([]); + if (guarded) { + expect(paths.map((observation) => observation.properties)).toContainEqual( + expect.objectContaining({ + resolution: "candidate", + conditions: expect.arrayContaining([ + expect.objectContaining({ expression: guarded }), + ]), + }), + ); + expect(paths[0]?.evidence).toMatchObject({ + authority: "static-relationship-inference", + state: "inferred", + location: { available: true, value: { source: "main.js" } }, + }); + } +}; + +cliTest( + "retains guarded factory fallthrough through CLI and real stdio MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-factory-completion-"); + const { call } = await connectLocalToolsMcp(); + const contract = toolContract("analyze_javascript_application"); + for (const scenario of scenarios) { + const source = factorySource(scenario); + const directory = join(root, scenario.name); + await mkdir(directory); + await writeFile(join(directory, "main.js"), source); + const runtime = [false, true].map((flag) => + runtimeChannels(source, flag), + ); + const hasRuntimePath = runtime.some((channels) => + channels.includes("test:copy"), + ); + expect(hasRuntimePath, scenario.name).toBe(scenario.candidate); + if ( + "guarded" in scenario && + scenario.guarded && + !("guardCondition" in scenario) + ) + expect(runtime).toEqual([["test:copy"], []]); + const response = await call("analyze_javascript_application", { + input_path: directory, + format: "directory", + }); + expect(response.isError, scenario.name).not.toBe(true); + const evidence = contract.outputSchema.parse(response.structuredContent); + verifyApplication( + javascriptApplicationAnalysisResultSchema.parse( + evidence.normalized_result, + ), + scenario.candidate, + "guarded" in scenario && scenario.guarded + ? "guardCondition" in scenario + ? scenario.guardCondition + : "!(flag)" + : false, + "inlineEvents" in scenario && scenario.inlineEvents + ? "complete" + : "partial", + ); + if (["control", "false", "unknown"].includes(scenario.name)) { + const analyzed = await cli.run({ + arguments: [ + "analyze-javascript-application", + directory, + "--artifact-format", + "directory", + "--json", + ], + }); + expect(analyzed.exitCode, analyzed.stderr).toBe(0); + expect(contract.outputSchema.parse(analyzed.json).evidence_id).toBe( + evidence.evidence_id, + ); + } + } + }, + 120_000, +); + +cliTest( + "does not execute unadvanced generator calls through public CLI/MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-generator-call-"); + const { call } = await connectLocalToolsMcp(); + const contract = toolContract("analyze_javascript_application"); + for (const [name, kind, expected, advance] of [ + ["regular", "", 1, false], + ["generator", "*", 0, false], + ["async-generator", "async *", 0, false], + ["advanced-generator", "*", 1, true], + ] as const) { + const declaration = + kind === "async *" ? "async function*" : `function${kind}`; + const source = `const e=require("electron"); + ${declaration} send(channel){e.ipcRenderer.send(channel);} + e.contextBridge.exposeInMainWorld("api",{start:()=>send("generator:test")${advance ? ".next()" : ""}});`; + const channels: string[] = []; + runInNewContext( + source, + { + require: () => ({ + ipcRenderer: { send: (channel: string) => channels.push(channel) }, + contextBridge: { + exposeInMainWorld: ( + _key: string, + api: { start: () => unknown }, + ) => api.start(), + }, + }), + }, + { timeout: 1_000 }, + ); + expect(channels.length, name).toBe(expected); + const directory = join(root, name); + await mkdir(directory); + await writeFile(join(directory, "main.js"), source); + const response = await call("analyze_javascript_application", { + input_path: directory, + format: "directory", + }); + expect(response.isError, name).not.toBe(true); + const evidence = contract.outputSchema.parse(response.structuredContent); + const result = javascriptApplicationAnalysisResultSchema.parse( + evidence.normalized_result, + ); + expect(result.summary.ipc.literal_channels, name).toBe( + advance ? 0 : expected, + ); + if (advance) expect(result.graph.coverage.status).toBe("partial"); + const paths = result.graph.nodes + .filter( + (node) => + node.identity.strategy === "artifact-local-key" && + node.identity.namespace === "electron-call-path-step", + ) + .flatMap((node) => node.observations) + .filter( + (observation) => + observation.properties.terminal === "electron.ipcRenderer.send" && + observation.properties.entry === "api.start" && + observation.properties.step_kind === "terminal", + ); + expect(paths.length, name).toBe(advance ? 0 : expected); + const analyzed = await cli.run({ + arguments: [ + "analyze-javascript-application", + directory, + "--artifact-format", + "directory", + "--json", + ], + }); + expect(analyzed.exitCode, analyzed.stderr).toBe(0); + expect(contract.outputSchema.parse(analyzed.json).evidence_id).toBe( + evidence.evidence_id, + ); + } + }, + 30_000, +);