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 569a4895d..00bbaefba 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 fa9ab2596..384b8fe67 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 5f6c05f3a..0df194a05 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 7df1464b6..578c53a13 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 000000000..1d012c1df --- /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 e78205532..d3a99f270 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 8a55b74d4..78fc72c1c 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 c1e0a56be..72a462ba8 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 d5fb29558..3e964f332 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 3d2631394..247a2f02c 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 000000000..09f2d6e2c --- /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 f9b4fac92..a77121785 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 01e14f262..a68503b3a 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 995dc1d12..f9fe157f9 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 000000000..2067e614b --- /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 8302e7857..218761fbc 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 9eced5413..d948e54f7 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 0d64bd552..4e310bf08 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 0d029337c..96f65a7e7 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 4ca3d913d..22fa1b0a6 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 7c0705405..d94cb7ec7 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 4e4960fb4..568cf4411 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 abf8daf9b..51d5957c6 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 81bfce577..e43ccd44c 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 ea3a9becb..a7b5b181f 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 8dcc0ce4d..36e953712 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 ea28fc56a..b02005394 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 c388a358b..fe24fe408 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 3c7004f06..741b070cf 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 eff377b71..99de494e2 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 78e59d74b..460f849dd 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 51139b6d4..64a69c4ab 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 27e628428..fffb3f5fb 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 2bce47d24..68ed49e17 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 b7ef2ad69..9422eacff 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 df46f721b..d1cdd2a6b 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 096adb50b..6af56f1a7 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 49810f6c6..c23182d9b 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 945dd4714..8e398bc77 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 abce52827..e3bf4c6e8 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 b2a197936..7a135fdea 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 1536e3252..3f6232def 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 fcc102281..57a5012b0 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 e5871c2b3..7acf03b05 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 a5a9fe93c..e225c0af1 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 4c0c82536..3733db5cb 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 c3f4ed5e9..d3b688ca4 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 cd5e3eedd..63f44337e 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 90a61aeb1..e7d1c1240 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 c81418eed..d82edde74 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 faa46e7ee..a19f74982 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 9cfaa88b4..4b339ec06 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 0634b341e..06c606820 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 c31aef320..fba83df6b 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 fd14ef93c..2f6efff32 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 ec7b0341e..cb4a3b7de 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 c988717c1..b3bf43bfa 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 e70e765ab..6f9cdb845 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 000000000..4d2b88c52 --- /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 000000000..a219727a1 --- /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 000000000..f0f03e0a0 --- /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 26f1b91a7..fd9884f95 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 93b1a7ab3..74fb9ab22 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 a510ea17f..cfab06c6b 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 88be2f269..05b4465ef 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 abf88802f..b9b1146d8 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],