From fcd364069a74fa9b82f9924caf6b4a804304ae6d Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:17:01 +0800 Subject: [PATCH] fix(apple): retain Interface Builder facts during finalization (#9953) --- .../apple/InterfaceBuilderAnalysis.ts | 82 ++++++---- .../InterfaceBuilderFinalization.test.ts | 153 ++++++++++++++++++ src/domain/analysisErrorBase.ts | 2 + src/domain/apple/interfaceBuilderGraph.ts | 4 + 4 files changed, 209 insertions(+), 32 deletions(-) create mode 100644 src/artifacts/apple/InterfaceBuilderFinalization.test.ts diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index b4b7975e9e..edf282f181 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -48,6 +48,7 @@ import { buildInterfaceBuilderAnalysis, interfaceBuilderLimitsSchema, type InterfaceBuilderDocumentInput, + type InterfaceBuilderAnalysis, } from "../../domain/apple/interfaceBuilderGraph.js"; import { projectNibViewHierarchy } from "./NibViewHierarchy.js"; import { jsonValueSchema, type JsonValue } from "../../domain/jsonValue.js"; @@ -124,9 +125,9 @@ const decodeInterfaceBuilderBundle = async ( const decodeBudget = new InterfaceBuilderDecodeBudget( MAX_AGGREGATE_DECODE_BYTES, ); - let outcome: { kind: "completed" } | { kind: "failed"; cause: unknown } = { - kind: "completed", - }; + let outcome: + | { kind: "completed"; value: InterfaceBuilderAnalysis } + | { kind: "failed"; cause: unknown }; const retainOmittedArchive = ( path: string, reason: string, @@ -289,6 +290,34 @@ const decodeInterfaceBuilderBundle = async ( else unreportedInvalidArchives += 1; } } + const result = buildInterfaceBuilderAnalysis({ + targetSha256: input.targetSha256, + ...(input.resourceRoot === undefined + ? {} + : { resourceRoot: input.resourceRoot }), + toolVersion: "rea-interface-builder/1", + documents, + limits, + }); + const value = finalizeHierarchyCoverage(result, { + unexaminedSources, + incompleteHierarchies, + unresolvedConnections, + unknownDates, + datePrecisionOverlaps, + unknownScalars, + nonFiniteReals, + negativeZeroReals, + invalid, + unreportedInvalidArchives, + omittedArchives, + unreportedOmittedArchives, + omissionCounts, + attempted, + omitted, + traversal: traversal.snapshot(), + }); + outcome = { kind: "completed", value }; } catch (cause: unknown) { outcome = { kind: "failed", cause }; } @@ -297,36 +326,25 @@ const decodeInterfaceBuilderBundle = async ( reader, resource: input.bundlePath, }); + const finalOutcome = + outcome.kind === "completed" && input.signal?.aborted + ? { + kind: "failed" as const, + cause: new ArtifactReaderFailure( + "cancelled", + "Interface Builder inspection cancelled during reader finalization", + { cause: input.signal.reason, partialObservation: outcome.value }, + ), + } + : outcome; if (released.kind === "failed") - throw resources.cleanupFailure(outcome, [released]); - if (outcome.kind === "failed") throw outcome.cause; - const result = buildInterfaceBuilderAnalysis({ - targetSha256: input.targetSha256, - ...(input.resourceRoot === undefined - ? {} - : { resourceRoot: input.resourceRoot }), - toolVersion: "rea-interface-builder/1", - documents, - limits, - }); - return finalizeHierarchyCoverage(result, { - unexaminedSources, - incompleteHierarchies, - unresolvedConnections, - unknownDates, - datePrecisionOverlaps, - unknownScalars, - nonFiniteReals, - negativeZeroReals, - invalid, - unreportedInvalidArchives, - omittedArchives, - unreportedOmittedArchives, - omissionCounts, - attempted, - omitted, - traversal: traversal.snapshot(), - }); + throw resources.cleanupFailure( + finalOutcome, + [released], + outcome.kind === "completed" ? outcome.value : undefined, + ); + if (finalOutcome.kind === "failed") throw finalOutcome.cause; + return finalOutcome.value; }; /** Select lexically earliest document paths independent of host traversal order. */ diff --git a/src/artifacts/apple/InterfaceBuilderFinalization.test.ts b/src/artifacts/apple/InterfaceBuilderFinalization.test.ts new file mode 100644 index 0000000000..06f9482a1e --- /dev/null +++ b/src/artifacts/apple/InterfaceBuilderFinalization.test.ts @@ -0,0 +1,153 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { buildBinary } from "plist"; +import { expect, it, vi } from "vitest"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js"; +import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; +import { ArtifactReaderFailure } from "../ArtifactReader.js"; +import { ArtifactResourceScope } from "../ArtifactResourceScope.js"; +import { translateArtifactFailure } from "../ArtifactOperationFailure.js"; + +const bundle = async () => { + const path = await createTestTempDirectory("rea-ib-finalization-"); + await mkdir(join(path, "Contents", "Resources"), { recursive: true }); + await writeFile( + join(path, "Contents", "Resources", "Panel.nib"), + Buffer.from( + buildBinary({ + "com.apple.ibtool.document.objects": { + button: { customClass: "NSButton", label: "Retained button" }, + }, + "com.apple.ibtool.document.connections": {}, + "com.apple.ibtool.document.hierarchy": [], + "com.apple.ibtool.document.classes": {}, + }), + ), + ); + return path; +}; + +it.each([ + { abort: false, failClose: false }, + { abort: true, failClose: false }, + { abort: false, failClose: true }, + { abort: true, failClose: true }, +])( + "retains completed IB facts across finalization %j", + async ({ abort, failClose }) => { + const bundlePath = await bundle(); + const input = { bundlePath, targetSha256: "a".repeat(64) }; + const expected = await analyzeInterfaceBuilderBundle(input); + expect(expected.documents).toHaveLength(1); + expect(expected.graph.nodes.map(({ name }) => name)).toContain( + "Retained button", + ); + const resources = new ArtifactResourceScope(); + const caller = new AbortController(); + const reason = { stage: "IB final close" }; + const denied = new Error("controlled IB directory close failure"); + let attempts = 0; + const nativeClose = DirectoryArtifactReader.prototype.close; + const close = vi + .spyOn(DirectoryArtifactReader.prototype, "close") + .mockImplementation(async function (this: DirectoryArtifactReader) { + attempts++; + if (attempts === 1) { + if (abort) caller.abort(reason); + if (failClose) throw denied; + } + await nativeClose.call(this); + }); + try { + const outcome = await analyzeInterfaceBuilderBundle({ + ...input, + resources, + signal: caller.signal, + }).then( + (value) => ({ ok: true as const, value }), + (cause) => ({ ok: false as const, cause: cause as unknown }), + ); + if (!abort && !failClose) { + expect(outcome).toEqual({ ok: true, value: expected }); + return; + } + expect(outcome.ok).toBe(false); + if (outcome.ok) throw new Error("Expected finalization failure"); + expect(outcome.cause).toMatchObject({ + reason: abort ? "cancelled" : "io", + partialObservation: expected, + }); + const projected = translateArtifactFailure( + "decode_interface_builder", + outcome.cause, + ); + if (abort) expect(projected.cause).toBe(reason); + expect(projected.partialObservation).toEqual(expected); + expect(projected.cleanupIncomplete).toBe(failClose); + expect(resources.hasPending).toBe(failClose); + await resources.close(); + expect(resources.hasPending).toBe(false); + expect(projected.cleanupIncomplete).toBe(false); + expect(attempts).toBe(failClose ? 2 : 1); + } finally { + await resources.close(); + close.mockRestore(); + } + }, +); + +it.each([false, true])( + "keeps a physical IB read failure primary during late abort (close fails: %s)", + async (failClose) => { + const resources = new ArtifactResourceScope(); + const bundlePath = await bundle(); + const caller = new AbortController(); + const reason = { stage: "after physical refusal" }; + const failure = new ArtifactReaderFailure( + "integrity", + "controlled physical archive refusal", + ); + const open = vi + .spyOn(DirectoryArtifactReader.prototype, "open") + .mockRejectedValue(failure); + const nativeClose = DirectoryArtifactReader.prototype.close; + let attempts = 0; + const close = vi + .spyOn(DirectoryArtifactReader.prototype, "close") + .mockImplementation(async function (this: DirectoryArtifactReader) { + attempts++; + if (attempts === 1) { + caller.abort(reason); + if (failClose) throw new Error("controlled close failure"); + } + await nativeClose.call(this); + }); + try { + const rejected: unknown = await analyzeInterfaceBuilderBundle({ + bundlePath, + targetSha256: "a".repeat(64), + resources, + signal: caller.signal, + }).catch((cause: unknown) => cause); + expect(rejected).toMatchObject({ + reason: "integrity", + message: failure.message, + }); + if (failClose) expect((rejected as Error).cause).toBe(failure); + else expect(rejected).toBe(failure); + const projected = translateArtifactFailure( + "decode_interface_builder", + rejected, + ); + expect(projected.partialObservation).toBeUndefined(); + expect(projected.cleanupIncomplete).toBe(failClose); + await resources.close(); + expect(projected.cleanupIncomplete).toBe(false); + } finally { + await resources.close(); + open.mockRestore(); + close.mockRestore(); + } + }, +); diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 5f04101798..e3408b7d7f 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -1,3 +1,4 @@ +import type { InterfaceBuilderAnalysis } from "./apple/interfaceBuilderGraph.js"; import type { PartialTaskMeasurement } from "./desktop/taskDifficulty.js"; import type { AppleIpsPartialObservation } from "./native/appleIps.js"; import type { PlaywrightTrace } from "./playwrightTrace/playwrightTrace.js"; @@ -495,6 +496,7 @@ import type { WebArtifactAcquisition } from "./webArtifactAcquisition.js"; /** Provider-neutral evidence collected before a typed analysis failure. */ export type AnalysisPartialObservation = + | InterfaceBuilderAnalysis | PartialTaskMeasurement | { readonly kind: "omitted-observation"; diff --git a/src/domain/apple/interfaceBuilderGraph.ts b/src/domain/apple/interfaceBuilderGraph.ts index b793991a03..61f1dbab49 100644 --- a/src/domain/apple/interfaceBuilderGraph.ts +++ b/src/domain/apple/interfaceBuilderGraph.ts @@ -41,6 +41,10 @@ export const interfaceBuilderAnalysisSchema = bundleResourceBoundResultSchema( }, ); +export type InterfaceBuilderAnalysis = z.output< + typeof interfaceBuilderAnalysisSchema +>; + export interface InterfaceBuilderDocumentInput { readonly relativePath: string; readonly archiveSha256: string;