fix(apple): retain unsupported NIB connectors in connection coverage

Recognize AppKit binding connectors as explicit unknown connection facts and
report partial semantic coverage rather than silently omitting them. Share
format-aware connector admission across keyed archives and NIBArchive.

Make the keyed archive table own parsed class and superclass facts for names,
array hierarchy traversal, inherited connectors, and class-table projection.
Preserve malformed class evidence and exclude unrelated custom class names.

Validation: 84 focused tests passed, including compiled CLI, filesystem,
MCP summary, and retained views; check:fast and test discovery passed.
Independent source review found no blocker. Production CLI and stdio MCP
replayed a read-only copy of Terminal 2.15's picker NIB: 13 connections with
partial unsupported-type coverage, versus baseline 9 with complete coverage.
Advertised schemas and retained coverage results validated with the SDK/Ajv.
No app, analysis target, or optional deep provider was executed.

Fixes #5590
This commit is contained in:
morluto committed 2026-10-21 00:31:00 +08:00
1 parent a35623707e
commit 14cd78c322
7 files changed
+415 -43

No files matched your search

@@ -4,6 +4,7 @@ import { parseBinary } from "plist";
import { appleResourceKeyFields } from "../../domain/apple/appleAssetCatalog.js";
import {
interfaceBuilderConnectionFieldNames,
isInterfaceBuilderConnectionClass,
isUIKitRuntimeConnectionClass,
} from "../../domain/apple/interfaceBuilderKeyedArchive.js";
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
@@ -576,7 +577,7 @@ const projectNibArchive = (
// AppKit connectors use NS-prefixed keys; UIKit's runtime outlet and
// event connections use UI-prefixed keys with the same roles.
const uiKit = isUIKitRuntimeConnectionClass(className);
if (!uiKit && !className.includes("Connector")) continue;
if (!isInterfaceBuilderConnectionClass(className)) continue;
const values = object.values;
const connectionFields = interfaceBuilderConnectionFieldNames(className);
const source = reference(values[connectionFields.source]);
+17
View File
@@ -77,6 +77,23 @@ describe("NIBArchive decoder", () => {
expect(decoded.coder_version).toBe(10);
});
it("counts AppKit outlet, action, and binding connector objects", () => {
const archive = encodeArchive({
classes: [
"NSNibOutletConnector",
"NSNibControlConnector",
"NSNibBindingConnector",
],
objects: [
{ classIndex: 0, values: {} },
{ classIndex: 1, values: {} },
{ classIndex: 2, values: {} },
],
});
expect(decodeNibArchive(archive).connection_object_count).toBe(3);
});
it("rejects malformed references and unsupported format versions", () => {
const valid = encodeArchive({
classes: ["NSObject"],
+2 -3
View File
@@ -1,5 +1,6 @@
import type { JsonValue } from "../../domain/jsonValue.js";
import { hasNibArchiveSignature } from "../../domain/apple/appleArchiveFormat.js";
import { isInterfaceBuilderConnectionClass } from "../../domain/apple/interfaceBuilderKeyedArchive.js";
import { TextDecoder } from "node:util";
import {
InterfaceBuilderDecodeBudget,
@@ -147,9 +148,7 @@ export const decodeNibArchive = (
});
let connectionObjectCount = 0;
for (const object of objects)
if (
/(?:Outlet|Connection|Segue|ActionConnection)/iu.test(object.class_name)
)
if (isInterfaceBuilderConnectionClass(object.class_name))
connectionObjectCount += 1;
return {
@@ -707,7 +707,170 @@ describe("AppKit keyed archive connectors", () => {
}),
]);
});
});
describe("unsupported keyed archive connections", () => {
it("keeps unsupported binding connectors in connection coverage", () => {
const raw = {
$archiver: "NSKeyedArchiver",
$objects: [
"$null",
{ $class: { UID: 6 } },
{ $class: { UID: 6 } },
{
$class: { UID: 7 },
NSSource: { UID: 1 },
NSDestination: { UID: 2 },
NSLabel: "outlet",
},
{
$class: { UID: 8 },
NSSource: { UID: 1 },
NSDestination: { UID: 2 },
NSLabel: "performAction:",
},
{
$class: { UID: 9 },
NSSource: { UID: 1 },
NSDestination: { UID: 2 },
NSLabel: "value",
},
{ $classname: "NSView", $classes: ["NSView", "NSObject"] },
{
$classname: "NSNibOutletConnector",
$classes: ["NSNibOutletConnector", "NSNibConnector", "NSObject"],
},
{
$classname: "NSNibControlConnector",
$classes: ["NSNibControlConnector", "NSNibConnector", "NSObject"],
},
{
$classname: "NSNibBindingConnector",
$classes: ["NSNibBindingConnector", "NSNibConnector", "NSObject"],
},
],
$top: {},
};
const parsed = parseInterfaceBuilderRecords(raw);
expect(parsed.connections.map(({ kind }) => kind)).toEqual([
"outlet",
"action",
"other",
]);
const analysis = buildInterfaceBuilderAnalysis({
targetSha256: hash,
toolVersion: "test",
documents: [
{
relativePath: "MainMenu.nib",
archiveSha256: hash,
documentKind: "nib",
raw,
},
],
limits: interfaceBuilderLimitsSchema.parse({}),
});
expect(analysis.documents[0]?.connection_count).toBe(3);
expect(analysis.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "connections:MainMenu.nib",
status: "partial",
reason: "unsupported_connection_type",
examined: 3,
omitted: 0,
}),
);
expect(analysis.graph.edges).toContainEqual(
expect.objectContaining({
relation: "contains",
reason: "unsupported_connection_type",
evidence: [
expect.objectContaining({ description: "connection type other" }),
],
}),
);
});
});
describe("keyed archive class identity", () => {
it("does not classify a custom class containing Connector as a connection", () => {
const parsed = parseInterfaceBuilderRecords({
$archiver: "NSKeyedArchiver",
$objects: [
"$null",
{
$class: { UID: 2 },
NSSource: { UID: 1 },
NSDestination: { UID: 1 },
},
{
$classname: "TelemetryConnectorState",
$classes: ["TelemetryConnectorState", "NSObject"],
},
],
$top: {},
});
expect(parsed.objects).toContainEqual(
expect.objectContaining({
class_name: "TelemetryConnectorState",
id: "1",
}),
);
expect(parsed.connections).toEqual([]);
expect(parsed.unresolvedConnections).toBe(0);
const inheritedConnector = parseInterfaceBuilderRecords({
$archiver: "NSKeyedArchiver",
$objects: [
"$null",
{
$class: { UID: 3 },
NSSource: { UID: 1 },
NSDestination: { UID: 1 },
},
{ $classname: "NSNibConnector", $classes: ["NSNibConnector"] },
{
$classname: "CustomBindingConnector",
$classes: ["CustomBindingConnector", "NSNibConnector", "NSObject"],
},
],
$top: {},
});
expect(inheritedConnector.connections).toMatchObject([
{ kind: "other", source_id: "1", destination_id: "1" },
]);
});
it("preserves a malformed class UID without inferring a connector", () => {
const parsed = parseInterfaceBuilderRecords({
$archiver: "NSKeyedArchiver",
$objects: [
"$null",
{
$class: { UID: 99 },
NSSource: { UID: 1 },
NSDestination: { UID: 1 },
},
],
$top: {},
});
expect(parsed.objects).toContainEqual(
expect.objectContaining({
class_name: null,
id: "1",
attributes: expect.objectContaining({ $class: { UID: 99 } }),
}),
);
expect(parsed.connections).toEqual([]);
expect(parsed.unresolvedConnections).toBe(0);
});
});
describe("AppKit control connectors", () => {
it("projects an AppKit action from the control through the selector to its target", () => {
const analysis = buildInterfaceBuilderAnalysis({
targetSha256: hash,
+8 -2
View File
@@ -453,6 +453,9 @@ export const buildInterfaceBuilderAnalysis = (input: {
// no connection record, so its coverage is partial with its own reason
// rather than absent or truncated.
const connectionsUnresolved = parsed.unresolvedConnections > 0;
const unsupportedConnections = parsed.connections.some(
({ kind }) => kind === "other",
);
omittedObjects +=
parsed.omittedObjects +
Math.max(0, parsed.objects.length - documentObjectCount);
@@ -500,7 +503,8 @@ export const buildInterfaceBuilderAnalysis = (input: {
status:
connectionsTruncated ||
documentOmittedConnections > 0 ||
connectionsUnresolved
connectionsUnresolved ||
unsupportedConnections
? "partial"
: "complete",
reason:
@@ -508,7 +512,9 @@ export const buildInterfaceBuilderAnalysis = (input: {
? "connection_limit_reached"
: connectionsUnresolved
? "connection_unresolved"
: null,
: unsupportedConnections
? "unsupported_connection_type"
: null,
examined: Math.min(
parsed.connections.length,
input.limits.max_connections,
@@ -38,6 +38,13 @@ type InterfaceBuilderConnection = z.infer<typeof connection>;
export const isUIKitRuntimeConnectionClass = (className: string): boolean =>
/^UIRuntime\w*Connection$/u.test(className);
/** AppKit NIB connector classes and UIKit runtime connection records. */
export const isInterfaceBuilderConnectionClass = (className: string): boolean =>
isUIKitRuntimeConnectionClass(className) ||
/^NSNib(?:Connector|BindingConnector|ControlConnector|OutletConnector)$/u.test(
className,
);
/** The connection class selects the source archive field namespace. */
export const interfaceBuilderConnectionFieldNames = (className: string) =>
isUIKitRuntimeConnectionClass(className)
@@ -167,13 +174,51 @@ const HIERARCHY_KEY =
* names the referenced object; the hierarchy walk visits each object once.
*/
const keyedArchiveTable = (objectTable: readonly unknown[]) => {
const classNameOf = (value: unknown): string | null => {
const classReference = record(value).$class;
const uid = archiveUid(classReference);
return firstString(
record(uid === undefined ? classReference : objectTable[uid]).$classname,
);
type ClassFacts = {
readonly name: string | null;
readonly rawSuperclasses: unknown[];
readonly superclasses: readonly string[];
};
const classFactsByUid = new Map<number, ClassFacts>();
const parseClassFacts = (value: unknown): ClassFacts | null => {
const descriptor = record(value);
const rawSuperclasses = Array.isArray(descriptor.$classes)
? descriptor.$classes
: [];
if (
!Object.hasOwn(descriptor, "$classname") &&
rawSuperclasses.length === 0
)
return null;
return {
name: firstString(descriptor.$classname),
rawSuperclasses,
superclasses: rawSuperclasses.filter(
(name): name is string => typeof name === "string",
),
};
};
const classFactsForUid = (uid: number): ClassFacts | null => {
const cached = classFactsByUid.get(uid);
if (cached !== undefined) return cached;
if (!Number.isInteger(uid) || uid < 0 || uid >= objectTable.length)
return null;
const facts = parseClassFacts(objectTable[uid]);
if (facts !== null) classFactsByUid.set(uid, facts);
return facts;
};
const classFactsOfReference = (reference: unknown): ClassFacts | null => {
const uid = archiveUid(reference);
return uid === undefined
? parseClassFacts(reference)
: classFactsForUid(uid);
};
const classFactsOfObject = (value: unknown) =>
classFactsOfReference(record(value).$class);
const classNameOf = (value: unknown): string | null =>
classFactsOfObject(value)?.name ?? null;
const hasClassSuperclass = (value: unknown, superclass: string): boolean =>
classFactsOfObject(value)?.superclasses.includes(superclass) ?? false;
/** The string an archived string object holds, if it is one. */
const archivedString = (target: unknown): string | undefined => {
if (typeof target === "string") return target;
@@ -294,20 +339,23 @@ const keyedArchiveTable = (objectTable: readonly unknown[]) => {
return { items: root, omittedReferences };
};
const archivedArrayClass = (classReference: unknown): boolean => {
const uid = archiveUid(classReference);
const descriptor = record(
uid === undefined ? classReference : objectTable[uid],
);
const classNames = Array.isArray(descriptor.$classes)
? descriptor.$classes.filter(
(name): name is string => typeof name === "string",
)
: [];
const superclasses =
classFactsOfReference(classReference)?.superclasses ?? [];
return (
classNames.includes("NSArray") || classNames.includes("NSMutableArray")
superclasses.includes("NSArray") ||
superclasses.includes("NSMutableArray")
);
};
return { resolve, hierarchy };
const classTable = () =>
Object.fromEntries(
objectTable.flatMap((_, uid) => {
const facts = classFactsForUid(uid);
return facts === null || facts.name === null
? []
: [[facts.name, { superclasses: facts.rawSuperclasses }]];
}),
);
return { resolve, hierarchy, hasClassSuperclass, classTable };
};
/** Project class and connection objects from an NSKeyedArchiver object table. */
@@ -317,7 +365,12 @@ const parseKeyedArchive = (
if (root.$archiver !== "NSKeyedArchiver" || !Array.isArray(root.$objects))
return null;
const objectTable: readonly unknown[] = root.$objects;
const { resolve, hierarchy: toHierarchy } = keyedArchiveTable(objectTable);
const {
resolve,
hierarchy: toHierarchy,
hasClassSuperclass,
classTable,
} = keyedArchiveTable(objectTable);
const objects: InterfaceBuilderObject[] = [];
const connections: InterfaceBuilderConnection[] = [];
let objectCount = 0;
@@ -363,9 +416,8 @@ const parseKeyedArchive = (
);
if (
className !== null &&
/(?:Outlet|Connection|Segue|ActionConnection|ControlConnector)/iu.test(
className,
)
(isInterfaceBuilderConnectionClass(className) ||
hasClassSuperclass(raw, "NSNibConnector"))
) {
const controlAction = /ControlConnector/iu.test(className);
const uiKit = isUIKitRuntimeConnectionClass(className);
@@ -417,22 +469,7 @@ const parseKeyedArchive = (
}
const top = record(root.$top);
const hierarchy = toHierarchy(top.root ?? top.UITopLevelObjectsKey);
const classes = Object.fromEntries(
objectTable.flatMap((raw) => {
const item = record(raw);
const name = firstString(item.$classname);
return name === null
? []
: [
[
name,
{
superclasses: Array.isArray(item.$classes) ? item.$classes : [],
},
],
];
}),
);
const classes = classTable();
return {
objects,
connections,
@@ -745,6 +745,155 @@ cliTest(
},
);
cliTest(
"compiled CLI reports unsupported keyed archive bindings in connection coverage",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-ib-binding-coverage-");
const bundle = join(root, "Example.app");
const contents = join(bundle, "Contents");
const resources = join(contents, "Resources");
await mkdir(resources, { recursive: true });
await mkdir(join(contents, "MacOS"), { recursive: true });
await writeFile(
join(contents, "MacOS", "App"),
thinMach(0xfeedfacf, 0x0100000c),
);
await writeFile(
join(contents, "Info.plist"),
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
);
await writeFile(
join(resources, "MainMenu.nib"),
buildBinary({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: [
"$null",
{ $class: { UID: 6 } },
{ $class: { UID: 6 } },
{
$class: { UID: 7 },
NSSource: { UID: 1 },
NSDestination: { UID: 2 },
NSLabel: "outlet",
},
{
$class: { UID: 8 },
NSSource: { UID: 1 },
NSDestination: { UID: 2 },
NSLabel: "performAction:",
},
{
$class: { UID: 9 },
NSSource: { UID: 1 },
NSDestination: { UID: 2 },
NSLabel: "value",
},
{ $classname: "NSView", $classes: ["NSView", "NSObject"] },
{
$classname: "NSNibOutletConnector",
$classes: ["NSNibOutletConnector", "NSNibConnector", "NSObject"],
},
{
$classname: "NSNibControlConnector",
$classes: ["NSNibControlConnector", "NSNibConnector", "NSObject"],
},
{
$classname: "NSNibBindingConnector",
$classes: ["NSNibBindingConnector", "NSNibConnector", "NSObject"],
},
],
$top: { root: { UID: 1 } },
}),
);
const output = await cli.run({
arguments: ["decode-interface-builder", bundle, "--json"],
environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" },
});
expect(output.exitCode, output.stderr).toBe(0);
const result = interfaceBuilderAnalysisSchema.parse(
parseEvidence(output.json).normalized_result,
);
expect(result.documents[0]?.connection_count).toBe(3);
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "connections:Contents/Resources/MainMenu.nib",
status: "partial",
reason: "unsupported_connection_type",
examined: 3,
omitted: 0,
}),
);
expect(result.graph.edges).toContainEqual(
expect.objectContaining({
relation: "contains",
reason: "unsupported_connection_type",
}),
);
},
);
it("projects an NIBArchive binding as an unsupported partial connection", async () => {
const root = await createTestTempDirectory("rea-ib-nibarchive-binding-");
const bundle = join(root, "Example.app");
const resources = join(bundle, "Contents", "Resources");
await mkdir(resources, { recursive: true });
await writeFile(
join(resources, "Main.nib"),
encodeNibArchiveFixture({
classes: [
"NSView",
"NSNibBindingConnector",
"NSString",
"TelemetryConnectorState",
],
objects: [
{ classIndex: 0, values: {} },
{ classIndex: 0, values: {} },
{
classIndex: 1,
values: {
NSSource: { ref: 0 },
NSDestination: { ref: 1 },
NSLabel: { ref: 3 },
},
},
{ classIndex: 2, values: { "NS.bytes": "value" } },
{
classIndex: 3,
values: { NSSource: { ref: 0 }, NSDestination: { ref: 1 } },
},
],
}),
);
const result = await analyzeInterfaceBuilderBundle({
bundlePath: bundle,
targetSha256: "e".repeat(64),
});
expect(result.documents[0]?.connection_count).toBe(1);
expect(result.graph.nodes).toContainEqual(
expect.objectContaining({ name: "TelemetryConnectorState" }),
);
expect(result.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "connections:Contents/Resources/Main.nib",
status: "partial",
reason: "unsupported_connection_type",
examined: 1,
omitted: 0,
}),
);
expect(result.graph.edges).toContainEqual(
expect.objectContaining({
relation: "contains",
reason: "unsupported_connection_type",
}),
);
});
cliTest(
"compiled CLI preserves a good archive and reports repeated key and data omissions",
async ({ cli }) => {