diff --git a/.jscpd.json b/.jscpd.json index a8235f65d..e97069273 100644 --- a/.jscpd.json +++ b/.jscpd.json @@ -1,6 +1,6 @@ { - "_comment": "Test files are intentionally NOT excluded: the gate used to report zero clones because it skipped **/*.test.ts, hiding all test-tree duplication. Current actual is ~2.6% lines across src+ and tests/, down from 3.4% when tests were included. Lower this as duplication is removed.", - "threshold": 2.7, + "_comment": "Test files are intentionally NOT excluded: the gate used to report zero clones because it skipped **/*.test.ts, hiding all test-tree duplication. Current actual is ~2.4% lines across src+ and tests/, down from 3.4% when tests were included. Lower this as duplication is removed.", + "threshold": 2.5, "reporters": ["consoleFull"], "ignore": [ "node_modules/**", diff --git a/knip.json b/knip.json index 1fdacc008..4d3744f3d 100644 --- a/knip.json +++ b/knip.json @@ -1,21 +1,10 @@ { "$schema": "./node_modules/knip/schema.json", - "entry": [ - "src/main.ts", - "src/cli.ts", - "src/contracts/errorSchemas.ts", - "scripts/run-development-tests.mjs" - ], + "entry": ["src/main.ts", "src/cli.ts", "src/contracts/errorSchemas.ts"], "project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"], "ignoreDependencies": ["electron"], "ignoreBinaries": ["ps"], "ignoreExportsUsedInFile": true, - "ignoreIssues": { - "src/domain/mobileApplicationInvestigation.ts": ["types"], - "src/domain/objcSwiftMetadata.ts": ["types"], - "src/domain/interfaceBuilderGraph.ts": ["types"], - "src/domain/nativeInvestigationGraph.ts": ["types"] - }, "rules": { "files": "error", "dependencies": "error", diff --git a/src/domain/mobileApplicationInvestigation.ts b/src/domain/mobileApplicationInvestigation.ts index 2455bb399..95a4b531f 100644 --- a/src/domain/mobileApplicationInvestigation.ts +++ b/src/domain/mobileApplicationInvestigation.ts @@ -43,7 +43,6 @@ export const iosManifestSchema = z.strictObject({ ui_required_device_capabilities: z.array(z.string()).default([]), app_permissions: z.array(z.string()).default([]), }); -export type IosManifest = z.infer; /** Signing information. */ export const signingInfoSchema = z.strictObject({ is_signed: z.boolean().default(false), diff --git a/src/domain/objcSwiftMetadata.ts b/src/domain/objcSwiftMetadata.ts index a8aacbbb7..745f9eff5 100644 --- a/src/domain/objcSwiftMetadata.ts +++ b/src/domain/objcSwiftMetadata.ts @@ -18,7 +18,6 @@ export const objcPropertyAttributeSchema = z.strictObject({ is_copy: z.boolean().default(false), is_strong: z.boolean().default(false), }); -export type ObjcPropertyAttribute = z.infer; /** Objective-C method metadata. */ export const objcMethodSchema = z.strictObject({ @@ -39,7 +38,6 @@ export const objcPropertySchema = z.strictObject({ getter: z.string().nullable(), setter: z.string().nullable(), }); -export type ObjcProperty = z.infer; /** Objective-C protocol metadata. */ export const objcProtocolSchema = z.strictObject({ @@ -48,7 +46,6 @@ export const objcProtocolSchema = z.strictObject({ optional_methods: z.array(objcMethodSchema).default([]), properties: z.array(objcPropertySchema).default([]), }); -export type ObjcProtocol = z.infer; /** Objective-C class metadata. */ export const objcClassSchema = z.strictObject({ @@ -106,7 +103,6 @@ export const nativeMetadataDecodeSchema = z.strictObject({ status: z.enum(["decoded", "partial", "unsupported", "invalid"]), reason: z.string().nullable(), }); -export type NativeMetadataDecode = z.infer; /** Evidence attached to one dispatch edge or metadata record. */ export const nativeMetadataEvidenceSchema = z.strictObject({ @@ -142,7 +138,6 @@ export const objcIvarSchema = z.strictObject({ decode: nativeMetadataDecodeSchema, evidence: z.array(nativeMetadataEvidenceSchema).min(1), }); -export type ObjcIvar = z.infer; /** Encoded Objective-C protocol declaration with required and optional method facts. */ export const objcProtocolRecordSchema = z.strictObject({ @@ -179,7 +174,6 @@ export const swiftConformanceSchema = z.strictObject({ decode: nativeMetadataDecodeSchema, evidence: z.array(nativeMetadataEvidenceSchema).min(1), }); -export type SwiftConformance = z.infer; /** A typed slot in a Swift protocol witness table or class vtable. */ export const swiftDispatchSlotSchema = z.strictObject({ @@ -194,7 +188,6 @@ export const swiftDispatchSlotSchema = z.strictObject({ decode: nativeMetadataDecodeSchema, evidence: z.array(nativeMetadataEvidenceSchema).min(1), }); -export type SwiftDispatchSlot = z.infer; /** Signed-relative pointer as stored in an Apple binary metadata structure. */ export const relativePointerSchema = z.strictObject({ @@ -206,7 +199,6 @@ export const relativePointerSchema = z.strictObject({ decode: nativeMetadataDecodeSchema, evidence: z.array(nativeMetadataEvidenceSchema).min(1), }); -export type RelativePointer = z.infer; /** A raw Swift-mangled symbol when a provider has not decoded its metadata. */ export const swiftSymbolSchema = z.strictObject({ @@ -225,9 +217,6 @@ export const nativeMetadataCoverageSchema = z.strictObject({ examined: z.number().int().nonnegative(), decoded: z.number().int().nonnegative(), }); -export type NativeMetadataCoverage = z.infer< - typeof nativeMetadataCoverageSchema ->; /** Swift declaration kind. */ export const swiftDeclKindSchema = z.enum([ @@ -250,7 +239,6 @@ export const swiftAccessLevelSchema = z.enum([ "public", "open", ]); -export type SwiftAccessLevel = z.infer; /** Swift declaration metadata. */ export const swiftDeclSchema = z.strictObject({ kind: swiftDeclKindSchema, @@ -275,8 +263,6 @@ export const dbOperationSchema = z.enum([ "export", "import", ]); -export type DbOperation = z.infer; - /** Database save operation result. */ export const dbSaveResultSchema = z.strictObject({ operation: dbOperationSchema, @@ -322,9 +308,6 @@ export const nativeDispatchMetadataResultSchema = z.strictObject({ analysis_profile_digest: z.string().min(1).nullable(), result: objcSwiftMetadataSchema, }); -export type NativeDispatchMetadataResult = z.infer< - typeof nativeDispatchMetadataResultSchema ->; /** Decode only facts visible in a provider's existing name inventory. */ export const inspectNativeDispatchMetadata = ( diff --git a/tests/composition/analysis-sessions/androidApplication.test.ts b/tests/composition/analysis-sessions/androidApplication.test.ts index 393037ad8..e3707ddd2 100644 --- a/tests/composition/analysis-sessions/androidApplication.test.ts +++ b/tests/composition/analysis-sessions/androidApplication.test.ts @@ -10,6 +10,10 @@ import { import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + createNonApplicationZipInventory, + requireSuccessfulProjection, +} from "../../support/applicationSessionFixture.js"; import { projectAndroidApplicationEvidence } from "../../../src/application/AndroidApplicationService.js"; import { runProviderAnalysis } from "../../../src/application/DirectAnalysis.js"; @@ -50,14 +54,11 @@ describe("Android application projection", () => { const second = projectAndroidApplicationEvidence({ inventory_evidence: [inventory], }); - expect(first.ok).toBe(true); - expect(second.ok).toBe(true); - if (!first.ok || !second.ok) return; const left = androidApplicationProjectionResultSchema.parse( - first.value.normalized_result, + requireSuccessfulProjection(first).normalized_result, ); const right = androidApplicationProjectionResultSchema.parse( - second.value.normalized_result, + requireSuccessfulProjection(second).normalized_result, ); expect(left).toEqual(right); expect(left).toMatchObject({ @@ -87,13 +88,8 @@ describe("Android application projection", () => { }); it("rejects non-APK inventory Evidence", async () => { - const root = await createTestTempDirectory("rea-android-invalid-"); - const path = join(root, "fixture.zip"); - const writer = new ZipWriter(new Uint8ArrayWriter()); - await writer.add("one.js", new TextReader("one")); - await writeFile(path, await writer.close()); - const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), + const inventory = await createNonApplicationZipInventory( + "rea-android-invalid-", ); expect( projectAndroidApplicationEvidence({ inventory_evidence: [inventory] }), diff --git a/tests/composition/analysis-sessions/appleApplication.test.ts b/tests/composition/analysis-sessions/appleApplication.test.ts index cd2983b15..664644991 100644 --- a/tests/composition/analysis-sessions/appleApplication.test.ts +++ b/tests/composition/analysis-sessions/appleApplication.test.ts @@ -11,6 +11,10 @@ import { describe, expect, it } from "vitest"; import { fragmentInventoryEvidence } from "../../fixtures/artifactEvidence.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + createNonApplicationZipInventory, + requireSuccessfulProjection, +} from "../../support/applicationSessionFixture.js"; import { projectAppleApplicationEvidence } from "../../../src/application/AppleApplicationService.js"; import { runProviderAnalysis } from "../../../src/application/DirectAnalysis.js"; @@ -109,14 +113,11 @@ describe("Apple application projection", () => { const second = projectAppleApplicationEvidence({ inventory_evidence: [inventory], }); - expect(first.ok).toBe(true); - expect(second.ok).toBe(true); - if (!first.ok || !second.ok) return; const left = appleApplicationProjectionResultSchema.parse( - first.value.normalized_result, + requireSuccessfulProjection(first).normalized_result, ); const right = appleApplicationProjectionResultSchema.parse( - second.value.normalized_result, + requireSuccessfulProjection(second).normalized_result, ); expect(left).toEqual(right); expect(left).toMatchObject({ @@ -149,14 +150,8 @@ describe("Apple application projection", () => { }); it("rejects non-IPA Evidence", async () => { - const root = await createTestTempDirectory("rea-apple-invalid-"); - const path = join(root, "fixture.zip"); - const writer = new ZipWriter(new Uint8ArrayWriter()); - await writer.add("one.js", new TextReader("one")); - await writeFile(path, await writer.close()); - const inventory = parseEvidence( - await runProviderAnalysis(path, "inventory_artifact", {}), - ); + const inventory = + await createNonApplicationZipInventory("rea-apple-invalid-"); expect( projectAppleApplicationEvidence({ inventory_evidence: [inventory] }), ).toMatchObject({ diff --git a/tests/composition/analysis-sessions/binarySession.test.ts b/tests/composition/analysis-sessions/binarySession.test.ts index e478301ff..c0635deb9 100644 --- a/tests/composition/analysis-sessions/binarySession.test.ts +++ b/tests/composition/analysis-sessions/binarySession.test.ts @@ -2,6 +2,9 @@ import type { AnalysisClient, AnalysisProvider, } from "../../../src/application/AnalysisProvider.js"; +import type { BinarySession } from "../../../src/application/BinarySession.js"; +import type { JsonValue } from "../../../src/domain/jsonValue.js"; +import type { RecordUnknownInput } from "../../../src/domain/residualUnknown.js"; import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; import { HopperStartError, @@ -93,6 +96,44 @@ const client = (fail = false): AnalysisClient => ({ close: () => Promise.resolve(), }); +/** Open a target and run one address_name query, asserting both succeed. */ +const openAndExecuteAddressName = async ( + session: BinarySession, + target: string, + parameters: Readonly>, +): Promise => { + expect((await session.open(target)).ok).toBe(true); + expect((await session.execute("address_name", parameters)).ok).toBe(true); +}; + +/** Shared network-reachability unknown input at one severity. */ +const networkReachabilityUnknown = ( + severity: "medium" | "high", +): RecordUnknownInput => ({ + question: "Does this function reach the network?", + severity, + domain: "network", + supporting_evidence_ids: [], + contradicting_evidence_ids: [], + required_authority: "shipped-artifact", + required_confidence: "observed", + required_environment: null, + recommended_probes: [ + { operation: "analyze_function", rationale: "Inspect its callers." }, + ], + relationships: [], +}); + +/** Record the shared network-reachability unknown, asserting success. */ +const recordNetworkReachabilityUnknown = ( + session: BinarySession, + severity: "medium" | "high", +): void => { + expect(session.recordUnknown(networkReachabilityUnknown(severity)).ok).toBe( + true, + ); +}; + describe("detached provider and target metadata", () => { it("returns detached provider and target metadata", async () => { const [first] = await createBinarySessionTargets(); @@ -349,31 +390,11 @@ describe("snapshot switch rollback", () => { await copyFile(first, second); const calls: string[] = []; const session = createTestBinarySession(createCacheProvider(calls)); - expect((await session.open(first)).ok).toBe(true); - expect( - ( - await session.execute("address_name", { - address: "0x1000", - document: "first", - }) - ).ok, - ).toBe(true); - expect( - session.recordUnknown({ - question: "Does this function reach the network?", - severity: "medium", - domain: "network", - supporting_evidence_ids: [], - contradicting_evidence_ids: [], - required_authority: "shipped-artifact", - required_confidence: "observed", - required_environment: null, - recommended_probes: [ - { operation: "analyze_function", rationale: "Inspect its callers." }, - ], - relationships: [], - }).ok, - ).toBe(true); + await openAndExecuteAddressName(session, first, { + address: "0x1000", + document: "first", + }); + recordNetworkReachabilityUnknown(session, "medium"); const originalCache = session.exportAnalysisSnapshot(); expect(originalCache).toMatchObject({ ok: true, @@ -382,22 +403,7 @@ describe("snapshot switch rollback", () => { const conflicting = createTestBinarySession(createCacheProvider([])); expect((await conflicting.open(second)).ok).toBe(true); - expect( - conflicting.recordUnknown({ - question: "Does this function reach the network?", - severity: "high", - domain: "network", - supporting_evidence_ids: [], - contradicting_evidence_ids: [], - required_authority: "shipped-artifact", - required_confidence: "observed", - required_environment: null, - recommended_probes: [ - { operation: "analyze_function", rationale: "Inspect its callers." }, - ], - relationships: [], - }).ok, - ).toBe(true); + recordNetworkReachabilityUnknown(conflicting, "high"); const conflictingSnapshot = conflicting.exportAnalysisSnapshot(); expect(conflictingSnapshot.ok).toBe(true); if (!conflictingSnapshot.ok || !originalCache.ok) return; @@ -425,8 +431,7 @@ describe("snapshot cache eligibility", () => { const [first] = await createBinarySessionTargets(); const calls: string[] = []; const session = createTestBinarySession(createCacheProvider(calls)); - expect((await session.open(first)).ok).toBe(true); - expect((await session.execute("address_name", {})).ok).toBe(true); + await openAndExecuteAddressName(session, first, {}); expect((await session.execute("address_name", {})).ok).toBe(true); expect(calls).toEqual(["health", "address_name", "address_name"]); expect(session.exportAnalysisSnapshot()).toMatchObject({ diff --git a/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts b/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts index d63992df3..f34587348 100644 --- a/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts +++ b/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts @@ -184,26 +184,18 @@ it("keeps suffix-named ASAR directories in the graph and analyzes their files", ), ).toBe(true); - const result = await reconstructJavaScriptArtifact({ - input_path: archive, - format: "asar", - }); - - expect(result.statistics.parsed_javascript_files).toBeGreaterThan(0); - expect(result.graph.nodes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: "javascript-asset", - observations: expect.arrayContaining([ - expect.objectContaining({ - properties: expect.objectContaining({ - path: "node_modules/@zip.js/entry.js", - }), + await reconstructAsarArchive(archive, [ + expect.objectContaining({ + kind: "javascript-asset", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + path: "node_modules/@zip.js/entry.js", }), - ]), - }), - ]), - ); + }), + ]), + }), + ]); }); it("keeps ASAR analysis usable when unpacked native companion bytes are absent", async () => { @@ -228,17 +220,9 @@ it("keeps ASAR analysis usable when unpacked native companion bytes are absent", ]), }); - const result = await reconstructJavaScriptArtifact({ - input_path: archive, - format: "asar", - }); - - expect(result.statistics.parsed_javascript_files).toBeGreaterThan(0); - expect(result.graph.nodes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: "javascript-asset" }), - ]), - ); + await reconstructAsarArchive(archive, [ + expect.objectContaining({ kind: "javascript-asset" }), + ]); }); it("recurses into filesystem-backed ASAR containers without losing container-relative paths", async () => { @@ -360,6 +344,21 @@ const assertSemanticLinks = ( expect(endpointJson).toContain("token=fixture-secret"); }; +const reconstructAsarArchive = async ( + archive: string, + expectedNodes: readonly unknown[], +): Promise>> => { + const result = await reconstructJavaScriptArtifact({ + input_path: archive, + format: "asar", + }); + expect(result.statistics.parsed_javascript_files).toBeGreaterThan(0); + expect(result.graph.nodes).toEqual( + expect.arrayContaining([...expectedNodes]), + ); + return result; +}; + const fixtureDirectory = async (): Promise => { const root = await createTestTempDirectory("rea-javascript-artifact-"); await writeJavaScriptArtifactFixture(root); diff --git a/tests/composition/javascript-applications/javascriptBundlerAdapters.test.ts b/tests/composition/javascript-applications/javascriptBundlerAdapters.test.ts index e9bd194dc..0aa829798 100644 --- a/tests/composition/javascript-applications/javascriptBundlerAdapters.test.ts +++ b/tests/composition/javascript-applications/javascriptBundlerAdapters.test.ts @@ -1,29 +1,43 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; - import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { reconstructJavaScriptArtifact } from "../../../src/application/JavaScriptArtifactReconstruction.js"; -import type { - ApplicationEdge, - ApplicationNode, - JavaScriptApplicationGraph, -} from "../../../src/domain/javascriptApplicationGraph.js"; +import { + findChunkNode, + findGraphEdge, + findModuleNodeByKey, + reconstructJavaScriptFixture, + writeFixtureFiles, +} from "../../support/javascriptApplicationFixture.js"; describe("Webpack and Rspack runtime adapters", () => { it("recovers runtime entry modules, async chunks, and factory require aliases", async () => { const root = await bundlerFixture(); - const result = await reconstructJavaScriptArtifact({ input_path: root }); + const result = await reconstructJavaScriptFixture(root); const graph = result.graph; - const webpackEntry = chunk(graph, "webpackChunkcustomPortal", "main"); - const webpackLazy = chunk(graph, "webpackChunkcustomPortal", "lazy"); - const webpackModule = moduleNode(graph, "10"); - const rspackEntry = chunk(graph, "rspackChunkcustomPortal", "editor"); - const rspackModel = chunk(graph, "rspackChunkcustomPortal", "model"); - const rspackModule = moduleNode(graph, "src/entry.ts"); + const webpackEntry = findChunkNode( + graph, + "webpackChunkcustomPortal", + "main", + ); + const webpackLazy = findChunkNode( + graph, + "webpackChunkcustomPortal", + "lazy", + ); + const webpackModule = findModuleNodeByKey(graph, "10"); + const rspackEntry = findChunkNode( + graph, + "rspackChunkcustomPortal", + "editor", + ); + const rspackModel = findChunkNode( + graph, + "rspackChunkcustomPortal", + "model", + ); + const rspackModule = findModuleNodeByKey(graph, "src/entry.ts"); expect(webpackEntry?.observations[0]?.properties).toMatchObject({ bundler: "webpack", @@ -49,32 +63,42 @@ describe("Webpack and Rspack runtime adapters", () => { runtime_entry: true, }); - expect(edge(graph, webpackEntry, webpackModule, "loads")).toMatchObject({ + expect( + findGraphEdge(graph, webpackEntry, webpackModule, "loads"), + ).toMatchObject({ properties: expect.objectContaining({ kind: "bundler-entry-module", resolution_status: "resolved", }), }); - expect(edge(graph, webpackEntry, webpackLazy, "imports")).toMatchObject({ + expect( + findGraphEdge(graph, webpackEntry, webpackLazy, "imports"), + ).toMatchObject({ properties: expect.objectContaining({ kind: "bundler-async-chunk", resolution_status: "resolved", }), }); - expect(edge(graph, webpackModule, webpackLazy, "imports")).toMatchObject({ + expect( + findGraphEdge(graph, webpackModule, webpackLazy, "imports"), + ).toMatchObject({ properties: expect.objectContaining({ kind: "dynamic-import", specifier: "chunk:lazy", resolved_path: "renderer/chunks/runtime.js#chunk:lazy", }), }); - expect(edge(graph, rspackEntry, rspackModel, "imports")).toMatchObject({ + expect( + findGraphEdge(graph, rspackEntry, rspackModel, "imports"), + ).toMatchObject({ properties: expect.objectContaining({ kind: "bundler-async-chunk", resolution_status: "resolved", }), }); - expect(edge(graph, rspackModule, rspackModel, "imports")).toMatchObject({ + expect( + findGraphEdge(graph, rspackModule, rspackModel, "imports"), + ).toMatchObject({ properties: expect.objectContaining({ kind: "dynamic-import", specifier: "chunk:model", @@ -103,11 +127,9 @@ describe("Webpack and Rspack runtime adapters", () => { it("recovers esbuild wrapper modules and Vite preload dependencies", async () => { const root = await esmRuntimeFixture(); - const { graph } = await reconstructJavaScriptArtifact({ - input_path: root, - }); - const commonJsModule = moduleNode(graph, "src/dep.js"); - const esmModule = moduleNode(graph, "src/core.js"); + const { graph } = await reconstructJavaScriptFixture(root); + const commonJsModule = findModuleNodeByKey(graph, "src/dep.js"); + const esmModule = findModuleNodeByKey(graph, "src/core.js"); expect(commonJsModule?.observations[0]?.properties).toMatchObject({ bundler: "esbuild", @@ -132,10 +154,8 @@ describe("Webpack and Rspack runtime adapters", () => { const bundlerFixture = async (): Promise => { const root = await createTestTempDirectory("rea-bundler-adapters-"); - await mkdir(join(root, "renderer", "chunks"), { recursive: true }); - await writeFile( - join(root, "renderer", "chunks", "runtime.js"), - ` + await writeFixtureFiles(root, { + "renderer/chunks/runtime.js": ` var __webpack_module_cache__ = {}; (globalThis["webpackChunkcustomPortal"] = globalThis["webpackChunkcustomPortal"] || []).push([ ["main"], @@ -179,15 +199,14 @@ const bundlerFixture = async (): Promise => { } ]); `, - ); + }); return root; }; const esmRuntimeFixture = async (): Promise => { const root = await createTestTempDirectory("rea-esm-runtime-adapters-"); - await writeFile( - join(root, "main.js"), - ` + await writeFixtureFiles(root, { + "main.js": ` const __vite__mapDeps = (indexes, map = __vite__mapDeps, dependencies = (map.f || (map.f = ["./feature.js", "./main.css"]))) => indexes.map((index) => dependencies[index]); @@ -204,51 +223,8 @@ const esmRuntimeFixture = async (): Promise => { __vite__mapDeps([0, 1]); init_core(); `, - ); - await writeFile(join(root, "feature.js"), "export const feature = true;"); - await writeFile(join(root, "main.css"), ".app { color: green; }"); + "feature.js": "export const feature = true;", + "main.css": ".app { color: green; }", + }); return root; }; - -const chunk = ( - graph: JavaScriptApplicationGraph, - runtime: string, - chunkKey: string, -): ApplicationNode | undefined => - graph.nodes.find( - ({ kind, observations }) => - kind === "javascript-chunk" && - observations.some( - ({ properties }) => - properties.runtime === runtime && - Array.isArray(properties.chunk_keys) && - properties.chunk_keys.includes(chunkKey), - ), - ); - -const moduleNode = ( - graph: JavaScriptApplicationGraph, - moduleKey: string, -): ApplicationNode | undefined => - graph.nodes.find( - ({ kind, observations }) => - kind === "javascript-module" && - observations.some( - ({ properties }) => properties.module_key === moduleKey, - ), - ); - -const edge = ( - graph: JavaScriptApplicationGraph, - source: ApplicationNode | undefined, - target: ApplicationNode | undefined, - relation: string, -): ApplicationEdge | undefined => - graph.edges.find( - ({ source_node_id, target_node_id, relation: edgeRelation }) => - source !== undefined && - target !== undefined && - source_node_id === source.node_id && - target_node_id === target.node_id && - edgeRelation === relation, - ); diff --git a/tests/composition/javascript-applications/javascriptModuleRelationships.test.ts b/tests/composition/javascript-applications/javascriptModuleRelationships.test.ts index e6cc84da8..2db52caa5 100644 --- a/tests/composition/javascript-applications/javascriptModuleRelationships.test.ts +++ b/tests/composition/javascript-applications/javascriptModuleRelationships.test.ts @@ -1,31 +1,32 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { reconstructJavaScriptArtifact } from "../../../src/application/JavaScriptArtifactReconstruction.js"; -import type { - ApplicationEdge, - ApplicationNode, - JavaScriptApplicationGraph, -} from "../../../src/domain/javascriptApplicationGraph.js"; +import { + findExportNode, + findRelationshipEdge, + findSourceModule, + reconstructJavaScriptFixture, + writeFixtureFiles, +} from "../../support/javascriptApplicationFixture.js"; describe("CommonJS and ESM module relationships", () => { it("composes bindings, re-exports, dynamic imports, and JSON modules", async () => { const root = await moduleFixture(); - const first = await reconstructJavaScriptArtifact({ input_path: root }); - const second = await reconstructJavaScriptArtifact({ input_path: root }); + const first = await reconstructJavaScriptFixture(root); + const second = await reconstructJavaScriptFixture(root); const graph = first.graph; expect(second.graph).toEqual(graph); - expect(sourceModule(graph, "main.cjs")).toBeDefined(); - expect(sourceModule(graph, "consumer.mjs")).toBeDefined(); - expect(sourceModule(graph, "dependency.mjs")).toBeDefined(); + expect(findSourceModule(graph, "main.cjs")).toBeDefined(); + expect(findSourceModule(graph, "consumer.mjs")).toBeDefined(); + expect(findSourceModule(graph, "dependency.mjs")).toBeDefined(); expect( - relationship(graph, { + findRelationshipEdge(graph, { kind: "require", specifier: "./dependency.mjs", resolvedPath: "dependency.mjs", @@ -46,7 +47,7 @@ describe("CommonJS and ESM module relationships", () => { ]), ); expect( - relationship(graph, { + findRelationshipEdge(graph, { kind: "require", specifier: "fixture-package", resolvedPath: "node_modules/fixture-package/cjs.cjs", @@ -54,7 +55,7 @@ describe("CommonJS and ESM module relationships", () => { }), ).toBeDefined(); expect( - relationship(graph, { + findRelationshipEdge(graph, { kind: "import", specifier: "fixture-package", resolvedPath: "node_modules/fixture-package/esm.mjs", @@ -63,7 +64,7 @@ describe("CommonJS and ESM module relationships", () => { }), ).toBeDefined(); expect( - relationship(graph, { + findRelationshipEdge(graph, { kind: "import", specifier: "./values.js", resolvedPath: "values.js", @@ -72,7 +73,7 @@ describe("CommonJS and ESM module relationships", () => { }), ).toBeDefined(); expect( - relationship(graph, { + findRelationshipEdge(graph, { kind: "re-export", specifier: "./star.js", resolvedPath: "star.js", @@ -81,7 +82,7 @@ describe("CommonJS and ESM module relationships", () => { }), ).toBeDefined(); - const forwarded = exportNode(graph, "main.cjs", "forwarded"); + const forwarded = findExportNode(graph, "main.cjs", "forwarded"); expect(forwarded).toBeDefined(); expect( graph.edges.some( @@ -101,11 +102,11 @@ describe("CommonJS and ESM module relationships", () => { properties.resolved_path === "lazy.js", ), ).toMatchObject({ - source_node_id: sourceModule(graph, "consumer.mjs")?.node_id, + source_node_id: findSourceModule(graph, "consumer.mjs")?.node_id, }); expect( - relationship(graph, { + findRelationshipEdge(graph, { kind: "require", specifier: "./data.json", resolvedPath: "data.json", @@ -115,7 +116,7 @@ describe("CommonJS and ESM module relationships", () => { target_json_status: "included", }); expect( - relationship(graph, { + findRelationshipEdge(graph, { kind: "require", specifier: "./broken.json", resolvedPath: "broken.json", @@ -125,7 +126,7 @@ describe("CommonJS and ESM module relationships", () => { target_json_status: "invalid", }); expect( - relationship(graph, { + findRelationshipEdge(graph, { kind: "require", specifier: "electron", })?.properties, @@ -164,8 +165,8 @@ describe("complete static application projections", () => { writeFile(join(root, "many.json"), JSON.stringify(jsonKeys)), ]); - const { graph } = await reconstructJavaScriptArtifact({ input_path: root }); - const module = sourceModule(graph, "many-exports.mjs"); + const { graph } = await reconstructJavaScriptFixture(root); + const module = findSourceModule(graph, "many-exports.mjs"); const exportObservation = module?.observations.find( ({ properties }) => properties.semantic_role === "source-module", ); @@ -187,14 +188,9 @@ describe("complete static application projections", () => { const moduleFixture = async (): Promise => { const root = await createTestTempDirectory("rea-module-relationships-"); - await mkdir(join(root, "node_modules", "fixture-package"), { - recursive: true, - }); - await Promise.all([ - writeFile(join(root, "package.json"), '{"main":"main.cjs"}'), - writeFile( - join(root, "main.cjs"), - ` + await writeFixtureFiles(root, { + "package.json": '{"main":"main.cjs"}', + "main.cjs": ` const { value: importedValue } = require("./dependency.mjs"); const data = require("./data.json"); const broken = require("./broken.json"); @@ -207,104 +203,32 @@ const moduleFixture = async (): Promise => { exports.packageValue = packageValue; require(dynamicName); `, - ), - writeFile( - join(root, "dependency.mjs"), - ` + "dependency.mjs": ` export { value } from "./values.js"; export * from "./star.js"; `, - ), - writeFile( - join(root, "consumer.mjs"), - ` + "consumer.mjs": ` import { value as alias } from "./values.js"; import packageDefault from "fixture-package"; export { alias as relayed }; void import("./lazy.js"); void packageDefault; `, - ), - writeFile(join(root, "values.js"), "export const value = 42;"), - writeFile(join(root, "star.js"), "export const extra = true;"), - writeFile(join(root, "lazy.js"), "export default 'lazy';"), - writeFile(join(root, "data.json"), '{"name":"fixture","value":42}'), - writeFile(join(root, "broken.json"), '{"name":'), - writeFile( - join(root, "node_modules", "fixture-package", "package.json"), - JSON.stringify({ - exports: { - ".": { - import: "./esm.mjs", - require: "./cjs.cjs", - }, + "values.js": "export const value = 42;", + "star.js": "export const extra = true;", + "lazy.js": "export default 'lazy';", + "data.json": '{"name":"fixture","value":42}', + "broken.json": '{"name":', + "node_modules/fixture-package/package.json": JSON.stringify({ + exports: { + ".": { + import: "./esm.mjs", + require: "./cjs.cjs", }, - }), - ), - writeFile( - join(root, "node_modules", "fixture-package", "esm.mjs"), - "export default 'esm';", - ), - writeFile( - join(root, "node_modules", "fixture-package", "cjs.cjs"), - "module.exports = 'cjs';", - ), - ]); + }, + }), + "node_modules/fixture-package/esm.mjs": "export default 'esm';", + "node_modules/fixture-package/cjs.cjs": "module.exports = 'cjs';", + }); return root; }; - -interface RelationshipQuery { - readonly kind: string; - readonly specifier: string; - readonly resolvedPath?: string; - readonly importedName?: string; - readonly localName?: string; - readonly exportedName?: string; -} - -const relationship = ( - graph: JavaScriptApplicationGraph, - query: RelationshipQuery, -): ApplicationEdge | undefined => - graph.edges.find( - ({ relation, properties }) => - relation === "imports" && - properties.module_link_kind === query.kind && - properties.specifier === query.specifier && - (query.resolvedPath === undefined || - properties.resolved_path === query.resolvedPath) && - (query.importedName === undefined || - properties.imported_name === query.importedName) && - (query.localName === undefined || - properties.local_name === query.localName) && - (query.exportedName === undefined || - properties.exported_name === query.exportedName), - ); - -const sourceModule = ( - graph: JavaScriptApplicationGraph, - path: string, -): ApplicationNode | undefined => - graph.nodes.find( - (node) => - node.kind === "javascript-module" && - node.observations.some( - ({ properties }) => properties.logical_module_key === path, - ), - ); - -const exportNode = ( - graph: JavaScriptApplicationGraph, - path: string, - name: string, -): ApplicationNode | undefined => - graph.nodes.find( - (node) => - node.kind === "javascript-module" && - node.observations.some( - ({ properties }) => - properties.semantic_role === "export-binding" && - properties.module_path === path && - properties.exported_name === name, - ), - ); diff --git a/tests/process-global/verifierRun.test.ts b/tests/process-global/verifierRun.test.ts index 88b5cfe35..276ddf7b9 100644 --- a/tests/process-global/verifierRun.test.ts +++ b/tests/process-global/verifierRun.test.ts @@ -7,6 +7,7 @@ import { completeVerifierRun, createVerifierRun, } from "../../scripts/lib/verifier-run.mjs"; +import { waitForExit } from "../support/process/processFixture.js"; const execFileAsync = promisify(execFile); const CHILD_SHUTDOWN_GRACE_MS = 1_000; @@ -154,27 +155,6 @@ const stopChild = async (child: ChildProcess): Promise => { } }; -const waitForExit = ( - child: ChildProcess, - timeoutMs: number, -): Promise => - new Promise((resolve) => { - if (child.exitCode !== null || child.signalCode !== null) { - resolve(true); - return; - } - const timer = setTimeout(() => { - child.removeListener("exit", onExit); - resolve(false); - }, timeoutMs); - timer.unref(); - const onExit = (): void => { - clearTimeout(timer); - resolve(true); - }; - child.once("exit", onExit); - }); - const restoreRunId = (runId: string | undefined): void => { if (runId === undefined) delete process.env.REA_PROCESS_RUN_ID; else process.env.REA_PROCESS_RUN_ID = runId; diff --git a/tests/support/applicationSessionFixture.ts b/tests/support/applicationSessionFixture.ts new file mode 100644 index 000000000..364426a54 --- /dev/null +++ b/tests/support/applicationSessionFixture.ts @@ -0,0 +1,38 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect } from "vitest"; + +import { runProviderAnalysis } from "../../src/application/DirectAnalysis.js"; +import type { AnalysisError } from "../../src/domain/errors.js"; +import { type Evidence, parseEvidence } from "../../src/domain/evidence.js"; +import type { Result } from "../../src/domain/result.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +/** Assert a projection succeeded and return its Evidence for schema parsing. */ +export const requireSuccessfulProjection = ( + result: Result, +): Evidence => { + expect(result.ok).toBe(true); + if (!result.ok) + throw new TypeError("Expected application projection to succeed"); + return result.value; +}; + +/** + * Build inventory Evidence from a ZIP that is neither an APK nor an IPA. + * Both mobile projections must reject it with an AnalysisInputError. + */ +export const createNonApplicationZipInventory = async ( + prefix: string, +): Promise => { + const root = await createTestTempDirectory(prefix); + const path = join(root, "fixture.zip"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("one.js", new TextReader("one")); + await writeFile(path, await writer.close()); + return parseEvidence( + await runProviderAnalysis(path, "inventory_artifact", {}), + ); +}; diff --git a/tests/support/javascriptApplicationFixture.ts b/tests/support/javascriptApplicationFixture.ts new file mode 100644 index 000000000..e648a32fd --- /dev/null +++ b/tests/support/javascriptApplicationFixture.ts @@ -0,0 +1,135 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { reconstructJavaScriptArtifact } from "../../src/application/JavaScriptArtifactReconstruction.js"; +import type { + ApplicationEdge, + ApplicationNode, + JavaScriptApplicationGraph, +} from "../../src/domain/javascriptApplicationGraph.js"; + +/** Write text fixture files, creating parent directories as needed. */ +export const writeFixtureFiles = async ( + root: string, + files: Readonly>, +): Promise => { + await Promise.all( + Object.entries(files).map(async ([relative, contents]) => { + const path = join(root, relative); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, contents); + }), + ); +}; + +/** Reconstruct one JavaScript artifact fixture directory. */ +export const reconstructJavaScriptFixture = ( + inputPath: string, +): Promise>> => + reconstructJavaScriptArtifact({ input_path: inputPath }); + +/** Find a bundler chunk node by runtime and chunk key. */ +export const findChunkNode = ( + graph: JavaScriptApplicationGraph, + runtime: string, + chunkKey: string, +): ApplicationNode | undefined => + graph.nodes.find( + ({ kind, observations }) => + kind === "javascript-chunk" && + observations.some( + ({ properties }) => + properties.runtime === runtime && + Array.isArray(properties.chunk_keys) && + properties.chunk_keys.includes(chunkKey), + ), + ); + +/** Find a bundler module node by module key. */ +export const findModuleNodeByKey = ( + graph: JavaScriptApplicationGraph, + moduleKey: string, +): ApplicationNode | undefined => + graph.nodes.find( + ({ kind, observations }) => + kind === "javascript-module" && + observations.some( + ({ properties }) => properties.module_key === moduleKey, + ), + ); + +/** Find one directed edge between two application nodes. */ +export const findGraphEdge = ( + graph: JavaScriptApplicationGraph, + source: ApplicationNode | undefined, + target: ApplicationNode | undefined, + relation: string, +): ApplicationEdge | undefined => + graph.edges.find( + ({ source_node_id, target_node_id, relation: edgeRelation }) => + source !== undefined && + target !== undefined && + source_node_id === source.node_id && + target_node_id === target.node_id && + edgeRelation === relation, + ); + +/** Module-link facts used to query one import edge. */ +export interface RelationshipQuery { + readonly kind: string; + readonly specifier: string; + readonly resolvedPath?: string; + readonly importedName?: string; + readonly localName?: string; + readonly exportedName?: string; +} + +/** Find one module-link import edge matching the query. */ +export const findRelationshipEdge = ( + graph: JavaScriptApplicationGraph, + query: RelationshipQuery, +): ApplicationEdge | undefined => + graph.edges.find( + ({ relation, properties }) => + relation === "imports" && + properties.module_link_kind === query.kind && + properties.specifier === query.specifier && + (query.resolvedPath === undefined || + properties.resolved_path === query.resolvedPath) && + (query.importedName === undefined || + properties.imported_name === query.importedName) && + (query.localName === undefined || + properties.local_name === query.localName) && + (query.exportedName === undefined || + properties.exported_name === query.exportedName), + ); + +/** Find a source module node by logical path. */ +export const findSourceModule = ( + graph: JavaScriptApplicationGraph, + path: string, +): ApplicationNode | undefined => + graph.nodes.find( + (node) => + node.kind === "javascript-module" && + node.observations.some( + ({ properties }) => properties.logical_module_key === path, + ), + ); + +/** Find one export-binding node by module path and export name. */ +export const findExportNode = ( + graph: JavaScriptApplicationGraph, + path: string, + name: string, +): ApplicationNode | undefined => + graph.nodes.find( + (node) => + node.kind === "javascript-module" && + node.observations.some( + ({ properties }) => + properties.semantic_role === "export-binding" && + properties.module_path === path && + properties.exported_name === name, + ), + ); diff --git a/tests/support/process/processFixture.ts b/tests/support/process/processFixture.ts index ef0466c19..8e6c25a79 100644 --- a/tests/support/process/processFixture.ts +++ b/tests/support/process/processFixture.ts @@ -1,5 +1,6 @@ import { spawn as nodeSpawn, + type ChildProcess, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio, } from "node:child_process"; @@ -37,8 +38,9 @@ export interface TestProcesses { shutdown(): Promise; } -const waitForExit = ( - child: ChildProcessWithoutNullStreams, +/** Wait for a child process to exit within a timeout, reporting completion. */ +export const waitForExit = ( + child: ChildProcess, timeoutMs: number, ): Promise => new Promise((resolve) => {