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.
This commit is contained in:
parent
077d6c407b
commit
392f1f3107
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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.`);
|
||||
|
|
@ -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 <verified-hopper.deb> 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"]),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <private-mount> <verified-hopper.dmg>",
|
||||
);
|
||||
});
|
||||
|
||||
it("installs, verifies, opens, and cleans the official package", async () => {
|
||||
const host = new FakeMacHopperHost();
|
||||
await expect(installMacHopper({}, host)).resolves.toEqual({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
"<key>$top</key>",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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",
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {};
|
||||
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]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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") =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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<typeof parse>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
|
|
|||
|
|
@ -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 = <Value>(
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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[] => {
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>> = {
|
||||
é: 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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
`<?xml version="1.0" encoding="UTF-8"?><plist version="1.0">${body}</plist>`;
|
||||
|
|
@ -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.",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: "<dynamic-import>",
|
||||
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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<CallExpression | ImportExpression> = [];
|
||||
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, {
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
["<dynamic-import>", "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: [],
|
||||
});
|
||||
});
|
||||
|
|
@ -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: [] });
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof relationshipSchema>[],
|
||||
): z.infer<typeof relationshipSchema>[] =>
|
||||
[...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]),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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<int>::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<int>::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(
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ describe.skipIf(process.platform !== "darwin")(
|
|||
"<key>Blob</key><data>AAEC</data>",
|
||||
"<key>Built</key><date>2020-01-01T00:00:00Z</date>",
|
||||
"<key>Ratio</key><real>nan</real>",
|
||||
"<key>Nested</key><array><dict>",
|
||||
"<key>Ratio</key><real>0.5</real>",
|
||||
"<key>Unbounded</key><real>-infinity</real>",
|
||||
"<key></key><string>empty key</string>",
|
||||
"</dict></array>",
|
||||
"</dict></plist>",
|
||||
].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"),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
"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: "<dynamic-import>",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: "<dynamic-import>",
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
|
|
|
|||
|
|
@ -42,6 +42,11 @@ export const typeScriptDialectCases = [
|
|||
kind: "static-import",
|
||||
source: 'const element = <section />;\nimport "./dep.js";',
|
||||
},
|
||||
{
|
||||
path: "component.d.tsx",
|
||||
kind: "static-import",
|
||||
source: 'const element = <section />;\nimport "./dep.js";',
|
||||
},
|
||||
{
|
||||
path: "component.jsx",
|
||||
kind: "static-import",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue