mirror of
https://github.com/morluto/rea
synced 2026-10-26 22:28:27 -04:00
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user