fix(apple): retain Interface Builder facts during finalization (#9953)

This commit is contained in:
morluto authored and GitHub committed 2026-11-11 17:17:01 +08:00
1 parent e9e106f580
commit fcd364069a
4 files changed
+209 -32

No files matched your search

+50 -32
View File
@@ -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. */
@@ -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();
}
},
);
+2
View File
@@ -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";
@@ -41,6 +41,10 @@ export const interfaceBuilderAnalysisSchema = bundleResourceBoundResultSchema(
},
);
export type InterfaceBuilderAnalysis = z.output<
typeof interfaceBuilderAnalysisSchema
>;
export interface InterfaceBuilderDocumentInput {
readonly relativePath: string;
readonly archiveSha256: string;