fix(apple): derive hierarchy completeness once per document

Include omitted roots, unresolved retained endpoints and serialized NIB omissions in the owning hierarchy status. Reuse that status for summaries and coverage; remove the adapter recomputation and preserve per-document isolation.

Validation: 18 graph and 12 real NIB filesystem/built CLI tests pass.

Based on #3156.

Co-authored-by: Aliciawque <166712943+Aliciawque@users.noreply.github.com>
This commit is contained in:
morlutoandAliciawque committed 2026-10-14 13:58:16 +08:00
1 parent 583166785f
commit 7cfcd166e3
3 files changed
+117 -44

No files matched your search

@@ -149,6 +149,9 @@ export const analyzeInterfaceBuilderBundle = async (input: {
? "storyboard_scene"
: "nib",
raw,
...(nib === null || nib.omitted === 0
? {}
: { omittedSerializedHierarchyReferences: nib.omitted }),
});
} catch (cause: unknown) {
if (input.signal?.aborted === true) throw cause;
@@ -266,34 +269,14 @@ const finalizeHierarchyCoverage = (
...result,
documents: result.documents.map((document) => ({
...document,
// The dictionary parser only sees connectors that were projected, so the
// omission recorded where an ownerless connector was dropped has to be
// added here for this producer to report the same document count as the
// keyed-archive producer for the same recognized connector.
connection_count:
document.connection_count +
(unresolvedConnections.get(document.relative_path) ?? 0),
hierarchy_complete:
document.hierarchy_complete &&
!incompleteHierarchies.has(document.relative_path),
})),
graph: {
...result.graph,
coverage: [
...result.graph.coverage.map((facet) => {
const omittedHierarchy = incompleteHierarchies.get(
facet.facet.replace(/^hierarchy:/u, ""),
);
if (
facet.facet.startsWith("hierarchy:") &&
omittedHierarchy !== undefined
)
return {
...facet,
status: "partial" as const,
reason: "serialized_view_hierarchy_incomplete",
omitted: facet.omitted + omittedHierarchy,
};
const omittedConnection = unresolvedConnections.get(
facet.facet.replace(/^connections:/u, ""),
);
@@ -336,7 +319,6 @@ const finalizeHierarchyCoverage = (
result.graph.truncated ||
counts.omitted > 0 ||
archiveDecodePartial ||
incompleteHierarchies.size > 0 ||
unresolvedConnections.size > 0,
},
limitations: [
@@ -360,6 +360,83 @@ describe("Interface Builder graph limits", () => {
});
});
describe("Interface Builder hierarchy completeness", () => {
it("marks only the later document incomplete when its hierarchy root is omitted", () => {
const document = (relativePath: string, objectId: string) => ({
relativePath,
archiveSha256: hash,
documentKind: "nib" as const,
raw: {
"com.apple.ibtool.document.objects": {
[objectId]: { class: "UIView" },
},
"com.apple.ibtool.document.hierarchy": [{ objectID: objectId }],
},
});
const result = buildInterfaceBuilderAnalysis({
targetSha256: hash,
toolVersion: "test",
documents: [
document("First.nib", "first"),
document("Second.nib", "second"),
],
limits: interfaceBuilderLimitsSchema.parse({ max_objects: 2 }),
});
expect(
result.documents.map(({ hierarchy_complete }) => hierarchy_complete),
).toEqual([true, false]);
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "hierarchy:Second.nib",
status: "partial",
reason: "graph_limit_reached",
omitted: 1,
}),
);
});
it("marks retained hierarchy edges with unresolved endpoints partial", () => {
const result = buildInterfaceBuilderAnalysis({
targetSha256: hash,
toolVersion: "test",
documents: [
{
relativePath: "Endpoint.nib",
archiveSha256: hash,
documentKind: "nib",
raw: {
"com.apple.ibtool.document.objects": {
kept: { class: "UIView" },
omitted: { class: "UIView" },
},
"com.apple.ibtool.document.hierarchy": [{ objectID: "omitted" }],
},
},
],
limits: interfaceBuilderLimitsSchema.parse({ max_objects: 2 }),
});
expect(result.documents[0]?.hierarchy_complete).toBe(false);
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "hierarchy:Endpoint.nib",
status: "partial",
reason: "graph_limit_reached",
omitted: 0,
}),
);
expect(result.graph.edges).toContainEqual(
expect.objectContaining({
relation: "contains",
resolution: "unresolved",
to: null,
reason: "endpoint_omitted_by_object_limit",
}),
);
});
});
describe("keyed archive object references", () => {
it("keeps referenced objects as stubs so shared and cyclic references stay finite", () => {
// Each view references its window, superview, and next key view, so
+37 -23
View File
@@ -35,6 +35,8 @@ export interface InterfaceBuilderDocumentInput {
readonly archiveSha256: string;
readonly documentKind: "nib" | "storyboard_scene";
readonly raw: unknown;
/** Serialized hierarchy links omitted before normalized graph projection. */
readonly omittedSerializedHierarchyReferences?: number;
}
type InvestigationNode = z.infer<typeof nativeInvestigationNodeSchema>;
@@ -128,6 +130,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
let hierarchyEdgeCount = 0;
let documentOmittedConnections = 0;
let documentOmittedHierarchyEdges = 0;
let hierarchyTruncated = false;
const objectNodeId = (objectId: string): string =>
graphId("object", [objectId]);
const addEdge = (
@@ -141,6 +144,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
) {
truncated = true;
if (isHierarchy) {
hierarchyTruncated = true;
documentOmittedHierarchyEdges += 1;
} else {
omittedConnections += 1;
@@ -160,9 +164,11 @@ export const buildInterfaceBuilderAnalysis = (input: {
description: string,
reason?: string,
) => {
const isHierarchy = relation === "contains" && id.includes(":hierarchy:");
if (!nodes.has(from)) {
truncated = true;
if (relation === "contains" && id.includes(":hierarchy:")) {
if (isHierarchy) {
hierarchyTruncated = true;
documentOmittedHierarchyEdges += 1;
} else {
omittedConnections += 1;
@@ -172,7 +178,10 @@ export const buildInterfaceBuilderAnalysis = (input: {
}
const evidence = evidenceFor(description);
const unresolvedEndpoint = to !== null && !nodes.has(to);
if (unresolvedEndpoint) truncated = true;
if (unresolvedEndpoint) {
truncated = true;
if (isHierarchy) hierarchyTruncated = true;
}
const edgeTo = unresolvedEndpoint ? null : to;
const edgeReason = unresolvedEndpoint
? "endpoint_omitted_by_object_limit"
@@ -198,11 +207,10 @@ export const buildInterfaceBuilderAnalysis = (input: {
evidence,
limitations: [],
},
relation === "contains" && id.includes(":hierarchy:"),
isHierarchy,
);
};
let hierarchyCount = 0;
let hierarchyTruncated = false;
const visitHierarchy = (
initial: unknown,
initialParentId: string,
@@ -405,7 +413,10 @@ export const buildInterfaceBuilderAnalysis = (input: {
const objectsTruncated =
parsed.omittedObjects > 0 || parsed.objects.length > documentObjectCount;
const parserOmittedHierarchyReferences = parsed.omittedHierarchyReferences;
omittedHierarchyReferences += parserOmittedHierarchyReferences;
const omittedSerializedHierarchyReferences =
document.omittedSerializedHierarchyReferences ?? 0;
omittedHierarchyReferences +=
parserOmittedHierarchyReferences + omittedSerializedHierarchyReferences;
const parserOmittedConnections = parsed.omittedConnections;
const connectionsTruncated =
parserOmittedConnections > 0 ||
@@ -421,6 +432,15 @@ export const buildInterfaceBuilderAnalysis = (input: {
Math.max(0, parsed.connections.length - input.limits.max_connections) +
parserOmittedConnections +
parsed.unresolvedConnections;
const hierarchyStatus =
parserOmittedHierarchyReferences > 0 ||
omittedSerializedHierarchyReferences > 0
? "partial"
: parsed.hierarchy.length === 0
? "unsupported"
: hierarchyTruncated
? "partial"
: "complete";
summaries.push({
relative_path: document.relativePath,
archive_sha256: document.archiveSha256,
@@ -430,10 +450,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
parsed.connections.length +
parserOmittedConnections +
parsed.unresolvedConnections,
hierarchy_complete:
parsed.hierarchy.length > 0 &&
!hierarchyTruncated &&
parserOmittedHierarchyReferences === 0,
hierarchy_complete: hierarchyStatus === "complete",
});
coverage.push(
{
@@ -474,25 +491,22 @@ export const buildInterfaceBuilderAnalysis = (input: {
},
{
facet: `hierarchy:${document.relativePath}`,
status:
parserOmittedHierarchyReferences > 0
? "partial"
: parsed.hierarchy.length === 0
? "unsupported"
: hierarchyTruncated || documentOmittedHierarchyEdges > 0
? "partial"
: "complete",
status: hierarchyStatus,
reason:
parserOmittedHierarchyReferences > 0
? "archive_hierarchy_references_missing"
: parsed.hierarchy.length === 0
? "archive_hierarchy_not_decoded"
: hierarchyTruncated || documentOmittedHierarchyEdges > 0
? "graph_limit_reached"
: null,
: omittedSerializedHierarchyReferences > 0
? "serialized_view_hierarchy_incomplete"
: parsed.hierarchy.length === 0
? "archive_hierarchy_not_decoded"
: hierarchyTruncated
? "graph_limit_reached"
: null,
examined: hierarchyCount,
omitted:
documentOmittedHierarchyEdges + parserOmittedHierarchyReferences,
documentOmittedHierarchyEdges +
parserOmittedHierarchyReferences +
omittedSerializedHierarchyReferences,
},
{
facet: `classes:${document.relativePath}`,