mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(apple): qualify unexamined nib content coverage
This commit is contained in:
1 parent
84f4eb42e1
commit
79323128f2
3 files changed
+117
-3
No files matched your search
@@ -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",
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user