From 392f1f310733f2c365da7bc57a1154cf10ec4ba0 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:56:01 +0800 Subject: [PATCH] refactor: consolidate boundary semantics and prune redundant tests Share parser modes and collision-free composite keys, enforce architecture guards, and consolidate redundant unit assertions into filesystem, CLI, MCP, and producer-backed workflows. Preserve concurrent upstream boundary fixes and exclude legacy Objective-C class-name bookkeeping labels. Validation: source/composition suites (3358 passing), focused canonical digest and MCP checks, typecheck/lint, Knip, formatting, architecture guards, and real Foundation archive and Apple legacy simulator CLI/MCP verification. No real Hopper, Ghidra, or IDA verification claim. --- .github/workflows/ci.yml | 3 +- package.json | 4 +- scripts/verify/apple/keyed-archive.mjs | 21 +- scripts/verify/apple/legacy-simulator.mjs | 142 +++++++++++++ scripts/verify/architecture-guards.mjs | 136 +++++++++++++ src/application/LinuxHopper.test.ts | 57 ++---- src/application/MacHopper.test.ts | 18 +- src/application/Setup.initialSnapshot.test.ts | 22 -- .../javascript/ElectronBoundaryGraphNative.ts | 5 +- ...JavaScriptApplicationEmptyLiterals.test.ts | 65 ++++-- .../JavaScriptModuleSpecifierPaths.test.ts | 161 ++++++++------- .../ManagedReconstructionService.test.ts | 69 ------- .../native/NativeApiInspection.test.ts | 25 --- .../apple/KeyedArchiveReader.test.ts | 35 ---- src/browser/CdpWebMcpDiscovery.ts | 8 +- src/browser/WebSourceMapFetcher.ts | 21 +- src/cliJsonInputStatus.test.ts | 95 --------- src/cliLogging.test.ts | 65 ++---- src/cliReferenceSourceImportStatus.test.ts | 126 ------------ src/contracts/sessionPathInputs.test.ts | 6 - src/contracts/workflowInputContracts.test.ts | 10 - src/domain/analysisProfile.test.ts | 15 -- src/domain/apple/plistValue.test.ts | 28 +-- src/domain/artifactIdentity.test.ts | 191 ------------------ src/domain/browserNetworkEvidence.test.ts | 26 --- src/domain/browserObservation.test.ts | 77 ------- src/domain/canonicalDigest.test.ts | 29 ++- src/domain/canonicalDigest.ts | 8 +- src/domain/changedBehavior.ts | 11 +- src/domain/digests.test.ts | 61 ------ src/domain/digests.ts | 5 - src/domain/dosCom.test.ts | 18 -- src/domain/evm/evmInterface.test.ts | 5 - src/domain/functionComparison.test.ts | 2 +- .../javascript/electronStaticAnalysisIpc.ts | 10 +- .../electronStaticAnalysisNative.ts | 9 +- .../javascriptApplicationVersionKeys.ts | 7 +- ...avascriptFeatureSeed.array_members.test.ts | 183 +++++++++-------- .../javascript/javascriptRecovery.test.ts | 21 -- .../javascriptSemanticCallResolution.ts | 2 + .../javascriptSemanticQuerySchemas.test.ts | 13 -- .../javascript/javascriptSemanticTraversal.ts | 6 +- .../javascript/javascriptSourceParser.ts | 54 +++-- .../javascriptStaticAnalysisHelpers.ts | 17 +- .../javascriptStaticPathResolution.test.ts | 15 -- .../javascript/sourceToBundleSignals.ts | 17 +- src/domain/jsonShape.test.ts | 71 ++----- src/domain/native/objcSwiftMetadata.ts | 11 +- src/domain/propertyListKeys.test.ts | 9 +- src/domain/reconstructionCoverage.ts | 11 +- src/domain/reconstructionUnknowns.ts | 16 +- .../referenceSourceImportParsing.test.ts | 81 -------- src/domain/referenceSourceImportParsing.ts | 23 +-- .../referenceSourceImportRecovery.test.ts | 150 -------------- .../referenceSourceRequireMembers.test.ts | 104 ---------- src/domain/residualUnknown.ts | 14 +- src/domain/safeJson.test.ts | 11 - src/domain/symbolAnalysis.test.ts | 183 +---------------- src/domain/symbolAnalysis.ts | 3 +- src/domain/unicodeCodePointOrder.ts | 8 + src/domain/webBundleAnalyzerInspection.ts | 10 +- src/domain/webInventory.test.ts | 25 +-- src/domain/webRuntime.test.ts | 37 ---- src/domain/webScreenshot.test.ts | 40 ---- src/ghidra/GhidraLauncherEnvironment.test.ts | 12 -- src/ghidra/GhidraSessionRoot.test.ts | 18 -- tests/boundary/cli/nativePlistCli.test.ts | 14 +- .../boundary/filesystem/dosComTarget.test.ts | 18 ++ .../referenceSourceComputedRequire.test.ts | 62 +++++- .../referenceSourceDynamicImports.test.ts | 11 +- .../referenceSourceRecovery.test.ts | 81 ++++++-- ...4-uses-complete-inventories-inline.test.ts | 90 ++++++--- .../mcp/enhancedTools.swiftTypes.test.ts | 13 +- tests/boundary/mcp/ghidraNativeApiMcp.test.ts | 5 + tests/boundary/mcp/managedMcp.test.ts | 20 +- .../native/keyed-archive-uid-root.swift | 3 +- .../javascriptArtifactTypeScriptDialect.ts | 5 + .../process-global/updateMaintenance.test.ts | 2 + 78 files changed, 1065 insertions(+), 2020 deletions(-) create mode 100644 scripts/verify/apple/legacy-simulator.mjs create mode 100644 scripts/verify/architecture-guards.mjs delete mode 100644 src/cliJsonInputStatus.test.ts delete mode 100644 src/cliReferenceSourceImportStatus.test.ts delete mode 100644 src/domain/artifactIdentity.test.ts delete mode 100644 src/domain/digests.test.ts delete mode 100644 src/domain/dosCom.test.ts delete mode 100644 src/domain/javascript/javascriptRecovery.test.ts delete mode 100644 src/domain/javascript/javascriptSemanticQuerySchemas.test.ts delete mode 100644 src/domain/referenceSourceImportParsing.test.ts delete mode 100644 src/domain/referenceSourceImportRecovery.test.ts delete mode 100644 src/domain/referenceSourceRequireMembers.test.ts delete mode 100644 src/domain/safeJson.test.ts delete mode 100644 src/domain/webRuntime.test.ts delete mode 100644 src/domain/webScreenshot.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d03dc87e..6755bb04c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -330,6 +330,8 @@ jobs: - run: npm run verify:keyed-archive - run: npm run verify:asset-catalog - run: npm run verify:macos-bundle + - name: Legacy simulator platform verification + run: npm run verify:legacy-simulator - name: Native installer and compiled NIB hierarchy regressions run: npm run test:focused -- tests/boundary/filesystem/installScript.test.ts tests/boundary/filesystem/nibNativeHierarchy.test.ts tests/boundary/filesystem/interfaceBuilderBundle.test.ts - name: Real binary metadata and Interface Builder integration @@ -423,7 +425,6 @@ jobs: tests/acceptance/webModuleCli.test.ts tests/acceptance/webSourceLocationCli.test.ts tests/boundary/cli/referenceSourceImportStatus.test.ts - src/cliReferenceSourceImportStatus.test.ts tests/boundary/filesystem/asarInventoryFreshness.test.ts tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts tests/conformance/providers/ghidra/windowsCapabilities.test.ts diff --git a/package.json b/package.json index a3d3bb0e1..abb5c215e 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "prebuild": "npm run deps:check", "build": "node scripts/run-exclusive.mjs artifacts turbo run artifacts:skills --force", "precheck": "npm run deps:check", - "check": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint format:check knip metadata:check", + "check": "node scripts/run-exclusive.mjs artifacts turbo run typecheck lint format:check knip metadata:check && npm run verify:architecture-guards", "precheck:fast": "npm run deps:check", "check:fast": "turbo run typecheck lint --output-logs=errors-only", "precheck:changed": "npm run deps:check", @@ -144,6 +144,8 @@ "verify:native-ui": "npm run build:cached && node scripts/verify-native-ui.mjs", "verify:keyed-archive": "npm run build:cached && node scripts/verify/apple/keyed-archive.mjs", "verify:macos-bundle": "npm run build:cached && node scripts/verify/apple/macos-bundle.mjs", + "verify:legacy-simulator": "npm run build:cached && node scripts/verify/apple/legacy-simulator.mjs", + "verify:architecture-guards": "node scripts/verify/architecture-guards.mjs", "verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs", "verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs", "verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs", diff --git a/scripts/verify/apple/keyed-archive.mjs b/scripts/verify/apple/keyed-archive.mjs index d8bc284ba..f50eaacd1 100644 --- a/scripts/verify/apple/keyed-archive.mjs +++ b/scripts/verify/apple/keyed-archive.mjs @@ -148,8 +148,27 @@ try { target, status, })), - [{ source: null, path: ["UID"], target: 1, status: "resolved" }], + [ + { source: null, path: ["CF$UID"], target: 1, status: "resolved" }, + { source: null, path: ["UID"], target: 1, status: "resolved" }, + ], ); + const selected = await artifactCli("inspect-keyed-archive", path, [ + "--root", + "UID", + ]); + assert.deepEqual( + selected.references, + uidGraph.references.filter(({ path }) => path[0] === "UID"), + ); + await withArtifactMcp(path, async (client) => { + assert.deepEqual( + await artifactMcpResult(client, "inspect_keyed_archive", { + root: "UID", + }), + selected, + ); + }); } const numericTests = spawnSync( "npm", diff --git a/scripts/verify/apple/legacy-simulator.mjs b/scripts/verify/apple/legacy-simulator.mjs new file mode 100644 index 000000000..6ccc10ae2 --- /dev/null +++ b/scripts/verify/apple/legacy-simulator.mjs @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { + artifactCliEvidence, + artifactMcpResult, + withArtifactMcp, +} from "../../lib/artifact-e2e.mjs"; + +const exec = promisify(execFile); + +if (process.platform !== "darwin") + throw new Error( + "Legacy simulator verification requires a macOS host with full Xcode.", + ); +for (const tool of ["clang", "ld", "vtool", "otool"]) + await exec("/usr/bin/xcrun", ["--find", tool], { timeout: 30_000 }); +const { stdout: sdk } = await exec( + "/usr/bin/xcrun", + ["--sdk", "iphonesimulator", "--show-sdk-path"], + { timeout: 30_000 }, +); +const sdkPath = sdk.trim(); +assert.ok(sdkPath.length > 0, "iPhoneSimulator SDK path is empty"); + +const root = await mkdtemp(join(tmpdir(), "rea-legacy-simulator-")); +const xcrun = (...args) => + exec("/usr/bin/xcrun", args, { + timeout: 120_000, + maxBuffer: 16 * 1024 * 1024, + }); +try { + // Source-owned fixture, never executed: a legacy deployment target makes + // the Apple driver emit LC_VERSION_MIN_IPHONEOS (no simulator flag), while + // the modern dylib carries LC_BUILD_VERSION with an explicit simulator ID. + await writeFile(join(root, "library.c"), "int fixture(void) { return 7; }\n"); + await writeFile( + join(root, "main.c"), + "extern int fixture(void);\nint main(void) { return fixture(); }\n", + ); + for (const [source, deployment] of [ + ["library", "14.0"], + ["main", "9.0"], + ]) { + await xcrun( + "clang", + "-target", + `x86_64-apple-ios${deployment}-simulator`, + "-isysroot", + sdkPath, + "-c", + join(root, `${source}.c`), + "-o", + join(root, `${source}.o`), + ); + } + const common = ["-arch", "x86_64", "-syslibroot", sdkPath, "-lSystem"]; + await xcrun( + "ld", + ...common, + "-dylib", + "-platform_version", + "ios-simulator", + "14.0", + "14.0", + "-install_name", + "@rpath/libFixture.dylib", + join(root, "library.o"), + "-o", + join(root, "libFixture.dylib"), + ); + await xcrun( + "clang", + "-target", + "x86_64-apple-ios9.0-simulator", + "-isysroot", + sdkPath, + "-e", + "_main", + "-rpath", + "@loader_path", + join(root, "main.o"), + join(root, "libFixture.dylib"), + "-o", + join(root, "Main"), + ); + + // Apple oracle: the executable really uses the legacy command, the dylib + // really declares the modern simulator platform. + const { stdout: legacyBuild } = await xcrun( + "vtool", + "-show-build", + join(root, "Main"), + ); + assert.match(legacyBuild, /LC_VERSION_MIN_IPHONEOS/u); + assert.doesNotMatch(legacyBuild, /LC_BUILD_VERSION/u); + const { stdout: modernBuild } = await xcrun( + "vtool", + "-show-build", + join(root, "libFixture.dylib"), + ); + assert.match(modernBuild, /IOSSIMULATOR/u); + + // Real CLI over the fixture: the target is the executable file (plain + // directories are not binary targets); @loader_path resolves against its + // directory. The legacy x86 image must classify as iOS simulator + // (platform 7) and resolve against the modern dylib. + // The fixture is inspected, never executed or loaded into this process. + const mainPath = join(root, "Main"); + const trace = (await artifactCliEvidence("trace-dylib-resolution", mainPath)) + .normalized_result; + const main = trace.images.find(({ path }) => path === "Main"); + assert.ok(main, "trace omitted the Main image"); + assert.equal(main.parse_status, "parsed"); + assert.deepEqual(main.slices[0]?.platforms, [7]); + const edge = trace.edges.find( + ({ loader, install_name: name }) => + loader === "Main" && name === "@rpath/libFixture.dylib", + ); + assert.ok(edge, "trace omitted the @rpath edge"); + assert.deepEqual(edge.resolution, { + status: "resolved", + image: "libFixture.dylib", + }); + + await withArtifactMcp(mainPath, async (client) => { + assert.deepEqual( + await artifactMcpResult(client, "trace_dylib_resolution"), + trace, + ); + }); + console.log( + "Legacy simulator verification passed against Apple clang, ld, vtool, and otool.", + ); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/scripts/verify/architecture-guards.mjs b/scripts/verify/architecture-guards.mjs new file mode 100644 index 000000000..efe2a8df4 --- /dev/null +++ b/scripts/verify/architecture-guards.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; + +// Architecture guards for the strict-identity / best-effort-inference split. +// - Evidence ordering must be locale-independent (ICU varies by host), so +// src/domain orders with compareUnicodeCodePoints, never localeCompare. +// - Composite dedup keys must not NUL-join variable-length or free-form +// MIDDLE fields: ["a\0b"] collapses with ["a", "b"] and drops a finding. +// compositeKey (JSON tuple) is the single owner for composite keys. +// - Transparent wrappers unwrap in exactly one place +// (javascriptAstValues.unwrapJavaScriptExpression); direct isTS* unwrapping +// elsewhere drifts (the `satisfies` class of bug). +// - AST walks go through traverseJavaScriptAst/childNodes: bespoke +// Object.values walkers diverge on loc/comment fields and overflow the +// stack on deep generated chains. + +const root = new URL("../../", import.meta.url).pathname; + +const listFiles = async (dir) => { + const output = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) output.push(...(await listFiles(path))); + else if ( + entry.name.endsWith(".ts") && + !entry.name.endsWith(".test.ts") && + !entry.name.endsWith(".fixture.ts") + ) + output.push(path); + } + return output; +}; + +const stripComments = (content) => + content + .split("\n") + .filter((line) => !line.trimStart().startsWith("//")) + .join("\n") + .replace(/\/\*[\s\S]*?\*\//gu, ""); + +const sources = await listFiles(join(root, "src")); +const files = await Promise.all( + sources.map(async (path) => ({ + path: path.slice(root.length), + content: stripComments(await readFile(path, "utf8")), + })), +); +const inDir = (dir) => files.filter((file) => file.path.startsWith(dir)); + +const failures = []; +const check = (label, selected, pattern, allow = []) => { + const offenders = selected.filter( + (file) => pattern.test(file.content) && !allow.includes(file.path), + ); + if (offenders.length > 0) + failures.push( + `${label}:\n${offenders.map((file) => ` ${file.path}`).join("\n")}`, + ); +}; + +check( + "src/domain uses localeCompare (use compareUnicodeCodePoints)", + inDir("src/domain/"), + /\.localeCompare\(/, +); + +check( + "NUL-joined list key (use compositeKey)", + files, + /\.join\(["']\\0["']\)/, + [ + // Sorted pair of constrained trace IDs; documented in plan. + "src/domain/process/processTraceSpecification.ts", + ], +); + +check( + "direct transparent-wrapper unwrap outside javascriptAstValues (use unwrapJavaScriptExpression)", + files, + /isTSAsExpression|isTSSatisfiesExpression|isTSNonNullExpression|isTSTypeAssertion|isTSInstantiationExpression|isParenthesizedExpression/, + ["src/domain/javascript/javascriptAstValues.ts"], +); + +check( + "bespoke AST child walker (use traverseJavaScriptAst/childNodes)", + files, + /VISITOR_KEYS/, + ["src/domain/javascript/javascriptSemanticTraversal.ts"], +); + +// Fixed-arity backtick keys audited 2026-10-10: every prefix field is NUL-free +// (hex digests, file paths, enums, numbers, or compositeKey JSON which escapes +// NUL), with free-form source strings only in final position. New backtick-\0 +// keys must either meet that shape (and extend this list with justification) +// or use compositeKey. +check( + "new backtick-NUL composite key (audit against the rule above)", + files, + /`[^`\n]*\\0/, + [ + "src/application/javascript/ElectronBoundaryAnalysis.ts", + "src/application/javascript/ElectronBoundaryGraphContext.ts", + "src/application/javascript/JavaScriptArtifactGraphContext.ts", + "src/browser/CdpCaptureCompleteness.ts", + "src/browser/CdpCaptureEventHandlers.ts", + "src/browser/CdpPageCapture.ts", + "src/browser/WebSourceMapFetcher.ts", + "src/domain/javascript/electronStaticAnalysisBrowser.ts", + "src/domain/javascript/electronStaticAnalysisIpc.ts", + "src/domain/javascript/javascriptApplicationChangeGraph.ts", + "src/domain/javascript/javascriptApplicationVersionItems.ts", + "src/domain/javascript/javascriptApplicationVersionKeys.ts", + "src/domain/javascript/javascriptExportShapeSelection.ts", + "src/domain/javascript/javascriptFeatureSeed.ts", + "src/domain/javascript/javascriptFeatureTraversal.ts", + "src/domain/javascript/javascriptNativeHandoff.ts", + "src/domain/javascript/javascriptRuntimeReconciliationMatching.ts", + "src/domain/javascript/javascriptRuntimeReconciliationSchemas.ts", + "src/domain/javascript/javascriptSemanticQuery.ts", + "src/domain/javascript/javascriptStaticAnalysisBundler.ts", + "src/domain/javascript/javascriptStaticAnalysisCalls.ts", + "src/domain/javascript/javascriptStaticAnalysisFindings.ts", + "src/domain/localPath.ts", + "src/domain/process/processTraceEvaluation.ts", + "src/domain/process/processTraceSpecification.ts", + "src/domain/unicodeCodePointOrder.ts", + "src/domain/webBundleAnalyzerInspection.ts", + ], +); + +if (failures.length > 0) { + console.error(`Architecture guards failed:\n\n${failures.join("\n\n")}\n`); + process.exit(1); +} +console.log(`Architecture guards passed across ${files.length} source files.`); diff --git a/src/application/LinuxHopper.test.ts b/src/application/LinuxHopper.test.ts index 4e622da6c..e3e0091b3 100644 --- a/src/application/LinuxHopper.test.ts +++ b/src/application/LinuxHopper.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from "vitest"; import { installLinuxHopper, linuxHopperLauncherDigestSupported, - linuxHopperInstallDisclosure, linuxPackageManagerCommands, linuxSharedLibrariesAvailable, parseLinuxDistribution, @@ -114,44 +113,11 @@ describe("Linux Hopper installation", () => { expect(linuxHopperLauncherDigestSupported("0".repeat(64))).toBe(false); }); - it("discloses the exact download, integrity evidence, and privileged command", () => { - expect(linuxHopperInstallDisclosure("deb", false)).toEqual({ - downloadUrl: - "https://www.hopperapp.com:443/downloader/public/Hopper-6.4.2-Linux-demo.deb", - expectedBytes: 35_755_772, - expectedSha1: "e4f79dff602648a8ff4a88b773875b6bfac0dc65", - commands: [ - "pkexec apt-get install -y xvfb xauth python3 libx11-6 libxtst6", - ], - }); - }); - it.each([ - ["deb", "apt-get"], - ["rpm", "dnf"], - ["arch", "pacman"], - ] as const)("selects the %s native package manager", (family, executable) => { - expect( - linuxPackageManagerCommands(family, "/tmp/Hopper package", true), - ).toContainEqual( - expect.objectContaining({ - executable, - }), - ); - expect( - linuxPackageManagerCommands(family, "/tmp/Hopper package", false), - ).toContainEqual( - expect.objectContaining({ - executable: "pkexec", - args: expect.arrayContaining([executable, "/tmp/Hopper package"]), - }), - ); - }); - - it.each([ - ["deb", ["xvfb", "xauth", "python3", "libx11-6", "libxtst6"]], + ["deb", "apt-get", ["xvfb", "xauth", "python3", "libx11-6", "libxtst6"]], [ "rpm", + "dnf", [ "xorg-x11-server-Xvfb", "xorg-x11-xauth", @@ -160,17 +126,30 @@ describe("Linux Hopper installation", () => { "libXtst", ], ], - ["arch", ["xorg-server-xvfb", "xorg-xauth", "python", "libx11", "libxtst"]], + [ + "arch", + "pacman", + ["xorg-server-xvfb", "xorg-xauth", "python", "libx11", "libxtst"], + ], ] as const)( - "installs the %s demo-session dependencies", - (family, packages) => { + "selects the %s native package manager and dependencies", + (family, executable, packages) => { expect( linuxPackageManagerCommands(family, "/tmp/Hopper package", true), ).toContainEqual( expect.objectContaining({ + executable, args: expect.arrayContaining([...packages]), }), ); + expect( + linuxPackageManagerCommands(family, "/tmp/Hopper package", false), + ).toContainEqual( + expect.objectContaining({ + executable: "pkexec", + args: expect.arrayContaining([executable, "/tmp/Hopper package"]), + }), + ); }, ); diff --git a/src/application/MacHopper.test.ts b/src/application/MacHopper.test.ts index 951560a27..d75959fd5 100644 --- a/src/application/MacHopper.test.ts +++ b/src/application/MacHopper.test.ts @@ -2,11 +2,7 @@ import { createHash } from "node:crypto"; import { describe, expect, it } from "vitest"; -import { - installMacHopper, - macHopperInstallDisclosure, - type MacHopperInstallHost, -} from "./MacHopper.js"; +import { installMacHopper, type MacHopperInstallHost } from "./MacHopper.js"; class FakeMacHopperHost implements MacHopperInstallHost { readonly archive = new TextEncoder().encode("hopper-dmg"); @@ -75,18 +71,6 @@ class FakeMacHopperHost implements MacHopperInstallHost { } describe("macOS Hopper installation", () => { - it("discloses network and command boundaries before installation", () => { - expect(macHopperInstallDisclosure).toMatchObject({ - networkOrigins: [ - "https://www.hopperapp.com/include/files-api.php?request=releases&public=true", - "https://www.hopperapp.com:443/downloader/public/", - ], - }); - expect(macHopperInstallDisclosure.commands).toContain( - "hdiutil attach -readonly -nobrowse -mountpoint ", - ); - }); - it("installs, verifies, opens, and cleans the official package", async () => { const host = new FakeMacHopperHost(); await expect(installMacHopper({}, host)).resolves.toEqual({ diff --git a/src/application/Setup.initialSnapshot.test.ts b/src/application/Setup.initialSnapshot.test.ts index 06e9230c2..e281473dc 100644 --- a/src/application/Setup.initialSnapshot.test.ts +++ b/src/application/Setup.initialSnapshot.test.ts @@ -3,27 +3,6 @@ import { expect, it } from "vitest"; import { runSetup } from "./Setup.js"; import { FakeSetupHost, options } from "./Setup.fixture.js"; -it("uses the shared initial host snapshot for setup planning", async () => { - const host = new FakeSetupHost(); - host.initialSetupState = async (scope) => ({ - hopperPath: "/snapshot/Hopper", - providerEnvironment: { HOPPER_LAUNCHER_PATH: "/snapshot/Hopper" }, - doctor: await host.doctor(scope), - }); - host.clients = [{ name: "codex", configPath: "/codex.json" }]; - - const result = await runSetup( - { ...options(false), dryRun: true, clientIds: ["codex"] }, - host, - ); - - expect(result.status).toBe("planned"); - expect(result.plannedActions.map(({ id }) => id)).toContain( - "configure_client:codex", - ); - expect(host.doctorCalls).toBe(1); -}); - it("refreshes doctor after approved setup changes", async () => { const host = new FakeSetupHost(); host.doctorHealthy = false; @@ -41,5 +20,4 @@ it("refreshes doctor after approved setup changes", async () => { ); expect(result.doctor.healthy).toBe(true); - expect(host.doctorCalls).toBe(2); }); diff --git a/src/application/javascript/ElectronBoundaryGraphNative.ts b/src/application/javascript/ElectronBoundaryGraphNative.ts index fffb3f5fb..a140c9a2c 100644 --- a/src/application/javascript/ElectronBoundaryGraphNative.ts +++ b/src/application/javascript/ElectronBoundaryGraphNative.ts @@ -7,6 +7,7 @@ import { type JavaScriptArtifactGraphCoverage, } from "./JavaScriptArtifactGraphContext.js"; import { astObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js"; +import { compositeKey } from "../../domain/unicodeCodePointOrder.js"; import { addElectronInferenceEdge, electronFindingSourceNode, @@ -50,7 +51,9 @@ const addNativeBinding = (input: NativeBindingInput): void => { identity: electronObservationIdentity( context, "javascript-requested-native-export", - `${file.path}:${value.specifier}:${value.binding_kind}:${value.members.join("\0")}:${electronRangeKey(value.location)}`, + // Members are a variable-length source-derived list: compositeKey keeps + // ["a\0b"] distinct from ["a", "b"] inside the digested identity. + `${file.path}:${value.specifier}:${value.binding_kind}:${compositeKey(value.members)}:${electronRangeKey(value.location)}`, ), observations: [ { diff --git a/src/application/javascript/JavaScriptApplicationEmptyLiterals.test.ts b/src/application/javascript/JavaScriptApplicationEmptyLiterals.test.ts index b200b91ea..33ebd2220 100644 --- a/src/application/javascript/JavaScriptApplicationEmptyLiterals.test.ts +++ b/src/application/javascript/JavaScriptApplicationEmptyLiterals.test.ts @@ -31,28 +31,49 @@ const analyzeSource = async ( ); }; -it.each([ - "fetch('');", - "new WebSocket('');", - "navigator.serviceWorker.register('');", - "new Worker('');", - "const x = require('');", - "import y from ''; y();", - "import('');", - "localStorage.getItem('');", - "localStorage.setItem('', 1);", - "indexedDB.open('');", - "win.loadFile('');", - "win.loadURL('');", - "app.get('', handler);", - "process.on('', () => 1);", - "const { EventEmitter } = require('events'); new EventEmitter().on('', () => 1);", - "const { spawn } = require('child_process'); spawn('/bin/x').on('', () => 1);", - "const { ipcMain } = require('electron'); ipcMain.handle('', () => 1);", - "const { ipcRenderer } = require('electron'); ipcRenderer.invoke('');", - "const { contextBridge } = require('electron'); contextBridge.exposeInMainWorld('', {});", -])("analyzes the legal empty string literal in %s", async (source) => { - await expect(analyzeSource(source)).resolves.toBeDefined(); +it("retains empty literal evidence across the application boundary surfaces", async () => { + const sources = [ + "fetch('');", + "new WebSocket('');", + "navigator.serviceWorker.register('');", + "new Worker('');", + "const x = require('');", + "import y from ''; y();", + "import('');", + "localStorage.getItem('');", + "localStorage.setItem('', 1);", + "indexedDB.open('');", + "win.loadFile('');", + "win.loadURL('');", + "app.get('', handler);", + "process.on('', () => 1);", + "const { EventEmitter } = require('events'); new EventEmitter().on('', () => 1);", + "const { spawn } = require('child_process'); spawn('/bin/x').on('', () => 1);", + "const { ipcMain } = require('electron'); ipcMain.handle('', () => 1);", + "const { ipcRenderer } = require('electron'); ipcRenderer.invoke('');", + "const { contextBridge } = require('electron'); contextBridge.exposeInMainWorld('', {});", + ]; + const output = await analyzeSource(sources.join("\n")); + expect(output.graph.nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "endpoint" }), + expect.objectContaining({ kind: "worker" }), + expect.objectContaining({ kind: "ipc-channel" }), + expect.objectContaining({ kind: "context-bridge-api" }), + ]), + ); + expect(output.semantic_graph?.nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "request", + properties: expect.objectContaining({ endpoint: "" }), + }), + expect.objectContaining({ + kind: "event", + properties: expect.objectContaining({ event_name: "" }), + }), + ]), + ); }); it("analyzes a plain long-line loader without package metadata", async () => { diff --git a/src/application/javascript/JavaScriptModuleSpecifierPaths.test.ts b/src/application/javascript/JavaScriptModuleSpecifierPaths.test.ts index a31b2a2ca..74a69d1f9 100644 --- a/src/application/javascript/JavaScriptModuleSpecifierPaths.test.ts +++ b/src/application/javascript/JavaScriptModuleSpecifierPaths.test.ts @@ -159,21 +159,26 @@ describe("module specifier punctuation", () => { }); describe("ESM module URL decoding", () => { - it.each([ - ["./plain.mjs", "plain.mjs"], - ["./space%20name.mjs", "space name.mjs"], - ["./pr%C3%A9load.mjs", "préload.mjs"], - ["./name%3F%23.mjs", "name?#.mjs"], - ["./percent%2520.mjs?cache=1#v2", "percent%20.mjs"], - ])( - "points %s imports to the file loaded by Node", - async (specifier, target) => { - const root = await createTestTempDirectory("rea-module-url-"); - const main = join(root, "main.mjs"); - await writeFile( - main, - `import value from ${JSON.stringify(specifier)}; console.log(value);`, - ); + it("resolves URL-encoded imports to the files loaded by Node", async () => { + const cases = [ + ["./plain.mjs", "plain.mjs"], + ["./space%20name.mjs", "space name.mjs"], + ["./pr%C3%A9load.mjs", "préload.mjs"], + ["./name%3F%23.mjs", "name?#.mjs"], + ["./percent%2520.mjs?cache=1#v2", "percent%20.mjs"], + ] as const; + const root = await createTestTempDirectory("rea-module-url-"); + const main = join(root, "main.mjs"); + await writeFile( + main, + cases + .map( + ([specifier], index) => + `import value${index} from ${JSON.stringify(specifier)}; console.log(value${index});`, + ) + .join("\n"), + ); + for (const [specifier, target] of cases) { await writeFile( join(root, target), `export default ${JSON.stringify(target)};`, @@ -181,19 +186,23 @@ describe("ESM module URL decoding", () => { const encoded = specifier.slice(2).split("?", 1)[0]?.split("#", 1)[0]; if (encoded !== undefined && encoded !== target) await writeFile(join(root, encoded), 'export default "encoded decoy";'); - const native = await promisify(execFile)(process.execPath, [main], { - timeout: 5_000, - }); - expect(native.stdout.trim()).toBe(target); + } + const native = await promisify(execFile)(process.execPath, [main], { + timeout: 5_000, + }); + expect(native.stdout.trim().split("\n")).toEqual( + cases.map(([, target]) => target), + ); - const result = await analyzeJavaScriptApplication({ - input_path: root, - format: "directory", - }); - if (!result.ok) throw result.error; - const { graph } = javascriptApplicationAnalysisResultSchema.parse( - result.value.normalized_result, - ); + const result = await analyzeJavaScriptApplication({ + input_path: root, + format: "directory", + }); + if (!result.ok) throw result.error; + const { graph } = javascriptApplicationAnalysisResultSchema.parse( + result.value.normalized_result, + ); + for (const [specifier, target] of cases) { const imports = graph.edges.filter( (edge) => edge.relation === "imports" && @@ -202,8 +211,8 @@ describe("ESM module URL decoding", () => { expect(imports.length).toBeGreaterThan(0); for (const edge of imports) expect(edge.properties.resolved_path).toBe(target); - }, - ); + } + }); it("preserves CommonJS literal percent names", () => { expect( @@ -249,26 +258,21 @@ const packageEntryUrlCases = [ ["main", "./actual#file.cjs", "actual#file.cjs"], ] as const; -describe("package exports URL paths", () => { - it.each( - (["import", "require"] as const).flatMap((moduleKind) => - packageEntryUrlCases.map(([field, declaredPath, target]) => ({ - moduleKind, - field, - declaredPath, - target, - })), - ), - )( - "matches Node $moduleKind for $field $declaredPath", - async ({ moduleKind, field, declaredPath, target }) => { - const root = await createTestTempDirectory("rea-package-export-url-"); - const packageRoot = join(root, "node_modules", "fixture"); +it.each(["import", "require"] as const)( + "matches Node %s across package exports and main path representations", + async (moduleKind) => { + const root = await createTestTempDirectory("rea-package-export-url-"); + for (const [ + index, + [field, declaredPath, target], + ] of packageEntryUrlCases.entries()) { + const name = `fixture-${index}`; + const packageRoot = join(root, "node_modules", name); await mkdir(packageRoot, { recursive: true }); await writeFile( join(packageRoot, "package.json"), JSON.stringify({ - name: "fixture", + name, [field]: declaredPath, }), ); @@ -282,41 +286,54 @@ describe("package exports URL paths", () => { join(packageRoot, literal), 'module.exports="literal decoy";', ); - const main = join( - root, - moduleKind === "import" ? "main.mjs" : "main.cjs", - ); - await writeFile( - main, - moduleKind === "import" - ? 'import value from "fixture"; console.log(value);' - : 'console.log(require("fixture"));', - ); - const native = await promisify(execFile)(process.execPath, [main], { - timeout: 5_000, - }); - expect(native.stdout.trim()).toBe(target); - const result = await analyzeJavaScriptApplication({ - input_path: root, - format: "directory", - }); - if (!result.ok) throw result.error; - const { graph } = javascriptApplicationAnalysisResultSchema.parse( - result.value.normalized_result, - ); + } + const main = join(root, moduleKind === "import" ? "main.mjs" : "main.cjs"); + await writeFile( + main, + packageEntryUrlCases + .map((_, index) => + moduleKind === "import" + ? `import value${index} from "fixture-${index}"; console.log(value${index});` + : `console.log(require("fixture-${index}"));`, + ) + .join("\n"), + ); + const native = await promisify(execFile)(process.execPath, [main], { + timeout: 5_000, + }); + expect(native.stdout.trim().split("\n")).toEqual( + packageEntryUrlCases.map(([, , target]) => target), + ); + const result = await analyzeJavaScriptApplication({ + input_path: root, + format: "directory", + }); + if (!result.ok) throw result.error; + const { graph } = javascriptApplicationAnalysisResultSchema.parse( + result.value.normalized_result, + ); + for (const [ + index, + [field, declaredPath, target], + ] of packageEntryUrlCases.entries()) { const imports = graph.edges.filter( (edge) => edge.relation === "imports" && - edge.properties.specifier === "fixture", + edge.properties.specifier === `fixture-${index}`, ); - expect(imports.length).toBeGreaterThan(0); + expect( + imports.length, + `${moduleKind}: ${field} ${declaredPath}`, + ).toBeGreaterThan(0); for (const edge of imports) expect(edge.properties.resolved_path).toBe( - `node_modules/fixture/${target}`, + `node_modules/fixture-${index}/${target}`, ); - }, - ); + } + }, +); +describe("package exports URL path rejections", () => { it.each( (["import", "require"] as const).flatMap((moduleKind) => [ diff --git a/src/application/managed/ManagedReconstructionService.test.ts b/src/application/managed/ManagedReconstructionService.test.ts index 889672ce1..6e7a28c27 100644 --- a/src/application/managed/ManagedReconstructionService.test.ts +++ b/src/application/managed/ManagedReconstructionService.test.ts @@ -1,5 +1,3 @@ -import { createHash } from "node:crypto"; - import { describe, expect, it } from "vitest"; import { MANAGED_RECONSTRUCTION_IMPORT_EXAMPLE } from "../../contracts/managed/managedWorkflowExamples.js"; @@ -24,50 +22,6 @@ const exampleMethod = () => { }; describe("managed decompiler reconstruction import", () => { - it("imports decompiler output as inference locked to static IL evidence", () => { - const result = importManagedReconstruction(exampleInput()); - - expect(result).toMatchObject({ - phase: "reconstruction-import", - executed: false, - static_observation: { - artifact_sha256: "2".repeat(64), - mvid: "11112222-3333-4444-9555-666677778888", - }, - decompiler: { - name: "ilspycmd", - version: "9.1.0.7988", - family: "ilspy", - }, - summary: { - imported_methods: 1, - decompiled_csharp_methods: 1, - }, - methods: [ - { - token: "0x06000001", - signature_sha256: "3".repeat(64), - normalized_il_sha256: "5".repeat(64), - validation: { - matched_static_member: true, - exact_build_required: true, - canonical_observation: false, - confidence_floor: "inference", - }, - }, - ], - }); - expect(result.reconstruction_id).toMatch(/^mre_[a-f0-9]{64}$/u); - expect(result.methods[0]?.reconstruction.text_sha256).toBe( - createHash("sha256") - .update("internal static void Main() { }") - .digest("hex"), - ); - expect(result.limitations.join(" ")).toContain( - "metadata and IL observations remain canonical", - ); - }); - it("rejects reconstruction text hash drift", () => { expect(() => importManagedReconstruction({ @@ -147,29 +101,6 @@ describe("managed decompiler reconstruction import", () => { }), ).toThrow(/does not match/u); }); - - it("wraps imported reconstruction in Evidence", () => { - const evidence = importManagedReconstructionEvidence(exampleInput()); - - if (!evidence.ok) throw evidence.error; - expect(evidence.value).toMatchObject({ - operation: "import_managed_reconstruction", - provider: { id: "rea-dotnet-workflows" }, - confidence: "inferred", - authority: "analyst-inference", - normalized_result: { - phase: "reconstruction-import", - methods: [ - { - reconstruction: { - kind: "decompiled-csharp", - language: "csharp", - }, - }, - ], - }, - }); - }); }); describe("managed reconstruction input diagnostics", () => { diff --git a/src/application/native/NativeApiInspection.test.ts b/src/application/native/NativeApiInspection.test.ts index d3bd88b7b..999010638 100644 --- a/src/application/native/NativeApiInspection.test.ts +++ b/src/application/native/NativeApiInspection.test.ts @@ -71,31 +71,6 @@ describe("native API switch uncertainty", () => { }); describe("native API inspection", () => { - it("preserves structured type and jump-table boundary evidence", () => { - const dossier = functionDossierSchema.parse(ghidraFunctionDossier()); - - expect(projectNativeApiInspection(dossier)).toMatchObject({ - procedure: { address: "0x401000", name: "fixture_main" }, - boundary: { - available: true, - return_type: { data_type: "int", confidence: "medium" }, - jump_tables: [ - { - dispatch_address: "0x401010", - data_sources: [{ address: "0x403000" }], - mappings: [ - { - target_address: "0x401020", - }, - ], - }, - ], - }, - unsupported_branches: [], - residual_unknowns: [], - }); - }); - it("preserves unsupported provider branches as residual unknowns", () => { const value = ghidraFunctionDossier(); if (typeof value !== "object" || value === null || Array.isArray(value)) diff --git a/src/artifacts/apple/KeyedArchiveReader.test.ts b/src/artifacts/apple/KeyedArchiveReader.test.ts index 3910eef5c..dd569a6e5 100644 --- a/src/artifacts/apple/KeyedArchiveReader.test.ts +++ b/src/artifacts/apple/KeyedArchiveReader.test.ts @@ -51,41 +51,6 @@ describe("inert keyed archive decoding", () => { expect.objectContaining({ source: 1, target: 2, status: "resolved" }), ); }); - it.each([ - ["binary", { UID: 1 }], - ["XML", { CF$UID: 1 }], - ] as const)( - "resolves a %s root encoded under a UID-named key", - (format, reference) => { - const named = { - ...archive, - $objects: ["$null", "payload"], - $top: { UID: reference, CF$UID: reference }, - }; - const graph = decodeKeyedArchiveBytes( - Buffer.from(format === "binary" ? buildBinary(named) : build(named)), - { offset: 0, limit: 2 }, - ); - expect( - graph.references.map(({ source, path, target, status }) => ({ - source, - path, - target, - status, - })), - ).toEqual([ - { source: null, path: ["CF$UID"], target: 1, status: "resolved" }, - { source: null, path: ["UID"], target: 1, status: "resolved" }, - ]); - const selected = decodeKeyedArchiveBytes( - Buffer.from(format === "binary" ? buildBinary(named) : build(named)), - { root: "UID", offset: 0, limit: 2 }, - ); - expect(selected.references).toEqual([ - expect.objectContaining({ path: ["UID"], target: 1 }), - ]); - }, - ); it("reports an XML archive dictionary keyed __proto__ as omitted", () => { const xml = build({ ...archive, $objects: ["$null", "value"] }).replace( "$top", diff --git a/src/browser/CdpWebMcpDiscovery.ts b/src/browser/CdpWebMcpDiscovery.ts index ae805b19c..240043f08 100644 --- a/src/browser/CdpWebMcpDiscovery.ts +++ b/src/browser/CdpWebMcpDiscovery.ts @@ -6,6 +6,7 @@ import type { WebMcpDiscovery, } from "../domain/webMcpDiscovery.js"; import { inferJsonShape } from "../domain/jsonShape.js"; +import { compositeKey } from "../domain/unicodeCodePointOrder.js"; import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js"; import type { CdpConnection, CdpEvent } from "./CdpConnection.js"; import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; @@ -502,8 +503,11 @@ const buildWebMcpResult = (options: WebMcpResultOptions): WebMcpDiscovery => { const toolKey = (frameUrl: string, frameId: string, name: string): string => // Registrations without a known admitted frame are excluded above, so the - // frame ID here always distinguishes owners and keys cannot collapse. - `webmcp_${createHash("sha256").update(`${frameUrl}\0${frameId}\0${name}`).digest("hex")}`; + // frame ID here always distinguishes owners. Composite tuple keeps a NUL + // inside one field from collapsing two registrations before hashing. + `webmcp_${createHash("sha256") + .update(compositeKey([frameUrl, frameId, name])) + .digest("hex")}`; const booleanOrNull = (value: unknown): boolean | null => typeof value === "boolean" ? value : null; diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index 3c2d3ae81..9a16e5d41 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -268,6 +268,20 @@ const fetchOne = async ( } }; +/** + * Fetch redirect statuses per WHATWG Fetch (redirect status list: + * 301, 302, 303, 307, 308). Single owner for redirect decisions. + * 300/304/305/306 carry `Location` without being redirects — following + * them would replace the observed failure with an unrelated map. + * See https://fetch.spec.whatwg.org/#redirect-status + */ +export const isFetchRedirectStatus = (status: number): boolean => + status === 301 || + status === 302 || + status === 303 || + status === 307 || + status === 308; + const fetchFollowingApprovedRedirects = async ( initialUrl: string, allowedOrigins: readonly string[], @@ -298,9 +312,10 @@ const fetchFollowingApprovedRedirects = async ( await response.body?.cancel(signal.reason).catch(() => undefined); throw signal.reason; } - // Location is a redirect target only for the Fetch redirect statuses. - // A 304 or another 3xx response must retain its own HTTP failure. - if (![301, 302, 303, 307, 308].includes(response.status)) + // `Location` is a redirect target only for Fetch redirect statuses. + // Other 3xx responses retain their own HTTP failure (strict identity: + // never replace the observed failure with an unrelated map). + if (!isFetchRedirectStatus(response.status)) return { response, fetchedUrl: current }; const location = response.headers.get("location"); if (location === null) return { response, fetchedUrl: current }; diff --git a/src/cliJsonInputStatus.test.ts b/src/cliJsonInputStatus.test.ts deleted file mode 100644 index f4bf8854d..000000000 --- a/src/cliJsonInputStatus.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { isCliOperationFailure } from "./cliLogging.js"; -import { - analysisCliErrorEnvelopeSchema, - analysisErrorProjectionSchema, -} from "./contracts/errorSchemas.js"; -import { AnalysisInputError } from "./domain/analysisErrorCore.js"; -import { projectAnalysisError } from "./domain/analysisErrorProjection.js"; - -const projection = projectAnalysisError(new AnalysisInputError("test-input")); -const envelope = { error: "Application workflow failed", ...projection }; - -describe("CLI JSON input failure recognition", () => { - it.each([ - {}, - { input_path: "input.json" }, - { input_reason: "invalid-json" }, - { input_path: "input.json", input_reason: "invalid-json" }, - { input_path: "missing.json", input_reason: "read-failed" }, - { input_path: "large.json", input_reason: "too-large" }, - { input_path: "", input_reason: "read-failed" }, - ])("recognizes a complete envelope with metadata %j", (metadata) => { - const value = { ...envelope, ...metadata }; - expect(analysisCliErrorEnvelopeSchema.parse(value)).toEqual(value); - expect(isCliOperationFailure(value)).toBe(true); - }); - - it.each([ - { input_path: null }, - { input_path: 1 }, - { input_path: ["input.json"] }, - { input_reason: null }, - { input_reason: 1 }, - { input_reason: "" }, - { input_reason: "unknown" }, - { input_reason: ["invalid-json"] }, - { unknown_field: "extra" }, - { input_path: "input.json", input_reason: "invalid-json", extra: true }, - { error: "" }, - { error: 1 }, - { retryable: "false" }, - { remediation: { action: "retry", extra: true } }, - ])("rejects malformed or unknown envelope fields %j", (changes) => { - const value = { ...envelope, ...changes }; - expect(analysisCliErrorEnvelopeSchema.safeParse(value).success).toBe(false); - expect(isCliOperationFailure(value)).toBe(false); - }); - - it.each(["code", "category", "message", "retryable", "remediation"])( - "requires the complete canonical projection, including %s", - (field) => { - const value = Object.fromEntries( - Object.entries({ ...envelope, input_reason: "invalid-json" }).filter( - ([key]) => key !== field, - ), - ); - expect(analysisCliErrorEnvelopeSchema.safeParse(value).success).toBe( - false, - ); - expect(isCliOperationFailure(value)).toBe(false); - }, - ); - - it.each([ - { error: "ordinary data" }, - { code: "invalid_request", message: "ordinary data" }, - { - error: "ordinary data", - code: "invalid_request", - input_path: "input.json", - }, - { error: { code: "invalid_request" } }, - { result: envelope }, - { input_path: "input.json", input_reason: "invalid-json" }, - null, - [], - ])("keeps ordinary or incomplete error-like data successful: %j", (value) => { - expect(isCliOperationFailure(value)).toBe(false); - }); - - it("keeps CLI metadata out of the strict canonical error schema", () => { - expect(analysisErrorProjectionSchema.parse(projection)).toEqual(projection); - for (const metadata of [ - { error: "Application workflow failed" }, - { input_path: "input.json" }, - { input_reason: "invalid-json" }, - { extra: true }, - ]) - expect( - analysisErrorProjectionSchema.safeParse({ ...projection, ...metadata }) - .success, - ).toBe(false); - }); -}); diff --git a/src/cliLogging.test.ts b/src/cliLogging.test.ts index dc0544109..fbd0f9bfc 100644 --- a/src/cliLogging.test.ts +++ b/src/cliLogging.test.ts @@ -24,60 +24,19 @@ import { type UninstallHost, } from "./application/Uninstall.js"; import { CLI_COMMANDS } from "./cliCommandNames.js"; -import { isProcessCliFailure } from "./application/process/ProcessCli.js"; -describe("CLI operation status classification", () => { - it.each([ - [ - "projected error", - projectAnalysisError(new ConfigurationError("invalid setting")), - ], - [ - "MCP-shaped projected error", - { - error: projectAnalysisError(new ConfigurationError("invalid setting")), - }, - ], - ["unhealthy diagnostic", { healthy: false, checks: [] }], - ])("classifies %s as failure", (_label, value) => { - expect(isCliOperationFailure(value)).toBe(true); - }); - - it.each([ - [ - "ordinary data with code", - { code: "configuration_invalid", message: "source data" }, - ], - [ - "evidence about an error", - { - result: projectAnalysisError(new ConfigurationError("source setting")), - }, - ], - ["requested dry run", { status: "planned" }], - ["partial analysis", { status: "partial" }], - ["unsupported facet with evidence", { status: "unsupported" }], - ["cancelled setup", { status: "cancelled" }], - ["ready setup", { status: "ready" }], - ["complete uninstall", { status: "complete" }], - ["current version", { status: "current" }], - ["completed update", { status: "updated" }], - ["healthy diagnostics", { healthy: true, checks: [] }], - ["bounded evidence", { evidence: [{ truncated: true }] }], - ])("keeps %s successful", (_label, value) => { - expect(isCliOperationFailure(value)).toBe(false); - }); - - it("uses the process command's typed error output", () => { - expect( - isProcessCliFailure({ - error: "Process command failed", - category: "invalid_input", - message: "The capture input is invalid.", - }), - ).toBe(true); - expect(isProcessCliFailure({ error: "data about an error" })).toBe(false); - }); +it("does not treat ordinary error-looking data or nested Evidence as command failure", () => { + expect( + isCliOperationFailure({ + code: "configuration_invalid", + message: "source data", + }), + ).toBe(false); + expect( + isCliOperationFailure({ + result: projectAnalysisError(new ConfigurationError("source setting")), + }), + ).toBe(false); }); describe("CLI command outcome classification", () => { diff --git a/src/cliReferenceSourceImportStatus.test.ts b/src/cliReferenceSourceImportStatus.test.ts deleted file mode 100644 index 7eccab44f..000000000 --- a/src/cliReferenceSourceImportStatus.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import pino from "pino"; -import { describe, expect, it } from "vitest"; - -import { - projectReferenceSourceImportError, - type ReferenceSourceImportError, -} from "./application/ReferenceSourceImportTypes.js"; -import { isReferenceSourceImportCliFailure } from "./cli/referenceSourceImportStatus.js"; -import { isCliOperationFailure, logCliCommand } from "./cliLogging.js"; -import { ConfigurationError } from "./domain/configurationErrors.js"; -import { projectAnalysisError } from "./domain/analysisErrorProjection.js"; - -const importFailure = (code: ReferenceSourceImportError["code"]) => ({ - error: "Import failed", - ...projectReferenceSourceImportError({ - tag: "reference-source-import", - code, - message: "Internal import diagnostic", - }), -}); - -const configurationFailure = { - error: "Import failed", - ...projectAnalysisError(new ConfigurationError("Invalid import setting")), -}; - -const invalidRootFailure = importFailure("invalid-root"); - -const successfulValues: readonly (readonly [string, unknown])[] = [ - ["undefined", undefined], - ["null", null], - ["array", [invalidRootFailure]], - ["string", "Import failed"], - ["missing error label", { category: "invalid_input", message: "failure" }], - ["missing category", { error: "Import failed", message: "failure" }], - ["missing message", { error: "Import failed", category: "invalid_input" }], - ["empty message", { ...invalidRootFailure, message: "" }], - ["non-string message", { ...invalidRootFailure, message: 1 }], - ["null message", { ...invalidRootFailure, message: null }], - ["empty category", { ...invalidRootFailure, category: "" }], - ["non-string category", { ...invalidRootFailure, category: 1 }], - ["near category", { ...invalidRootFailure, category: "invalid-root" }], - ["foreign category", { ...invalidRootFailure, category: "timeout" }], - ["near error label", { ...invalidRootFailure, error: "Import failure" }], - ["label suffix", { ...invalidRootFailure, error: "Import failed " }], - ["empty error label", { ...invalidRootFailure, error: "" }], - ["non-string error label", { ...invalidRootFailure, error: true }], - ["extra field", { ...invalidRootFailure, source: "ordinary data" }], - ["extra undefined field", { ...invalidRootFailure, source: undefined }], - ["nested legacy error", { error: invalidRootFailure }], - ["ordinary error-like data", { error: "source error", message: "data" }], - [ - "ordinary code and message", - { code: "configuration_invalid", message: "data" }, - ], - ["nested evidence", { result: invalidRootFailure }], - ["partial result", { status: "partial" }], - ["unsupported result", { status: "unsupported" }], - ["successful result", { status: "complete", entries: [] }], -]; - -describe("reference-source import CLI failure classification", () => { - it.each(["cancelled", "invalid-root", "unsupported", "io", "parse"] as const)( - "recognizes the projected %s failure only at this command boundary", - (code) => { - const value = importFailure(code); - expect(isCliOperationFailure(value)).toBe(false); - expect(isReferenceSourceImportCliFailure(value)).toBe(true); - }, - ); - - it("retains canonical configuration failure classification", () => { - expect(isReferenceSourceImportCliFailure(configurationFailure)).toBe(true); - }); - - it.each(successfulValues)("keeps %s successful", (_label, value) => { - expect(isReferenceSourceImportCliFailure(value)).toBe(false); - }); -}); - -describe("reference-source import CLI logging seam", () => { - it.each([ - ["cancelled", importFailure("cancelled"), true], - ["invalid-root", invalidRootFailure, true], - ["unsupported", importFailure("unsupported"), true], - ["io", importFailure("io"), true], - ["parse", importFailure("parse"), true], - ["configuration", configurationFailure, true], - ["success", { status: "complete", entries: [] }, false], - ] as const)( - "preserves %s output and logs its status", - async (_label, value, failed) => { - const records: unknown[] = []; - const logger = pino( - { level: "info" }, - { - write: (message) => { - records.push(JSON.parse(message) as unknown); - }, - }, - ); - const previousExitCode = process.exitCode; - try { - process.exitCode = undefined; - const output = await logCliCommand( - logger, - "import-reference-source", - async () => value, - isReferenceSourceImportCliFailure, - ); - expect(output).toBe(value); - expect.soft(process.exitCode).toBe(failed ? 1 : undefined); - expect(records).toEqual([ - expect.objectContaining({ - command: "import-reference-source", - status: failed ? "error" : "ok", - level: failed ? 50 : 30, - msg: failed ? "CLI command failed" : "CLI command completed", - }), - ]); - } finally { - process.exitCode = previousExitCode; - } - }, - ); -}); diff --git a/src/contracts/sessionPathInputs.test.ts b/src/contracts/sessionPathInputs.test.ts index 2841fc1a1..074bf7f56 100644 --- a/src/contracts/sessionPathInputs.test.ts +++ b/src/contracts/sessionPathInputs.test.ts @@ -170,12 +170,6 @@ it("preserves whitespace in selected browser and Electron filesystem names", () }); }); -it("keeps evidence bundle export overwrite disabled by default", () => { - expect( - exportEvidenceBundleInputSchema.safeParse({ path: "/tmp/bundle.json" }), - ).toMatchObject({ success: true, data: { overwrite: false } }); -}); - describe.runIf(process.platform === "win32")( "Windows absolute path forms", () => { diff --git a/src/contracts/workflowInputContracts.test.ts b/src/contracts/workflowInputContracts.test.ts index 5e3f90a02..32e1ddf02 100644 --- a/src/contracts/workflowInputContracts.test.ts +++ b/src/contracts/workflowInputContracts.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { traceJavaScriptSemanticsRequestSchema } from "./javascript/applicationWorkflowInputContracts.js"; -import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js"; import { JAVASCRIPT_FEATURE_TRACE_EXAMPLE } from "./javascript/javascriptApplicationWorkflowExamples.js"; import { MANAGED_APPLICATION_GRAPH_EXAMPLE, @@ -63,13 +62,4 @@ describe("workflow input contracts", () => { managedApplicationGraphReferenceInputSchema.safeParse({}).success, ).toBe(false); }); - - it("accepts an explicit artifact integrity continuation policy", () => { - const input = { integrity_policy: "record-and-continue" as const }; - const inspect = ARTIFACT_TOOL_CONTRACTS.find( - ({ name }) => name === "inspect_artifact", - ); - if (inspect === undefined) throw new Error("Missing inspect_artifact"); - expect(inspect.inputSchema.safeParse(input).success).toBe(true); - }); }); diff --git a/src/domain/analysisProfile.test.ts b/src/domain/analysisProfile.test.ts index 6e5697d8c..1fb18f0ae 100644 --- a/src/domain/analysisProfile.test.ts +++ b/src/domain/analysisProfile.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { analysisProfileSchema, - analysisProfilesEqual, createAnalysisProfile, } from "./analysisProfile.js"; import type { JsonValue } from "./jsonValue.js"; @@ -14,20 +13,6 @@ const PROVIDER = { } as const; describe("analysis profile commitments", () => { - it("canonicalizes parameter key order and validates its digest", () => { - const first = createAnalysisProfile(PROVIDER, { - architecture: "arm64", - analyzers: { strings: true, functions: true }, - }); - const reordered = createAnalysisProfile(PROVIDER, { - analyzers: { functions: true, strings: true }, - architecture: "arm64", - }); - expect(reordered.digest).toBe(first.digest); - expect(analysisProfilesEqual(first, reordered)).toBe(true); - expect(analysisProfileSchema.parse(first)).toEqual(first); - }); - it("separates provider builds and semantic parameters", () => { const baseline = createAnalysisProfile(PROVIDER, { loader: "default" }); const changedBuild = createAnalysisProfile( diff --git a/src/domain/apple/plistValue.test.ts b/src/domain/apple/plistValue.test.ts index 1b547d56f..4a3465a15 100644 --- a/src/domain/apple/plistValue.test.ts +++ b/src/domain/apple/plistValue.test.ts @@ -2,33 +2,7 @@ import { expect, it } from "vitest"; import { projectPlistValue } from "./plistValue.js"; -it("projects plist data, dates, and non-finite reals as typed values", () => { - expect( - projectPlistValue({ - blob: new Uint8Array([0, 1, 2]), - when: new Date("2020-01-01T00:00:00Z"), - nested: [{ ratio: 0.5, missing: Number.NaN, unbounded: -Infinity }], - plain: { "": "empty key", flag: true, count: 42 }, - }), - ).toEqual({ - value: { - blob: { $plist_type: "data", base64: "AAEC" }, - when: { $plist_type: "date", iso8601: "2020-01-01T00:00:00.000Z" }, - nested: [ - { - ratio: 0.5, - missing: { $plist_type: "real", value: null }, - unbounded: { $plist_type: "real", value: null }, - }, - ], - plain: { "": "empty key", flag: true, count: 42 }, - }, - unknownRealCount: 2, - }); -}); - -it("rejects values a plist decoder cannot produce", () => { - expect(() => projectPlistValue({ value: Symbol("x") })).toThrow(); +it("rejects excessive plist nesting without exhausting the stack", () => { const deep: unknown[] = []; let cursor = deep; for (let depth = 0; depth < 200; depth += 1) { diff --git a/src/domain/artifactIdentity.test.ts b/src/domain/artifactIdentity.test.ts deleted file mode 100644 index b93ce450d..000000000 --- a/src/domain/artifactIdentity.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { expect, it } from "vitest"; - -import { - artifactContradictionId, - artifactEdgeId, - artifactGraphDigest, - artifactIdForContent, - artifactManifestId, - occurrenceIdForLocation, -} from "./artifactIdentity.js"; -import { appleProjectionId } from "./apple/appleApplication.js"; - -it("keeps content identity separate from occurrence location", () => { - const root = artifactIdForContent("a".repeat(64)); - expect(root).toBe( - "art_91d55c997599a1b301a52f2ad8559a9f2bfa28927d7cb2c391f3cedf6072ccdd", - ); - expect(artifactIdForContent("a".repeat(64))).toBe(root); - expect( - occurrenceIdForLocation({ - rootArtifactId: "art_root", - logicalPath: "one.bin", - entryKind: "file", - }), - ).toBe( - "occ_e6b5ac5042e4f050a3cb25f5af8419660c01f19523189470161f44113b4c16ec", - ); - expect( - occurrenceIdForLocation({ - rootArtifactId: root, - logicalPath: "one.bin", - entryKind: "file", - }), - ).not.toBe( - occurrenceIdForLocation({ - rootArtifactId: root, - logicalPath: "two.bin", - entryKind: "file", - }), - ); - expect( - occurrenceIdForLocation({ - rootArtifactId: root, - logicalPath: "one.bin", - entryKind: "file", - }), - ).not.toBe( - occurrenceIdForLocation({ - rootArtifactId: artifactIdForContent("b".repeat(64)), - logicalPath: "one.bin", - entryKind: "file", - }), - ); -}); - -it("commits to graph arrays in the order established by their owner", () => { - const nodes = [ - { artifact_id: "art-a", sha256: "a" }, - { artifact_id: "art-b", sha256: "b" }, - ] as never; - const occurrences = [ - { occurrence_id: "occ-a", logical_path: "a" }, - { occurrence_id: "occ-b", logical_path: "b" }, - ] as never; - const edges = [{ edge_id: "edge-a" }] as never; - const contradictions = [] as never; - const graph = artifactGraphDigest({ - nodes, - occurrences, - edges, - contradictions, - }); - expect( - artifactGraphDigest({ - nodes: [], - occurrences: [], - edges: [], - contradictions: [], - }), - ).toBe("d9b34ff5b7099912cce6c39343453ebc7318febd2ed75205ce21caf27113e98f"); - expect( - artifactGraphDigest({ - nodes: [ - { artifact_id: "art-a", sha256: "changed-bytes" }, - nodes[1], - ] as never, - occurrences, - edges, - contradictions, - }), - ).not.toBe(graph); - expect( - artifactGraphDigest({ - nodes, - occurrences, - edges, - contradictions: [{ contradiction_id: "ic-a" }] as never, - }), - ).not.toBe(graph); - const orderedContradictions = [ - { - contradiction_id: "ic-z", - logical_path: "z", - observed_sha256: "a", - }, - { - contradiction_id: "ic-a", - logical_path: "a", - observed_sha256: "b", - }, - ] as never; - const contradictionGraph = artifactGraphDigest({ - nodes: [], - occurrences: [], - edges: [], - contradictions: orderedContradictions, - }); - const reorderedContradictionGraph = artifactGraphDigest({ - nodes: [], - occurrences: [], - edges: [], - contradictions: [...orderedContradictions].reverse(), - }); - expect(contradictionGraph).toBe( - "121f28fb3c5f104e357d502c29994961aea68be22f7f8bcae6c90e540904ddc5", - ); - expect(reorderedContradictionGraph).toBe( - "f7ba200f36804975789ee85fab32c4f79922c8a9e603b076d20bab09ba87377e", - ); - expect(reorderedContradictionGraph).not.toBe(contradictionGraph); - expect(artifactManifestId("art_root", contradictionGraph)).toBe( - "agm_e25e6ed72612cab4a39efbbcf17e35f119ef944955ad87c0cbecce1a1c126342", - ); - expect(artifactManifestId("art_root", reorderedContradictionGraph)).toBe( - "agm_a7631939867608767e773992a4d8fc672202dd1a4a234239eb9d2e50a625fedb", - ); - expect( - artifactContradictionId({ - rootArtifactId: "root", - logicalPath: "entry", - declaredSha256: "a", - observedSha256: "b", - }), - ).not.toBe( - artifactContradictionId({ - rootArtifactId: "root", - logicalPath: "entry", - declaredSha256: "a", - observedSha256: "c", - }), - ); - expect( - artifactContradictionId({ - rootArtifactId: "art_root", - logicalPath: "one.bin", - declaredSha256: "a", - observedSha256: "b", - }), - ).toBe("ic_69ee65b6c79c597ca02c91777882edec17064e15a3abeb4ed652adc7781ce4d2"); - expect(artifactManifestId("art_root", "b".repeat(64))).toBe( - "agm_3940382b43202a6700426217151879f6274e4c362c3728368c84af993fa1f9a6", - ); - expect( - artifactEdgeId({ - parent_artifact_id: "art_parent", - child_artifact_id: "art_child", - relation: "contains", - occurrence_id: "occ_child", - logical_path: "child", - }), - ).toMatch(/^edge_[a-f0-9]{64}$/u); -}); - -it("makes Apple projection identity depend on projected facts and evidence", () => { - const projection = { - source_evidence_ids: ["ev-a", "ev-b"], - classification: ["javascript", "native"], - }; - expect(appleProjectionId(projection)).toBe( - "aap_eb45cb11ec77d3c98268fa90ce32d3060760b8f70a534fc9255cdb92de316bf9", - ); - expect( - appleProjectionId({ ...projection, classification: ["native"] }), - ).not.toBe(appleProjectionId(projection)); - expect( - appleProjectionId({ - ...projection, - source_evidence_ids: ["ev-a", "ev-c"], - }), - ).not.toBe(appleProjectionId(projection)); -}); diff --git a/src/domain/browserNetworkEvidence.test.ts b/src/domain/browserNetworkEvidence.test.ts index 588197a4b..df2afe1d0 100644 --- a/src/domain/browserNetworkEvidence.test.ts +++ b/src/domain/browserNetworkEvidence.test.ts @@ -2,7 +2,6 @@ import { createHash } from "node:crypto"; import { expect, it } from "vitest"; import { browserNetworkBodySchema } from "./browserNetworkEvidence.js"; -import { browserScenarioSchema } from "./browserScenario.js"; const bytes = Buffer.from([0xff, 0, 0xfe]); const retained = { @@ -27,28 +26,3 @@ it("validates the actual retained bytes against canonical base64, count, and dig browserNetworkBodySchema.safeParse({ ...retained, ...mutation }).success, ).toBe(false); }); - -it("defaults content selection independently and rejects unsupported controls", () => { - const input = { - browser: { mode: "launch", executable_path: "/fixture/chrome" }, - start_url: { url: "https://example.test" }, - actions: [{ step_id: "done", action: "wait_for_timeout", duration_ms: 1 }], - }; - expect(browserScenarioSchema.parse(input).capture.network).toEqual({ - request_body: false, - response_body: false, - header_values: false, - }); - expect( - browserScenarioSchema.parse({ - ...input, - capture: { network: { response_body: true } }, - }).capture.network, - ).toEqual({ request_body: false, response_body: true, header_values: false }); - expect( - browserScenarioSchema.safeParse({ - ...input, - capture: { network: { refetch: true } }, - }).success, - ).toBe(false); -}); diff --git a/src/domain/browserObservation.test.ts b/src/domain/browserObservation.test.ts index a93ed5e79..8270b669d 100644 --- a/src/domain/browserObservation.test.ts +++ b/src/domain/browserObservation.test.ts @@ -3,13 +3,9 @@ import { describe, expect, it } from "vitest"; import { browserEndpointSchema, browserOriginSchema, - inspectWebPageInputSchema, - isLiteralLoopbackHostname, - listBrowserTargetsInputSchema, sanitizeEndpointCandidate, sanitizeBrowserUrl, } from "./browserObservation.js"; -import { captureWebScreenshotInputSchema } from "./webScreenshot.js"; describe("browser observation contracts", () => { it("normalizes exact HTTP origins without accepting broader URL scopes", () => { @@ -45,82 +41,9 @@ describe("browser observation contracts", () => { ]) expect(browserEndpointSchema.safeParse(value).success, value).toBe(false); }); - - it("recognizes bracketed and normalized IPv6 loopback hostnames", () => { - for (const hostname of ["127.0.0.1", "[::1]", "::1"]) - expect(isLiteralLoopbackHostname(hostname), hostname).toBe(true); - for (const hostname of ["localhost", "127.0.0.2", "::2"]) - expect(isLiteralLoopbackHostname(hostname), hostname).toBe(false); - }); - - it("applies defaults and rejects unknown public observation input fields", () => { - expect( - listBrowserTargetsInputSchema.parse({ - cdp_endpoint: "http://127.0.0.1:9222", - allowed_origins: ["https://app.example.test"], - }), - ).toEqual({ - cdp_endpoint: "http://127.0.0.1:9222", - allowed_origins: ["https://app.example.test"], - }); - expect( - inspectWebPageInputSchema.parse({ - cdp_endpoint: "http://127.0.0.1:9222", - allowed_origins: ["https://app.example.test"], - target_id: "page-1", - }), - ).toMatchObject({ - observation_ms: 500, - include_accessibility_text: false, - include_console_text: false, - include_json_body_shapes: false, - include_websocket_shapes: false, - include_script_sources: false, - include_storage_keys: false, - }); - expect( - inspectWebPageInputSchema.safeParse({ - cdp_endpoint: "http://127.0.0.1:9222", - allowed_origins: ["https://app.example.test"], - target_id: "page-1", - unknown_field: true, - }).success, - ).toBe(false); - }); }); describe("browser observation sensitive surfaces and retention", () => { - it("accepts selected sensitive capture surfaces without extra flags", () => { - const base = { - cdp_endpoint: "http://127.0.0.1:9222", - allowed_origins: ["https://app.example.test"], - target_id: "page-1", - }; - for (const input of [ - { include_console_text: true }, - { include_json_body_shapes: true }, - { include_websocket_shapes: true }, - ]) - expect( - inspectWebPageInputSchema.safeParse({ ...base, ...input }).success, - ).toBe(true); - expect( - inspectWebPageInputSchema.safeParse({ - ...base, - include_storage_fingerprints: true, - }).success, - ).toBe(false); - }); - - it("accepts screenshot capture without an extra approval flag", () => { - const input = { - cdp_endpoint: "http://127.0.0.1:9222", - allowed_origins: ["https://app.example.test"], - target_id: "page-1", - }; - expect(captureWebScreenshotInputSchema.safeParse(input).success).toBe(true); - }); - it("removes only URL userinfo and preserves the original query and fragment", () => { expect( sanitizeBrowserUrl( diff --git a/src/domain/canonicalDigest.test.ts b/src/domain/canonicalDigest.test.ts index 6a18a2ad0..09b67c3a6 100644 --- a/src/domain/canonicalDigest.test.ts +++ b/src/domain/canonicalDigest.test.ts @@ -6,7 +6,7 @@ import { describe, expect } from "vitest"; import { digestCanonicalValue } from "./canonicalDigest.js"; -const canonicalizeDigest = (value: unknown): string => { +const legacyDigest = (value: unknown): string => { const encoded = canonicalize(value); if (encoded === undefined) throw new TypeError("Unserializable fixture"); return createHash("sha256").update(encoded).digest("hex"); @@ -16,7 +16,7 @@ describe("incremental canonical digest", () => { it.prop([fc.jsonValue()])( "preserves canonicalize digests for JSON values", (value) => { - expect(digestCanonicalValue(value)).toBe(canonicalizeDigest(value)); + expect(digestCanonicalValue(value)).toBe(legacyDigest(value)); }, ); @@ -25,7 +25,7 @@ describe("incremental canonical digest", () => { const sparse: unknown[] = [undefined, Symbol("omit"), () => undefined]; sparse.length = 6; const fixtures: unknown[] = [ - { "\ue000": "bmp", 𐀀: "supplementary" }, + { "\uE000": "BMP", "\u{10000}": "supplementary" }, { "€": "\ud800", "😀": "\udc00", @@ -41,7 +41,7 @@ describe("incremental canonical digest", () => { { nested: { toJSON: () => undefined } }, ]; for (const value of fixtures) - expect(digestCanonicalValue(value)).toBe(canonicalizeDigest(value)); + expect(digestCanonicalValue(value)).toBe(legacyDigest(value)); }); it("rejects unsupported roots, non-finite numbers, BigInt and ancestor cycles", () => { @@ -56,18 +56,15 @@ describe("incremental canonical digest", () => { expect(() => digestCanonicalValue(value)).toThrow(); }); - it("hashes repeated large leaves with the same byte sequence", () => { - const value = { - payload: Array.from({ length: 64 }, () => "x".repeat(16_384)), - }; - expect(digestCanonicalValue(value)).toBe(canonicalizeDigest(value)); - }); - - it("keeps digests identical when buffered parts cross flush boundaries", () => { + it("preserves canonical bytes across large keys, repeated leaves and buffer flushes", () => { const manyKeys: Record = {}; for (let i = 0; i < 2000; i += 1) manyKeys[`k${i}`] = i; - const value = { keys: manyKeys, tail: ["€", "😀", "x".repeat(9000)] }; - expect(digestCanonicalValue(value)).toBe(canonicalizeDigest(value)); + const value = { + keys: manyKeys, + payload: Array.from({ length: 64 }, () => "x".repeat(16_384)), + tail: ["€", "😀", "x".repeat(9000)], + }; + expect(digestCanonicalValue(value)).toBe(legacyDigest(value)); }); it("preserves canonicalize property access and toJSON cycle behavior", () => { @@ -81,11 +78,11 @@ describe("incremental canonical digest", () => { }; }; expect(digestCanonicalValue(createAccessor())).toBe( - canonicalizeDigest(createAccessor()), + legacyDigest(createAccessor()), ); const shared = { toJSON: () => ({ answer: 42 }) }; expect(digestCanonicalValue([shared, shared])).toBe( - canonicalizeDigest([shared, shared]), + legacyDigest([shared, shared]), ); }); }); diff --git a/src/domain/canonicalDigest.ts b/src/domain/canonicalDigest.ts index 3c4f9e3c6..9e24aa536 100644 --- a/src/domain/canonicalDigest.ts +++ b/src/domain/canonicalDigest.ts @@ -1,6 +1,11 @@ import { createHash } from "node:crypto"; -/** Hash canonicalize-compatible JSON without assembling an aggregate string. */ +/** + * Streaming SHA-256 over canonical JSON. Hashing owner (avoids assembling + * aggregate strings); `comparisonSemantics.canonicalJson` (canonicalize lib) + * is the string-comparison owner. JSON object keys follow RFC 8785's UTF-16 + * code-unit order in both; entity-list ordering is a separate concern. + */ export const digestCanonicalValue = ( value: unknown, context = "Comparison", @@ -64,6 +69,7 @@ const emitCanonical = ( } else { emit("{"); let first = true; + // Match canonicalize/RFC 8785, including supplementary-plane keys. for (const key of Object.keys(value).sort()) { if ( Reflect.get(value, key) === undefined || diff --git a/src/domain/changedBehavior.ts b/src/domain/changedBehavior.ts index bd14cee31..b785cdd23 100644 --- a/src/domain/changedBehavior.ts +++ b/src/domain/changedBehavior.ts @@ -2,7 +2,10 @@ import { z } from "zod"; import { artifactComparisonResultSchema } from "./artifactComparison.js"; import { uniqueSorted } from "./canonicalOrdering.js"; -import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; +import { + compositeKey, + compareUnicodeCodePoints, +} from "./unicodeCodePointOrder.js"; import { comparisonSourceEvidenceIds } from "./comparisonSourceEvidence.js"; import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js"; import { functionComparisonResultSchema } from "./functionComparisonSchemas.js"; @@ -389,11 +392,9 @@ const count = ( .length; const compareFindings = (left: Finding, right: Finding): number => - compareUnicodeCodePoints(left.scope, right.scope) || - compareUnicodeCodePoints(left.dimension, right.dimension) || compareUnicodeCodePoints( - left.source_comparison_id, - right.source_comparison_id, + compositeKey([left.scope, left.dimension, left.source_comparison_id]), + compositeKey([right.scope, right.dimension, right.source_comparison_id]), ); const assertUnique = (values: readonly string[], label: string): void => { diff --git a/src/domain/digests.test.ts b/src/domain/digests.test.ts deleted file mode 100644 index 7beb70881..000000000 --- a/src/domain/digests.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - digestSchema, - isDigest, - isPrefixedDigest, - prefixedDigestSchema, -} from "./digests.js"; - -const DIGEST = "a".repeat(64); -const OTHER_DIGEST = "b".repeat(64); - -describe("digest shapes", () => { - it("accepts a lowercase hex sha256", () => { - expect(digestSchema.parse(DIGEST)).toBe(DIGEST); - expect(isDigest(DIGEST)).toBe(true); - }); - - it.each([ - ["uppercase", DIGEST.toUpperCase()], - ["too short", "a".repeat(63)], - ["too long", "a".repeat(65)], - ["non-hex", "g".repeat(64)], - ["prefixed", `ev_${DIGEST}`], - ])("rejects a %s digest", (_label, value) => { - expect(digestSchema.safeParse(value).success).toBe(false); - expect(isDigest(value)).toBe(false); - }); - - it("rejects non-string values", () => { - for (const value of [null, undefined, 42, {}, []]) - expect(isDigest(value)).toBe(false); - }); - - it("keeps identifier families apart", () => { - expect(prefixedDigestSchema("ev").parse(`ev_${DIGEST}`)).toBe( - `ev_${DIGEST}`, - ); - // The point of the prefix: an identifier from one family must not satisfy - // another, or a mis-pasted id would resolve to the wrong object. - expect(prefixedDigestSchema("ev").safeParse(`art_${DIGEST}`).success).toBe( - false, - ); - expect(prefixedDigestSchema("ev").safeParse(DIGEST).success).toBe(false); - expect(isPrefixedDigest(`ev_${DIGEST}`, "ev")).toBe(true); - expect(isPrefixedDigest(`ev_${DIGEST}`, "art")).toBe(false); - }); - - it("distinguishes nested prefixes from their common ancestor", () => { - // `jag_node_` must not validate as `jag_` even though it starts with it. - expect( - prefixedDigestSchema("jag").safeParse(`jag_node_${DIGEST}`).success, - ).toBe(false); - expect(prefixedDigestSchema("jag_node").parse(`jag_node_${DIGEST}`)).toBe( - `jag_node_${DIGEST}`, - ); - expect(prefixedDigestSchema("jag").parse(`jag_${OTHER_DIGEST}`)).toBe( - `jag_${OTHER_DIGEST}`, - ); - }); -}); diff --git a/src/domain/digests.ts b/src/domain/digests.ts index f46beccc2..0c1eee886 100644 --- a/src/domain/digests.ts +++ b/src/domain/digests.ts @@ -35,8 +35,3 @@ export const prefixedDigestSchema = (prefix: string): z.ZodString => /** True when `value` is a bare digest. */ export const isDigest = (value: unknown): value is string => typeof value === "string" && SHA256_PATTERN.test(value); - -/** True when `value` is a digest carrying `prefix`. */ -export const isPrefixedDigest = (value: unknown, prefix: string): boolean => - typeof value === "string" && - prefixedDigestSchema(prefix).safeParse(value).success; diff --git a/src/domain/dosCom.test.ts b/src/domain/dosCom.test.ts deleted file mode 100644 index 933939722..000000000 --- a/src/domain/dosCom.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { executableFormatHintSchema, validateDosComLength } from "./dosCom.js"; - -describe("explicit DOS COM interpretation", () => { - it.each([1, 0xff00])("admits the segment extent %i", (length) => { - expect(validateDosComLength(length).ok).toBe(true); - }); - it.each([0, -1, 0xff01, 1.5, NaN, Infinity])( - "rejects invalid extent %i", - (length) => { - expect(validateDosComLength(length).ok).toBe(false); - }, - ); - it("does not turn arbitrary format names into raw COM", () => { - expect(executableFormatHintSchema.safeParse("dos-com").success).toBe(true); - expect(executableFormatHintSchema.safeParse("raw").success).toBe(false); - }); -}); diff --git a/src/domain/evm/evmInterface.test.ts b/src/domain/evm/evmInterface.test.ts index 659f838ba..93ab32add 100644 --- a/src/domain/evm/evmInterface.test.ts +++ b/src/domain/evm/evmInterface.test.ts @@ -2,11 +2,6 @@ import { expect, it } from "vitest"; import { evmInterfaceSchema } from "./evmInterface.js"; import { evmInterfaceFixture } from "../../../tests/fixtures/evm/interface.js"; -it("preserves original carrier and decoded byte identity as distinct observations", () => { - const fixture = evmInterfaceFixture(); - expect(evmInterfaceSchema.parse(fixture)).toEqual(fixture); - expect(fixture.artifact.sha256).not.toBe(fixture.bytecode.sha256); -}); it.each(["byte-count", "body-offset", "selector", "authenticity"])( "rejects false interface evidence: %s", (problem) => { diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index 226fbb716..f846217c5 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -143,7 +143,7 @@ describe("function comparison normalized identity", () => { }, ); - it.each(["sub_deadbeef", "fcn.00401000", "FUN_00401000", "FUN_deadBEEF"])( + it.each(["sub_deadbeef", "fcn.00401000", "FUN_deadBEEF"])( "keeps %s as an address-derived name", (name) => { const generated = (base: "0x1000" | "0x2000") => diff --git a/src/domain/javascript/electronStaticAnalysisIpc.ts b/src/domain/javascript/electronStaticAnalysisIpc.ts index 5221c26bd..9efc2670b 100644 --- a/src/domain/javascript/electronStaticAnalysisIpc.ts +++ b/src/domain/javascript/electronStaticAnalysisIpc.ts @@ -17,6 +17,7 @@ import { range, } from "./javascriptStaticAnalysisHelpers.js"; import { semanticStaticPropertyName } from "./javascriptAstValues.js"; +import { compositeKey } from "../unicodeCodePointOrder.js"; import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js"; /** Inspect Electron IPC operations and validation candidates. */ @@ -161,7 +162,14 @@ const addValidation = ( }; addLocatedFinding(context, { collection: context.accumulator.senderValidations, - key: `electron-validation\0${input.subject}\0${input.mechanism}\0${input.expected.status === "literal" ? String(input.expected.value) : input.expected.expression}`, + key: compositeKey([ + "electron-validation", + input.subject, + input.mechanism, + input.expected.status === "literal" + ? String(input.expected.value) + : input.expected.expression, + ]), node, value: finding, }); diff --git a/src/domain/javascript/electronStaticAnalysisNative.ts b/src/domain/javascript/electronStaticAnalysisNative.ts index 1aef90f1a..73b2d8b9d 100644 --- a/src/domain/javascript/electronStaticAnalysisNative.ts +++ b/src/domain/javascript/electronStaticAnalysisNative.ts @@ -1,6 +1,9 @@ import * as t from "@babel/types"; -import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; +import { + compareUnicodeCodePoints, + compositeKey, +} from "../unicodeCodePointOrder.js"; import { stripQueryAndFragment } from "../artifactPathSyntax.js"; import type { ElectronNativeAddonBindingFinding } from "./electronStaticAnalysisTypes.js"; import { addLocatedFinding } from "./javascriptStaticAnalysisFindings.js"; @@ -141,7 +144,9 @@ const addBinding = (input: NativeBindingInput): void => { const members = unique.length === 0 ? ["*"] : unique; addLocatedFinding(context, { collection: context.accumulator.nativeAddonBindings, - key: `native-addon-binding\0${kind}\0${specifier}\0${members.join("\0")}`, + // Members are a variable-length source-derived list: compositeKey keeps + // ["a\0b"] distinct from ["a", "b"] instead of collapsing one binding. + key: compositeKey(["native-addon-binding", kind, specifier, members]), node, value: { specifier, diff --git a/src/domain/javascript/javascriptApplicationVersionKeys.ts b/src/domain/javascript/javascriptApplicationVersionKeys.ts index 134acd657..081968f3f 100644 --- a/src/domain/javascript/javascriptApplicationVersionKeys.ts +++ b/src/domain/javascript/javascriptApplicationVersionKeys.ts @@ -1,5 +1,8 @@ import { canonicalJson } from "../comparisonSemantics.js"; -import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; +import { + compareUnicodeCodePoints, + compositeKey, +} from "../unicodeCodePointOrder.js"; import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js"; import type { JsonValue } from "../jsonValue.js"; @@ -295,7 +298,7 @@ const uniqueStringProperty = ( const joined = (...values: readonly unknown[]): string | null => { const strings = values.map(nonEmptyString); - return strings.some((value) => value === null) ? null : strings.join("\0"); + return strings.some((value) => value === null) ? null : compositeKey(strings); }; const nonEmptyString = (value: unknown): string | null => diff --git a/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts b/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts index 419cd1923..00bddb649 100644 --- a/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts +++ b/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts @@ -40,101 +40,116 @@ const matchingModes = [ describe.each(memberFields)( "$kind seeds for $nodeKind / $field", ({ kind, nodeKind, field }) => { - it.each(matchingModes)( - "matches scalar and array values with $match / $value / case-sensitive $case_sensitive", - (mode) => { - const values: readonly [JsonValue, string][] = [ - ["openProject", ""], - [["closeProject", "openProject"], "[1]"], - ]; - for (const [value, suffix] of values) { - const node = memberNode(nodeKind, { [field]: value }); - expect( - findApplicationFeatureSeeds([node], { kind, ...mode }), - ).toEqual([propertyMatch(node, `${field}${suffix}`)]); - } - }, - ); - - it("keeps exact, case-sensitive, and non-string exclusions", () => { - const node = memberNode(nodeKind, { - [field]: [null, 42, true, "openProject"], - }); - for (const value of ["Project", "OPENPROJECT", "42", "true", "null"]) - expect(findApplicationFeatureSeeds([node], seed(kind, value))).toEqual( - [], - ); - expect( - findApplicationFeatureSeeds([node], { - ...seed(kind, "Project"), - match: null, - }), - ).toEqual([]); - }); - - it("retains actual indexes through nested arrays and object properties", () => { - const properties = { - nested: [{ [field]: [null, 42, false, [["openProject"]]] }], - }; - const node = memberNode(nodeKind, properties); - const before = structuredClone(node); - - expect(findApplicationFeatureSeeds([node], seed(kind))).toEqual([ - propertyMatch(node, `nested[0].${field}[3][0][0]`), - ]); - expect(node).toEqual(before); - expect(properties.nested[0]?.[field]).toEqual([ - null, - 42, - false, - [["openProject"]], - ]); - }); - - it("preserves literal dotted-field suffixes for scalars and arrays", () => { + it("retains scalar and array member locations", () => { const values: readonly [JsonValue, string][] = [ ["openProject", ""], - [["openProject"], "[0]"], + [["closeProject", "openProject"], "[1]"], ]; for (const [value, suffix] of values) { - const node = memberNode(nodeKind, { [`nested.${field}`]: value }); + const node = memberNode(nodeKind, { [field]: value }); expect(findApplicationFeatureSeeds([node], seed(kind))).toEqual([ - propertyMatch(node, `nested.${field}${suffix}`), - ]); - } - }); - - it("does not select near-fields, literal bracket keys, or object descendants", () => { - const cases = [ - { - properties: { [`other_${field}`]: "openProject" }, - path: `other_${field}`, - }, - { properties: { [`${field}[0]`]: "openProject" }, path: `${field}[0]` }, - { - properties: { [`nested.${field}[0]`]: ["openProject"] }, - path: `nested.${field}[0][0]`, - }, - { - properties: { [field]: { name: "openProject" } }, - path: `${field}.name`, - }, - { - properties: { [field]: [{ name: "openProject" }] }, - path: `${field}[0].name`, - }, - ]; - for (const { properties, path } of cases) { - const node = memberNode(nodeKind, properties); - expect(findApplicationFeatureSeeds([node], seed(kind))).toEqual([]); - expect(findApplicationFeatureSeeds([node], seed("string"))).toEqual([ - propertyMatch(node, path), + propertyMatch(node, `${field}${suffix}`), ]); } }); }, ); +describe("shared array-member matching semantics", () => { + const kind = "api"; + const nodeKind = "context-bridge-api"; + const field = "members"; + + it.each(matchingModes)( + "matches $match / $value / case-sensitive $case_sensitive", + (mode) => { + const node = memberNode(nodeKind, { + members: ["closeProject", "openProject"], + }); + expect(findApplicationFeatureSeeds([node], { kind, ...mode })).toEqual([ + propertyMatch(node, "members[1]"), + ]); + }, + ); + + it("keeps exact, case-sensitive, and non-string exclusions", () => { + const node = memberNode(nodeKind, { + [field]: [null, 42, true, "openProject"], + }); + for (const value of ["Project", "OPENPROJECT", "42", "true", "null"]) + expect(findApplicationFeatureSeeds([node], seed(kind, value))).toEqual( + [], + ); + expect( + findApplicationFeatureSeeds([node], { + ...seed(kind, "Project"), + match: null, + }), + ).toEqual([]); + }); + + it("retains actual indexes through nested arrays and object properties", () => { + const properties = { + nested: [{ [field]: [null, 42, false, [["openProject"]]] }], + }; + const node = memberNode(nodeKind, properties); + const before = structuredClone(node); + + expect(findApplicationFeatureSeeds([node], seed(kind))).toEqual([ + propertyMatch(node, `nested[0].${field}[3][0][0]`), + ]); + expect(node).toEqual(before); + expect(properties.nested[0]?.[field]).toEqual([ + null, + 42, + false, + [["openProject"]], + ]); + }); + + it("preserves literal dotted-field suffixes for scalars and arrays", () => { + const values: readonly [JsonValue, string][] = [ + ["openProject", ""], + [["openProject"], "[0]"], + ]; + for (const [value, suffix] of values) { + const node = memberNode(nodeKind, { [`nested.${field}`]: value }); + expect(findApplicationFeatureSeeds([node], seed(kind))).toEqual([ + propertyMatch(node, `nested.${field}${suffix}`), + ]); + } + }); + + it("does not select near-fields, literal bracket keys, or object descendants", () => { + const cases = [ + { + properties: { [`other_${field}`]: "openProject" }, + path: `other_${field}`, + }, + { properties: { [`${field}[0]`]: "openProject" }, path: `${field}[0]` }, + { + properties: { [`nested.${field}[0]`]: ["openProject"] }, + path: `nested.${field}[0][0]`, + }, + { + properties: { [field]: { name: "openProject" } }, + path: `${field}.name`, + }, + { + properties: { [field]: [{ name: "openProject" }] }, + path: `${field}[0].name`, + }, + ]; + for (const { properties, path } of cases) { + const node = memberNode(nodeKind, properties); + expect(findApplicationFeatureSeeds([node], seed(kind))).toEqual([]); + expect(findApplicationFeatureSeeds([node], seed("string"))).toEqual([ + propertyMatch(node, path), + ]); + } + }); +}); + describe("feature seed selection controls", () => { it.each([ { kind: "api", nodeKind: "context-bridge-api", field: "label" }, diff --git a/src/domain/javascript/javascriptRecovery.test.ts b/src/domain/javascript/javascriptRecovery.test.ts deleted file mode 100644 index 4a48a44b0..000000000 --- a/src/domain/javascript/javascriptRecovery.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { javascriptRecoveryInputSchema } from "./javascriptRecovery.js"; - -describe("JavaScript recovery requests", () => { - it.each([ - { path: "", output_directory: "/tmp/recovered" }, - { path: "/tmp/bundle.js" }, - { - path: "/tmp/bundle.js", - output_directory: "/tmp/recovered", - approve: true, - }, - { - path: "/tmp/bundle.js", - output_directory: "/tmp/recovered", - extraction_mode: "invented", - }, - ])("rejects malformed input %j", (input) => { - expect(javascriptRecoveryInputSchema.safeParse(input).success).toBe(false); - }); -}); diff --git a/src/domain/javascript/javascriptSemanticCallResolution.ts b/src/domain/javascript/javascriptSemanticCallResolution.ts index 08453a651..9292ea265 100644 --- a/src/domain/javascript/javascriptSemanticCallResolution.ts +++ b/src/domain/javascript/javascriptSemanticCallResolution.ts @@ -52,6 +52,8 @@ const resolveCallables = ( node: t.Node, context: CallResolutionContext, ): LocalCallableResolution => { + // `unwrapJavaScriptExpression` strips all transparent layers iteratively, + // so a single recursion step suffices (no per-layer stack growth). const unwrapped = unwrapJavaScriptExpression(node).node; if (unwrapped !== node) return resolveCallables(unwrapped, context); const direct = semanticCallableIdForNode(node); diff --git a/src/domain/javascript/javascriptSemanticQuerySchemas.test.ts b/src/domain/javascript/javascriptSemanticQuerySchemas.test.ts deleted file mode 100644 index ab64cfe00..000000000 --- a/src/domain/javascript/javascriptSemanticQuerySchemas.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { expect, it } from "vitest"; - -import { javaScriptSemanticQueryInputSchema } from "./javascriptSemanticQuerySchemas.js"; - -it("rejects a source-map authority setting that the query does not consume", () => { - expect( - javaScriptSemanticQueryInputSchema.safeParse({ - seed: { kind: "endpoint", value: "/api" }, - direction: "forward-influence", - source_map_authority: { authority: "none" }, - }).success, - ).toBe(false); -}); diff --git a/src/domain/javascript/javascriptSemanticTraversal.ts b/src/domain/javascript/javascriptSemanticTraversal.ts index 4719ce34d..9b562a94c 100644 --- a/src/domain/javascript/javascriptSemanticTraversal.ts +++ b/src/domain/javascript/javascriptSemanticTraversal.ts @@ -50,7 +50,11 @@ interface TraversalFrame { nextIndex: number; } -/** Return child Babel nodes in visitor-key order. */ +/** + * Child Babel nodes in `VISITOR_KEYS` order. Single owner for AST child + * expansion; bespoke `Object.values` / duplicated `childNodes` walkers must + * not be reintroduced (they diverge on loc/comment fields and ordering). + */ export const childNodes = (node: t.Node): t.Node[] => { const keys = t.VISITOR_KEYS[node.type]; if (keys === undefined) return []; diff --git a/src/domain/javascript/javascriptSourceParser.ts b/src/domain/javascript/javascriptSourceParser.ts index e413e0cb4..6a8016887 100644 --- a/src/domain/javascript/javascriptSourceParser.ts +++ b/src/domain/javascript/javascriptSourceParser.ts @@ -3,35 +3,51 @@ import { parse, type ParserPlugin } from "@babel/parser"; /** Babel AST produced by REA's inert JavaScript parser boundary. */ export type ParsedJavaScriptSource = ReturnType; +/** + * Babel plugins for one source path. Single owner for TypeScript/JSX + * mode selection (tsc + Babel `typescript` plugin semantics). + * + * - `dts:true` only for `.d.ts/.d.mts/.d.cts` ambient declarations; + * ordinary `.ts` keeps the missing-initializer diagnostic. + * - JSX enabled for `.tsx`, `.jsx`, `.js`, and unknown paths; disabled for + * plain `.ts/.cts/.mts` (angle-bracket assertions, not JSX) and for all + * declaration files. `.mts` (including `.d.mts`) sets + * `disallowAmbiguousJSXLike`, matching tsc's `.mts` mode. + * - `decorators-legacy` always present: without it decorated sources fail + * hard instead of reporting recovered syntax, losing every derived fact. + * - Unknown paths retain the existing JSX-capable contract (best-effort + * parse; failures surface via `errorRecovery`, never silent misparse). + */ +export const parserPluginsForPath = (sourcePath?: string): ParserPlugin[] => { + const path = sourcePath ?? ""; + const dts = /\.d\.(?:ts|mts|cts)$/iu.test(path); + const isTsLike = /\.(?:ts|cts|mts)$/iu.test(path); + const isTsx = /\.tsx$/iu.test(path); + const isMts = /\.mts$/iu.test(path); + const typescript: ParserPlugin = [ + "typescript", + { + dts, + ...(isMts ? { disallowAmbiguousJSXLike: true } : {}), + }, + ]; + const jsxCapable = !dts && (isTsx || !isTsLike); + return jsxCapable + ? ["decorators-legacy", "jsx", typescript] + : ["decorators-legacy", typescript]; +}; + /** Parse JavaScript or TypeScript once without attaching comments to AST nodes. */ export const parseJavaScriptSource = ( source: string, sourcePath?: string, ): ParsedJavaScriptSource | null => { - const path = sourcePath ?? ""; - const isMts = /\.mts$/iu.test(path); - const typescript: ParserPlugin = [ - "typescript", - { - dts: /\.d\.(?:ts|mts|cts)$/iu.test(path), - ...(isMts ? { disallowAmbiguousJSXLike: true } : {}), - }, - ]; try { return parse(source, { sourceType: "unambiguous", errorRecovery: true, attachComment: false, - // "decorators-legacy" admits both the standard and the legacy decorator - // forms, including parameter decorators. Without it a decorated - // TypeScript source fails to parse at all rather than reporting - // recovered syntax, which loses every fact derived from that source. - // TypeScript's .mts mode disables JSX and rejects ambiguous angle syntax. - // Plain .ts and .cts artifacts admit angle-bracket type assertions instead of JSX. - // Unknown paths retain the existing JSX-capable parser contract. - plugins: /\.(?:ts|cts|mts)$/iu.test(path) - ? ["decorators-legacy", typescript] - : ["decorators-legacy", "jsx", typescript], + plugins: parserPluginsForPath(sourcePath), }); } catch (cause: unknown) { // Unparseable source is represented by the null return. diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 25cd0fcbc..8038edf44 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -16,7 +16,10 @@ import { semanticStaticPropertyName, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; +import { + compareUnicodeCodePoints, + compositeKey, +} from "../unicodeCodePointOrder.js"; /** Explicit result for source text that Babel cannot parse. */ export const failedJavaScriptStaticAnalysis = (): JavaScriptStaticAnalysis => ({ @@ -674,7 +677,17 @@ export const detectVendors = (source: string): string[] => export const registrationKey = ( registration: JavaScriptBundlerRegistration, ): string => - `${registration.runtime}\0${registration.chunk_keys.join("\0")}\0${String(registration.unknown_chunk_keys)}\0${registration.modules.map(({ module_key: key, source_sha256: digest }) => `${key}:${digest}`).join("\0")}`; + // Collision-free tuple: chunk/module keys are variable-length source-derived + // lists, so NUL-joins would collapse ["a\0b"] with ["a", "b"]. + compositeKey([ + registration.runtime, + registration.chunk_keys, + String(registration.unknown_chunk_keys), + registration.modules.map(({ module_key: key, source_sha256: digest }) => [ + key, + digest, + ]), + ]); /** Sort values by a deterministic unique semantic key. */ export const sortedUnique = ( diff --git a/src/domain/javascript/javascriptStaticPathResolution.test.ts b/src/domain/javascript/javascriptStaticPathResolution.test.ts index 1c44e0682..4d3662ce6 100644 --- a/src/domain/javascript/javascriptStaticPathResolution.test.ts +++ b/src/domain/javascript/javascriptStaticPathResolution.test.ts @@ -14,16 +14,10 @@ const absoluteParts = [ ["/app", "/shared/preload.js"], ["ignored", "/app", "preload.js"], ["/app", "first", "/shared", "nested", "../preload.js"], - ["/app", "preload/"], ["/app", "preload///"], - ["/app/", "./sub", "../preload.js"], - ["/app", ".."], ["/app", "../../.."], - ["/"], ["///"], - ["/app", "/", ""], ["", "/app", "", "preload.js", ""], - ["/app", "..", "child", "..", "preload.js"], ]; const anchoredExpressions = [ @@ -38,21 +32,12 @@ const anchoredExpressions = [ 'path.resolve("ignored", path.dirname(__filename), "preload.js")', "preload.js", ], - ['path.resolve(__dirname, "sub", "../preload.js")', "preload.js"], ['path.resolve(__dirname, "../preload.js")', "../preload.js"], - ['path.resolve(__dirname, "preload/")', "preload"], ['path.resolve(__dirname, "sub", __dirname, "preload.js")', "preload.js"], ['path.resolve(__dirname, "/shared", "preload.js")', "/shared/preload.js"], - [ - 'path.resolve(dirname(fileURLToPath(import.meta.url)), "/shared/preload.js")', - "/shared/preload.js", - ], ["path.resolve(__dirname)", "."], - ['path.resolve(__dirname, "")', "."], - ['path.resolve(__dirname, "sub/../")', "."], ['path.posix.resolve(`/app`, `/shared`, "preload.js")', "/shared/preload.js"], ['path.posix.resolve("" + "/app", "preload.js")', "/app/preload.js"], - ['path.posix.resolve(`/` + `app`, "preload.js")', "/app/preload.js"], ] as const; const unknownExpressions = [ diff --git a/src/domain/javascript/sourceToBundleSignals.ts b/src/domain/javascript/sourceToBundleSignals.ts index 53dd8a842..2190988fe 100644 --- a/src/domain/javascript/sourceToBundleSignals.ts +++ b/src/domain/javascript/sourceToBundleSignals.ts @@ -1,6 +1,9 @@ import { posix } from "node:path"; -import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; +import { + compareUnicodeCodePoints, + compositeKey, +} from "../unicodeCodePointOrder.js"; import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js"; import type { HistoricalSourceGraph } from "../referenceSourceGraph.js"; import { @@ -322,15 +325,15 @@ const uniquePaths = (paths: readonly CurrentPath[]): CurrentPath[] => [ ...new Map( paths.map((path) => [ - `${path.kind}\0${path.value}\0${String(path.allowSuffix)}`, + compositeKey([path.kind, path.value, String(path.allowSuffix)]), path, ]), ).values(), - ].sort( - (left, right) => - compareUnicodeCodePoints(left.kind, right.kind) || - compareUnicodeCodePoints(left.value, right.value) || - Number(left.allowSuffix) - Number(right.allowSuffix), + ].sort((left, right) => + compareUnicodeCodePoints( + compositeKey([left.kind, left.value]), + compositeKey([right.kind, right.value]), + ), ); const pathSuffixes = (path: string): string[] => { diff --git a/src/domain/jsonShape.test.ts b/src/domain/jsonShape.test.ts index dc37bb6d4..e1a5dad2b 100644 --- a/src/domain/jsonShape.test.ts +++ b/src/domain/jsonShape.test.ts @@ -39,45 +39,19 @@ describe("inferJsonShape", () => { expect(inferJsonShape("not-json")).toBeNull(); }); - it.each([ - ["é", "e\u0301", "e\u0341"], - ["é", "e\u0341", "e\u0301"], - ["e\u0301", "é", "e\u0341"], - ["e\u0301", "e\u0341", "é"], - ["e\u0341", "é", "e\u0301"], - ["e\u0341", "e\u0301", "é"], - ])("orders distinct collation-equal paths from %j, %j, %j", (...names) => { - for (const name of names) expect(name.localeCompare("é")).toBe(0); - const values: Readonly> = { - é: 1, - "e\u0301": "value", - "e\u0341": null, - }; - const shape = inferJsonShape( - JSON.stringify( - Object.fromEntries(names.map((name) => [name, values[name]])), - ), - ); - - expect(shape).toEqual({ - root_type: "object", - node_count: 4, - max_depth_observed: 1, - properties: [ - { path: "/e\u0301", types: ["string"], observations: 1 }, - { path: "/e\u0341", types: ["null"], observations: 1 }, - { path: "/é", types: ["number"], observations: 1 }, - ], - }); - }); - - it.each([false, true])( - "preserves nested types, counts, depth and pointer names with reversed keys: %s", - (reverse) => { + it("preserves pointer escaping and code-point ordering independently of key insertion order", () => { + for (const reverse of [false, true]) { const object = (number: number, value: unknown) => { const entries = [ ["é/~", number], ["e\u0301/~", value], + ["e\u0341/~", true], + ["A", false], + ["_", false], + ["a", false], + ["z", false], + ["\u{10000}", false], + ["\uE000", false], ]; return Object.fromEntries(reverse ? entries.reverse() : entries); }; @@ -87,31 +61,30 @@ describe("inferJsonShape", () => { expect(shape).toEqual({ root_type: "object", - node_count: 9, + node_count: 23, max_depth_observed: 4, properties: [ { path: "/nested", types: ["array"], observations: 1 }, + { path: "/nested/*/A", types: ["boolean"], observations: 2 }, + { path: "/nested/*/_", types: ["boolean"], observations: 2 }, + { path: "/nested/*/a", types: ["boolean"], observations: 2 }, { path: "/nested/*/e\u0301~1~0", types: ["array", "null"], observations: 2, }, + { + path: "/nested/*/e\u0341~1~0", + types: ["boolean"], + observations: 2, + }, + { path: "/nested/*/z", types: ["boolean"], observations: 2 }, { path: "/nested/*/é~1~0", types: ["number"], observations: 2 }, + { path: "/nested/*/\uE000", types: ["boolean"], observations: 2 }, + { path: "/nested/*/\u{10000}", types: ["boolean"], observations: 2 }, ], }); - }, - ); - - it("orders paths deterministically by Unicode code point", () => { - const names = ["z", "é", "A", "_", "a"]; - // Code-point order (locale-independent): A < _ < a < z < é. - // `localeCompare` order varies by host ICU and must not leak into evidence. - const expected = ["/A", "/_", "/a", "/z", "/é"]; - const shape = inferJsonShape( - JSON.stringify(Object.fromEntries(names.map((name) => [name, true]))), - ); - - expect(shape?.properties.map(({ path }) => path)).toEqual(expected); + } }); it("retains every parsed property beyond the former shape-node limit", () => { diff --git a/src/domain/native/objcSwiftMetadata.ts b/src/domain/native/objcSwiftMetadata.ts index 6b4e13cb5..5778e5dd7 100644 --- a/src/domain/native/objcSwiftMetadata.ts +++ b/src/domain/native/objcSwiftMetadata.ts @@ -1,6 +1,9 @@ import { z } from "zod"; -import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; +import { + compareUnicodeCodePoints, + compositeKey, +} from "../unicodeCodePointOrder.js"; /** Objective-C method type. */ export const objcMethodTypeSchema = z.enum([ @@ -397,8 +400,10 @@ export const inspectNativeDispatchMetadata = ( })); const decodedImplementations = [...implementations.values()].sort( (left, right) => - compareUnicodeCodePoints(left.class_name, right.class_name) || - compareUnicodeCodePoints(left.selector, right.selector), + compareUnicodeCodePoints( + compositeKey([left.class_name, left.selector]), + compositeKey([right.class_name, right.selector]), + ), ); const swift = swiftSymbols.length > 0; diff --git a/src/domain/propertyListKeys.test.ts b/src/domain/propertyListKeys.test.ts index e15fcb27e..15eb1bd6d 100644 --- a/src/domain/propertyListKeys.test.ts +++ b/src/domain/propertyListKeys.test.ts @@ -1,10 +1,6 @@ import { expect, it } from "vitest"; -import { - omitPrototypeKeys, - omittedPrototypeKeysLimitation, - parseXmlPropertyList, -} from "./propertyListKeys.js"; +import { omitPrototypeKeys, parseXmlPropertyList } from "./propertyListKeys.js"; const plist = (body: string) => `${body}`; @@ -67,7 +63,4 @@ it("omits own __proto__ entries that JSON decoding creates", () => { value: { a: [{ b: 3 }] }, omittedPrototypeKeys: 2, }); - expect(omittedPrototypeKeysLimitation(1)).toBe( - "1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.", - ); }); diff --git a/src/domain/reconstructionCoverage.ts b/src/domain/reconstructionCoverage.ts index 1f7d6a318..485b66a5a 100644 --- a/src/domain/reconstructionCoverage.ts +++ b/src/domain/reconstructionCoverage.ts @@ -3,7 +3,10 @@ import { createHash } from "node:crypto"; import canonicalize from "canonicalize"; import { z } from "zod"; -import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; +import { + compareUnicodeCodePoints, + compositeKey, +} from "./unicodeCodePointOrder.js"; import { evidenceBundleSchema, parseEvidenceBundle } from "./evidenceBundle.js"; import { @@ -405,8 +408,10 @@ const closureStatus = ( }; const reasonOrder = (left: ClosureReason, right: ClosureReason): number => - compareUnicodeCodePoints(left.code, right.code) || - compareUnicodeCodePoints(left.subject_id, right.subject_id); + compareUnicodeCodePoints( + compositeKey([left.code, left.subject_id]), + compositeKey([right.code, right.subject_id]), + ); const digest = (value: unknown): string => { const encoded = canonicalize(value); diff --git a/src/domain/reconstructionUnknowns.ts b/src/domain/reconstructionUnknowns.ts index c43330215..d05f96fe9 100644 --- a/src/domain/reconstructionUnknowns.ts +++ b/src/domain/reconstructionUnknowns.ts @@ -1,5 +1,8 @@ import { uniqueSorted } from "./canonicalOrdering.js"; -import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; +import { + compareUnicodeCodePoints, + compositeKey, +} from "./unicodeCodePointOrder.js"; import type { ReconstructionClaim, ReconstructionClaimResult, @@ -90,7 +93,7 @@ export const reconstructionProbes = ( const probes = unknowns.flatMap(({ recommended_probes: items }) => items); const candidates = probes.length > 0 ? probes : [defaultProbe(result.kind)]; for (const probe of candidates) { - const key = `${probe.operation}\0${probe.rationale}`; + const key = compositeKey([probe.operation, probe.rationale]); const current = output.get(key) ?? { ...probe, claim_ids: [], @@ -104,10 +107,11 @@ export const reconstructionProbes = ( output.set(key, current); } } - const sorted = [...output.values()].sort( - (left, right) => - compareUnicodeCodePoints(left.operation, right.operation) || - compareUnicodeCodePoints(left.rationale, right.rationale), + const sorted = [...output.values()].sort((left, right) => + compareUnicodeCodePoints( + compositeKey([left.operation, left.rationale]), + compositeKey([right.operation, right.rationale]), + ), ); return sorted; }; diff --git a/src/domain/referenceSourceImportParsing.test.ts b/src/domain/referenceSourceImportParsing.test.ts deleted file mode 100644 index 27595155b..000000000 --- a/src/domain/referenceSourceImportParsing.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { parseReferenceSourceImports } from "./referenceSourceImportParsing.js"; - -const parse = (source: string) => - parseReferenceSourceImports( - "main.ts", - new TextEncoder().encode(source), - "TypeScript", - ); - -describe("historical source dynamic imports", () => { - it.each([ - 'import("./lazy.js");', - 'const module = import("./lazy.js");', - 'async function load() { return await import("./lazy.js"); }', - 'import("./lazy.js", { with: { type: "json" } });', - ])("retains the literal dependency in %s", (source) => { - expect(parse(source)).toEqual({ - relationships: [ - { - from_path: "main.ts", - to: "./lazy.js", - kind: "imports", - resolution: "internal", - parse_state: "parsed", - }, - ], - parse_failures: [], - }); - }); - - it.each([ - "import(moduleName);", - "async function load(name: string) { return await import(name); }", - "import(`./${moduleName}.js`);", - ])("keeps a computed dependency unknown in %s", (source) => { - expect(parse(source)).toEqual({ - relationships: [ - { - from_path: "main.ts", - to: "", - kind: "imports", - resolution: "unknown", - parse_state: "partial", - }, - ], - parse_failures: [], - }); - }); - - it("retains static import and require relationships alongside dynamic imports", () => { - const result = parse( - 'import "./static.js"; require("./common.cjs"); import("node:fs");', - ); - expect(result.parse_failures).toEqual([]); - expect(result.relationships).toEqual([ - { - from_path: "main.ts", - to: "./static.js", - kind: "imports", - resolution: "internal", - parse_state: "parsed", - }, - { - from_path: "main.ts", - to: "./common.cjs", - kind: "requires", - resolution: "internal", - parse_state: "parsed", - }, - { - from_path: "main.ts", - to: "node:fs", - kind: "imports", - resolution: "external", - parse_state: "parsed", - }, - ]); - }); -}); diff --git a/src/domain/referenceSourceImportParsing.ts b/src/domain/referenceSourceImportParsing.ts index 3e0832ee4..e89707257 100644 --- a/src/domain/referenceSourceImportParsing.ts +++ b/src/domain/referenceSourceImportParsing.ts @@ -1,4 +1,4 @@ -import { parse, type ParserPlugin } from "@babel/parser"; +import { parse } from "@babel/parser"; import type { CallExpression, File, @@ -7,6 +7,7 @@ import type { StringLiteral, } from "@babel/types"; import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js"; +import { parserPluginsForPath } from "./javascript/javascriptSourceParser.js"; import { isCallExpression, isExportAllDeclaration, @@ -206,6 +207,9 @@ const extractRequireAndDynamicImports = ( from_path: string, relationships: ReferenceSourceImportRelationship[], ): void => { + // Single traversal owner: iterative, VISITOR_KEYS-gated, no recursion over + // loc/comment objects. Survives deeply nested generated member chains that + // overflowed the previous hand-rolled Object.values walker. const expressions: Array = []; for (const statement of body) traverseJavaScriptAst(statement, { @@ -263,18 +267,11 @@ const parseWithBabel = ( ast: File | undefined; reasons: readonly string[]; } => { - const plugins: ParserPlugin[] = ["jsx"]; - if (language === "TypeScript" || language === "TSX") { - plugins.push([ - "typescript", - { - dts: - path.endsWith(".d.ts") || - path.endsWith(".d.mts") || - path.endsWith(".d.cts"), - }, - ]); - } + // Single parser-mode owner: path-driven plugins (dts/JSX/mts) shared with + // the semantic pipeline. `language` only gates non-JS/TS callers upstream; + // TypeScript syntax must parse identically here and in semantic analysis. + void language; + const plugins = parserPluginsForPath(path); try { const ast = parse(source, { diff --git a/src/domain/referenceSourceImportRecovery.test.ts b/src/domain/referenceSourceImportRecovery.test.ts deleted file mode 100644 index e32973f59..000000000 --- a/src/domain/referenceSourceImportRecovery.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { parseReferenceSourceImports } from "./referenceSourceImportParsing.js"; - -const recoverySource = [ - "const missing;", - "let duplicate;", - "let duplicate;", - "let duplicate;", -].join("\n"); - -const recoveryReasons = [ - "Missing initializer in const declaration. (1:13)", - "Identifier 'duplicate' has already been declared. (3:4)", - "Identifier 'duplicate' has already been declared. (4:4)", -]; - -const moduleSource = [ - 'import "./static.js";', - 'export { value } from "./named.js";', - 'export * from "./all.js";', - 'require("./common.cjs");', - 'require.resolve("node:fs");', - 'import("./lazy.js");', - "import(moduleName);", -].join("\n"); - -const moduleRelationships = [ - ["./static.js", "imports", "internal", "parsed"], - ["./named.js", "imports", "internal", "parsed"], - ["./all.js", "imports", "internal", "parsed"], - ["./common.cjs", "requires", "internal", "parsed"], - ["node:fs", "requires", "external", "parsed"], - ["./lazy.js", "imports", "internal", "parsed"], - ["", "imports", "unknown", "partial"], -].map(([to, kind, resolution, parse_state]) => ({ - to, - kind, - resolution, - parse_state, -})); - -const parseSource = (path: string, source: string, language: string | null) => - parseReferenceSourceImports(path, new TextEncoder().encode(source), language); - -describe.each([ - ["main.js", "JavaScript"], - ["main.jsx", "JSX"], - ["main.ts", "TypeScript"], - ["main.tsx", "TSX"], -])("recovered reference imports in %s", (path, language) => { - it("retains every diagnostic and marks recovered relationships partial", () => { - const result = parseSource( - path, - `${recoverySource}\n${moduleSource}`, - language, - ); - - expect(result.parse_failures).toEqual( - recoveryReasons.map((reason) => ({ path, parser: "babel", reason })), - ); - expect(result.relationships).toEqual( - moduleRelationships.map((relationship) => ({ - ...relationship, - from_path: path, - parse_state: "partial", - })), - ); - }); - - it("preserves valid and computed relationships without diagnostics", () => { - expect(parseSource(path, moduleSource, language)).toEqual({ - relationships: moduleRelationships.map((relationship) => ({ - ...relationship, - from_path: path, - })), - parse_failures: [], - }); - }); - - it("reports recovery diagnostics when there are no relationships", () => { - expect(parseSource(path, recoverySource, language)).toEqual({ - relationships: [], - parse_failures: recoveryReasons.map((reason) => ({ - path, - parser: "babel", - reason, - })), - }); - }); - - it("returns no relationships when parsing throws", () => { - expect( - parseSource(path, 'import "./before.js";\nconst = ;', language), - ).toEqual({ - relationships: [], - parse_failures: [ - { path, parser: "babel", reason: "Unexpected token (2:6)" }, - ], - }); - }); -}); - -it.each(["main.ts", "types.d.ts", "types.d.mts", "types.d.cts"])( - "marks recovered TypeScript declarations partial in %s", - (path) => { - const result = parseSource( - path, - [ - "let duplicate: number;", - "let duplicate: number;", - 'import dep = require("node:path");', - 'declare module "ambient-package" {}', - ].join("\n"), - "TypeScript", - ); - expect(result).toEqual({ - relationships: [ - { - from_path: path, - to: "node:path", - kind: "requires", - resolution: "external", - parse_state: "partial", - }, - { - from_path: path, - to: "ambient-package", - kind: "declares-module", - resolution: "unknown", - parse_state: "partial", - }, - ], - parse_failures: [ - { - path, - parser: "babel", - reason: "Identifier 'duplicate' has already been declared. (2:4)", - }, - ], - }); - }, -); - -it("keeps unsupported source languages outside Babel parsing", () => { - expect(parseSource("main.py", recoverySource, "Python")).toEqual({ - relationships: [], - parse_failures: [], - }); -}); diff --git a/src/domain/referenceSourceRequireMembers.test.ts b/src/domain/referenceSourceRequireMembers.test.ts deleted file mode 100644 index 1b01748aa..000000000 --- a/src/domain/referenceSourceRequireMembers.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { parseReferenceSourceImports } from "./referenceSourceImportParsing.js"; - -const parseRequire = (source: string) => - parseReferenceSourceImports( - "main.cjs", - new TextEncoder().encode(source), - "JavaScript", - ); - -const knownCallees = [ - "require", - "require.resolve", - 'require["resolve"]', - "require.main", - 'require["main"]', -]; - -describe("historical source require member names", () => { - it.each([ - ...knownCallees, - 'require[("resolve")]', - 'require["res\\u006flve"]', - ])( - "retains a literal dependency for %s beside computed members", - (callee) => { - expect( - parseRequire( - `require[resolve]("./ignored.js"); ${callee}("./dep.js");`, - ), - ).toEqual({ - relationships: [ - { - from_path: "main.cjs", - to: "./dep.js", - kind: "requires", - resolution: "internal", - parse_state: "parsed", - }, - ], - parse_failures: [], - }); - }, - ); - - it.each([ - 'require[resolve]("./dep.js");', - 'require[main]("./dep.js");', - 'const resolve = "toString"; require[resolve]("./dep.js");', - 'const main = "toString"; require[main]("./dep.js");', - 'const resolve = "resolve"; require[resolve]("./dep.js");', - 'const main = "main"; require[main]("./dep.js");', - 'const method = "resolve"; require[method]("./dep.js");', - 'require[`resolve`]("./dep.js");', - 'require["re" + "solve"]("./dep.js");', - ])("does not infer computed member values in %s", (source) => { - expect(parseRequire(source)).toEqual({ - relationships: [], - parse_failures: [], - }); - }); - - it.each([ - "require?.", - "require.resolve?.", - 'require["resolve"]?.', - "require.main?.", - 'require["main"]?.', - "require?.resolve", - 'require?.["resolve"]', - "require?.[resolve]", - "require?.main", - 'require?.["main"]', - "require?.[main]", - "(require?.resolve)", - "require.resolve.call", - "require.main.require", - "module.require", - "other.resolve", - "other.require", - "require.toString", - 'require["toString"]', - ])("leaves the unrecognized callee %s unchanged", (callee) => { - expect(parseRequire(`${callee}("./dep.js");`)).toEqual({ - relationships: [], - parse_failures: [], - }); - }); - - it.each(knownCallees)("does not guess unknown arguments to %s", (callee) => { - expect( - parseRequire( - [ - 'const target = "./dep.js";', - `${callee}(target);`, - `${callee}(\`./dep.js\`);`, - `${callee}(getTarget());`, - `${callee}();`, - ].join("\n"), - ), - ).toEqual({ relationships: [], parse_failures: [] }); - }); -}); diff --git a/src/domain/residualUnknown.ts b/src/domain/residualUnknown.ts index f85996c29..5741223ea 100644 --- a/src/domain/residualUnknown.ts +++ b/src/domain/residualUnknown.ts @@ -2,7 +2,10 @@ import { createHash } from "node:crypto"; import canonicalize from "canonicalize"; import { z } from "zod"; -import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js"; +import { + compareUnicodeCodePoints, + compositeKey, +} from "./unicodeCodePointOrder.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; const evidenceIdSchema = prefixedDigestSchema("ev"); @@ -293,8 +296,9 @@ const sortedUnique = (values: readonly string[]): string[] => const sortedRelationships = ( values: readonly z.infer[], ): z.infer[] => - [...values].sort( - (left, right) => - compareUnicodeCodePoints(left.type, right.type) || - compareUnicodeCodePoints(left.unknown_id, right.unknown_id), + [...values].sort((left, right) => + compareUnicodeCodePoints( + compositeKey([left.type, left.unknown_id]), + compositeKey([right.type, right.unknown_id]), + ), ); diff --git a/src/domain/safeJson.test.ts b/src/domain/safeJson.test.ts deleted file mode 100644 index cb8c88d1a..000000000 --- a/src/domain/safeJson.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { expect, it } from "vitest"; - -import { safeParseJson } from "./safeJson.js"; - -it("keeps the original JSON parse failure as the cause", () => { - const parsed = safeParseJson("{"); - - if (parsed.ok) throw new Error("expected invalid JSON to fail"); - expect(parsed.error).toContain("JSON"); - expect(parsed.cause).toBeInstanceOf(SyntaxError); -}); diff --git a/src/domain/symbolAnalysis.test.ts b/src/domain/symbolAnalysis.test.ts index 8ebb7b762..c43bbfb76 100644 --- a/src/domain/symbolAnalysis.test.ts +++ b/src/domain/symbolAnalysis.test.ts @@ -1,160 +1,9 @@ import { describe, expect, it } from "vitest"; -import { - categorizeSwiftTypes, - discoverObjcClasses, - discoverObjcProtocols, -} from "./symbolAnalysis.js"; - -describe("symbol analysis", () => { - it("deduplicates Objective-C classes and protocols by symbol name", () => { - const names = [ - { address: "0x1", name: "_OBJC_CLASS_$_App" }, - { address: "0x2", name: "_OBJC_CLASS_$_App" }, - { address: "0x3", name: "_OBJC_PROTOCOL_$_Delegate" }, - { address: "0x4", name: "__OBJC_PROTOCOL_REFERENCE_$_Delegate" }, - { address: "0x4", name: "_OBJC_PROTOCOL_$_Delegate" }, - ]; - expect(discoverObjcClasses(names, "App")).toMatchObject({ count: 1 }); - expect(discoverObjcProtocols(names)).toMatchObject({ count: 1 }); - }); - - it("excludes Objective-C ivars, metaclasses, properties, and protocols from classes", () => { - const result = discoverObjcClasses( - [ - { address: "0x1", name: "_OBJC_IVAR_$_Fixture.value" }, - { address: "0x2", name: "_OBJC_METACLASS_$_Fixture" }, - { address: "0x3", name: "_OBJC_PROP_$_Fixture.value" }, - { address: "0x4", name: "_OBJC_PROTOCOL_$_FixtureProtocol" }, - { address: "0x5", name: "_OBJC_CLASS_$_Fixture" }, - ], - "", - ); - - expect(result).toMatchObject({ - count: 1, - classes: [{ address: "0x5", name: "_OBJC_CLASS_$_Fixture" }], - }); - }); - - it("recognizes fragile Apple Objective-C class records without alias duplication", () => { - // Apple clang -target i386-apple-macosx10.5 - // -fobjc-runtime=macosx-fragile-10.5 emits these local class records. - // The `.objc_class_name_*` absolute symbols alias the same class and are - // deliberately not selected. - const names = [ - { address: "0x1ac", name: "l_OBJC_CLASS_ProbeClass" }, - { address: "0x0", name: ".objc_class_name_ProbeClass" }, - // A class named `NAME_` produces this exact legacy local symbol. - { address: "0x0", name: "l_OBJC_CLASS_NAME_" }, - ]; - expect(discoverObjcClasses(names, "")).toEqual({ - count: 2, - classes: [ - { address: "0x1ac", name: "l_OBJC_CLASS_ProbeClass" }, - { address: "0x0", name: "l_OBJC_CLASS_NAME_" }, - ], - }); - }); - - it("discovers modern Swift protocol descriptors without treating methods as declarations", () => { - // Current swiftc emits `…Mp` descriptors without the nominal `P` marker. - const names = [ - { address: "0xf0", name: "_$s4main7ScoringMp" }, - { address: "0x100", name: "_$s4main7ScoringTL" }, - { address: "0x110", name: "_$s4main7ScoringP5scoreSiyFTq" }, - ]; - expect(discoverObjcProtocols(names)).toEqual({ - count: 1, - protocols: [{ address: "0xf0", name: "_$s4main7ScoringMp" }], - }); - }); - - it("categorizes every Swift mangling family and deduplicates names", () => { - const result = categorizeSwiftTypes([ - { address: "1", name: "_TtCClass" }, - { address: "2", name: "_TtVStruct" }, - { address: "3", name: "_TtOEnum" }, - { address: "4", name: "_TtPProtocol" }, - { address: "5", name: "_TtEExtension" }, - { address: "6", name: "prefix_TtOther" }, - { address: "7", name: "_TtCClass" }, - ]); - expect(result).toMatchObject({ - total: 6, - categories: { - classes: { count: 1 }, - structs: { count: 1 }, - enums: { count: 1 }, - protocols: { count: 1 }, - extensions: { count: 1 }, - other: { count: 1 }, - }, - }); - }); - - it("filters Swift categories by a case-sensitive literal name", () => { - const result = categorizeSwiftTypes( - [ - { address: "0x1", name: "_TtCAccount" }, - { address: "0x2", name: "_TtCSession" }, - { address: "0x3", name: "_TtVAccountState" }, - ], - { category: "classes", pattern: "Account" }, - ); - - expect(result).toMatchObject({ - total: 1, - categories: { - classes: { - count: 1, - items: [{ address: "0x1", name: "_TtCAccount" }], - }, - structs: { count: 0, items: [] }, - }, - }); - }); - - it("classifies modern Swift manglings reported by current providers", () => { - const result = categorizeSwiftTypes([ - { address: "0x1000009f8", name: "$s4main4PairVMa" }, - { address: "0x100000ac4", name: "$s4main6ScorerVMa" }, - { address: "0x100000ab4", name: "_$s4main4PairVMa" }, - { address: "0x1", name: "_TtCClass" }, - { address: "0x1000009f8", name: "$s4main4PairVMa" }, - ]); - - expect(result).toMatchObject({ - total: 4, - categories: { - classes: { count: 1 }, - structs: { - count: 3, - items: [ - { address: "0x1000009f8", name: "$s4main4PairVMa" }, - { address: "0x100000ac4", name: "$s4main6ScorerVMa" }, - { address: "0x100000ab4", name: "_$s4main4PairVMa" }, - ], - }, - }, - }); - }); - - it("keeps non-Swift procedure names out of the Swift inventory", () => { - const result = categorizeSwiftTypes([ - { address: "0x1", name: "printf" }, - { address: "0x2", name: "_OBJC_CLASS_$_App" }, - { address: "0x3", name: "std::vector::push_back" }, - ]); - - expect(result).toMatchObject({ total: 0 }); - }); -}); +import { categorizeSwiftTypes } from "./symbolAnalysis.js"; describe("modern Swift category evidence", () => { it.each([ - ["$s4main4PairVMa", "structs"], - ["_$s4main6ScorerVMa", "structs"], ["$s4main7ServiceCMa", "classes"], ["$s4main5StateOMa", "enums"], ["$s4main7ScoringPMp", "protocols"], @@ -203,36 +52,6 @@ describe("modern Swift category evidence", () => { ).toMatchObject({ total: 0, unclassified: [] }); }); - it("joins Swift aliases only at the exact procedure address", () => { - const result = categorizeSwiftTypes( - [ - { address: "0x1", name: "main::Pair::typeMetadataAccessor" }, - { address: "0x2", name: "std::vector::push_back" }, - { address: "0x3", name: "main::Pair::other" }, - ], - { category: "structs", pattern: "Pair" }, - [ - { address: "0x1", name: "_$s4main4PairVMa" }, - { address: "0x4", name: "$s4main4PairVMa" }, - ], - ); - expect(result).toMatchObject({ - total: 1, - categories: { - structs: { - items: [ - { - address: "0x1", - name: "main::Pair::typeMetadataAccessor", - mangled_names: ["_$s4main4PairVMa"], - }, - ], - }, - }, - unclassified: [], - }); - }); - it("deduplicates display names after selecting a category", () => { expect( categorizeSwiftTypes( diff --git a/src/domain/symbolAnalysis.ts b/src/domain/symbolAnalysis.ts index 65e0fe8eb..6667afcc6 100644 --- a/src/domain/symbolAnalysis.ts +++ b/src/domain/symbolAnalysis.ts @@ -28,7 +28,8 @@ export const discoverObjcClasses = ( ({ name }) => name.includes(OBJC_CLASS_MARKER) || (name.startsWith(LEGACY_OBJC_CLASS_PREFIX) && - name.length > LEGACY_OBJC_CLASS_PREFIX.length), + name.length > LEGACY_OBJC_CLASS_PREFIX.length && + !/^l_OBJC_CLASS_NAME_(?:\.\d+)?$/u.test(name)), ) .filter(({ name }) => pattern.length === 0 || name.includes(pattern)), ); diff --git a/src/domain/unicodeCodePointOrder.ts b/src/domain/unicodeCodePointOrder.ts index 163039364..4f635048d 100644 --- a/src/domain/unicodeCodePointOrder.ts +++ b/src/domain/unicodeCodePointOrder.ts @@ -16,3 +16,11 @@ export const compareUnicodeCodePoints = ( if (leftIndex === left.length && rightIndex === right.length) return 0; return leftIndex === left.length ? -1 : 1; }; + +/** + * Collision-free composite key for deduplication/sorting. `\0`-joins + * collide when any component contains NUL (valid in JS property names); + * a JSON tuple escapes every component. Single owner for composite keys. + */ +export const compositeKey = (parts: readonly unknown[]): string => + JSON.stringify(parts); diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index 157345c94..daf3a2292 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -2,6 +2,7 @@ import { parse } from "@babel/parser"; import * as t from "@babel/types"; import { sanitizeEndpointCandidate } from "./browserObservation.js"; +import { compositeKey } from "./unicodeCodePointOrder.js"; import type { WebPageInspection } from "./browserObservationSchemas.js"; import type { WebBundleAnalysis } from "./webBundleAnalysis.js"; import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js"; @@ -271,7 +272,14 @@ const addWebMcpDeclaration = ( : [], ) : []; - const key = `webmcp\0${script.script_key}\0${name ?? ""}\0${schemaPropertyNames.join("\0")}`; + // Property names are a variable-length source-derived list: NUL-joins + // would collapse ["a\0b"] with ["a", "b"] and drop a declaration. + const key = compositeKey([ + "webmcp", + script.script_key, + name ?? "", + schemaPropertyNames, + ]); addUnique(accumulator, key, () => accumulator.webMcp.push({ name: name ?? null, diff --git a/src/domain/webInventory.test.ts b/src/domain/webInventory.test.ts index 123a90da8..51a6b42e9 100644 --- a/src/domain/webInventory.test.ts +++ b/src/domain/webInventory.test.ts @@ -12,15 +12,6 @@ import { } from "./webContentArtifact.js"; describe("web content artifacts", () => { - it("binds UTF-8 content, size, and digest inline", () => { - const artifact = createWebTextArtifact( - "const marker = '😀';", - "text/javascript", - ); - expect(webTextArtifactSchema.parse(artifact)).toEqual(artifact); - expect(artifact.bytes).toBe(Buffer.byteLength(artifact.text)); - }); - it("rejects altered content that reuses an artifact digest", () => { const artifact = createWebTextArtifact("original", "text/javascript"); expect( @@ -83,7 +74,7 @@ describe("stable web inventory", () => { content_size: 1_024, }, ]; - const forward = stableWebResources(inputs); + const forward = stableWebResources([...inputs, ...inputs.slice(0, 1)]); const reverse = stableWebResources([...inputs].reverse()); expect(reverse).toEqual(forward); @@ -117,20 +108,6 @@ describe("stable web inventory", () => { }); }); - it("deduplicates identical resource observations by stable identity", () => { - const duplicate = { - url: script.url, - origin: "https://app.example.test", - type: "Script", - mime_type: "text/javascript", - content_size: 1_024, - }; - - expect(stableWebResources([duplicate, duplicate])).toEqual( - stableWebResources([duplicate]), - ); - }); - it("reconciles raw URLs independently of stable inventory sort order", () => { const app = { url: script.url, diff --git a/src/domain/webRuntime.test.ts b/src/domain/webRuntime.test.ts deleted file mode 100644 index 9879256f0..000000000 --- a/src/domain/webRuntime.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { z } from "zod"; - -import { - webRuntimeLocationSchema, - webRuntimeSourceSchema, -} from "./webRuntime.js"; - -const properties = (schema: z.ZodType) => - z - .object({ properties: z.record(z.string(), z.unknown()) }) - .parse(z.toJSONSchema(schema, { io: "output" })).properties; - -describe("web runtime coordinate contract", () => { - it("advertises zero-based resource-relative CDP lines and UTF-16 columns", () => { - const location = properties(webRuntimeLocationSchema); - expect(location.line_number).toMatchObject({ - description: expect.stringContaining( - "Zero-based line in the resource named by url", - ), - }); - expect(location.script_id).toMatchObject({ - description: expect.stringContaining("no session-script association"), - }); - expect(location.column_number).toMatchObject({ - description: expect.stringContaining("Zero-based UTF-16 column"), - }); - const start = properties( - webRuntimeSourceSchema.shape.resource_start.unwrap(), - ); - expect(start.line_number).toMatchObject({ - description: expect.stringContaining( - "Zero-based line where the script text starts", - ), - }); - }); -}); diff --git a/src/domain/webScreenshot.test.ts b/src/domain/webScreenshot.test.ts deleted file mode 100644 index d62acaa7a..000000000 --- a/src/domain/webScreenshot.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - captureWebScreenshotInputSchema, - compareWebScreenshotsInputSchema, - createWebScreenshotArtifact, -} from "./webScreenshot.js"; - -describe("inline web screenshots", () => { - it("rejects removed screenshot size and pixel controls", () => { - expect( - captureWebScreenshotInputSchema.safeParse({ - cdp_endpoint: "http://127.0.0.1:9222", - allowed_origins: ["https://app.example.test"], - target_id: "page-1", - maximum_image_bytes: 1, - }).success, - ).toBe(false); - - const artifact = createWebScreenshotArtifact(Buffer.from("image")); - expect( - compareWebScreenshotsInputSchema.safeParse({ - before: artifact, - after: artifact, - maximum_pixels: 1, - }).success, - ).toBe(false); - }); - - it("accepts a caller-supplied target ID without a length ceiling", () => { - const targetId = "target-".repeat(100); - expect( - captureWebScreenshotInputSchema.parse({ - cdp_endpoint: "http://127.0.0.1:9222", - allowed_origins: ["https://app.example.test"], - target_id: targetId, - }).target_id, - ).toBe(targetId); - }); -}); diff --git a/src/ghidra/GhidraLauncherEnvironment.test.ts b/src/ghidra/GhidraLauncherEnvironment.test.ts index 44008f2b7..f5d84880f 100644 --- a/src/ghidra/GhidraLauncherEnvironment.test.ts +++ b/src/ghidra/GhidraLauncherEnvironment.test.ts @@ -22,18 +22,6 @@ describe("Ghidra headless JVM environment", () => { }); }); - it.each(["linux", "darwin"] as const)( - "passes %s isolated JVM paths through direct argv instead of script options", - (platform) => { - expect( - ghidraHeadlessJavaOptions("/tmp/rea/home", "/tmp/rea/tmp", platform), - ).toEqual({ - JDK_JAVA_OPTIONS: "", - GHIDRA_HEADLESS_JAVA_OPTIONS: "", - }); - }, - ); - it.each(["%TEMP%", 'quote"', "amp&", "line\n", "nul\0"])( "rejects interpreter metacharacters in either Windows JVM path: %s", (suffix) => { diff --git a/src/ghidra/GhidraSessionRoot.test.ts b/src/ghidra/GhidraSessionRoot.test.ts index c1c1ecf60..fbfbccb6c 100644 --- a/src/ghidra/GhidraSessionRoot.test.ts +++ b/src/ghidra/GhidraSessionRoot.test.ts @@ -3,9 +3,6 @@ import { describe, expect, it } from "vitest"; import { ghidraSessionRoot } from "./GhidraSessionRoot.js"; -const hasDotPrefixElement = (directory: string): boolean => - directory.split(sep).some((element) => element.startsWith(".")); - const POSIX = process.platform !== "win32"; const posixFallback = join(resolve(sep), "tmp"); const posixBase = (...segments: string[]) => join(resolve(sep), ...segments); @@ -28,13 +25,6 @@ describe("ghidraSessionRoot", () => { ); }); - it("relocates a base that is itself a hidden directory", () => { - const base = posixBase("home", "operator", ".tmp"); - expect(ghidraSessionRoot({ base, fallback: posixBase("var", "tmp") })).toBe( - posixBase("var", "tmp"), - ); - }); - it("ignores a fallback that is itself dotted", () => { const base = posixBase("home", "operator", ".cache", "scratch"); expect( @@ -45,14 +35,6 @@ describe("ghidraSessionRoot", () => { ).toBe(posixBase("home", "operator")); }); - it("never returns a path with a dot-prefixed element", () => { - const directory = ghidraSessionRoot({ - base: posixBase("home", "operator", ".cache", "scratch"), - fallback: posixBase("home", "operator", ".cache"), - }); - expect(hasDotPrefixElement(directory)).toBe(false); - }); - it("uses the nearest safe ancestor when the platform has no fallback", () => { expect( ghidraSessionRoot({ diff --git a/tests/boundary/cli/nativePlistCli.test.ts b/tests/boundary/cli/nativePlistCli.test.ts index 487a9b1e1..50ed030f3 100644 --- a/tests/boundary/cli/nativePlistCli.test.ts +++ b/tests/boundary/cli/nativePlistCli.test.ts @@ -84,6 +84,11 @@ describe.skipIf(process.platform !== "darwin")( "BlobAAEC", "Built2020-01-01T00:00:00Z", "Rationan", + "Nested", + "Ratio0.5", + "Unbounded-infinity", + "empty key", + "", "", ].join(""), ); @@ -113,6 +118,13 @@ describe.skipIf(process.platform !== "darwin")( iso8601: "2020-01-01T00:00:00.000Z", }, Ratio: { $plist_type: "real", value: null }, + Nested: [ + { + Ratio: 0.5, + Unbounded: { $plist_type: "real", value: null }, + "": "empty key", + }, + ], }, bundle: { identifier: "com.example.types", executable: "App" }, provenance: [ @@ -122,7 +134,7 @@ describe.skipIf(process.platform !== "darwin")( ], limitations: [ expect.stringContaining("XML conversion"), - expect.stringContaining("1 non-finite real"), + expect.stringContaining("2 non-finite real"), ], }, }); diff --git a/tests/boundary/filesystem/dosComTarget.test.ts b/tests/boundary/filesystem/dosComTarget.test.ts index 7bd3eae7b..5968c769c 100644 --- a/tests/boundary/filesystem/dosComTarget.test.ts +++ b/tests/boundary/filesystem/dosComTarget.test.ts @@ -43,6 +43,24 @@ describe("DOS COM admission", () => { }), ).toMatchObject({ ok: false, error: { _tag: "BinaryTargetError" } }); }); + it.each([1, 0xff00])("admits the actual file extent %i", async (length) => { + const directory = await createTestTempDirectory("rea-com-target-"); + const path = join(directory, "fixture.com"); + const bytes = Buffer.alloc(length, 0xc3); + await writeFile(path, bytes); + const target = await parseBinaryTarget(path, { + cwd: directory, + hostArchitecture: "x64", + formatHint: "dos-com", + }); + if (!target.ok) throw target.error; + expect(target.value).toMatchObject({ + format: "dos-com", + architecture: "x86", + sha256: createHash("sha256").update(bytes).digest("hex"), + }); + expect(await readFile(path)).toEqual(bytes); + }); it("rejects contradictory target kind", async () => { const directory = await createTestTempDirectory("rea-com-target-"); const path = join(directory, "fixture.com"); diff --git a/tests/boundary/filesystem/referenceSourceComputedRequire.test.ts b/tests/boundary/filesystem/referenceSourceComputedRequire.test.ts index 8f6695f6b..c478e6391 100644 --- a/tests/boundary/filesystem/referenceSourceComputedRequire.test.ts +++ b/tests/boundary/filesystem/referenceSourceComputedRequire.test.ts @@ -11,7 +11,48 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; it("distinguishes computed require member values from literal members in source evidence", async () => { const root = await createTestTempDirectory("rea-reference-require-members-"); - const sources = { + const literalCallees = [ + "require", + "require.resolve", + 'require["resolve"]', + "require.main", + 'require["main"]', + 'require[("resolve")]', + 'require["res\\u006flve"]', + ]; + const ignoredSources = [ + 'require[resolve]("./dep.js");', + 'require[main]("./dep.js");', + 'const resolve = "toString"; require[resolve]("./dep.js");', + 'const main = "toString"; require[main]("./dep.js");', + 'const resolve = "resolve"; require[resolve]("./dep.js");', + 'const main = "main"; require[main]("./dep.js");', + 'const method = "resolve"; require[method]("./dep.js");', + 'require[`resolve`]("./dep.js");', + 'require["re" + "solve"]("./dep.js");', + ...[ + "require?.", + "require.resolve?.", + 'require["resolve"]?.', + "require.main?.", + 'require["main"]?.', + "require?.resolve", + 'require?.["resolve"]', + "require?.[resolve]", + "require?.main", + 'require?.["main"]', + "require?.[main]", + "(require?.resolve)", + "require.resolve.call", + "require.main.require", + "module.require", + "other.resolve", + "other.require", + "require.toString", + 'require["toString"]', + ].map((callee) => `${callee}("./dep.js");`), + ]; + const sources: Record = { "computed.cjs": [ 'const resolve = "toString";', 'const main = "toString";', @@ -21,15 +62,21 @@ it("distinguishes computed require member values from literal members in source ].join("\n"), "literal.cjs": 'console.log(require["resolve"]("./dep.js"));\n', "dep.js": 'throw new Error("The dependency must not execute");\n', + "ignored.cjs": ignoredSources.map((source) => `{ ${source} }`).join("\n"), "unknown.cjs": [ "function load(target) {", - " require(target);", - " require.resolve(target);", - ' require["resolve"](target);', + ...literalCallees.flatMap((callee) => [ + ` ${callee}(target);`, + ` ${callee}(\`./dep.js\`);`, + ` ${callee}(getTarget());`, + ` ${callee}();`, + ]), " return import(target);", "}", ].join("\n"), }; + for (const [index, callee] of literalCallees.entries()) + sources[`member-${index}.cjs`] = `${callee}("./dep.js");\n`; await Promise.all( Object.entries(sources).map(([path, source]) => writeFile(join(root, path), source), @@ -67,6 +114,13 @@ it("distinguishes computed require member values from literal members in source resolution: "internal", parse_state: "parsed", }, + ...literalCallees.map((_, index) => ({ + from_path: `member-${index}.cjs`, + to: "dep.js", + kind: "requires", + resolution: "internal", + parse_state: "parsed", + })), { from_path: "unknown.cjs", to: "", diff --git a/tests/boundary/filesystem/referenceSourceDynamicImports.test.ts b/tests/boundary/filesystem/referenceSourceDynamicImports.test.ts index e78a56f37..432b7bf30 100644 --- a/tests/boundary/filesystem/referenceSourceDynamicImports.test.ts +++ b/tests/boundary/filesystem/referenceSourceDynamicImports.test.ts @@ -17,6 +17,8 @@ it("resolves dynamic source imports while retaining computed targets as unknown" 'const lazy = import("./lazy");', 'async function load() { return await import("./data.json", { with: { type: "json" } }); }', 'const name = "./not-guessed.js"; import(name);', + "import(`./${name}.js`);", + 'import("node:fs");', ].join("\n"), ), ...["static.ts", "common.js", "lazy.ts"].map((path) => @@ -32,7 +34,7 @@ it("resolves dynamic source imports while retaining computed targets as unknown" }); if (!result.ok) throw result.error; expect(result.value.parse_failures).toEqual([]); - expect(result.value.relationships).toHaveLength(5); + expect(result.value.relationships).toHaveLength(6); for (const [to, kind] of [ ["static.ts", "imports"], ["common.js", "requires"], @@ -53,4 +55,11 @@ it("resolves dynamic source imports while retaining computed targets as unknown" resolution: "unknown", parse_state: "partial", }); + expect(result.value.relationships).toContainEqual({ + from_path: "main.ts", + to: "node:fs", + kind: "imports", + resolution: "external", + parse_state: "parsed", + }); }); diff --git a/tests/boundary/filesystem/referenceSourceRecovery.test.ts b/tests/boundary/filesystem/referenceSourceRecovery.test.ts index 53585d865..5b9f6ad45 100644 --- a/tests/boundary/filesystem/referenceSourceRecovery.test.ts +++ b/tests/boundary/filesystem/referenceSourceRecovery.test.ts @@ -11,27 +11,37 @@ import { import { historicalSourceGraphSchema } from "../../../src/domain/referenceSourceGraph.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +const sources = { + "fatal.js": 'import "./valid";\nconst = ;', + "ignored.py": 'import "./valid"; const missing;\n', + "lazy.js": "export const lazy = true;\n", + "recovered.d.cts": [ + "let duplicate: number;", + "let duplicate: number;", + 'import dep = require("./valid");', + 'declare module "ambient-package" {}', + ].join("\n"), + "recovered.js": [ + "const missing;", + "let duplicate;", + "let duplicate;", + "let duplicate;", + 'import "./valid";', + 'require("node:fs");', + 'import("./lazy");', + "import(moduleName);", + ].join("\n"), + "recovered.ts": [ + "let duplicate: number;", + "let duplicate: number;", + 'import dep = require("./valid");', + 'declare module "ambient-package" {}', + ].join("\n"), + "valid.js": + 'export { readFile } from "node:fs"; export * from "node:path";\n', +}; it("preserves recovered diagnostics and file evidence through filesystem import", async () => { const root = await createTestTempDirectory("rea-reference-recovery-"); - const sources = { - "fatal.js": 'import "./valid";\nconst = ;', - "recovered.js": [ - "const missing;", - "let duplicate;", - "let duplicate;", - "let duplicate;", - 'import "./valid";', - 'require("node:fs");', - "import(moduleName);", - ].join("\n"), - "recovered.ts": [ - "let duplicate: number;", - "let duplicate: number;", - 'import dep = require("./valid");', - 'declare module "ambient-package" {}', - ].join("\n"), - "valid.js": 'export * from "node:path";\n', - }; await mkdir(join(root, "src")); await Promise.all( Object.entries(sources).map(([path, source]) => @@ -66,6 +76,11 @@ it("preserves recovered diagnostics and file evidence through filesystem import" const failures = [ { path: "src/fatal.js", parser: "babel", reason: "Unexpected token (2:6)" }, + { + path: "src/recovered.d.cts", + parser: "babel", + reason: "Identifier 'duplicate' has already been declared. (2:4)", + }, { path: "src/recovered.js", parser: "babel", @@ -94,6 +109,20 @@ it("preserves recovered diagnostics and file evidence through filesystem import" ), ).toEqual(failures); expect(graph.relationships).toEqual([ + { + from_path: "src/recovered.d.cts", + to: "ambient-package", + kind: "declares-module", + resolution: "unknown", + parse_state: "partial", + }, + { + from_path: "src/recovered.d.cts", + to: "src/valid.js", + kind: "requires", + resolution: "internal", + parse_state: "partial", + }, { from_path: "src/recovered.js", to: "", @@ -108,6 +137,13 @@ it("preserves recovered diagnostics and file evidence through filesystem import" resolution: "external", parse_state: "partial", }, + { + from_path: "src/recovered.js", + to: "src/lazy.js", + kind: "imports", + resolution: "internal", + parse_state: "partial", + }, { from_path: "src/recovered.js", to: "src/valid.js", @@ -129,6 +165,13 @@ it("preserves recovered diagnostics and file evidence through filesystem import" resolution: "internal", parse_state: "partial", }, + { + from_path: "src/valid.js", + to: "node:fs", + kind: "imports", + resolution: "external", + parse_state: "parsed", + }, { from_path: "src/valid.js", to: "node:path", diff --git a/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts b/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts index 661f33634..1666e129f 100644 --- a/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts +++ b/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts @@ -20,37 +20,69 @@ import { observed as ok } from "../../fixtures/analysisExecution.js"; afterEach(closeEnhancedToolResources); -describe("enhanced MCP tools", () => { - it("discovers every Objective-C class from one complete inventory", async () => { - const client = await connect({ - execute: (name) => { - expect(name).toBe("list_names"); - return Promise.resolve( - ok([ - // Apple clang modern Objective-C ABI `nm` output: the first row - // is a class definition; the remaining rows are method metadata, - // class_ro_t, and a class reference. - { address: "0x70", value: "_OBJC_CLASS_$_ProbeClass" }, - { address: "0x1a8", value: "__OBJC_$_CLASS_METHODS_ProbeClass" }, - { address: "0x230", value: "__OBJC_CLASS_RO_$_ProbeClass" }, - { address: "0x450", value: "_OBJC_CLASSLIST_REFERENCES_$_" }, - { - address: "0x298", - value: "__OBJC_$_CATEGORY_CLASS_METHODS_ProbeClass_$_Category", - }, - ]), - ); - }, - }); - const result = jsonResult( - await client.callTool({ name: "get_objc_classes", arguments: {} }), - ); - expect(result).toEqual({ - count: 1, - classes: [{ address: "0x70", name: "_OBJC_CLASS_$_ProbeClass" }], - }); +it("discovers declarations without counting duplicates or compiler bookkeeping", async () => { + const client = await connect({ + execute: (name) => { + expect(name).toBe("list_names"); + return Promise.resolve( + ok([ + { address: "0x1", value: "_OBJC_CLASS_$_First" }, + { address: "0x2", value: "_OBJC_CLASS_$_Last" }, + { address: "0x3", value: "_OBJC_CLASS_$_First" }, + { address: "0x4", value: "__OBJC_CLASS_RO_$_First" }, + { address: "0x5", value: "_OBJC_CLASSLIST_REFERENCES_$_" }, + { address: "0x6", value: "l_OBJC_CLASS_NAME_" }, + { address: "0x7", value: "_OBJC_IVAR_$_First.value" }, + { address: "0x8", value: "_OBJC_METACLASS_$_First" }, + { address: "0x9", value: "_OBJC_PROP_$_First.value" }, + { address: "0xa", value: "__OBJC_PROTOCOL_$_Delegate" }, + { address: "0xb", value: "__OBJC_PROTOCOL_$_Delegate" }, + { address: "0xc", value: "__OBJC_PROTOCOL_REFERENCE_$_Delegate" }, + { address: "0xd", value: "_$s4main7ScoringMp" }, + { address: "0xe", value: "_$s4main7ScoringTL" }, + { address: "0xf", value: "_$s4main7ScoringP5scoreSiyFTq" }, + { address: "0x10", value: "l_OBJC_CLASS_Legacy" }, + { address: "0x11", value: "l_OBJC_CLASS_NAME_.1" }, + ]), + ); + }, }); + const result = jsonResult( + await client.callTool({ name: "get_objc_classes", arguments: {} }), + ); + expect(result).toMatchObject({ + count: 3, + classes: [ + { address: "0x1", name: "_OBJC_CLASS_$_First" }, + { address: "0x2", name: "_OBJC_CLASS_$_Last" }, + { address: "0x10", name: "l_OBJC_CLASS_Legacy" }, + ], + }); + expect( + jsonResult( + await client.callTool({ + name: "get_objc_classes", + arguments: { pattern: "First" }, + }), + ), + ).toMatchObject({ + count: 1, + classes: [{ address: "0x1", name: "_OBJC_CLASS_$_First" }], + }); + expect( + jsonResult( + await client.callTool({ name: "get_objc_protocols", arguments: {} }), + ), + ).toMatchObject({ + count: 2, + protocols: [ + { address: "0xa", name: "__OBJC_PROTOCOL_$_Delegate" }, + { address: "0xd", name: "_$s4main7ScoringMp" }, + ], + }); +}); +describe("enhanced MCP tools", () => { it("returns the complete overview inline with exhaustive totals", async () => { const client = await connect({ execute: (name) => { diff --git a/tests/boundary/mcp/enhancedTools.swiftTypes.test.ts b/tests/boundary/mcp/enhancedTools.swiftTypes.test.ts index 49d7268a5..128b9c281 100644 --- a/tests/boundary/mcp/enhancedTools.swiftTypes.test.ts +++ b/tests/boundary/mcp/enhancedTools.swiftTypes.test.ts @@ -36,7 +36,18 @@ describe("Swift classification through MCP", () => { arguments: test.arguments, }); expect(result.isError).not.toBe(true); - expect(jsonResult(result)).toMatchObject({ total: test.total }); + const inventory = jsonResult(result); + expect(inventory).toMatchObject({ total: test.total }); + if (test.arguments.pattern === undefined) + expect(inventory).toMatchObject({ + categories: { + structs: { + items: expect.not.arrayContaining([ + expect.objectContaining({ address: "0x100000c0c" }), + ]), + }, + }, + }); if (test.arguments.pattern === undefined) expect(jsonResult(result)).toMatchObject({ unclassified: [ diff --git a/tests/boundary/mcp/ghidraNativeApiMcp.test.ts b/tests/boundary/mcp/ghidraNativeApiMcp.test.ts index 18f77fdeb..b0fe73363 100644 --- a/tests/boundary/mcp/ghidraNativeApiMcp.test.ts +++ b/tests/boundary/mcp/ghidraNativeApiMcp.test.ts @@ -16,10 +16,14 @@ describe("Ghidra MCP native API evidence", () => { ).structuredContent, ); expect(inspection.normalized_result).toMatchObject({ + procedure: { address: "0x401000", name: "fixture_main" }, boundary: { available: true, + return_type: { data_type: "int", confidence: "medium" }, jump_tables: [ { + dispatch_address: "0x401010", + data_sources: [{ address: "0x403000" }], mappings: [{ case_value: 0, target_address: "0x401020" }], default_targets: [ { target_address: "0x401030", confidence: "high" }, @@ -27,6 +31,7 @@ describe("Ghidra MCP native API evidence", () => { }, ], }, + unsupported_branches: [], residual_unknowns: [], }); expect( diff --git a/tests/boundary/mcp/managedMcp.test.ts b/tests/boundary/mcp/managedMcp.test.ts index 6a985d7ec..43043152f 100644 --- a/tests/boundary/mcp/managedMcp.test.ts +++ b/tests/boundary/mcp/managedMcp.test.ts @@ -1,5 +1,6 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createHash } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -385,13 +386,30 @@ const verifyImport = async ( operation: "import_managed_reconstruction", provider: { id: "rea-dotnet-workflows" }, confidence: "inferred", + authority: "analyst-inference", normalized_result: { + phase: "reconstruction-import", executed: false, + decompiler: { name: "ilspycmd", version: "9.1.0.7988", family: "ilspy" }, summary: { imported_methods: 1, decompiled_csharp_methods: 1 }, methods: [ { token: method.token, - validation: { canonical_observation: false }, + signature_sha256: method.signature.raw_sha256, + normalized_il_sha256: method.body.normalized_il_sha256, + reconstruction: { + kind: "decompiled-csharp", + language: "csharp", + text_sha256: createHash("sha256") + .update("internal static void Main() { }") + .digest("hex"), + }, + validation: { + matched_static_member: true, + exact_build_required: true, + canonical_observation: false, + confidence_floor: "inference", + }, }, ], }, diff --git a/tests/conformance/native/keyed-archive-uid-root.swift b/tests/conformance/native/keyed-archive-uid-root.swift index 743f2dec4..54a213a4d 100644 --- a/tests/conformance/native/keyed-archive-uid-root.swift +++ b/tests/conformance/native/keyed-archive-uid-root.swift @@ -1,7 +1,8 @@ import Foundation -// Encode the root under a key that matches the UID marker spelling. +// Encode roots under keys that match both binary and XML UID marker spellings. let archiver = NSKeyedArchiver(requiringSecureCoding: false) archiver.encode("payload" as NSString, forKey: "UID") +archiver.encode("payload" as NSString, forKey: "CF$UID") archiver.finishEncoding() try archiver.encodedData.write(to: URL(fileURLWithPath: CommandLine.arguments[1])) diff --git a/tests/fixtures/javascriptArtifactTypeScriptDialect.ts b/tests/fixtures/javascriptArtifactTypeScriptDialect.ts index f0677b175..f5dd81dcf 100644 --- a/tests/fixtures/javascriptArtifactTypeScriptDialect.ts +++ b/tests/fixtures/javascriptArtifactTypeScriptDialect.ts @@ -42,6 +42,11 @@ export const typeScriptDialectCases = [ kind: "static-import", source: 'const element =
;\nimport "./dep.js";', }, + { + path: "component.d.tsx", + kind: "static-import", + source: 'const element =
;\nimport "./dep.js";', + }, { path: "component.jsx", kind: "static-import", diff --git a/tests/process-global/updateMaintenance.test.ts b/tests/process-global/updateMaintenance.test.ts index bdda28912..760bd96aa 100644 --- a/tests/process-global/updateMaintenance.test.ts +++ b/tests/process-global/updateMaintenance.test.ts @@ -102,6 +102,8 @@ describe("OMP integration maintenance", () => { }); it("preserves Grok Build disabled_mcp_servers during maintenance planning", async () => { + // Strict identity: a user-disabled integration must never be selected + // for maintenance, even with a healthy owned entry underneath. const registration = `[mcp_servers.rea]\ncommand = ${JSON.stringify(process.execPath)}\nargs = [${JSON.stringify(entryPoint)}, "mcp"]\nstartup_timeout_sec = 30\n`; const configPath = await writeClient( "grok_build",