From 62b37111e7f09887eb712c6e8e721e0334cf10b9 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:40:05 +0800 Subject: [PATCH 1/5] fix(javascript): handle deep AST traversal and addition without stack overflow (#915) * fix(javascript): avoid recursion and eager ancestry copies in deep ASTs * fix(javascript): traverse static analysis and bundler ASTs iteratively --- .../javascript/javascriptAstFingerprint.ts | 22 +++++--- ...avascriptSemanticAnalysis.dataflow.test.ts | 3 ++ ...vascriptSemanticAnalysis.rejection.test.ts | 51 +++++++++++++++++- .../javascriptSemanticProjection.ts | 4 +- .../javascriptSemanticTraversal.test.ts | 53 +++++++++++++++++++ .../javascript/javascriptSemanticTraversal.ts | 41 +++++++++----- .../javascript/javascriptSemanticValues.ts | 38 ++++++++++--- .../javascript/javascriptStaticAnalysis.ts | 12 +++-- .../javascriptStaticAnalysisBundler.ts | 19 ++++--- 9 files changed, 203 insertions(+), 40 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticTraversal.test.ts diff --git a/src/domain/javascript/javascriptAstFingerprint.ts b/src/domain/javascript/javascriptAstFingerprint.ts index 569a4895d5..00bbaefba0 100644 --- a/src/domain/javascript/javascriptAstFingerprint.ts +++ b/src/domain/javascript/javascriptAstFingerprint.ts @@ -2,6 +2,8 @@ import { createHash } from "node:crypto"; import * as t from "@babel/types"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; + import { compareCodePoints } from "../canonicalOrdering.js"; import { propertyName } from "./javascriptAstValues.js"; import { stringValue } from "./javascriptStaticAnalysisHelpers.js"; @@ -14,9 +16,11 @@ export interface StaticExports { /** Derive a rename-resistant syntax fingerprint without code execution. */ export const fingerprintJavaScriptAst = (node: t.Node): string => { const tokens: string[] = []; - t.traverseFast(node, (current) => { - tokens.push(...semanticTokens(current)); - return undefined; + traverseJavaScriptAst(node, { + enter: (current) => { + tokens.push(...semanticTokens(current)); + return undefined; + }, }); return createHash("sha256").update(JSON.stringify(tokens)).digest("hex"); }; @@ -24,11 +28,13 @@ export const fingerprintJavaScriptAst = (node: t.Node): string => { /** Collect statically declared CommonJS/bundler export names from one factory. */ export const collectJavaScriptExports = (node: t.Node): StaticExports => { const exports = new Set(); - t.traverseFast(node, (current) => { - if (t.isAssignmentExpression(current)) - collectAssignmentExports(current.left, current.right, exports); - if (t.isCallExpression(current)) collectCallExports(current, exports); - return undefined; + traverseJavaScriptAst(node, { + enter: (current) => { + if (t.isAssignmentExpression(current)) + collectAssignmentExports(current.left, current.right, exports); + if (t.isCallExpression(current)) collectCallExports(current, exports); + return undefined; + }, }); return { values: [...exports].sort(compareCodePoints), diff --git a/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts index fa9ab25969..384b8fe671 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts @@ -282,6 +282,9 @@ describe("primitive addition values", () => { ['"x" + false', "xfalse"], ['null + "x"', "nullx"], ["3 + 4", 7], + ['1 + 2 + "3"', "33"], + ['1 + (2 + "3")', "123"], + ['(1 + 2) + ("3" + 4)', "334"], ])("recovers %s using primitive coercion", (expression, expected) => { const ir = analyzeJavaScriptSemantics(`const answer = ${expression};`); expect(topLevelBinding(ir, "answer").value).toEqual({ diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejection.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejection.test.ts index 5f6c05f3a1..0df194a05f 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejection.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejection.test.ts @@ -1,7 +1,17 @@ +import * as t from "@babel/types"; import { fc, it } from "@fast-check/vitest"; import { describe, expect } from "vitest"; -import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { + analyzeJavaScriptSemantics, + analyzeParsedJavaScriptSemantics, +} from "./javascriptSemanticAnalysis.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { analyzeParsedJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { + collectJavaScriptExports, + fingerprintJavaScriptAst, +} from "./javascriptAstFingerprint.js"; import type { JavaScriptSemanticValue } from "./javascriptSemanticIr.js"; import { onlyCallable, @@ -9,6 +19,45 @@ import { } from "./javascriptSemanticAnalysis.fixture.js"; describe("JavaScript semantic analysis: rejection 1", () => { + it.each(["left", "right"])( + "retains semantic facts and values from a deeply %s-chained AST", + (direction) => { + // Build an admitted AST directly: Babel's own recursion limit varies + // with parser optimization and must not determine this regression. + const parsed = parseJavaScriptSource("const result = 1; result;"); + const declaration = parsed?.program.body[0]; + if (parsed === null || !t.isVariableDeclaration(declaration)) + throw new TypeError("Expected a parsed variable declaration"); + const declarator = declaration.declarations[0]; + if (declarator === undefined) + throw new TypeError("Expected a variable declarator"); + let expression: t.Expression = t.numericLiteral(1); + for (let index = 1; index < 12000; index += 1) + expression = + direction === "left" + ? t.binaryExpression("+", expression, t.numericLiteral(1)) + : t.binaryExpression("+", t.numericLiteral(1), expression); + declarator.init = expression; + const staticAnalysis = analyzeParsedJavaScriptStaticSource("", parsed); + expect(staticAnalysis.parse_status).toBe("complete"); + expect(staticAnalysis.visited_ast_nodes).toBe(24006); + expect(collectJavaScriptExports(parsed).values).toEqual([]); + expect(fingerprintJavaScriptAst(parsed)).toMatch(/^[a-f0-9]{64}$/u); + const ir = analyzeParsedJavaScriptSemantics(parsed); + expect(ir.coverage.status).toBe("complete"); + const binding = topLevelBinding(ir, "result"); + expect(binding.value).toEqual({ status: "literal", value: 12000 }); + expect(ir.references).toEqual([ + expect.objectContaining({ + name: "result", + role: "read", + resolution: "resolved", + bindingId: binding.bindingId, + }), + ]); + }, + ); + it("retains complete finite facts and deeply nested static values", () => { const names = Array.from( { length: 280 }, diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index 7df1464b68..578c53a139 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -100,9 +100,9 @@ export const collectSemanticReferences = ( const output: JavaScriptSemanticReference[] = []; const seen = new Set(); traverseJavaScriptAst(program, { - enter: (node, parent, ancestors) => { + enter: (node, parent, readAncestors) => { if (!t.isIdentifier(node) || parent === null) return; - const role = semanticIdentifierRole(node, parent, ancestors); + const role = semanticIdentifierRole(node, parent, readAncestors()); if (role === null) return; // A compound assignment or update reads before it writes; emit both so // reference consumers see the read they actually execute. diff --git a/src/domain/javascript/javascriptSemanticTraversal.test.ts b/src/domain/javascript/javascriptSemanticTraversal.test.ts new file mode 100644 index 0000000000..1d012c1df2 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticTraversal.test.ts @@ -0,0 +1,53 @@ +import * as t from "@babel/types"; +import { expect, it } from "vitest"; + +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; + +it("preserves source-tree enter/exit order and detached ancestor snapshots", () => { + const parsed = parseJavaScriptSource("const answer = first + second;"); + if (parsed === null) + throw new TypeError("Expected a parsed JavaScript fixture"); + const visits: string[] = []; + const snapshots: (readonly t.Node[])[] = []; + traverseJavaScriptAst(parsed.program, { + enter: (node, parent, readAncestors) => { + visits.push(`enter:${node.type}`); + const ancestors = readAncestors(); + expect(ancestors.at(-1) ?? null).toBe(parent); + if (t.isIdentifier(node)) snapshots.push(ancestors); + }, + exit: (node) => visits.push(`exit:${node.type}`), + }); + expect(visits).toEqual([ + "enter:Program", + "enter:VariableDeclaration", + "enter:VariableDeclarator", + "enter:Identifier", + "exit:Identifier", + "enter:BinaryExpression", + "enter:Identifier", + "exit:Identifier", + "enter:Identifier", + "exit:Identifier", + "exit:BinaryExpression", + "exit:VariableDeclarator", + "exit:VariableDeclaration", + "exit:Program", + ]); + expect(snapshots.map((nodes) => nodes.map(({ type }) => type))).toEqual([ + ["Program", "VariableDeclaration", "VariableDeclarator"], + [ + "Program", + "VariableDeclaration", + "VariableDeclarator", + "BinaryExpression", + ], + [ + "Program", + "VariableDeclaration", + "VariableDeclarator", + "BinaryExpression", + ], + ]); +}); diff --git a/src/domain/javascript/javascriptSemanticTraversal.ts b/src/domain/javascript/javascriptSemanticTraversal.ts index e782055323..d3a99f2707 100644 --- a/src/domain/javascript/javascriptSemanticTraversal.ts +++ b/src/domain/javascript/javascriptSemanticTraversal.ts @@ -3,7 +3,8 @@ import * as t from "@babel/types"; /** * Visit every Babel AST node in deterministic source-tree order. * - * `ancestors` runs outermost-first and ends with the direct parent. Callers + * `readAncestors` returns a snapshot during `enter`, outermost-first and + * ending with the direct parent. Callers * need it whenever meaning depends on where a node sits, not just what its * immediate parent is — a binding inside `catch ({message})` or `({a} = o)` * has an ObjectProperty parent, so the parent alone cannot say whether the @@ -15,24 +16,40 @@ export const traverseJavaScriptAst = ( readonly enter: ( node: t.Node, parent: t.Node | null, - ancestors: readonly t.Node[], + readAncestors: () => readonly t.Node[], ) => void; readonly exit?: (node: t.Node, parent: t.Node | null) => void; }, ): void => { - const visit = ( - node: t.Node, - parent: t.Node | null, - ancestors: readonly t.Node[], - ): void => { - visitor.enter(node, parent, ancestors); - for (const child of childNodes(node)) - visit(child, node, [...ancestors, node]); - visitor.exit?.(node, parent); + const ancestors: t.Node[] = []; + const enter = (node: t.Node, parent: t.Node | null): TraversalFrame => { + visitor.enter(node, parent, () => [...ancestors]); + ancestors.push(node); + return { node, parent, children: childNodes(node), nextIndex: 0 }; }; - visit(root, null, []); + const pending = [enter(root, null)]; + while (pending.length > 0) { + const current = pending.at(-1); + if (current === undefined) break; + const child = current.children[current.nextIndex]; + if (child !== undefined) { + current.nextIndex += 1; + pending.push(enter(child, current.node)); + } else { + visitor.exit?.(current.node, current.parent); + ancestors.pop(); + pending.pop(); + } + } }; +interface TraversalFrame { + readonly node: t.Node; + readonly parent: t.Node | null; + readonly children: readonly t.Node[]; + nextIndex: number; +} + const childNodes = (node: t.Node): t.Node[] => { const keys: readonly string[] = t.VISITOR_KEYS[node.type] ?? []; return keys.flatMap((key) => { diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index 8a55b74d45..78fc72c1c6 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -268,12 +268,38 @@ const evaluateAddition = ( node: t.BinaryExpression, context: EvaluationContext, ): JavaScriptSemanticValue => { - const left = primitiveCandidates( - evaluateExpression(node.left, nestedContext(context)), - ); - const right = primitiveCandidates( - evaluateExpression(node.right, nestedContext(context)), - ); + const pending: { + readonly node: t.BinaryExpression; + left: JavaScriptSemanticValue | undefined; + }[] = []; + let current: t.Node = node; + while (true) { + if (t.isBinaryExpression(current, { operator: "+" })) { + pending.push({ node: current, left: undefined }); + current = current.left; + continue; + } + let value = evaluateExpression(current, nestedContext(context)); + while (true) { + const parent = pending.at(-1); + if (parent === undefined) return value; + if (parent.left === undefined) { + parent.left = value; + current = parent.node.right; + break; + } + value = addPrimitiveValues(parent.left, value); + pending.pop(); + } + } +}; + +const addPrimitiveValues = ( + leftValue: JavaScriptSemanticValue, + rightValue: JavaScriptSemanticValue, +): JavaScriptSemanticValue => { + const left = primitiveCandidates(leftValue); + const right = primitiveCandidates(rightValue); if (left === null || right === null) return { status: "unknown", reason: "Non-primitive addition." }; const values = left.flatMap((leftValue) => diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index c1e0a56be5..72a462ba87 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -1,5 +1,7 @@ import * as t from "@babel/types"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; + import { inspectElectronStaticNode } from "./electronStaticAnalysis.js"; import { detectVendors, @@ -56,10 +58,12 @@ const traverseStaticSource = ( file: ParsedJavaScriptSource, accumulator: AnalysisAccumulator, ): void => { - t.traverseFast(file, (node) => { - accumulator.visitedNodes += 1; - inspectNode(source, node, accumulator); - return undefined; + traverseJavaScriptAst(file, { + enter: (node) => { + accumulator.visitedNodes += 1; + inspectNode(source, node, accumulator); + return undefined; + }, }); }; diff --git a/src/domain/javascript/javascriptStaticAnalysisBundler.ts b/src/domain/javascript/javascriptStaticAnalysisBundler.ts index d5fb295580..3e964f332f 100644 --- a/src/domain/javascript/javascriptStaticAnalysisBundler.ts +++ b/src/domain/javascript/javascriptStaticAnalysisBundler.ts @@ -1,5 +1,7 @@ import * as t from "@babel/types"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; + import { collectJavaScriptExports, fingerprintJavaScriptAst, @@ -251,13 +253,16 @@ const collectBundlerCallArgumentValues = ( ): StaticValues => { const values: string[] = []; let unknown = 0; - t.traverseFast(factory, (node) => { - if (!t.isCallExpression(node) && !t.isNewExpression(node)) return undefined; - if (calleeName(node.callee) !== callee) return undefined; - const value = argumentValue(node.arguments[0]); - if (value === undefined) unknown += 1; - else values.push(value); - return undefined; + traverseJavaScriptAst(factory, { + enter: (node) => { + if (!t.isCallExpression(node) && !t.isNewExpression(node)) + return undefined; + if (calleeName(node.callee) !== callee) return undefined; + const value = argumentValue(node.arguments[0]); + if (value === undefined) unknown += 1; + else values.push(value); + return undefined; + }, }); return uniqueValues(values, unknown); }; From 900d7c8606cbc1ccbca818827a55bf2eab28bca4 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:40:18 -0700 Subject: [PATCH 2/5] fix(artifacts): project compiled AppKit actions from the control to its target (#905) * fix(artifacts): project compiled AppKit actions from the control to its target NSNibControlConnector archives the sending control as NSSource and the action target as NSDestination; a nil target is the first responder. The NIBArchive projection swapped them for control connectors, so every compiled AppKit action ran from its target to the control. The action trace, which follows control -> action, could not find them, and the selector node was keyed by the control instead of the target class. When the target was nil, the swapped source was null and the action was dropped: Chess lost 29 menu actions, KeepingYouAwake 7. Connectors now keep NSSource -> NSDestination. The NIBArchive test encoder moves to a fixture module so a bundle test can build compiled NIBs. Co-Authored-By: Claude Opus 5.5 * test(artifacts): report observed action routes when a compiled route is missing Co-Authored-By: Claude Opus 5.5 * test(artifacts): report compiled connector records when a storyboard route is missing Co-Authored-By: Claude Opus 5.5 * test(artifacts): write the storyboard fixture's action in Cocoa syntax MacFixture.storyboard declared its button action with the iOS attribute, . Cocoa documents name an action's receiver with target=. ibtool compiled the iOS spelling into an NSNibControlConnector whose NSSource is the view controller placeholder and whose NSDestination is the button. That is the reverse of what Xcode-authored AppKit NIBs contain (Music, Chess, KeepingYouAwake, and Transmission all archive NSSource as the control), and it is what the old inversion had matched. With target= the compiled fixture archives the button as NSSource. The temporary connector dump is removed; the observed routes remain in the assertion message. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/application/InterfaceBuilderAnalysis.ts | 14 ++- src/artifacts/NibArchive.fixture.ts | 94 ++++++++++++++++++ src/artifacts/NibArchive.test.ts | 95 +------------------ .../filesystem/interfaceBuilderBundle.test.ts | 92 +++++++++++++++++- .../interface-builder/MacFixture.storyboard | 2 +- 5 files changed, 193 insertions(+), 104 deletions(-) create mode 100644 src/artifacts/NibArchive.fixture.ts diff --git a/src/application/InterfaceBuilderAnalysis.ts b/src/application/InterfaceBuilderAnalysis.ts index 3d2631394d..247a2f02c2 100644 --- a/src/application/InterfaceBuilderAnalysis.ts +++ b/src/application/InterfaceBuilderAnalysis.ts @@ -277,16 +277,14 @@ const projectNibArchive = (archive: NibArchiveDocument) => { : className.includes("Control") ? "action" : className; - const controlAction = className.includes("Control"); - const connectionSource = controlAction ? destination : source; - const connectionDestination = controlAction ? source : destination; - if (connectionSource === null) continue; - (connections[String(connectionSource)] ??= []).push({ + // NSSource is the outlet owner or the sending control; NSDestination is + // the outlet value or the action target, and a nil target is the first + // responder. + (connections[String(source)] ??= []).push({ type, - "destination-id": - connectionDestination === null ? null : String(connectionDestination), + "destination-id": destination === null ? null : String(destination), label, - source_id: String(connectionSource), + source_id: String(source), archive_object_id: String(object.id), }); } diff --git a/src/artifacts/NibArchive.fixture.ts b/src/artifacts/NibArchive.fixture.ts new file mode 100644 index 0000000000..09f2d6e2c5 --- /dev/null +++ b/src/artifacts/NibArchive.fixture.ts @@ -0,0 +1,94 @@ +/** Encode a minimal NIBArchive object table for decoder and projection tests. */ +export const encodeNibArchiveFixture = (input: { + readonly classes: readonly string[]; + readonly objects: readonly { + readonly classIndex: number; + readonly values: Readonly< + Record + >; + }[]; +}): Buffer => { + const keys = [ + ...new Set(input.objects.flatMap(({ values }) => Object.keys(values))), + ]; + const keyIndex = new Map(keys.map((key, index) => [key, index])); + const valueRecords = input.objects.flatMap(({ values }) => + Object.entries(values).map(([key, value]) => + Buffer.concat([varint(keyIndex.get(key) ?? 0), encodeValue(value)]), + ), + ); + let valueStart = 0; + const records = input.objects.map(({ classIndex, values }) => { + const count = Object.keys(values).length; + const result = Buffer.concat([ + varint(classIndex), + varint(valueStart), + varint(count), + ]); + valueStart += count; + return result; + }); + const objects = Buffer.concat(records); + const encodedKeys = Buffer.concat( + keys.map((key) => + Buffer.concat([varint(Buffer.byteLength(key)), Buffer.from(key)]), + ), + ); + const values = Buffer.concat(valueRecords); + const classes = Buffer.concat( + input.classes.map((name) => { + const bytes = Buffer.from(name); + return Buffer.concat([varint(bytes.length), varint(0), bytes]); + }), + ); + const objectsOffset = 50; + const keysOffset = objectsOffset + objects.length; + const valuesOffset = keysOffset + encodedKeys.length; + const classesOffset = valuesOffset + values.length; + const header = Buffer.alloc(50); + header.write("NIBArchive", 0, "ascii"); + header.writeUInt32LE(1, 10); + header.writeUInt32LE(10, 14); + header.writeUInt32LE(input.objects.length, 18); + header.writeUInt32LE(objectsOffset, 22); + header.writeUInt32LE(keys.length, 26); + header.writeUInt32LE(keysOffset, 30); + header.writeUInt32LE(valueStart, 34); + header.writeUInt32LE(valuesOffset, 38); + header.writeUInt32LE(input.classes.length, 42); + header.writeUInt32LE(classesOffset, 46); + return Buffer.concat([header, objects, encodedKeys, values, classes]); +}; + +const varint = (value: number): Buffer => { + const bytes: number[] = []; + let remaining = value; + do { + let byte = remaining & 0x7f; + remaining >>>= 7; + if (remaining === 0) byte |= 0x80; + bytes.push(byte); + } while (remaining > 0); + return Buffer.from(bytes); +}; + +const encodeValue = ( + value: number | string | boolean | null | { ref: number }, +): Buffer => { + if (typeof value === "boolean") return Buffer.from([value ? 4 : 5]); + if (value === null) return Buffer.from([9]); + if (typeof value === "number") { + const bytes = Buffer.alloc(5); + bytes[0] = 2; + bytes.writeInt32LE(value, 1); + return bytes; + } + if (typeof value === "object") { + const bytes = Buffer.alloc(5); + bytes[0] = 10; + bytes.writeUInt32LE(value.ref, 1); + return bytes; + } + const data = Buffer.from(value); + return Buffer.concat([Buffer.from([8]), varint(data.length), data]); +}; diff --git a/src/artifacts/NibArchive.test.ts b/src/artifacts/NibArchive.test.ts index f9b4fac922..a771217853 100644 --- a/src/artifacts/NibArchive.test.ts +++ b/src/artifacts/NibArchive.test.ts @@ -1,100 +1,7 @@ import { describe, expect, it } from "vitest"; import { decodeNibArchive } from "./NibArchive.js"; - -const encodeArchive = (input: { - readonly classes: readonly string[]; - readonly objects: readonly { - readonly classIndex: number; - readonly values: Readonly< - Record - >; - }[]; -}): Buffer => { - const keys = [ - ...new Set(input.objects.flatMap(({ values }) => Object.keys(values))), - ]; - const keyIndex = new Map(keys.map((key, index) => [key, index])); - const valueRecords = input.objects.flatMap(({ values }) => - Object.entries(values).map(([key, value]) => - Buffer.concat([varint(keyIndex.get(key) ?? 0), encodeValue(value)]), - ), - ); - let valueStart = 0; - const records = input.objects.map(({ classIndex, values }) => { - const count = Object.keys(values).length; - const result = Buffer.concat([ - varint(classIndex), - varint(valueStart), - varint(count), - ]); - valueStart += count; - return result; - }); - const objects = Buffer.concat(records); - const encodedKeys = Buffer.concat( - keys.map((key) => - Buffer.concat([varint(Buffer.byteLength(key)), Buffer.from(key)]), - ), - ); - const values = Buffer.concat(valueRecords); - const classes = Buffer.concat( - input.classes.map((name) => { - const bytes = Buffer.from(name); - return Buffer.concat([varint(bytes.length), varint(0), bytes]); - }), - ); - const objectsOffset = 50; - const keysOffset = objectsOffset + objects.length; - const valuesOffset = keysOffset + encodedKeys.length; - const classesOffset = valuesOffset + values.length; - const header = Buffer.alloc(50); - header.write("NIBArchive", 0, "ascii"); - header.writeUInt32LE(1, 10); - header.writeUInt32LE(10, 14); - header.writeUInt32LE(input.objects.length, 18); - header.writeUInt32LE(objectsOffset, 22); - header.writeUInt32LE(keys.length, 26); - header.writeUInt32LE(keysOffset, 30); - header.writeUInt32LE(valueStart, 34); - header.writeUInt32LE(valuesOffset, 38); - header.writeUInt32LE(input.classes.length, 42); - header.writeUInt32LE(classesOffset, 46); - return Buffer.concat([header, objects, encodedKeys, values, classes]); -}; - -const varint = (value: number): Buffer => { - const bytes: number[] = []; - let remaining = value; - do { - let byte = remaining & 0x7f; - remaining >>>= 7; - if (remaining === 0) byte |= 0x80; - bytes.push(byte); - } while (remaining > 0); - return Buffer.from(bytes); -}; - -const encodeValue = ( - value: number | string | boolean | null | { ref: number }, -): Buffer => { - if (typeof value === "boolean") return Buffer.from([value ? 4 : 5]); - if (value === null) return Buffer.from([9]); - if (typeof value === "number") { - const bytes = Buffer.alloc(5); - bytes[0] = 2; - bytes.writeInt32LE(value, 1); - return bytes; - } - if (typeof value === "object") { - const bytes = Buffer.alloc(5); - bytes[0] = 10; - bytes.writeUInt32LE(value.ref, 1); - return bytes; - } - const data = Buffer.from(value); - return Buffer.concat([Buffer.from([8]), varint(data.length), data]); -}; +import { encodeNibArchiveFixture as encodeArchive } from "./NibArchive.fixture.js"; describe("NIBArchive decoder", () => { it("decodes bounded object, key, class, and reference tables", () => { diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 01e14f262b..a68503b3ab 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -8,6 +8,7 @@ import { buildBinary } from "plist"; import { describe, expect, it } from "vitest"; import { analyzeInterfaceBuilderBundle } from "../../../src/application/InterfaceBuilderAnalysis.js"; +import { encodeNibArchiveFixture } from "../../../src/artifacts/NibArchive.fixture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const compile = promisify(execFile); @@ -71,6 +72,84 @@ describe("compiled Interface Builder bundle reader", () => { ); }); + it("projects compiled AppKit actions from the control to their target", async () => { + const root = await createTestTempDirectory("rea-ib-test-"); + const bundle = join(root, "Example.app"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(resources, { recursive: true }); + // NSNibControlConnector archives the sending control as NSSource and its + // target as NSDestination; a nil target is the first responder. + await writeFile( + join(resources, "Panel.nib"), + encodeNibArchiveFixture({ + classes: [ + "NSNibExternalObjectPlaceholder", + "NSButton", + "NSNibControlConnector", + "NSString", + "NSMenuItem", + ], + objects: [ + { classIndex: 0, values: {} }, + { classIndex: 1, values: {} }, + { + classIndex: 2, + values: { + NSSource: { ref: 1 }, + NSDestination: { ref: 0 }, + NSLabel: { ref: 3 }, + }, + }, + { classIndex: 3, values: { "NS.bytes": "doOK:" } }, + { classIndex: 4, values: {} }, + { + classIndex: 2, + values: { + NSSource: { ref: 4 }, + NSDestination: null, + NSLabel: { ref: 6 }, + }, + }, + { classIndex: 3, values: { "NS.bytes": "arrangeInFront:" } }, + ], + }), + ); + + const analysis = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "e".repeat(64), + }); + const byId = new Map(analysis.graph.nodes.map((node) => [node.id, node])); + const routes = analysis.graph.nodes + .filter(({ kind }) => kind === "action") + .map((action) => ({ + action: action.name, + from: analysis.graph.edges + .filter( + ({ relation, to }) => + relation === "target_action" && to === action.id, + ) + .map(({ from }) => byId.get(from)?.name), + to: analysis.graph.edges + .filter( + ({ relation, from, to }) => + relation === "target_action" && + from === action.id && + (to === null || byId.get(to)?.kind !== "objc_selector"), + ) + .map(({ to }) => (to === null ? null : byId.get(to)?.name)), + })) + .sort((left, right) => left.action.localeCompare(right.action)); + expect(routes).toEqual([ + { action: "arrangeInFront:", from: ["NSMenuItem"], to: [null] }, + { + action: "doOK:", + from: ["NSButton"], + to: ["NSNibExternalObjectPlaceholder"], + }, + ]); + }); + it("honors cancellation during directory traversal", async () => { const root = await createTestTempDirectory("rea-ib-test-"); const controller = new AbortController(); @@ -183,7 +262,18 @@ describe("bounded Interface Builder archive decoding", () => { analysis.graph.nodes.find(({ id }) => id === from)?.kind === "control", ); - expect(actionSource).toBeDefined(); + const describeNode = (id: string | null) => { + const node = analysis.graph.nodes.find((item) => item.id === id); + return node === undefined ? String(id) : `${node.kind}:${node.name}`; + }; + const actionRoutes = analysis.graph.edges + .filter( + ({ relation, from, to }) => + relation === "target_action" && + (from === action?.id || to === action?.id), + ) + .map(({ from, to }) => `${describeNode(from)} -> ${describeNode(to)}`); + expect(actionSource, actionRoutes.join("; ")).toBeDefined(); expect( analysis.graph.edges.some( ({ from, relation, to }) => diff --git a/tests/fixtures/interface-builder/MacFixture.storyboard b/tests/fixtures/interface-builder/MacFixture.storyboard index 995dc1d128..f9fe157f92 100644 --- a/tests/fixtures/interface-builder/MacFixture.storyboard +++ b/tests/fixtures/interface-builder/MacFixture.storyboard @@ -11,7 +11,7 @@ <connections> - <action selector="buildTapped:" destination="vc1" id="action1"/> + <action selector="buildTapped:" target="vc1" id="action1"/> </connections> </button> </subviews> From 7a9f587c3a9998d828b83388438976527ca7074f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:48:39 +0800 Subject: [PATCH 3/5] perf(dotnet): index declaring-type ranges once per metadata table (#918) perf(dotnet): index ordered declaring-type ranges once per table --- src/dotnet/ManagedMemberInspectorCore.test.ts | 96 +++++++++++++++++++ src/dotnet/ManagedMemberInspectorCore.ts | 36 +++++++ src/dotnet/ManagedMemberRows.ts | 8 +- 3 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 src/dotnet/ManagedMemberInspectorCore.test.ts diff --git a/src/dotnet/ManagedMemberInspectorCore.test.ts b/src/dotnet/ManagedMemberInspectorCore.test.ts new file mode 100644 index 0000000000..2067e614be --- /dev/null +++ b/src/dotnet/ManagedMemberInspectorCore.test.ts @@ -0,0 +1,96 @@ +import { fc, it } from "@fast-check/vitest"; +import { describe, expect } from "vitest"; + +import { + createDeclaringTypeLookup, + declaringType, + type TypeRange, +} from "./ManagedMemberInspectorCore.js"; + +const range = ( + name: string, + fieldStart: number, + fieldEnd: number, + methodStart = fieldStart, + methodEnd = fieldEnd, +): TypeRange => ({ + token: name, + fullName: name, + fieldStart, + fieldEnd, + methodStart, + methodEnd, +}); + +describe("managed declaring type ownership", () => { + it("resolves field and method boundaries independently, including empty types and gaps", () => { + const ranges = [ + range("first", 1, 3, 1, 1), + range("empty", 3, 3, 1, 1), + range("last", 5, 7, 1, 4), + ]; + const field = createDeclaringTypeLookup(ranges, "field"); + const method = createDeclaringTypeLookup(ranges, "method"); + expect([0, 1, 2, 3, 4, 5, 6, 7].map(field)).toEqual([ + null, + { token: "first", fullName: "first" }, + { token: "first", fullName: "first" }, + null, + null, + { token: "last", fullName: "last" }, + { token: "last", fullName: "last" }, + null, + ]); + expect([0, 1, 2, 3, 4].map(method)).toEqual([ + null, + { token: "last", fullName: "last" }, + { token: "last", fullName: "last" }, + { token: "last", fullName: "last" }, + null, + ]); + }); + + it("retains the first observed owner for overlapping, reversed, or out-of-order ranges", () => { + const ranges = [ + range("first", 5, 9), + range("overlap", 1, 7), + range("reversed", 10, 2), + ]; + const lookup = createDeclaringTypeLookup(ranges, "method"); + expect(lookup(6)).toEqual({ token: "first", fullName: "first" }); + expect(lookup(2)).toEqual({ token: "overlap", fullName: "overlap" }); + expect(lookup(10)).toBeNull(); + expect(createDeclaringTypeLookup([], "method")(1)).toBeNull(); + }); + + it.prop([ + fc.array( + fc.record({ + fieldStart: fc.integer({ min: 0, max: 30 }), + fieldEnd: fc.integer({ min: 0, max: 30 }), + methodStart: fc.integer({ min: 0, max: 30 }), + methodEnd: fc.integer({ min: 0, max: 30 }), + }), + { maxLength: 20 }, + ), + ])( + "agrees with observed first-match ownership for arbitrary metadata ranges", + (raw) => { + const ranges = raw.map((value, index) => ({ + ...value, + token: String(index), + fullName: `Type${String(index)}`, + })); + for (const table of ["field", "method"] as const) { + const lookup = createDeclaringTypeLookup(ranges, table); + for (const row of [ + NaN, + -Infinity, + Infinity, + ...Array.from({ length: 32 }, (_, i) => i), + ]) + expect(lookup(row)).toEqual(declaringType(ranges, table, row)); + } + }, + ); +}); diff --git a/src/dotnet/ManagedMemberInspectorCore.ts b/src/dotnet/ManagedMemberInspectorCore.ts index 8302e7857b..218761fbc8 100644 --- a/src/dotnet/ManagedMemberInspectorCore.ts +++ b/src/dotnet/ManagedMemberInspectorCore.ts @@ -150,6 +150,42 @@ export const declaringType = ( return null; }; +/** Resolve member ownership without rescanning ordered type ranges per row. */ +export const createDeclaringTypeLookup = ( + ranges: readonly TypeRange[], + table: "field" | "method", +): ((row: number) => ReturnType<typeof declaringType>) => { + const entries = ranges.map((range) => ({ + range, + start: table === "field" ? range.fieldStart : range.methodStart, + end: table === "field" ? range.fieldEnd : range.methodEnd, + })); + const ordered = entries.every((entry, index) => { + const previous = entries[index - 1]; + return ( + entry.start <= entry.end && + (previous === undefined || previous.end <= entry.start) + ); + }); + // Malformed metadata can overlap or reverse ranges. Retain the original + // first-match interpretation rather than guessing a different owner. + if (!ordered) return (row) => declaringType(ranges, table, row); + return (row) => { + let first = 0; + let end = entries.length; + while (first < end) { + const middle = first + Math.floor((end - first) / 2); + const entry = entries[middle]; + if (entry === undefined) return null; + if (row >= entry.start && row < entry.end) + return { token: entry.range.token, fullName: entry.range.fullName }; + if (row < entry.start) end = middle; + else first = middle + 1; + } + return null; + }; +}; + export const parseTypes = ( bytes: Buffer, layout: ManagedMetadataLayout, diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts index 9eced54138..d948e54f76 100644 --- a/src/dotnet/ManagedMemberRows.ts +++ b/src/dotnet/ManagedMemberRows.ts @@ -1,7 +1,7 @@ import type { ManagedPeLayout } from "./ManagedPeReader.js"; import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; import { - declaringType, + createDeclaringTypeLookup, signature, type FieldCore, type ManagedCallEdge, @@ -33,6 +33,7 @@ export const parseFields = ( const fields: ManagedField[] = []; const core = new Map<string, FieldCore>(); const table = layout.table(4); + const declaringType = createDeclaringTypeLookup(ranges, "field"); for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { const cursor = metadataRowCursor(bytes, layout, 4, row); const flags = cursor.readUInt16(); @@ -48,7 +49,7 @@ export const parseFields = ( cursor.readIndex(layout.blobIndexSize), layout.blob.size, ); - const declared = declaringType(ranges, "field", row); + const declared = declaringType(row); const token = metadataToken(4, row); fields.push({ token, @@ -123,6 +124,7 @@ export const parseMethods = ({ const methods: ManagedMethod[] = []; const core = new Map<string, MethodCore>(); const table = layout.table(6); + const declaringType = createDeclaringTypeLookup(ranges, "method"); for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { const cursor = metadataRowCursor(bytes, layout, 6, row); const rva = cursor.readUInt32(); @@ -141,7 +143,7 @@ export const parseMethods = ({ layout.blob.size, ); cursor.readIndex(layout.tableIndexSize(8)); - const declared = declaringType(ranges, "method", row); + const declared = declaringType(row); const token = metadataToken(6, row); methods.push({ token, From 2db668f6ca91d2514a354fd459239b84031812e7 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:51:10 -0700 Subject: [PATCH 4/5] fix(cli): emit empty structured filter projections (#914) --- scripts/rea.mjs | 10 +++- src/cliOutput.ts | 44 +++++++++++++-- tests/boundary/cli/cliOutput.test.ts | 77 +++++++++++++++++++++++++++ tests/boundary/cli/dispatcher.test.ts | 2 +- 4 files changed, 126 insertions(+), 7 deletions(-) diff --git a/scripts/rea.mjs b/scripts/rea.mjs index 0d64bd5524..4e310bf080 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -41,6 +41,7 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { const { createCli } = await import("../dist/cli.js"); const { renderCliOutputArgumentError, + renderEmptyFilteredCliOutput, sanitizeCliOutput, validateCliOutputArguments, } = await import("../dist/cliOutput.js"); @@ -49,9 +50,16 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { process.stdout.write(renderCliOutputArgumentError(outputArguments)); process.exitCode = 1; } else { + let wroteOutput = false; await createCli().serve(args, { - stdout: (output) => process.stdout.write(sanitizeCliOutput(output)), + stdout: (output) => { + const sanitized = sanitizeCliOutput(output); + if (sanitized.length > 0) wroteOutput = true; + process.stdout.write(sanitized); + }, }); + if (!wroteOutput) + process.stdout.write(renderEmptyFilteredCliOutput(args) ?? ""); } } diff --git a/src/cliOutput.ts b/src/cliOutput.ts index 0d029337cc..96f65a7e7b 100644 --- a/src/cliOutput.ts +++ b/src/cliOutput.ts @@ -18,6 +18,12 @@ export type CliOutputArgumentValidation = type JsonRecord = Record<string, unknown>; +interface ParsedCliOutputArguments { + readonly filterOutput: boolean; + readonly format: string; + readonly tokenWindow: boolean; +} + const isRecord = (value: unknown): value is JsonRecord => typeof value === "object" && value !== null && !Array.isArray(value); @@ -28,16 +34,16 @@ const validationError = (value: JsonRecord): JsonRecord | undefined => { return undefined; }; -/** Reject text-window controls that would corrupt a structured document. */ -export const validateCliOutputArguments = ( +const parseCliOutputArguments = ( arguments_: readonly string[], -): CliOutputArgumentValidation => { +): ParsedCliOutputArguments => { let format: string = "toon"; + let filterOutput = false; let tokenWindow = false; for (let index = 0; index < arguments_.length; index += 1) { const argument = arguments_[index]; - // Incur scans builtins across `--` and accepts only the spaced --format form. - // Mirror its effective format so malformed requests still emit complete JSON. + // Incur scans builtins across `--` and accepts only spaced value forms. + // Mirror those effective values at the executable output boundary. if (argument === "--json") { format = "json"; continue; @@ -50,6 +56,13 @@ export const validateCliOutputArguments = ( } continue; } + if (argument === "--filter-output") { + if (arguments_[index + 1] !== undefined) { + filterOutput = true; + index += 1; + } + continue; + } if (argument === "--token-limit" || argument === "--token-offset") { if (arguments_[index + 1] !== undefined) { tokenWindow = true; @@ -63,6 +76,14 @@ export const validateCliOutputArguments = ( ) tokenWindow = true; } + return { filterOutput, format, tokenWindow }; +}; + +/** Reject text-window controls that would corrupt a structured document. */ +export const validateCliOutputArguments = ( + arguments_: readonly string[], +): CliOutputArgumentValidation => { + const { format, tokenWindow } = parseCliOutputArguments(arguments_); if ( tokenWindow && (format === "json" || format === "jsonl" || format === "yaml") @@ -76,6 +97,19 @@ export const validateCliOutputArguments = ( return { ok: true }; }; +/** Preserve a complete document when Incur filters the entire result away. */ +export const renderEmptyFilteredCliOutput = ( + arguments_: readonly string[], +): string | undefined => { + const { filterOutput, format } = parseCliOutputArguments(arguments_); + if ( + filterOutput && + (format === "json" || format === "jsonl" || format === "yaml") + ) + return "{}\n"; + return undefined; +}; + /** Render one complete structured error before command execution. */ export const renderCliOutputArgumentError = ( error: Exclude<CliOutputArgumentValidation, { readonly ok: true }>, diff --git a/tests/boundary/cli/cliOutput.test.ts b/tests/boundary/cli/cliOutput.test.ts index 4ca3d913d0..22fa1b0a65 100644 --- a/tests/boundary/cli/cliOutput.test.ts +++ b/tests/boundary/cli/cliOutput.test.ts @@ -8,6 +8,7 @@ import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; import { renderCliOutputArgumentError, + renderEmptyFilteredCliOutput, sanitizeCliOutput, validateCliOutputArguments, } from "../../../src/cliOutput.js"; @@ -140,9 +141,85 @@ describe("CLI output argument and sanitization boundary", () => { meta: { command: "analyze" }, }); }); + + it("renders an explicit empty projection for structured filtered output", () => { + for (const format of ["json", "jsonl", "yaml"]) + expect( + renderEmptyFilteredCliOutput([ + "providers", + "--format", + format, + "--filter-output", + "missing", + ]), + ).toBe("{}\n"); + expect( + renderEmptyFilteredCliOutput(["providers", "--filter-output", "missing"]), + ).toBeUndefined(); + expect( + renderEmptyFilteredCliOutput(["providers", "--format", "json"]), + ).toBeUndefined(); + }); }); describe("compiled CLI output boundary", () => { + workspaceCliTest( + "emits valid JSON when an output filter misses the top-level result", + async ({ cli }) => { + const result = await cli.run({ + arguments: [ + "analyze-javascript-application", + "tests/conformance/readiness/javascript-cli", + "--format", + "json", + "--filter-output", + "summary", + ], + }); + expect(result).toMatchObject({ + exitCode: 0, + stdout: "{}\n", + stderr: "", + json: {}, + }); + const nested = await cli.run({ + arguments: [ + "analyze-javascript-application", + "tests/conformance/readiness/javascript-cli", + "--format", + "json", + "--filter-output", + "normalized_result.not_a_field", + ], + }); + expect(nested).toMatchObject({ + exitCode: 0, + stderr: "", + json: { normalized_result: {} }, + }); + const selected = await cli.run({ + arguments: [ + "analyze-javascript-application", + "tests/conformance/readiness/javascript-cli", + "--format", + "json", + "--filter-output", + "normalized_result.summary", + ], + }); + expect(selected).toMatchObject({ + exitCode: 0, + stderr: "", + json: { + normalized_result: { + summary: expect.objectContaining({ browser_windows: 0 }), + }, + }, + }); + }, + CLI_INTEGRATION_TIMEOUT_MS, + ); + workspaceCliTest( "sanitizes a real missing-argument dispatcher failure", async ({ cli }) => { diff --git a/tests/boundary/cli/dispatcher.test.ts b/tests/boundary/cli/dispatcher.test.ts index 7c0705405f..d94cb7ec75 100644 --- a/tests/boundary/cli/dispatcher.test.ts +++ b/tests/boundary/cli/dispatcher.test.ts @@ -27,7 +27,7 @@ describe("executable dispatcher", () => { ), workspace.write( "dist/cliOutput.js", - "export const sanitizeCliOutput = (output) => output; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => '';\n", + "export const sanitizeCliOutput = (output) => output; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => ''; export const renderEmptyFilteredCliOutput = () => undefined;\n", ), workspace.write( "dist/mcpDoctor.js", From f0a68dce60e81b4d2f420bf774b61957a37326ac Mon Sep 17 00:00:00 2001 From: ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:57:33 +0800 Subject: [PATCH 5/5] refactor(artifacts): isolate JavaScript file acquisition (#916) --- docs/capability-migration.md | 22 +++++-- scripts/lib/module-boundaries.d.mts | 1 + scripts/lib/module-boundaries.mjs | 16 +++++ src/application/ArtifactInventory.ts | 2 +- src/application/ArtifactInventory/classify.ts | 2 +- .../ArtifactInventory/scanCanonical.ts | 2 +- .../ArtifactInventory/scanReader.ts | 2 +- src/application/ArtifactInventory/types.ts | 19 +----- .../javascript/ElectronBoundaryAnalysis.ts | 2 +- .../ElectronBoundaryGraphContext.ts | 2 +- .../javascript/ElectronBoundaryGraphIpc.ts | 2 +- .../javascript/ElectronBoundaryGraphNative.ts | 2 +- .../ElectronBoundaryGraphWindows.ts | 2 +- .../javascript/JavaScriptArtifactAnalysis.ts | 2 +- .../JavaScriptArtifactAnalysisTypes.ts | 2 +- .../JavaScriptArtifactGraphBuilder.ts | 2 +- .../JavaScriptArtifactGraphBundlers.ts | 2 +- .../JavaScriptArtifactGraphContext.ts | 2 +- .../JavaScriptArtifactGraphFindings.ts | 2 +- .../JavaScriptArtifactGraphStructure.ts | 2 +- .../JavaScriptArtifactPathResolution.test.ts | 2 +- .../JavaScriptArtifactPathResolution.ts | 2 +- .../JavaScriptArtifactReconstruction.ts | 18 +----- .../JavaScriptHtmlBaseResolution.test.ts | 2 +- .../javascript/JavaScriptJsonModules.ts | 2 +- .../JavaScriptModuleRelationships.ts | 2 +- .../JavaScriptModuleSpecifierPaths.test.ts | 2 +- .../JavaScriptSemanticGraphBuilder.test.ts | 2 +- .../JavaScriptSemanticGraphBuilder.ts | 2 +- .../JavaScriptSemanticGraphConstruction.ts | 2 +- .../JavaScriptSemanticGraphEvidence.ts | 2 +- ...criptSemanticGraphFingerprintProjection.ts | 2 +- .../JavaScriptSemanticGraphFlowProjection.ts | 2 +- .../JavaScriptSemanticGraphResources.test.ts | 2 +- .../JavaScriptSemanticGraphScope.test.ts | 2 +- .../hash.ts => artifacts/ArtifactHash.ts} | 7 ++- .../javascript/JavaScriptArtifactFiles.ts | 59 ++++--------------- .../javascript/JavaScriptArtifactReader.ts | 12 ++++ src/domain/artifactInventorySnapshot.ts | 18 ++++++ .../javascript/javascriptArtifactFiles.ts | 41 +++++++++++++ .../filesystem/artifactMzInventory.test.ts | 2 +- .../javascriptArtifactContentIdentity.test.ts | 2 +- .../javascriptArtifactReconstruction.test.ts | 2 +- .../packageConditionalExports.test.ts | 2 +- tests/evaluation/moduleBoundaries.test.ts | 42 +++++++++++++ 45 files changed, 202 insertions(+), 121 deletions(-) rename src/{application/ArtifactInventory/hash.ts => artifacts/ArtifactHash.ts} (78%) rename src/{application => artifacts}/javascript/JavaScriptArtifactFiles.ts (86%) create mode 100644 src/artifacts/javascript/JavaScriptArtifactReader.ts create mode 100644 src/domain/artifactInventorySnapshot.ts create mode 100644 src/domain/javascript/javascriptArtifactFiles.ts diff --git a/docs/capability-migration.md b/docs/capability-migration.md index 4e4960fb41..568cf4411c 100644 --- a/docs/capability-migration.md +++ b/docs/capability-migration.md @@ -277,11 +277,23 @@ unchanged sources under `scripts/fixtures/javascript-recovery/`. Its default REA entrypoint still resolves to `scripts/rea.mjs`. Managed conformance imports the relocated compiled workflow; owning CI source filters follow the new paths. -This application move preserves the current directory/ASAR reader boundary, -including root/nested byte checks, UTF-8 diagnostics, cancellation and owned -close. Reader selection requires a separately traced increment. Runtime ports -retain their distinct Inspector, passive Electron and active Electron effects; -shipped Electron hooks remain at their existing provider boundary. +`src/artifacts/javascript/` owns directory/ASAR reader construction and relevant +file acquisition, including root/nested byte verification, UTF-8 diagnostics and +nested reader cleanup. Its narrow factory returns the existing `ArtifactReader`; +the application retains input admission, inventory, graph construction and its +original root-reader `finally` close. Pure file facts live in +`src/domain/javascript/javascriptArtifactFiles.ts`, and the shared immutable +inventory snapshot lives in `src/domain/artifactInventorySnapshot.ts`. Inventory +options and integrity policy remain application-owned. Shared stream hashing +lives in `src/artifacts/ArtifactHash.ts`, preserving complete byte counts and the +bounded classification prefix. Acquisition/hash guards reject imports from +application, composition or caller adapters; JavaScript workflows use the factory +instead of importing concrete ASAR/directory reader classes. + +ZIP, native-DMG and Mach-O inventory reader selection keep their existing +lifecycles. Runtime ports retain their distinct Inspector, passive Electron and +active Electron effects; shipped Electron hooks remain at their existing +provider boundary. ## Binary application ownership diff --git a/scripts/lib/module-boundaries.d.mts b/scripts/lib/module-boundaries.d.mts index abf8daf9b7..51d5957c6b 100644 --- a/scripts/lib/module-boundaries.d.mts +++ b/scripts/lib/module-boundaries.d.mts @@ -5,6 +5,7 @@ export interface ModuleBoundaryViolation { readonly specifier: string; readonly target: string; readonly boundary: + | "artifact-acquisition" | "pure-layer" | "application-composition" | "provider-construction" diff --git a/scripts/lib/module-boundaries.mjs b/scripts/lib/module-boundaries.mjs index 81bfce577e..e43ccd44cc 100644 --- a/scripts/lib/module-boundaries.mjs +++ b/scripts/lib/module-boundaries.mjs @@ -60,6 +60,22 @@ const failedBoundary = (file, target) => { if (!file.startsWith("src/") || !target.startsWith("src/")) return undefined; const layer = file.split("/")[1]; const targetLayer = target.split("/")[1]; + if ( + file.startsWith("src/application/javascript/") && + /^src\/artifacts\/(?:Asar|Directory)ArtifactReader\.(?:js|ts)$/u.test( + target, + ) + ) + return "provider-construction"; + if ( + (file.startsWith("src/artifacts/javascript/") || + /^src\/artifacts\/ArtifactHash\.(?:js|ts)$/u.test(file)) && + (["application", "composition", "server", "cli", "main"].includes( + targetLayer, + ) || + /^src\/(?:cli|main)\./u.test(target)) + ) + return "artifact-acquisition"; if ( PROVIDER_ROOTS.has(layer) && /^src\/generatedMcpToolCatalog\.(?:js|ts)$/u.test(target) diff --git a/src/application/ArtifactInventory.ts b/src/application/ArtifactInventory.ts index ea3a9becbf..a7b5b181f9 100644 --- a/src/application/ArtifactInventory.ts +++ b/src/application/ArtifactInventory.ts @@ -4,7 +4,7 @@ import { artifactInventoryResultSchema, type ArtifactInventoryResult, } from "../domain/artifactGraph.js"; -import { abortIfNeeded } from "./ArtifactInventory/hash.js"; +import { abortIfNeeded } from "../artifacts/ArtifactHash.js"; import { scanCanonicalArtifactInventory } from "./ArtifactInventory/scanCanonical.js"; import type { ArtifactIntegrityPolicy, diff --git a/src/application/ArtifactInventory/classify.ts b/src/application/ArtifactInventory/classify.ts index 8dcc0ce4d9..36e953712b 100644 --- a/src/application/ArtifactInventory/classify.ts +++ b/src/application/ArtifactInventory/classify.ts @@ -1,7 +1,7 @@ import { open } from "node:fs/promises"; import { classifyArtifactContent } from "../ArtifactGraphConstruction.js"; -import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "./hash.js"; +import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../../artifacts/ArtifactHash.js"; import type { ArtifactNode } from "../../domain/artifactGraph.js"; import { hasZipSignature, diff --git a/src/application/ArtifactInventory/scanCanonical.ts b/src/application/ArtifactInventory/scanCanonical.ts index ea28fc56aa..b02005394b 100644 --- a/src/application/ArtifactInventory/scanCanonical.ts +++ b/src/application/ArtifactInventory/scanCanonical.ts @@ -23,7 +23,7 @@ import { } from "../ArtifactGraphConstruction.js"; import { canonicalDigest } from "../../domain/comparisonSemantics.js"; import { classifyRoot } from "./classify.js"; -import { hashReadable, type HashResult } from "./hash.js"; +import { hashReadable, type HashResult } from "../../artifacts/ArtifactHash.js"; import { createReader, inventoryLimitations } from "./reader.js"; import { scanReader, diff --git a/src/application/ArtifactInventory/scanReader.ts b/src/application/ArtifactInventory/scanReader.ts index c388a358be..fe24fe408e 100644 --- a/src/application/ArtifactInventory/scanReader.ts +++ b/src/application/ArtifactInventory/scanReader.ts @@ -22,7 +22,7 @@ import { STRICT_INTEGRITY_POLICY, type ArtifactIntegrityPolicy, } from "./types.js"; -import { hashReadable } from "./hash.js"; +import { hashReadable } from "../../artifacts/ArtifactHash.js"; export interface PendingIntegrityContradiction { readonly logicalPath: string; diff --git a/src/application/ArtifactInventory/types.ts b/src/application/ArtifactInventory/types.ts index 3c7004f060..741b070cfb 100644 --- a/src/application/ArtifactInventory/types.ts +++ b/src/application/ArtifactInventory/types.ts @@ -1,9 +1,3 @@ -import type { - ArtifactInventoryResult, - ArtifactNode, - IntegrityContradiction, -} from "../../domain/artifactGraph.js"; - /** Resolved integrity behavior admitted to the artifact scanner. */ export type ArtifactIntegrityPolicy = | { readonly mode: "fail" } @@ -19,15 +13,4 @@ export interface ArtifactInventoryOptions { readonly integrity?: ArtifactIntegrityPolicy | undefined; } -/** Immutable inventory produced by one complete artifact scan. */ -export interface ArtifactInventorySnapshot { - readonly manifest: ArtifactInventoryResult["manifest"]; - readonly nodes: readonly ArtifactNode[]; - readonly occurrences: ArtifactInventoryResult["occurrences"]; - readonly edges: ArtifactInventoryResult["edges"]; - readonly provenance: ReadonlyArray< - ArtifactInventoryResult["provenance"][number] - >; - readonly integrity_contradictions: readonly IntegrityContradiction[]; - readonly limitations: readonly string[]; -} +export type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; diff --git a/src/application/javascript/ElectronBoundaryAnalysis.ts b/src/application/javascript/ElectronBoundaryAnalysis.ts index eff377b710..99de494e2d 100644 --- a/src/application/javascript/ElectronBoundaryAnalysis.ts +++ b/src/application/javascript/ElectronBoundaryAnalysis.ts @@ -2,7 +2,7 @@ import { compareCodePoints } from "../../domain/canonicalOrdering.js"; import type { ElectronIpcFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js"; import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js"; /** One IPC fact retaining its exact owning artifact for graph projection. */ diff --git a/src/application/javascript/ElectronBoundaryGraphContext.ts b/src/application/javascript/ElectronBoundaryGraphContext.ts index 78e59d74b2..460f849dd0 100644 --- a/src/application/javascript/ElectronBoundaryGraphContext.ts +++ b/src/application/javascript/ElectronBoundaryGraphContext.ts @@ -1,7 +1,7 @@ import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraph.js"; import { sha256Text } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js"; import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import type { JavaScriptArtifactGraphContext, JavaScriptArtifactGraphCoverage, diff --git a/src/application/javascript/ElectronBoundaryGraphIpc.ts b/src/application/javascript/ElectronBoundaryGraphIpc.ts index 51139b6d42..64a69c4aba 100644 --- a/src/application/javascript/ElectronBoundaryGraphIpc.ts +++ b/src/application/javascript/ElectronBoundaryGraphIpc.ts @@ -1,6 +1,6 @@ import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraph.js"; import type { ElectronSenderValidationFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { artifactLocalIdentity, javascriptAnalysisCoverage, diff --git a/src/application/javascript/ElectronBoundaryGraphNative.ts b/src/application/javascript/ElectronBoundaryGraphNative.ts index 27e6284282..fffb3f5fb1 100644 --- a/src/application/javascript/ElectronBoundaryGraphNative.ts +++ b/src/application/javascript/ElectronBoundaryGraphNative.ts @@ -1,5 +1,5 @@ import type { ElectronNativeAddonBindingFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { javascriptAnalysisCoverage, resolveArtifactPath, diff --git a/src/application/javascript/ElectronBoundaryGraphWindows.ts b/src/application/javascript/ElectronBoundaryGraphWindows.ts index 2bce47d243..68ed49e177 100644 --- a/src/application/javascript/ElectronBoundaryGraphWindows.ts +++ b/src/application/javascript/ElectronBoundaryGraphWindows.ts @@ -4,7 +4,7 @@ import type { ElectronContextBridgeFinding, ElectronUtilityProcessFinding, } from "../../domain/javascript/electronStaticAnalysisTypes.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { artifactLocalIdentity, createElectronRoleNode, diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index b7ef2ad696..9422eacff8 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -14,7 +14,7 @@ import { failedJavaScriptStaticAnalysis } from "../../domain/javascript/javascri import type { JavaScriptArtifactFile, JavaScriptArtifactFileSet, -} from "./JavaScriptArtifactFiles.js"; +} from "../../domain/javascript/javascriptArtifactFiles.js"; import type { AnalyzedJavaScriptArtifactFile, JavaScriptArtifactAnalysis, diff --git a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts index df46f721bc..d1cdd2a6b5 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts @@ -3,7 +3,7 @@ import type { JavaScriptStaticAnalysis, } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; interface JavaScriptStructuredObservation { readonly path: string; diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index 096adb50b0..6af56f1a75 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -4,7 +4,7 @@ import { type JavaScriptApplicationGraph, } from "../../domain/javascript/javascriptApplicationGraph.js"; import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; -import type { JavaScriptArtifactFileSet } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFileSet } from "../../domain/javascript/javascriptArtifactFiles.js"; import { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js"; import type { JavaScriptArtifactGraphContext } from "./JavaScriptArtifactGraphContext.js"; import { diff --git a/src/application/javascript/JavaScriptArtifactGraphBundlers.ts b/src/application/javascript/JavaScriptArtifactGraphBundlers.ts index 49810f6c63..c23182d9bb 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBundlers.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBundlers.ts @@ -1,6 +1,6 @@ import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraph.js"; import type { JavaScriptBundlerRegistration } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { addAstContainsEdge, artifactLocalIdentity, diff --git a/src/application/javascript/JavaScriptArtifactGraphContext.ts b/src/application/javascript/JavaScriptArtifactGraphContext.ts index 945dd47147..8e398bc77a 100644 --- a/src/application/javascript/JavaScriptArtifactGraphContext.ts +++ b/src/application/javascript/JavaScriptArtifactGraphContext.ts @@ -11,7 +11,7 @@ import type { JavaScriptArtifactFile, JavaScriptArtifactFileSet, JavaScriptArtifactFileKind, -} from "./JavaScriptArtifactFiles.js"; +} from "../../domain/javascript/javascriptArtifactFiles.js"; import { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js"; import { artifactObservationEvidence, diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index abce528274..e3bf4c6e81 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -1,6 +1,6 @@ import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraph.js"; import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { addStaticInferenceEdge, artifactLocalIdentity, diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.ts index b2a1979367..7a135fdead 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.ts @@ -5,7 +5,7 @@ import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTyp import type { JavaScriptArtifactContainer, JavaScriptArtifactFile, -} from "./JavaScriptArtifactFiles.js"; +} from "../../domain/javascript/javascriptArtifactFiles.js"; import type { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js"; import { addArtifactContainsEdge, diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index 1536e32520..3f6232def5 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -4,7 +4,7 @@ import { resolveArtifactPathByContext, type ResolveArtifactPathInput, } from "./JavaScriptArtifactPathResolution.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { artifactLocalIdentity } from "./JavaScriptArtifactGraphContext.js"; import { applicationNodeIdentitySchema } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js"; import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js"; diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index fcc102281d..57a5012b02 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -1,7 +1,7 @@ import { builtinModules } from "node:module"; import { posix } from "node:path"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { admitsCanonicalPathSyntax, hasScheme, diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index e5871c2b36..7acf03b058 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -1,16 +1,12 @@ import { lstat, realpath } from "node:fs/promises"; -import { AsarArtifactReader } from "../../artifacts/AsarArtifactReader.js"; -import { - ArtifactReaderFailure, - type ArtifactReader, -} from "../../artifacts/ArtifactReader.js"; -import { DirectoryArtifactReader } from "../../artifacts/DirectoryArtifactReader.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { createJavaScriptArtifactReader as createReader } from "../../artifacts/javascript/JavaScriptArtifactReader.js"; import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js"; import type { JavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticGraph.js"; import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import { analyzeJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js"; -import { readJavaScriptArtifactFiles } from "./JavaScriptArtifactFiles.js"; +import { readJavaScriptArtifactFiles } from "../../artifacts/javascript/JavaScriptArtifactFiles.js"; import { buildJavaScriptArtifactGraph } from "./JavaScriptArtifactGraphBuilder.js"; import { javascriptArtifactReconstructionInputSchema, @@ -124,14 +120,6 @@ const resolveFormat = async ( return observed; }; -const createReader = ( - path: string, - format: "asar" | "directory", -): ArtifactReader => - format === "asar" - ? new AsarArtifactReader(path) - : new DirectoryArtifactReader(path); - const abortIfNeeded = (signal?: AbortSignal): void => { if (signal?.aborted === true) throw new ArtifactReaderFailure( diff --git a/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts b/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts index a5a9fe93cb..e225c0af17 100644 --- a/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts +++ b/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; const paths = [ "renderer/index.html", "assets/app.js", diff --git a/src/application/javascript/JavaScriptJsonModules.ts b/src/application/javascript/JavaScriptJsonModules.ts index 4c0c825366..3733db5cb6 100644 --- a/src/application/javascript/JavaScriptJsonModules.ts +++ b/src/application/javascript/JavaScriptJsonModules.ts @@ -1,6 +1,6 @@ import { compareCodePoints } from "../../domain/canonicalOrdering.js"; import type { JavaScriptJsonModuleObservation } from "./JavaScriptArtifactAnalysisTypes.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; /** Parse one approved JSON module without evaluating JavaScript or resolving imports. */ export const analyzeJavaScriptJsonModule = ( diff --git a/src/application/javascript/JavaScriptModuleRelationships.ts b/src/application/javascript/JavaScriptModuleRelationships.ts index c3f4ed5e9f..d3b688ca4b 100644 --- a/src/application/javascript/JavaScriptModuleRelationships.ts +++ b/src/application/javascript/JavaScriptModuleRelationships.ts @@ -11,7 +11,7 @@ import type { JavaScriptSemanticIr, JavaScriptSemanticModuleLink, } from "../../domain/javascript/javascriptSemanticIr.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { addAstContainsEdge, artifactLocalIdentity, diff --git a/src/application/javascript/JavaScriptModuleSpecifierPaths.test.ts b/src/application/javascript/JavaScriptModuleSpecifierPaths.test.ts index cd5e3eedde..63f44337eb 100644 --- a/src/application/javascript/JavaScriptModuleSpecifierPaths.test.ts +++ b/src/application/javascript/JavaScriptModuleSpecifierPaths.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js"; const filesFor = (paths: readonly string[]) => diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index 90a61aeb10..e7d1c1240a 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -2,7 +2,7 @@ import { expect, it } from "vitest"; import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.js"; import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js"; import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js"; diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts index c81418eedf..d82edde74f 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts @@ -14,7 +14,7 @@ import type { import { sourceRangesEqual as rangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js"; import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js"; import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { projectSemanticEvents, projectSemanticTimers, diff --git a/src/application/javascript/JavaScriptSemanticGraphConstruction.ts b/src/application/javascript/JavaScriptSemanticGraphConstruction.ts index faa46e7ee9..a19f749826 100644 --- a/src/application/javascript/JavaScriptSemanticGraphConstruction.ts +++ b/src/application/javascript/JavaScriptSemanticGraphConstruction.ts @@ -12,7 +12,7 @@ import type { JavaScriptSemanticGraphUnknown, } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { inferredSemanticEvidence, observedSemanticEvidence, diff --git a/src/application/javascript/JavaScriptSemanticGraphEvidence.ts b/src/application/javascript/JavaScriptSemanticGraphEvidence.ts index 9cfaa88b48..4b339ec066 100644 --- a/src/application/javascript/JavaScriptSemanticGraphEvidence.ts +++ b/src/application/javascript/JavaScriptSemanticGraphEvidence.ts @@ -1,7 +1,7 @@ import type { ApplicationGraphEvidence } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js"; import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraph.js"; import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; const INFERENCE_LIMITATION = "This relationship is a conservative static candidate; it does not prove runtime execution or causal flow."; diff --git a/src/application/javascript/JavaScriptSemanticGraphFingerprintProjection.ts b/src/application/javascript/JavaScriptSemanticGraphFingerprintProjection.ts index 0634b341e9..06c6068208 100644 --- a/src/application/javascript/JavaScriptSemanticGraphFingerprintProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphFingerprintProjection.ts @@ -4,7 +4,7 @@ import { type JavaScriptSemanticGraphNode, } from "../../domain/javascript/javascriptSemanticGraph.js"; import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { observedSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; /** Bind domain fingerprint components to exact semantic function nodes. */ diff --git a/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts b/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts index c31aef320d..fba83df6b0 100644 --- a/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts @@ -3,7 +3,7 @@ import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javasc import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js"; import { sourceRangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js"; import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { addSemanticGraphNode, addSemanticGraphRelation, diff --git a/src/application/javascript/JavaScriptSemanticGraphResources.test.ts b/src/application/javascript/JavaScriptSemanticGraphResources.test.ts index fd14ef93c1..2f6efff32d 100644 --- a/src/application/javascript/JavaScriptSemanticGraphResources.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphResources.test.ts @@ -2,7 +2,7 @@ import { expect, it } from "vitest"; import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.js"; import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js"; import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js"; diff --git a/src/application/javascript/JavaScriptSemanticGraphScope.test.ts b/src/application/javascript/JavaScriptSemanticGraphScope.test.ts index ec7b0341ef..cb4a3b7deb 100644 --- a/src/application/javascript/JavaScriptSemanticGraphScope.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphScope.test.ts @@ -2,7 +2,7 @@ import { expect, it } from "vitest"; import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.js"; import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; -import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js"; import { topLevelBinding } from "../../domain/javascript/javascriptSemanticAnalysis.fixture.js"; diff --git a/src/application/ArtifactInventory/hash.ts b/src/artifacts/ArtifactHash.ts similarity index 78% rename from src/application/ArtifactInventory/hash.ts rename to src/artifacts/ArtifactHash.ts index c988717c1c..b3bf43bfab 100644 --- a/src/application/ArtifactInventory/hash.ts +++ b/src/artifacts/ArtifactHash.ts @@ -1,9 +1,10 @@ import { createHash } from "node:crypto"; import type { Readable } from "node:stream"; -import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { streamChunkToBuffer } from "../../artifacts/StreamBytes.js"; +import { ArtifactReaderFailure } from "./ArtifactReader.js"; +import { streamChunkToBuffer } from "./StreamBytes.js"; +/** Full stream digest and byte count with bounded classification evidence. */ export type HashResult = { readonly sha256: string; readonly bytes: number; @@ -13,6 +14,7 @@ export type HashResult = { /** Bounded classification evidence; this is not an executable format size limit. */ export const ARTIFACT_CLASSIFICATION_PREFIX_BYTES = 8_192; +/** Preserve the inventory cancellation reason at a stream boundary. */ export const abortIfNeeded = (signal?: AbortSignal): void => { if (signal?.aborted === true) throw new ArtifactReaderFailure( @@ -21,6 +23,7 @@ export const abortIfNeeded = (signal?: AbortSignal): void => { ); }; +/** Hash every stream byte and retain its bounded classification prefix. */ export const hashReadable = async ( stream: Readable, signal?: AbortSignal, diff --git a/src/application/javascript/JavaScriptArtifactFiles.ts b/src/artifacts/javascript/JavaScriptArtifactFiles.ts similarity index 86% rename from src/application/javascript/JavaScriptArtifactFiles.ts rename to src/artifacts/javascript/JavaScriptArtifactFiles.ts index e70e765abc..6f9cdb845b 100644 --- a/src/application/javascript/JavaScriptArtifactFiles.ts +++ b/src/artifacts/javascript/JavaScriptArtifactFiles.ts @@ -3,61 +3,26 @@ import { isAbsolute } from "node:path"; import type { Readable } from "node:stream"; import { compareCodePoints } from "../../domain/canonicalOrdering.js"; -import { AsarArtifactReader } from "../../artifacts/AsarArtifactReader.js"; +import { AsarArtifactReader } from "../AsarArtifactReader.js"; import { ArtifactPathRegistry, normalizeArtifactPath, -} from "../../artifacts/ArtifactPaths.js"; +} from "../ArtifactPaths.js"; import { ArtifactReaderFailure, type ArtifactEntry, type ArtifactReader, -} from "../../artifacts/ArtifactReader.js"; -import { streamChunkToBuffer } from "../../artifacts/StreamBytes.js"; -import { hashReadable } from "../ArtifactInventory/hash.js"; -import type { ArtifactInventorySnapshot } from "../ArtifactInventory.js"; +} from "../ArtifactReader.js"; +import { streamChunkToBuffer } from "../StreamBytes.js"; +import { hashReadable } from "../ArtifactHash.js"; +import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; -/** Relevant file categories projected from the complete artifact inventory. */ -export type JavaScriptArtifactFileKind = - | "package-json" - | "json" - | "javascript" - | "html" - | "source-map" - | "native-addon"; - -/** One content-addressed local artifact file and its text availability. */ -export interface JavaScriptArtifactFile { - readonly path: string; - readonly container_sha256: string; - readonly sha256: string; - readonly bytes: number; - readonly inventory_artifact_id: string; - readonly kind: JavaScriptArtifactFileKind; - readonly unpacked: boolean; - readonly text: - | { readonly included: true; readonly value: string } - | { - readonly included: false; - readonly reason: "not-applicable" | "invalid-utf8"; - }; -} - -/** One filesystem-backed ASAR nested beneath a directory input. */ -export interface JavaScriptArtifactContainer { - readonly path: string; - readonly sha256: string; - readonly bytes: number; - readonly inventory_artifact_id: string; -} - -/** Deterministic relevant-file projection plus explicit omissions. */ -export interface JavaScriptArtifactFileSet { - readonly files: readonly JavaScriptArtifactFile[]; - readonly containers: readonly JavaScriptArtifactContainer[]; - readonly text_bytes_read: number; - readonly invalid_utf8_files: number; -} +import type { + JavaScriptArtifactFileKind, + JavaScriptArtifactFile, + JavaScriptArtifactContainer, + JavaScriptArtifactFileSet, +} from "../../domain/javascript/javascriptArtifactFiles.js"; interface ExpectedFile { readonly path: string; diff --git a/src/artifacts/javascript/JavaScriptArtifactReader.ts b/src/artifacts/javascript/JavaScriptArtifactReader.ts new file mode 100644 index 0000000000..4d2b88c528 --- /dev/null +++ b/src/artifacts/javascript/JavaScriptArtifactReader.ts @@ -0,0 +1,12 @@ +import { AsarArtifactReader } from "../AsarArtifactReader.js"; +import type { ArtifactReader } from "../ArtifactReader.js"; +import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; + +/** Create the reader for an admitted JavaScript artifact format. */ +export const createJavaScriptArtifactReader = ( + path: string, + format: "asar" | "directory", +): ArtifactReader => + format === "asar" + ? new AsarArtifactReader(path) + : new DirectoryArtifactReader(path); diff --git a/src/domain/artifactInventorySnapshot.ts b/src/domain/artifactInventorySnapshot.ts new file mode 100644 index 0000000000..a219727a12 --- /dev/null +++ b/src/domain/artifactInventorySnapshot.ts @@ -0,0 +1,18 @@ +import type { + ArtifactInventoryResult, + ArtifactNode, + IntegrityContradiction, +} from "./artifactGraph.js"; + +/** Immutable inventory produced by one complete artifact scan. */ +export interface ArtifactInventorySnapshot { + readonly manifest: ArtifactInventoryResult["manifest"]; + readonly nodes: readonly ArtifactNode[]; + readonly occurrences: ArtifactInventoryResult["occurrences"]; + readonly edges: ArtifactInventoryResult["edges"]; + readonly provenance: ReadonlyArray< + ArtifactInventoryResult["provenance"][number] + >; + readonly integrity_contradictions: readonly IntegrityContradiction[]; + readonly limitations: readonly string[]; +} diff --git a/src/domain/javascript/javascriptArtifactFiles.ts b/src/domain/javascript/javascriptArtifactFiles.ts new file mode 100644 index 0000000000..f0f03e0a08 --- /dev/null +++ b/src/domain/javascript/javascriptArtifactFiles.ts @@ -0,0 +1,41 @@ +/** Relevant file categories projected from the complete artifact inventory. */ +export type JavaScriptArtifactFileKind = + | "package-json" + | "json" + | "javascript" + | "html" + | "source-map" + | "native-addon"; + +/** One content-addressed local artifact file and its text availability. */ +export interface JavaScriptArtifactFile { + readonly path: string; + readonly container_sha256: string; + readonly sha256: string; + readonly bytes: number; + readonly inventory_artifact_id: string; + readonly kind: JavaScriptArtifactFileKind; + readonly unpacked: boolean; + readonly text: + | { readonly included: true; readonly value: string } + | { + readonly included: false; + readonly reason: "not-applicable" | "invalid-utf8"; + }; +} + +/** One filesystem-backed ASAR nested beneath a directory input. */ +export interface JavaScriptArtifactContainer { + readonly path: string; + readonly sha256: string; + readonly bytes: number; + readonly inventory_artifact_id: string; +} + +/** Deterministic relevant-file projection plus explicit omissions. */ +export interface JavaScriptArtifactFileSet { + readonly files: readonly JavaScriptArtifactFile[]; + readonly containers: readonly JavaScriptArtifactContainer[]; + readonly text_bytes_read: number; + readonly invalid_utf8_files: number; +} diff --git a/tests/boundary/filesystem/artifactMzInventory.test.ts b/tests/boundary/filesystem/artifactMzInventory.test.ts index 26f1b91a73..fd9884f957 100644 --- a/tests/boundary/filesystem/artifactMzInventory.test.ts +++ b/tests/boundary/filesystem/artifactMzInventory.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { classifyArtifactContent } from "../../../src/application/ArtifactGraphConstruction.js"; import { classifyRoot } from "../../../src/application/ArtifactInventory/classify.js"; -import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../../../src/application/ArtifactInventory/hash.js"; +import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../../../src/artifacts/ArtifactHash.js"; import { scanCanonicalArtifactInventory } from "../../../src/application/ArtifactInventory/scanCanonical.js"; import { targetFormatSchema } from "../../../src/contracts/toolOutputSchemaPrimitives.js"; import { artifactInventoryResultSchema } from "../../../src/domain/artifactGraph.js"; diff --git a/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts b/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts index 93b1a7ab35..74fb9ab221 100644 --- a/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts +++ b/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts @@ -5,7 +5,7 @@ import { createPackage } from "@electron/asar"; import { describe, expect, it } from "vitest"; import { scanArtifactInventory } from "../../../src/application/ArtifactInventory.js"; -import { readJavaScriptArtifactFiles } from "../../../src/application/javascript/JavaScriptArtifactFiles.js"; +import { readJavaScriptArtifactFiles } from "../../../src/artifacts/javascript/JavaScriptArtifactFiles.js"; import { AsarArtifactReader } from "../../../src/artifacts/AsarArtifactReader.js"; import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; diff --git a/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts b/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts index a510ea17f8..cfab06c6bb 100644 --- a/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts +++ b/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts @@ -6,7 +6,7 @@ import { expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { readJavaScriptArtifactFiles } from "../../../src/application/javascript/JavaScriptArtifactFiles.js"; +import { readJavaScriptArtifactFiles } from "../../../src/artifacts/javascript/JavaScriptArtifactFiles.js"; import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js"; import { scanArtifactInventory } from "../../../src/application/ArtifactInventory.js"; import { diff --git a/tests/boundary/javascript/packageConditionalExports.test.ts b/tests/boundary/javascript/packageConditionalExports.test.ts index 88be2f269f..05b4465ef5 100644 --- a/tests/boundary/javascript/packageConditionalExports.test.ts +++ b/tests/boundary/javascript/packageConditionalExports.test.ts @@ -8,7 +8,7 @@ import { resolveArtifactPathByContext, type ArtifactPathResolution, } from "../../../src/application/javascript/JavaScriptArtifactPathResolution.js"; -import type { JavaScriptArtifactFile } from "../../../src/application/javascript/JavaScriptArtifactFiles.js"; +import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const execute = promisify(execFile); const TARGETS = [ diff --git a/tests/evaluation/moduleBoundaries.test.ts b/tests/evaluation/moduleBoundaries.test.ts index abf88802fd..b9b1146d81 100644 --- a/tests/evaluation/moduleBoundaries.test.ts +++ b/tests/evaluation/moduleBoundaries.test.ts @@ -18,6 +18,48 @@ describe("incremental module import boundaries", () => { ["src/contracts/probe.ts", "../android/Provider.js", false], ["src/contracts/probe.ts", "../cli.js", false], ["src/contracts/probe.ts", "../composition/android.js", false], + [ + "src/artifacts/javascript/probe.ts", + "../../application/ArtifactInventory.js", + false, + ], + [ + "src/artifacts/javascript/probe.ts", + "../../composition/android.js", + false, + ], + ["src/artifacts/javascript/probe.ts", "../../cli.js", false], + [ + "src/artifacts/ArtifactHash.ts", + "../application/ArtifactInventory/hash.js", + false, + ], + [ + "src/artifacts/javascript/probe.ts", + "../../domain/artifactInventorySnapshot.js", + true, + ], + ["src/artifacts/javascript/probe.ts", "../AsarArtifactReader.js", true], + [ + "src/application/javascript/probe.ts", + "../../artifacts/AsarArtifactReader.js", + false, + ], + [ + "src/application/javascript/probe.ts", + "../../artifacts/DirectoryArtifactReader.js", + false, + ], + [ + "src/application/javascript/probe.ts", + "../../artifacts/javascript/JavaScriptArtifactReader.js", + true, + ], + [ + "src/application/ArtifactInventory/probe.ts", + "../../artifacts/AsarArtifactReader.js", + true, + ], ["src/domain/probe.test.ts", "../composition/firmware.js", false], ["src/application/probe.ts", "../android/JadxProvider.js", false], ["src/application/probe.ts", "../composition/android.js", false],