refactor(plist): unify number source association

This commit is contained in:
morluto committed 2026-10-27 10:22:33 +08:00
1 parent b7cae1ad8c
commit ded6542d93
3 files changed
+112 -35

No files matched your search

+36 -17
View File
@@ -45,32 +45,51 @@ export const createPlistNumberCollector = () => {
};
};
/** One association established from source literals, before adapter projection. */
type PlistNumberSource =
| { readonly kind: "integer"; readonly decimal: string }
| { readonly kind: "real" }
| { readonly kind: "unknown" };
/**
* Associate a decoded number with its source type. A retained JSON literal can
* narrow integer candidates that otherwise collide after numeric rounding.
* Missing or incomplete metadata never establishes a source type.
*/
export const resolvePlistNumberSource = (
value: number,
literals: PlistNumberLiterals | undefined,
sourceDecimal?: string,
): PlistNumberSource => {
if (literals === undefined || literals.incomplete) return { kind: "unknown" };
const decimals = literals.integers.get(value);
const real = literals.reals.has(value);
const integer =
sourceDecimal === undefined
? decimals !== undefined
: decimals?.has(sourceDecimal) === true;
if (real) return { kind: integer ? "unknown" : "real" };
if (!integer) return { kind: "unknown" };
const decimal =
sourceDecimal ?? (decimals?.size === 1 ? [...decimals][0] : undefined);
return decimal === undefined
? { kind: "unknown" }
: { kind: "integer", decimal };
};
/** Preserve unambiguous exact integers; disclose rounded or unclassified numbers. */
export const createPlistNumberProjection = (literals: PlistNumberLiterals) => {
let exactIntegerCount = 0;
let ambiguousNumberCount = 0;
const mapNumber = (item: number): JsonValue => {
if (Number.isSafeInteger(item) || !Number.isInteger(item)) return item;
if (literals.incomplete) {
ambiguousNumberCount += 1;
return item;
}
const decimals = literals.integers.get(item);
if (decimals === undefined) {
if (!literals.reals.has(item)) ambiguousNumberCount += 1;
return item;
}
const [decimal] = decimals;
if (
decimals.size !== 1 ||
decimal === undefined ||
literals.reals.has(item)
) {
ambiguousNumberCount += 1;
const source = resolvePlistNumberSource(item, literals);
if (source.kind !== "integer") {
if (source.kind === "unknown") ambiguousNumberCount += 1;
return item;
}
exactIntegerCount += 1;
return { $plist_type: "integer", decimal };
return { $plist_type: "integer", decimal: source.decimal };
};
const map = (item: JsonValue): JsonValue => {
if (typeof item === "number") return mapNumber(item);
+63
View File
@@ -3,6 +3,9 @@ import { buildBinary, type PlistValue } from "plist";
import { MAX_JSON_DEPTH, jsonValueSchema } from "../../domain/jsonValue.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { plistUidValue } from "../../domain/apple/plistUid.js";
import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js";
import { projectPlistValue } from "../../domain/apple/plistValue.js";
import { parseXmlPropertyList } from "../../domain/propertyListKeys.js";
import {
decodeBinaryPlistScalars,
scanBinaryPlistScalars,
@@ -907,3 +910,63 @@ it("retains nested own dictionary names consistently in native JSON, XML and bin
);
}
});
it.each([
[
"integer",
"<integer>9007199254740992</integer>",
"9007199254740992",
{ $plist_type: "integer", decimal: "9007199254740992" },
],
[
"real",
"<real>9007199254740992</real>",
"9007199254740992",
9007199254740992,
],
[
"integer/real collision",
"<integer>9007199254740992</integer><real>9007199254740992</real>",
"9007199254740992,9007199254740992",
9007199254740992,
],
] as const)(
"shares %s association across native JSON, XML and archive projection",
(_label, elements, json, expected) => {
const xml = `<plist><array>${elements}</array></plist>`;
const nativeJson = readPlistJson(`[${json}]`).project(xml);
const nativeXml = parsePlistXml(xml);
if (!nativeJson.ok) throw nativeJson.error;
if (!nativeXml.ok) throw nativeXml.error;
const decoded = parseXmlPropertyList(xml);
const numbers = createPlistNumberProjection(decoded.numberLiterals);
const archive = numbers.project(projectPlistValue(decoded.value).value);
const values = json.split(",").map(() => expected);
expect(nativeJson.value.value).toEqual(values);
expect(nativeXml.value.value).toEqual(values);
expect(archive).toEqual(values);
},
);
it("retains JSON source spelling when XML integers collide after rounding", () => {
const xml =
"<plist><array><integer>9007199254740992</integer><integer>9007199254740993</integer></array></plist>";
const json = readPlistJson("[9007199254740992,9007199254740993]").project(
xml,
);
const converted = parsePlistXml(xml);
if (!json.ok) throw json.error;
if (!converted.ok) throw converted.error;
expect(json.value.value).toEqual([
{ $plist_type: "integer", decimal: "9007199254740992" },
{ $plist_type: "integer", decimal: "9007199254740993" },
]);
expect(converted.value.value).toEqual([9007199254740992, 9007199254740992]);
expect(json.value.limitations).toEqual([
expect.stringContaining("2 integer value(s)"),
]);
expect(converted.value.limitations).toEqual([
expect.stringContaining("XML conversion"),
expect.stringContaining("2 number(s)"),
]);
});
+13 -18
View File
@@ -23,6 +23,7 @@ import { err, ok, type Result } from "../../domain/result.js";
import {
incompletePlistNumberLimitation,
createPlistNumberProjection,
resolvePlistNumberSource,
type PlistNumberLiterals,
} from "../../domain/apple/plistNumbers.js";
import {
@@ -201,17 +202,17 @@ const projectPlistJsonAttempt = (
return negativeZeroReal();
}
if (item instanceof PlistJsonIntegerLiteral) {
const integer =
literals?.incomplete === false &&
literals.integers.get(item.value)?.has(item.digits) === true;
const real =
literals?.incomplete === false && literals.reals.has(item.value);
if (integer && !real) {
const source = resolvePlistNumberSource(
item.value,
literals,
item.digits,
);
if (source.kind === "integer") {
exactIntegerCount += 1;
return { $plist_type: "integer", decimal: item.digits };
return { $plist_type: "integer", decimal: source.decimal };
}
// An integral <real> decodes exactly; only its JSON spelling is integral.
if (real && !integer) return item.value;
if (source.kind === "real") return item.value;
ambiguousNumberCount += 1;
return item.value;
}
@@ -332,20 +333,14 @@ const projectPlistXml = (
let exactIntegerCount = 0;
let ambiguousNumberCount = 0;
const value = mapJsonNumbers(projected.value, (item) => {
const decimals = literals.integers.get(item);
if (decimals === undefined) return item;
const [decimal] = decimals;
if (
literals.incomplete ||
decimals.size !== 1 ||
decimal === undefined ||
literals.reals.has(item)
) {
if (!literals.integers.has(item)) return item;
const source = resolvePlistNumberSource(item, literals);
if (source.kind !== "integer") {
ambiguousNumberCount += 1;
return item;
}
exactIntegerCount += 1;
return { $plist_type: "integer", decimal };
return { $plist_type: "integer", decimal: source.decimal };
});
return ok({
value,