fix(apple): preserve NIBArchive special real values

Fixes #6583
This commit is contained in:
morluto
2026-10-23 20:59:12 +08:00
parent 869d2bc2ec
commit 43e7917ec2
5 changed files with 148 additions and 8 deletions
@@ -12,6 +12,8 @@ import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
import {
projectPlistValue,
binaryDatePrecisionOverlapLimitation,
negativeZeroRealLimitation,
nonFiniteRealLimitation,
unknownDateLimitation,
unknownScalarLimitation,
} from "../../domain/apple/plistValue.js";
@@ -75,6 +77,8 @@ export const analyzeInterfaceBuilderBundle = async (input: {
const unknownDates = new Map<string, number>();
const datePrecisionOverlaps = new Map<string, number>();
const unknownScalars = new Map<string, number>();
const nonFiniteReals = new Map<string, number>();
const negativeZeroReals = new Map<string, number>();
let omitted = 0;
let attempted = 0;
let aggregateInputBytes = 0;
@@ -186,6 +190,10 @@ export const analyzeInterfaceBuilderBundle = async (input: {
incompleteHierarchies.set(entry.path, nib.omitted);
if (nib !== null && nib.unresolvedConnections > 0)
unresolvedConnections.set(entry.path, nib.unresolvedConnections);
if (nib !== null && nib.nonFiniteRealCount > 0)
nonFiniteReals.set(entry.path, nib.nonFiniteRealCount);
if (nib !== null && nib.negativeZeroRealCount > 0)
negativeZeroReals.set(entry.path, nib.negativeZeroRealCount);
if (omittedPrototypeKeys !== 0)
prototypeKeyOmissions.set(entry.path, omittedPrototypeKeys);
if (unknownDateCount !== 0)
@@ -241,6 +249,8 @@ export const analyzeInterfaceBuilderBundle = async (input: {
unknownDates,
datePrecisionOverlaps,
unknownScalars,
nonFiniteReals,
negativeZeroReals,
invalid,
omittedArchives,
unreportedOmittedArchives,
@@ -312,6 +322,8 @@ const finalizeHierarchyCoverage = (
unknownDates: ReadonlyMap<string, number>;
datePrecisionOverlaps: ReadonlyMap<string, number>;
unknownScalars: ReadonlyMap<string, number>;
nonFiniteReals: ReadonlyMap<string, number>;
negativeZeroReals: ReadonlyMap<string, number>;
invalid: readonly string[];
omittedArchives: readonly { path: string; reason: string }[];
unreportedOmittedArchives: number;
@@ -326,6 +338,8 @@ const finalizeHierarchyCoverage = (
unknownDates,
datePrecisionOverlaps,
unknownScalars,
nonFiniteReals,
negativeZeroReals,
invalid,
omittedArchives,
unreportedOmittedArchives,
@@ -420,6 +434,12 @@ const finalizeHierarchyCoverage = (
...[...unknownScalars].map(
([path, count]) => `${path}: ${unknownScalarLimitation(count)}`,
),
...[...nonFiniteReals].map(
([path, count]) => `${path}: ${nonFiniteRealLimitation(count)}`,
),
...[...negativeZeroReals].map(
([path, count]) => `${path}: ${negativeZeroRealLimitation(count)}`,
),
...omittedArchives.map(
({ path, reason }) =>
`${path}: omitted because ${reason.replaceAll("_", " ")}.`,
@@ -484,6 +504,21 @@ const projectNibArchive = (
archive.classes.length * 96,
"NIBArchive class projection exceeds the aggregate Interface Builder decode budget",
);
let nonFiniteRealCount = 0;
let negativeZeroRealCount = 0;
const projectedValues = new Map<
number,
Readonly<Record<string, JsonValue>>
>();
for (const object of archive.objects) {
const projected = projectPlistValue(object.values);
nonFiniteRealCount += projected.nonFiniteRealCount;
negativeZeroRealCount += projected.negativeZeroRealCount;
projectedValues.set(
object.id,
projected.value as Readonly<Record<string, JsonValue>>,
);
}
const dereference = (value: JsonValue | undefined): JsonValue | undefined => {
if (typeof value !== "object" || value === null || Array.isArray(value))
return value;
@@ -566,10 +601,12 @@ const projectNibArchive = (
}
budget.reserveJsonStringOccurrence(name);
const nibValues = Object.fromEntries(
Object.entries(object.values).map(([key, value]) => {
budget.reserveJsonPropertyNameOccurrence(key);
return [key, dereference(value) ?? null];
}),
Object.entries(projectedValues.get(object.id) ?? {}).map(
([key, value]) => {
budget.reserveJsonPropertyNameOccurrence(key);
return [key, dereference(value) ?? null];
},
),
);
// Normalize only recognized scalar fields at the format owner. A keyed
// archive's authored nibValues dictionary must not gain this meaning.
@@ -715,6 +752,8 @@ const projectNibArchive = (
return {
omitted: viewHierarchy.omitted + hierarchyOmitted + merged.omitted,
unresolvedConnections,
nonFiniteRealCount,
negativeZeroRealCount,
raw: jsonValueSchema.parse({
"com.apple.ibtool.document.objects": objects,
"com.apple.ibtool.document.connections": connections,
+30 -2
View File
@@ -4,7 +4,16 @@ export const encodeNibArchiveFixture = (input: {
readonly objects: readonly {
readonly classIndex: number;
readonly values: Readonly<
Record<string, number | string | boolean | null | { ref: number }>
Record<
string,
| number
| string
| boolean
| null
| { ref: number }
| { float32: number }
| { float64: number }
>
>;
}[];
}): Buffer => {
@@ -73,7 +82,14 @@ const varint = (value: number): Buffer => {
};
const encodeValue = (
value: number | string | boolean | null | { ref: number },
value:
| number
| string
| boolean
| null
| { ref: number }
| { float32: number }
| { float64: number },
): Buffer => {
// Tags as written by ibtool and read by AppKit: 4 is NO, 5 is YES.
if (typeof value === "boolean") return Buffer.from([value ? 5 : 4]);
@@ -85,6 +101,18 @@ const encodeValue = (
return bytes;
}
if (typeof value === "object") {
if ("float32" in value) {
const bytes = Buffer.alloc(5);
bytes[0] = 6;
bytes.writeFloatLE(value.float32, 1);
return bytes;
}
if ("float64" in value) {
const bytes = Buffer.alloc(9);
bytes[0] = 7;
bytes.writeDoubleLE(value.float64, 1);
return bytes;
}
const bytes = Buffer.alloc(5);
bytes[0] = 10;
bytes.writeUInt32LE(value.ref, 1);
+27
View File
@@ -36,6 +36,33 @@ const archiveWithOverlappingObjectRanges = (objectCount: number): Buffer => {
};
describe("NIBArchive decoder", () => {
it("preserves non-finite and negative-zero real values for typed projection", () => {
const values = decodeNibArchive(
encodeArchive({
classes: ["NSObject"],
objects: [
{
classIndex: 0,
values: {
nan: { float32: Number.NaN },
positive: { float32: Number.POSITIVE_INFINITY },
negative: { float64: Number.NEGATIVE_INFINITY },
negativeZero32: { float32: -0 },
negativeZero64: { float64: -0 },
nil: null,
},
},
],
}),
).objects[0]?.values;
expect(Number.isNaN(values?.nan)).toBe(true);
expect(values?.positive).toBe(Number.POSITIVE_INFINITY);
expect(values?.negative).toBe(Number.NEGATIVE_INFINITY);
expect(Object.is(values?.negativeZero32, -0)).toBe(true);
expect(Object.is(values?.negativeZero64, -0)).toBe(true);
expect(values?.nil).toBeNull();
});
it("keeps leading U+FEFF in length-prefixed keys and class names", () => {
const archive = encodeArchive({
classes: ["\uFEFFWidget"],
+2 -2
View File
@@ -401,14 +401,14 @@ const readValue = (
{
const value = bytes.readFloatLE(cursor.offset);
cursor.offset += 4;
return Number.isFinite(value) ? value : null;
return value;
}
case NibValueType.float64:
ensureRange(cursor.offset, 8, end, `value ${index}`);
{
const value = bytes.readDoubleLE(cursor.offset);
cursor.offset += 8;
return Number.isFinite(value) ? value : null;
return value;
}
case NibValueType.data: {
const length = readVarint(bytes, cursor, end);
@@ -327,6 +327,52 @@ describe("compiled Interface Builder bundle reader", () => {
});
describe("keyed archive hierarchy coverage", () => {
it("projects NIBArchive special reals as typed values with per-document counts", async () => {
const root = await createTestTempDirectory("rea-nib-real-projection-");
const bundle = join(root, "Example.app");
const resources = join(bundle, "Contents", "Resources");
await mkdir(resources, { recursive: true });
await writeFile(
join(resources, "Numbers.nib"),
encodeNibArchiveFixture({
classes: ["UIView"],
objects: [
{
classIndex: 0,
values: {
nan: { float32: Number.NaN },
positive: { float32: Number.POSITIVE_INFINITY },
negative: { float64: Number.NEGATIVE_INFINITY },
negativeZero32: { float32: -0 },
negativeZero64: { float64: -0 },
nil: null,
},
},
],
}),
);
const result = await analyzeInterfaceBuilderBundle({
bundlePath: bundle,
targetSha256: "f".repeat(64),
});
const values = result.graph.nodes[0]?.attributes.nibValues;
expect(values).toMatchObject({
nan: { $plist_type: "real", value: "nan" },
positive: { $plist_type: "real", value: "+infinity" },
negative: { $plist_type: "real", value: "-infinity" },
negativeZero32: { $plist_type: "real", value: "-0" },
negativeZero64: { $plist_type: "real", value: "-0" },
nil: null,
});
expect(result.limitations).toContain(
'Numbers.nib: 3 non-finite real value(s) are reported as { "$plist_type": "real", "value": "<spelling>" } using canonical numeric labels nan, +infinity or -infinity because a JSON number cannot carry them.',
);
expect(result.limitations).toContain(
'Numbers.nib: 2 negative-zero real value(s) are reported as { "$plist_type": "real", "value": "-0" } because a JSON number cannot carry the sign of zero.',
);
});
it("projects a deep keyed-archive hierarchy through NSArray wrappers", async () => {
const root = await createTestTempDirectory("rea-ib-test-");
const bundle = join(root, "Example.app");