fix(apple): qualify unexamined nib content coverage

This commit is contained in:
morluto committed 2026-10-24 03:11:07 +08:00
1 parent 84f4eb42e1
commit 79323128f2
3 files changed
+117 -3

No files matched your search

+4
View File
@@ -454,6 +454,10 @@ complete observation.
undecodable filenames and skipped symlinks, as partial coverage. Known
executable roots remain available; unexamined names and symlink targets
cannot establish an empty root set or the absence of other roots.
Embedded `UINib` archive data remains retained and undecoded; affected
object, connection, class and hierarchy coverage is partial. Recognized
`UIStoryboardShowSegueTemplate` source records similarly qualify connection
coverage until their navigation semantics are decoded.
- `inspect_asset_catalog` / `rea inspect-asset-catalog <app>` reads compiled
`Assets.car` metadata through macOS `assetutil --info`. Catalog digests, raw
rendition fields, pagination and exact UI resource-name matches are returned.
@@ -88,6 +88,7 @@ export const analyzeInterfaceBuilderBundle = async (input: {
const omittedArchives: OmittedArchive[] = [];
let unreportedOmittedArchives = 0;
const incompleteHierarchies = new Map<string, number>();
const unexaminedSources = new Map<string, readonly NibUnexaminedSource[]>();
const unresolvedConnections = new Map<string, number>();
const prototypeKeyOmissions = new Map<string, number | null>();
const unknownDates = new Map<string, number>();
@@ -217,6 +218,8 @@ export const analyzeInterfaceBuilderBundle = async (input: {
numberLiterals: undefined,
};
requireInterfaceBuilderArchive(raw);
if (nib !== null && nib.unexaminedSources.length > 0)
unexaminedSources.set(entry.path, nib.unexaminedSources);
if (nib !== null && nib.omitted > 0)
incompleteHierarchies.set(entry.path, nib.omitted);
if (nib !== null && nib.unresolvedConnections > 0)
@@ -283,6 +286,7 @@ export const analyzeInterfaceBuilderBundle = async (input: {
limits,
});
return finalizeHierarchyCoverage(result, {
unexaminedSources,
incompleteHierarchies,
unresolvedConnections,
prototypeKeyOmissions,
@@ -384,6 +388,7 @@ const retainCandidate = (
const finalizeHierarchyCoverage = (
result: ReturnType<typeof buildInterfaceBuilderAnalysis>,
counts: {
unexaminedSources: ReadonlyMap<string, readonly NibUnexaminedSource[]>;
incompleteHierarchies: ReadonlyMap<string, number>;
unresolvedConnections: ReadonlyMap<string, number>;
prototypeKeyOmissions: ReadonlyMap<string, number | null>;
@@ -401,6 +406,7 @@ const finalizeHierarchyCoverage = (
},
) => {
const {
unexaminedSources,
incompleteHierarchies,
unresolvedConnections,
prototypeKeyOmissions,
@@ -415,6 +421,7 @@ const finalizeHierarchyCoverage = (
} = counts;
const archiveDecodePartial =
invalid.length > 0 ||
unexaminedSources.size > 0 ||
prototypeKeyOmissions.size > 0 ||
counts.omitted > 0 ||
counts.traversal.status === "unknown";
@@ -422,6 +429,11 @@ const finalizeHierarchyCoverage = (
...result,
documents: result.documents.map((document) => ({
...document,
hierarchy_complete:
document.hierarchy_complete &&
!(unexaminedSources.get(document.relative_path) ?? []).some((source) =>
source.facets.includes("hierarchy"),
),
connection_count:
document.connection_count +
(unresolvedConnections.get(document.relative_path) ?? 0),
@@ -433,6 +445,18 @@ const finalizeHierarchyCoverage = (
const omittedConnection = unresolvedConnections.get(
facet.facet.replace(/^connections:/u, ""),
);
const [kind, ...pathParts] = facet.facet.split(":");
const source = (
unexaminedSources.get(pathParts.join(":")) ?? []
).find((source) =>
source.facets.some((affected) => affected === kind),
);
if (source !== undefined)
return {
...facet,
status: "partial" as const,
reason: facet.reason ?? source.reason,
};
return facet.facet.startsWith("connections:") &&
omittedConnection !== undefined
? {
@@ -467,9 +491,11 @@ const finalizeHierarchyCoverage = (
? "one_or_more_archives_invalid"
: prototypeKeyOmissions.size > 0
? "dictionary_entries_omitted"
: counts.traversal.status === "unknown"
? "source_traversal_unknown"
: null,
: unexaminedSources.size > 0
? "serialized_archive_content_unexamined"
: counts.traversal.status === "unknown"
? "source_traversal_unknown"
: null,
examined: counts.attempted,
omitted: counts.omitted,
},
@@ -483,6 +509,9 @@ const finalizeHierarchyCoverage = (
limitations: [
...result.limitations,
...counts.traversal.limitations,
...[...unexaminedSources].flatMap(([path, sources]) =>
sources.map((source) => `${path}: ${source.description}`),
),
"Compiled Interface Builder archives are private serialized object graphs. The decoder reports only recognized keyed-archive objects and connections; unrecognized object classes and fields remain unknown.",
...(incompleteHierarchies.size === 0
? []
@@ -530,12 +559,44 @@ const finalizeHierarchyCoverage = (
};
};
interface NibUnexaminedSource {
readonly reason: string;
readonly facets: readonly (
| "objects"
| "connections"
| "hierarchy"
| "classes"
)[];
readonly description: string;
}
/** Project decoded NIB records into the same bounded graph input as ibtool. */
const projectNibArchive = (
archive: NibArchiveDocument,
budget: InterfaceBuilderDecodeBudget,
maxHierarchyNodes: number,
) => {
const unexaminedSources: NibUnexaminedSource[] = [];
for (const object of archive.objects) {
const name = object.class_name.replace(/\0+$/u, "");
if (
name === "UINib" &&
["UINib.archiveData", "archiveData"].some((key) =>
Object.hasOwn(object.values, key),
)
)
unexaminedSources.push({
reason: "embedded_nib_archive_not_decoded",
facets: ["objects", "connections", "hierarchy", "classes"],
description: `UINib object ${object.id} retains embedded archive data whose objects, connections, classes and hierarchy are not decoded.`,
});
if (name === "UIStoryboardShowSegueTemplate")
unexaminedSources.push({
reason: "storyboard_segue_template_not_decoded",
facets: ["connections"],
description: `Storyboard segue template object ${object.id} is retained as source data; its connection is not decoded.`,
});
}
const byId = new Map(archive.objects.map((object) => [object.id, object]));
const normalizedClassNames = new Map<string, string>();
const normalizeClassName = (value: string): string => {
@@ -836,6 +897,7 @@ const projectNibArchive = (
);
return {
omitted: viewHierarchy.omitted + hierarchyOmitted + merged.omitted,
unexaminedSources,
unresolvedConnections,
nonFiniteRealCount,
negativeZeroRealCount,
@@ -2184,3 +2184,51 @@ it("preserves authored binary UID-shaped dictionaries through UI archive decodin
actual: { archiveUID: 2, className: "NSView" },
});
});
it.each(["embedded", "segue", "neither"] as const)(
"reports recognized unexamined nib source coverage: %s",
async (source) => {
const bundle = await createTestTempDirectory("rea-ui-unexamined-sources-");
await writeFile(
join(bundle, "Main.nib"),
encodeNibArchiveFixture({
classes: ["UINib", "UIStoryboardShowSegueTemplate", "NSView"],
objects: [
{ classIndex: 2, values: {} },
...(source === "embedded"
? [{ classIndex: 0, values: { "UINib.archiveData": { ref: 0 } } }]
: []),
...(source === "segue" ? [{ classIndex: 1, values: {} }] : []),
],
}),
);
const result = await analyzeInterfaceBuilderBundle({
bundlePath: bundle,
targetSha256: "a".repeat(64),
});
for (const kind of ["objects", "classes"])
expect(
result.graph.coverage.find(({ facet }) => facet === `${kind}:Main.nib`)
?.status,
).toBe(source === "embedded" ? "partial" : "complete");
expect(
result.graph.coverage.find(
({ facet }) => facet === "connections:Main.nib",
)?.status,
).toBe(source === "neither" ? "complete" : "partial");
expect(
result.graph.coverage.find(({ facet }) => facet === "archive_decode")
?.status,
).toBe(source === "neither" ? "complete" : "partial");
if (source !== "neither") {
expect(result.graph.truncated).toBe(true);
expect(result.limitations).toContainEqual(
expect.stringContaining(
source === "embedded"
? "embedded archive data"
: "segue template object",
),
);
}
},
);