Merge pull request #700 from kaoru0822-kitauji/fix/json-shape-collation

fix(comparison): break JSON shape collation ties
This commit is contained in:
Kaoru0822 authored and GitHub committed 2026-10-06 01:10:26 -07:00
commit 217e8cf59f
3 files changed
+358 -1

No files matched your search

+79
View File
@@ -39,6 +39,85 @@ describe("inferJsonShape", () => {
expect(inferJsonShape("not-json")).toBeNull();
});
it.each([
["é", "e\u0301", "e\u0341"],
["é", "e\u0341", "e\u0301"],
["e\u0301", "é", "e\u0341"],
["e\u0301", "e\u0341", "é"],
["e\u0341", "é", "e\u0301"],
["e\u0341", "e\u0301", "é"],
])("orders distinct collation-equal paths from %j, %j, %j", (...names) => {
for (const name of names) expect(name.localeCompare("é")).toBe(0);
const values: Readonly<Record<string, unknown>> = {
é: 1,
"e\u0301": "value",
"e\u0341": null,
};
const shape = inferJsonShape(
JSON.stringify(
Object.fromEntries(names.map((name) => [name, values[name]])),
),
);
expect(shape).toEqual({
root_type: "object",
node_count: 4,
max_depth_observed: 1,
properties: [
{ path: "/e\u0301", types: ["string"], observations: 1 },
{ path: "/e\u0341", types: ["null"], observations: 1 },
{ path: "/é", types: ["number"], observations: 1 },
],
});
});
it.each([false, true])(
"preserves nested types, counts, depth and pointer names with reversed keys: %s",
(reverse) => {
const object = (number: number, value: unknown) => {
const entries = [
["é/~", number],
["e\u0301/~", value],
];
return Object.fromEntries(reverse ? entries.reverse() : entries);
};
const shape = inferJsonShape(
JSON.stringify({ nested: [object(1, null), object(2, [null])] }),
);
expect(shape).toEqual({
root_type: "object",
node_count: 9,
max_depth_observed: 4,
properties: [
{ path: "/nested", types: ["array"], observations: 1 },
{
path: "/nested/*/e\u0301~1~0",
types: ["array", "null"],
observations: 2,
},
{ path: "/nested/*/é~1~0", types: ["number"], observations: 2 },
],
});
},
);
it("preserves the existing locale order for paths that do not tie", () => {
const names = ["z", "é", "A", "_", "a"];
const expected = names
.map((name) => `/${name}`)
.sort((left, right) => left.localeCompare(right));
expect(new Set(expected).size).toBe(names.length);
for (const [index, left] of expected.entries())
for (const right of expected.slice(index + 1))
expect(left.localeCompare(right)).not.toBe(0);
const shape = inferJsonShape(
JSON.stringify(Object.fromEntries(names.map((name) => [name, true]))),
);
expect(shape?.properties.map(({ path }) => path)).toEqual(expected);
});
it("retains every parsed property beyond the former shape-node limit", () => {
const content = Object.fromEntries(
Array.from({ length: 5_001 }, (_, index) => [`field_${index}`, index]),
+7 -1
View File
@@ -1,5 +1,7 @@
import { z } from "zod";
import { compareCodePoints } from "./canonicalOrdering.js";
const jsonValueTypeSchema = z.enum([
"object",
"array",
@@ -88,7 +90,11 @@ export const inferJsonShape = (text: string): JsonShape | null => {
types: [...value.types].sort(),
observations: value.observations,
}))
.sort((left, right) => left.path.localeCompare(right.path)),
.sort(
(left, right) =>
left.path.localeCompare(right.path) ||
compareCodePoints(left.path, right.path),
),
});
};