mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(apple): classify Interface Builder edges at their source
Pass hierarchy or connection identity from the producing call rather than searching graph IDs containing caller-selected paths and authored IDs. Keep connection omissions from making a retained hierarchy appear partial. Replace mirrored ID-substring test oracles with exact endpoint relationships. Cover marker-bearing paths and authored identities through domain, CLI/MCP, summary and byte-retention boundaries. Validation: affected source and compiled/public tests passed, including native Foundation/ibtool fixture lanes; one Linux-only invalid-byte filename case skipped on macOS. Static, docs and discovery checks passed.
This commit is contained in:
1 parent
34563fcc9b
commit
e21b131f25
8 files changed
+166
-66
No files matched your search
@@ -509,6 +509,61 @@ describe("Interface Builder graph limits", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ relativePath: "Main.nib", sourceId: "missing" },
|
||||
{ relativePath: "Views/:hierarchy:/Main.nib", sourceId: "missing" },
|
||||
{ relativePath: "Main.nib", sourceId: "missing:hierarchy:source" },
|
||||
])(
|
||||
"keeps connection omissions separate from complete hierarchy for $relativePath / $sourceId",
|
||||
({ relativePath, sourceId }) => {
|
||||
const result = buildInterfaceBuilderAnalysis({
|
||||
targetSha256: hash,
|
||||
toolVersion: "test",
|
||||
documents: [
|
||||
{
|
||||
relativePath,
|
||||
archiveSha256: hash,
|
||||
documentKind: "nib",
|
||||
raw: {
|
||||
"com.apple.ibtool.document.objects": {
|
||||
kept: { class: "UIView" },
|
||||
},
|
||||
"com.apple.ibtool.document.hierarchy": [{ objectID: "kept" }],
|
||||
"com.apple.ibtool.document.connections": {
|
||||
[sourceId]: [
|
||||
{ type: "outlet", label: "view", destinationId: "kept" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
limits: interfaceBuilderLimitsSchema.parse({ max_objects: 2 }),
|
||||
});
|
||||
expect(result.documents[0]?.hierarchy_complete).toBe(true);
|
||||
expect(result.graph.nodes).toHaveLength(2);
|
||||
expect(result.graph.edges).toHaveLength(1);
|
||||
expect(result.graph.truncated).toBe(true);
|
||||
expect(result.graph.coverage).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
facet: `hierarchy:${relativePath}`,
|
||||
status: "complete",
|
||||
reason: null,
|
||||
examined: 1,
|
||||
omitted: 0,
|
||||
},
|
||||
{
|
||||
facet: `connections:${relativePath}`,
|
||||
status: "partial",
|
||||
reason: "connection_limit_reached",
|
||||
examined: 1,
|
||||
omitted: 2,
|
||||
},
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
describe("Interface Builder hierarchy completeness", () => {
|
||||
it("marks only the later document incomplete when its hierarchy root is omitted", () => {
|
||||
const document = (relativePath: string, objectId: string) => ({
|
||||
|
||||
@@ -191,6 +191,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
||||
else connectionEdgeCount += 1;
|
||||
};
|
||||
const connect = (
|
||||
category: "hierarchy" | "connection",
|
||||
id: string,
|
||||
from: string,
|
||||
to: string | null,
|
||||
@@ -198,7 +199,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
||||
description: string,
|
||||
reason?: string,
|
||||
) => {
|
||||
const isHierarchy = relation === "contains" && id.includes(":hierarchy:");
|
||||
const isHierarchy = category === "hierarchy";
|
||||
if (!nodes.has(from)) {
|
||||
truncated = true;
|
||||
if (isHierarchy) {
|
||||
@@ -296,6 +297,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
||||
)
|
||||
hierarchyTruncated = true;
|
||||
connect(
|
||||
"hierarchy",
|
||||
graphId("hierarchy", [parentId, childId]),
|
||||
parentId,
|
||||
childId,
|
||||
@@ -343,6 +345,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
||||
evidence: evidenceFor(`action ${item.id}`),
|
||||
});
|
||||
connect(
|
||||
"connection",
|
||||
graphId("action-edge", [item.id, "source"]),
|
||||
sourceId,
|
||||
actionId,
|
||||
@@ -359,6 +362,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
||||
evidence: evidenceFor(`selector ${item.label}`),
|
||||
});
|
||||
connect(
|
||||
"connection",
|
||||
graphId("action-edge", [item.id, "selector"]),
|
||||
actionId,
|
||||
selectorId,
|
||||
@@ -368,6 +372,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
||||
}
|
||||
if (destinationId === null || !nodes.has(destinationId))
|
||||
connect(
|
||||
"connection",
|
||||
graphId("action-edge", [item.id, "destination"]),
|
||||
actionId,
|
||||
null,
|
||||
@@ -377,6 +382,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
||||
);
|
||||
else
|
||||
connect(
|
||||
"connection",
|
||||
graphId("action-edge", [item.id, "destination"]),
|
||||
actionId,
|
||||
destinationId,
|
||||
@@ -394,6 +400,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
||||
evidence: evidenceFor(`outlet ${item.id}`),
|
||||
});
|
||||
connect(
|
||||
"connection",
|
||||
graphId("outlet-edge", [item.id, "source"]),
|
||||
sourceId,
|
||||
outletId,
|
||||
@@ -402,6 +409,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
||||
);
|
||||
if (destinationId === null || !nodes.has(destinationId))
|
||||
connect(
|
||||
"connection",
|
||||
graphId("outlet-edge", [item.id, "destination"]),
|
||||
outletId,
|
||||
null,
|
||||
@@ -411,6 +419,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
||||
);
|
||||
else
|
||||
connect(
|
||||
"connection",
|
||||
graphId("outlet-edge", [item.id, "destination"]),
|
||||
outletId,
|
||||
destinationId,
|
||||
@@ -420,6 +429,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
||||
} else if (item.kind === "segue") {
|
||||
if (destinationId === null || !nodes.has(destinationId))
|
||||
connect(
|
||||
"connection",
|
||||
graphId("segue", [index]),
|
||||
sourceId,
|
||||
null,
|
||||
@@ -429,6 +439,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
||||
);
|
||||
else
|
||||
connect(
|
||||
"connection",
|
||||
graphId("segue", [index]),
|
||||
sourceId,
|
||||
destinationId,
|
||||
@@ -437,6 +448,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
||||
);
|
||||
} else {
|
||||
connect(
|
||||
"connection",
|
||||
graphId("unknown-connection", [index]),
|
||||
sourceId,
|
||||
null,
|
||||
|
||||
@@ -312,9 +312,21 @@ it.each([
|
||||
omitted: 0,
|
||||
}),
|
||||
);
|
||||
const source = result.graph.nodes.find(
|
||||
({ attributes }) => attributes.interface_builder_object_id === "2",
|
||||
);
|
||||
const outlet = result.graph.nodes.find(({ kind }) => kind === "outlet");
|
||||
expect(source).toBeDefined();
|
||||
expect(outlet).toBeDefined();
|
||||
expect(
|
||||
result.graph.edges.filter(({ id }) => id.includes(":hierarchy:")),
|
||||
).toEqual([]);
|
||||
result.graph.edges.filter(({ relation }) => relation === "contains"),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
from: source?.id,
|
||||
to: outlet?.id,
|
||||
resolution: "observed",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts a dangling named root without substituting a valid generic root", () => {
|
||||
|
||||
@@ -79,14 +79,42 @@ const cases = [
|
||||
omittedEdges: 0,
|
||||
unresolvedEdge: true,
|
||||
},
|
||||
{
|
||||
name: "a marker-bearing connection source is omitted without losing hierarchy coverage",
|
||||
archives: [
|
||||
[
|
||||
"Connection.nib",
|
||||
build({
|
||||
"com.apple.ibtool.document.objects": {
|
||||
kept: { class: "UIView" },
|
||||
},
|
||||
"com.apple.ibtool.document.hierarchy": [{ objectID: "kept" }],
|
||||
"com.apple.ibtool.document.connections": {
|
||||
"missing:hierarchy:source": [
|
||||
{ type: "outlet", label: "view", destinationId: "kept" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
] as const,
|
||||
hierarchyComplete: [true],
|
||||
document: "Connection.nib",
|
||||
omittedEdges: 0,
|
||||
unresolvedEdge: false,
|
||||
omittedConnections: 2,
|
||||
},
|
||||
];
|
||||
|
||||
cliTest.for(cases)(
|
||||
"reports partial hierarchy through the CLI when $name",
|
||||
async (
|
||||
{ archives, hierarchyComplete, document, omittedEdges, unresolvedEdge },
|
||||
{ cli },
|
||||
) => {
|
||||
"preserves hierarchy and connection coverage through the CLI when $name",
|
||||
async (testCase, { cli }) => {
|
||||
const {
|
||||
archives,
|
||||
hierarchyComplete,
|
||||
document,
|
||||
omittedEdges,
|
||||
unresolvedEdge,
|
||||
} = testCase;
|
||||
const root = await createTestTempDirectory("rea-ib-hierarchy-limits-");
|
||||
const app = join(root, "Fixture.app");
|
||||
const contents = join(app, "Contents");
|
||||
@@ -129,12 +157,20 @@ cliTest.for(cases)(
|
||||
expect(report.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: `hierarchy:Contents/Resources/${document}`,
|
||||
status: "partial",
|
||||
reason: "graph_limit_reached",
|
||||
status: hierarchyComplete.every(Boolean) ? "complete" : "partial",
|
||||
reason: hierarchyComplete.every(Boolean) ? null : "graph_limit_reached",
|
||||
examined: 1,
|
||||
omitted: omittedEdges,
|
||||
}),
|
||||
);
|
||||
if ("omittedConnections" in testCase)
|
||||
expect(report.graph.coverage).toContainEqual({
|
||||
facet: `connections:Contents/Resources/${document}`,
|
||||
status: "partial",
|
||||
reason: "connection_limit_reached",
|
||||
examined: 1,
|
||||
omitted: testCase.omittedConnections,
|
||||
});
|
||||
if (unresolvedEdge)
|
||||
expect(report.graph.edges[0]).toMatchObject({
|
||||
relation: "contains",
|
||||
|
||||
@@ -144,7 +144,7 @@ cliTest.for(["xml", "binary"] as const)(
|
||||
{ $class: uid(7), "NS.objects": [uid(1)] },
|
||||
{ $classname: "NSArray", $classes: ["NSArray", "NSObject"] },
|
||||
"2",
|
||||
"shared",
|
||||
"shared:hierarchy:authored",
|
||||
],
|
||||
$top: { UINibTopLevelObjectsKey: uid(6) },
|
||||
};
|
||||
@@ -170,58 +170,56 @@ cliTest.for(["xml", "binary"] as const)(
|
||||
const report = interfaceBuilderAnalysisSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
const nodes = new Map(report.graph.nodes.map((node) => [node.id, node]));
|
||||
expect(
|
||||
report.graph.edges
|
||||
.filter(({ id }) => id.includes(":hierarchy:"))
|
||||
.map(({ from, to, resolution }) => ({
|
||||
from:
|
||||
nodes.get(from)?.attributes.interface_builder_object_id ??
|
||||
"document",
|
||||
to:
|
||||
to === null
|
||||
? null
|
||||
: nodes.get(to)?.attributes.interface_builder_object_id,
|
||||
resolution,
|
||||
})),
|
||||
).toEqual([
|
||||
{ from: "document", to: "1", resolution: "observed" },
|
||||
{ from: "1", to: "2", resolution: "observed" },
|
||||
]);
|
||||
expect(
|
||||
report.graph.nodes.find(
|
||||
({ attributes }) => attributes.interface_builder_object_id === "1",
|
||||
),
|
||||
).toMatchObject({
|
||||
const root = report.graph.nodes.find(
|
||||
({ attributes }) => attributes.interface_builder_object_id === "1",
|
||||
);
|
||||
expect(root).toMatchObject({
|
||||
attributes: {
|
||||
id: "2",
|
||||
archiveUID: 2,
|
||||
objectID: "2",
|
||||
"object-id": "shared",
|
||||
"object-id": "shared:hierarchy:authored",
|
||||
title: "Root",
|
||||
},
|
||||
});
|
||||
const root = report.graph.nodes.find(
|
||||
({ attributes }) => attributes.interface_builder_object_id === "1",
|
||||
);
|
||||
const child = report.graph.nodes.find(
|
||||
({ attributes }) => attributes.interface_builder_object_id === "2",
|
||||
);
|
||||
expect(child).toMatchObject({
|
||||
name: "Child",
|
||||
attributes: { objectID: "2", "object-id": "shared" },
|
||||
attributes: { objectID: "2", "object-id": "shared:hierarchy:authored" },
|
||||
});
|
||||
const outlet = report.graph.nodes.find(({ kind }) => kind === "outlet");
|
||||
expect(outlet).toMatchObject({
|
||||
id: 'ib:"Contents/Resources/Main.nib":outlet:["archive:3"]',
|
||||
});
|
||||
expect(report.graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
from: child?.id,
|
||||
to: outlet?.id,
|
||||
relation: "contains",
|
||||
resolution: "observed",
|
||||
}),
|
||||
const document = report.graph.nodes.find(({ kind }) => kind === "scene");
|
||||
expect(document).toMatchObject({ name: "Contents/Resources/Main.nib" });
|
||||
const containment = report.graph.edges.filter(
|
||||
({ relation }) => relation === "contains",
|
||||
);
|
||||
expect(containment).toHaveLength(3);
|
||||
expect(containment).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
from: document?.id,
|
||||
to: root?.id,
|
||||
relation: "contains",
|
||||
resolution: "observed",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
from: root?.id,
|
||||
to: child?.id,
|
||||
relation: "contains",
|
||||
resolution: "observed",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
from: child?.id,
|
||||
to: outlet?.id,
|
||||
relation: "contains",
|
||||
resolution: "observed",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(report.graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -532,10 +532,7 @@ describe("keyed archive hierarchy coverage", () => {
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
analysis.graph.edges.filter(
|
||||
({ relation, id }) =>
|
||||
relation === "contains" && id.includes(":hierarchy:"),
|
||||
),
|
||||
analysis.graph.edges.filter(({ relation }) => relation === "contains"),
|
||||
).toHaveLength(40);
|
||||
});
|
||||
|
||||
@@ -568,10 +565,7 @@ describe("keyed archive hierarchy coverage", () => {
|
||||
)?.id;
|
||||
const childNames = analysis.graph.edges
|
||||
.filter(
|
||||
({ from, relation, id }) =>
|
||||
from === rootId &&
|
||||
relation === "contains" &&
|
||||
id.includes(":hierarchy:"),
|
||||
({ from, relation }) => from === rootId && relation === "contains",
|
||||
)
|
||||
.map(({ to }) => (to === null ? null : archiveIdsByNodeId.get(to)));
|
||||
expect(childNames).toEqual(["3", "5"]);
|
||||
@@ -609,10 +603,7 @@ describe("keyed archive hierarchy coverage", () => {
|
||||
),
|
||||
).toMatchObject({ status: "complete", examined: 41, omitted: 0 });
|
||||
expect(
|
||||
analysis.graph.edges.filter(
|
||||
({ relation, id }) =>
|
||||
relation === "contains" && id.includes(":hierarchy:"),
|
||||
),
|
||||
analysis.graph.edges.filter(({ relation }) => relation === "contains"),
|
||||
).toHaveLength(41);
|
||||
},
|
||||
120_000,
|
||||
|
||||
+2
-4
@@ -111,10 +111,8 @@ const expectNativeViewHierarchy = (result: Analysis, oracle: Oracle) => {
|
||||
return node.id;
|
||||
};
|
||||
const children = result.graph.edges.filter(
|
||||
({ id, from, relation }) =>
|
||||
from === nodeId(oracle.rootUID) &&
|
||||
relation === "contains" &&
|
||||
id.includes(":hierarchy:"),
|
||||
({ from, relation }) =>
|
||||
from === nodeId(oracle.rootUID) && relation === "contains",
|
||||
);
|
||||
expect(children.map(({ to }) => to)).toEqual(oracle.childUIDs.map(nodeId));
|
||||
expect(children).toHaveLength(2);
|
||||
|
||||
+2
-4
@@ -69,10 +69,8 @@ const expectView = (result: Analysis, oracle: Oracle, uid: number) => {
|
||||
};
|
||||
const childEdges = (result: Analysis, oracle: Oracle) =>
|
||||
result.graph.edges.filter(
|
||||
({ id, from, relation }) =>
|
||||
from === viewId(result, oracle.rootUID) &&
|
||||
relation === "contains" &&
|
||||
id.includes(":hierarchy:"),
|
||||
({ from, relation }) =>
|
||||
from === viewId(result, oracle.rootUID) && relation === "contains",
|
||||
);
|
||||
|
||||
/** Native producer and independent plist oracle precede every consumer assertion. */
|
||||
|
||||
Reference in new issue
Block a user