diff --git a/src/domain/android/androidApplication.ts b/src/domain/android/androidApplication.ts index eb6c671e7d..c46fb9661a 100644 --- a/src/domain/android/androidApplication.ts +++ b/src/domain/android/androidApplication.ts @@ -1,6 +1,9 @@ import { z } from "zod"; -import { parseArtifactInventoryEvidence } from "../artifactInventoryEvidence.js"; +import { + hasCompleteInventoryCoverage, + parseArtifactInventoryEvidence, +} from "../artifactInventoryEvidence.js"; import { digestSchema } from "../digests.js"; import { prefixedDigestSchema } from "../digests.js"; import { nativeRuntimeConvention } from "../nativeConvention.js"; @@ -62,6 +65,7 @@ export const androidApplicationProjectionResultSchema = z.strictObject({ bridge_candidate_coverage: bridgeCandidateCoverageSchema, coverage: z.strictObject({ status: z.enum(["complete-within-inventory", "partial"]), + // Count completeness is separate from the declared coverage status. inventory_complete: z.boolean(), }), limitations: z.array(z.string().min(1)), @@ -95,8 +99,8 @@ export const projectAndroidApplication = ( ); const components = classified; const limitations = [ - ...(!inventory.complete - ? ["Source inventory pages are incomplete; absence is unknown."] + ...(!hasCompleteInventoryCoverage(inventory) + ? ["Source inventory coverage is partial; absence is unknown."] : []), "Manifest, resource, signing, and bytecode semantics require a dedicated Android provider; this projection reports exact inventory paths and hashes only.", "Runtime families are inferred from inventory formats and paths; filename suffixes do not establish valid DEX or JVM class bytes.", diff --git a/src/domain/apple/appleApplication.test.ts b/src/domain/apple/appleApplication.test.ts index 8acde829f1..9caf21fcbb 100644 --- a/src/domain/apple/appleApplication.test.ts +++ b/src/domain/apple/appleApplication.test.ts @@ -138,6 +138,7 @@ const inventoryEvidence = ( occurrences: sortedOccurrences, edges: [], provenance: [], + coverage_status: "complete", limitations: [], }), }, diff --git a/src/domain/apple/appleApplication.ts b/src/domain/apple/appleApplication.ts index bbf6519ee6..d46ddf26b0 100644 --- a/src/domain/apple/appleApplication.ts +++ b/src/domain/apple/appleApplication.ts @@ -17,7 +17,10 @@ import { isSwiftRuntimeLibrary, nativeRuntimeConvention, } from "../nativeConvention.js"; -import { parseArtifactInventoryEvidence } from "../artifactInventoryEvidence.js"; +import { + hasCompleteInventoryCoverage, + parseArtifactInventoryEvidence, +} from "../artifactInventoryEvidence.js"; import { evidenceSchema } from "../evidence.js"; import { digestSchema } from "../digests.js"; import { prefixedDigestSchema } from "../digests.js"; @@ -88,6 +91,7 @@ export const appleApplicationProjectionResultSchema = z.strictObject({ bridge_candidate_coverage: bridgeCandidateCoverageSchema, coverage: z.strictObject({ status: z.enum(["complete-within-inventory", "partial"]), + // Count completeness is separate from the declared coverage status. inventory_complete: z.boolean(), }), limitations: z.array(z.string().min(1)), @@ -165,9 +169,10 @@ export const projectAppleApplication = ( ); const uninventoriedImage = rootFormat === "dmg" && inventory.occurrences.length === 1; - const complete = inventory.complete && !uninventoriedImage; + const inventoryComplete = hasCompleteInventoryCoverage(inventory); + const complete = inventoryComplete && !uninventoriedImage; const limitations = projectionLimitations({ - complete: inventory.complete, + complete: inventoryComplete, uninventoriedImage, roots, bundles, @@ -390,7 +395,7 @@ const projectionLimitations = (facts: { readonly bridgeCandidateCoverage: AppleApplicationProjectionResult["bridge_candidate_coverage"]; }): string[] => [ ...(!facts.complete - ? ["Source inventory pages are incomplete; absence is unknown."] + ? ["Source inventory coverage is partial; absence is unknown."] : []), ...(facts.uninventoriedImage ? [ diff --git a/src/domain/artifactComparison.ts b/src/domain/artifactComparison.ts index e47b28a22e..9d7a766af2 100644 --- a/src/domain/artifactComparison.ts +++ b/src/domain/artifactComparison.ts @@ -8,6 +8,7 @@ import type { } from "./artifactGraph.js"; import { evidenceSchema } from "./evidence.js"; import { + hasCompleteInventoryCoverage, parseArtifactInventoryEvidence, type InventorySet, } from "./artifactInventoryEvidence.js"; @@ -103,10 +104,8 @@ export const compareArtifacts = ( const right = parseArtifactInventoryEvidence(rightEvidence); const leftComplete = left.inventory.complete; const rightComplete = right.inventory.complete; - const leftCovered = - leftComplete && left.inventory.coverage_status === "complete"; - const rightCovered = - rightComplete && right.inventory.coverage_status === "complete"; + const leftCovered = hasCompleteInventoryCoverage(left.inventory); + const rightCovered = hasCompleteInventoryCoverage(right.inventory); const limitations = [ ...left.inventory.limitations.map((item) => `Left: ${item}`), ...right.inventory.limitations.map((item) => `Right: ${item}`), diff --git a/src/domain/artifactInventoryEvidence.ts b/src/domain/artifactInventoryEvidence.ts index 6d2528ec19..213364816f 100644 --- a/src/domain/artifactInventoryEvidence.ts +++ b/src/domain/artifactInventoryEvidence.ts @@ -28,10 +28,16 @@ export interface InventorySet { readonly edges: readonly ArtifactInventoryResult["edges"][number][]; readonly integrityContradictions: readonly IntegrityContradiction[]; readonly limitations: readonly string[]; + /** True when merged node, occurrence, and edge counts match the manifest. */ readonly complete: boolean; readonly coverage_status: ArtifactInventoryCoverage; } +/** Whether all graph members are present and the producer reports full coverage. */ +export const hasCompleteInventoryCoverage = ( + inventory: InventorySet, +): boolean => inventory.complete && inventory.coverage_status === "complete"; + export interface ParsedInventorySet { readonly evidence: readonly Evidence[]; readonly inventory: InventorySet; diff --git a/src/domain/harmony/harmonyApplication.ts b/src/domain/harmony/harmonyApplication.ts index 51dacd1489..71755e8281 100644 --- a/src/domain/harmony/harmonyApplication.ts +++ b/src/domain/harmony/harmonyApplication.ts @@ -1,6 +1,9 @@ import { z } from "zod"; -import { parseArtifactInventoryEvidence } from "../artifactInventoryEvidence.js"; +import { + hasCompleteInventoryCoverage, + parseArtifactInventoryEvidence, +} from "../artifactInventoryEvidence.js"; import { digestSchema } from "../digests.js"; import { prefixedDigestSchema } from "../digests.js"; import { nativeRuntimeConvention } from "../nativeConvention.js"; @@ -66,6 +69,7 @@ export const harmonyApplicationProjectionResultSchema = z.strictObject({ bridge_candidate_coverage: bridgeCandidateCoverageSchema, coverage: z.strictObject({ status: z.enum(["complete-within-inventory", "partial"]), + // Count completeness is separate from the declared coverage status. inventory_complete: z.boolean(), }), limitations: z.array(z.string().min(1)), @@ -108,8 +112,8 @@ export const projectHarmonyApplication = ( classified.native_libraries, ); const limitations = [ - ...(!inventory.complete - ? ["Source inventory pages are incomplete; absence is unknown."] + ...(!hasCompleteInventoryCoverage(inventory) + ? ["Source inventory coverage is partial; absence is unknown."] : []), ...(unavailable.length > 0 ? [ diff --git a/src/domain/mobileApplicationGraphProjection.ts b/src/domain/mobileApplicationGraphProjection.ts index 328388ebe2..93e4143c5c 100644 --- a/src/domain/mobileApplicationGraphProjection.ts +++ b/src/domain/mobileApplicationGraphProjection.ts @@ -3,7 +3,10 @@ import { createHash } from "node:crypto"; import canonicalize from "canonicalize"; import { z } from "zod"; -import type { ParsedInventorySet } from "./artifactInventoryEvidence.js"; +import { + hasCompleteInventoryCoverage, + type ParsedInventorySet, +} from "./artifactInventoryEvidence.js"; import { evidenceSchema } from "./evidence.js"; import { digestSchema } from "./digests.js"; import { prefixedDigestSchema } from "./digests.js"; @@ -49,10 +52,11 @@ export const projectionCoverage = ( budgetComplete: boolean, ): { status: "complete-within-inventory" | "partial"; + /** True when all manifest graph-member counts are present, independent of source coverage. */ inventory_complete: boolean; } => ({ status: - inventory.complete && budgetComplete + hasCompleteInventoryCoverage(inventory) && budgetComplete ? ("complete-within-inventory" as const) : ("partial" as const), inventory_complete: inventory.complete, diff --git a/tests/boundary/filesystem/android/application.test.ts b/tests/boundary/filesystem/android/application.test.ts index c976b591c9..28fa4d8c86 100644 --- a/tests/boundary/filesystem/android/application.test.ts +++ b/tests/boundary/filesystem/android/application.test.ts @@ -227,6 +227,9 @@ describe("case-distinct APK resource names", () => { const inventory = parseEvidence( await runProviderAnalysis(path, "inventory_artifact", {}), ); + expect(inventory.normalized_result).toMatchObject({ + coverage_status: "partial", + }); const result = projectAndroidApplicationEvidence({ inventory_evidence: [inventory], }); @@ -234,6 +237,13 @@ describe("case-distinct APK resource names", () => { requireSuccessfulProjection(result).normalized_result, ); expect(projection.root_format).toBe("apk"); + expect(projection.coverage).toEqual({ + status: "partial", + inventory_complete: true, + }); + expect(projection.limitations).toContain( + "Source inventory coverage is partial; absence is unknown.", + ); expect(projection.components.manifests).toHaveLength(1); expect(projection.components.dex).toHaveLength(1); expect(JSON.stringify(inventory)).toContain("res/2F.xml"); diff --git a/tests/boundary/filesystem/appleApplication.test.ts b/tests/boundary/filesystem/appleApplication.test.ts index 7cd50246a1..00ef24ba58 100644 --- a/tests/boundary/filesystem/appleApplication.test.ts +++ b/tests/boundary/filesystem/appleApplication.test.ts @@ -75,6 +75,42 @@ async function createCompleteAppleProjection() { } describe("Apple application projection", () => { + it("keeps a case-colliding real IPA inventory partial", async () => { + const root = await createTestTempDirectory("rea-apple-case-collision-"); + const path = join(root, "CaseCollision.ipa"); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add( + "Payload/Example.app/Contents/Info.plist", + new TextReader("upper"), + ); + await writer.add( + "Payload/Example.app/contents/Info.plist", + new TextReader("lower"), + ); + await writeFile(path, await writer.close()); + + const inventory = parseEvidence( + await runProviderAnalysis(path, "inventory_artifact", {}), + ); + expect(inventory.normalized_result).toMatchObject({ + coverage_status: "partial", + }); + const result = projectAppleApplicationEvidence({ + inventory_evidence: [inventory], + }); + const projection = appleApplicationProjectionResultSchema.parse( + requireSuccessfulProjection(result).normalized_result, + ); + + expect(projection.coverage).toEqual({ + status: "partial", + inventory_complete: true, + }); + expect(projection.limitations).toContain( + "Source inventory coverage is partial; absence is unknown.", + ); + }); + it("projects deterministic IPA components and bridge hypotheses from exact inventory Evidence", async () => { const root = await createTestTempDirectory("rea-apple-"); const path = join(root, "Fixture.ipa"); diff --git a/tests/boundary/filesystem/artifactComparison.test.ts b/tests/boundary/filesystem/artifactComparison.test.ts index 38bfde2649..ca40596d38 100644 --- a/tests/boundary/filesystem/artifactComparison.test.ts +++ b/tests/boundary/filesystem/artifactComparison.test.ts @@ -172,6 +172,9 @@ describe("artifact inventory coverage", () => { authority: "shipped-artifact", }, ); + expect(parseArtifactInventoryEvidence(partial).inventory.complete).toBe( + true, + ); expect(compareArtifacts(partial, partial)).toMatchObject({ status: "unknown", summary: { unchanged: 0, unknown: 1 }, diff --git a/tests/boundary/filesystem/harmony/application.test.ts b/tests/boundary/filesystem/harmony/application.test.ts index 5f758c0033..b2870e6c58 100644 --- a/tests/boundary/filesystem/harmony/application.test.ts +++ b/tests/boundary/filesystem/harmony/application.test.ts @@ -214,6 +214,34 @@ describe("HarmonyOS packaging boundaries", () => { }); describe("HarmonyOS inventory coverage", () => { + it("keeps a case-colliding inventory partial in the application projection", async () => { + const path = await writeApplicationZip( + "rea-harmony-case-collision-", + "Fixture.hap", + [ + { path: "module.json", content: '{"module":{}}' }, + { path: "MODULE.JSON", content: '{"module":{}}' }, + ], + ); + const inventory = await inventoryApplicationPackage(path); + expect(inventory.normalized_result).toMatchObject({ + coverage_status: "partial", + }); + + const projection = requireDeterministicProjection( + projectHarmonyApplicationEvidence, + inventory, + harmonyApplicationProjectionResultSchema, + ); + expect(projection.coverage).toEqual({ + status: "partial", + inventory_complete: true, + }); + expect(projection.limitations).toContain( + "Source inventory coverage is partial; absence is unknown.", + ); + }); + it.each(ZIP_PACKAGE_FORMATS)( "retains archive identity and ZIP coverage for %s", async (format) => {