Skip to content

Commit 7ef99f9

Browse files
committed
Ensure that output JSON is deterministic
1 parent a2ad43d commit 7ef99f9

6 files changed

Lines changed: 143 additions & 4 deletions

File tree

packages/excel-parser/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ The JSON output contains the following top-level keys:
5959
- `allocations` - Record of SupplierAllocation objects keyed by ID
6060
- `supplierPacks` - Record of SupplierPack objects keyed by ID
6161

62+
Object keys are written in sorted order to keep generated JSON deterministic across runs.
63+
6264
#### Parse Excel to a file-store directory
6365

6466
When `--output-dir` is used, the parser writes one JSON file per record using the directory names expected by the file-store package:

packages/excel-parser/src/__tests__/config-store-output.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,21 @@ describe("writeParseResultToConfigStore", () => {
203203
await expect(readFile(packFile, "utf8")).resolves.toContain(
204204
' "billingId": "BILL-001"',
205205
);
206+
await expect(readFile(packFile, "utf8")).resolves.toBe(`{
207+
"billingId": "BILL-001",
208+
"createdAt": "2026-01-01T00:00:00.000Z",
209+
"description": "Basic pack specification for local testing",
210+
"id": "pack-spec-1",
211+
"name": "Standard Letter Pack",
212+
"postage": {
213+
"deliveryDays": 2,
214+
"id": "postage-standard",
215+
"size": "STANDARD"
216+
},
217+
"status": "DRAFT",
218+
"updatedAt": "2026-01-01T00:00:00.000Z",
219+
"version": 1
220+
}\n`);
206221
} finally {
207222
await rm(outputDir, { recursive: true, force: true });
208223
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { stringifyJsonWithSortedKeys } from "../json-output";
2+
3+
describe("stringifyJsonWithSortedKeys", () => {
4+
it("sorts object keys recursively while preserving array order", () => {
5+
const output = stringifyJsonWithSortedKeys(
6+
{
7+
z: {
8+
d: 4,
9+
a: 1,
10+
},
11+
a: [
12+
{
13+
z: 2,
14+
a: 1,
15+
},
16+
"keep-order",
17+
{
18+
b: 2,
19+
a: 1,
20+
},
21+
],
22+
m: true,
23+
},
24+
2,
25+
);
26+
27+
expect(output).toBe(`{
28+
"a": [
29+
{
30+
"a": 1,
31+
"z": 2
32+
},
33+
"keep-order",
34+
{
35+
"a": 1,
36+
"b": 2
37+
}
38+
],
39+
"m": true,
40+
"z": {
41+
"a": 1,
42+
"d": 4
43+
}
44+
}`);
45+
});
46+
47+
it("produces the same output for equivalent objects with different insertion order", () => {
48+
const left = {
49+
variants: {
50+
beta: {
51+
status: "DRAFT",
52+
id: "beta",
53+
},
54+
alpha: {
55+
status: "DRAFT",
56+
id: "alpha",
57+
},
58+
},
59+
packs: {
60+
packB: {
61+
version: 1,
62+
id: "pack-b",
63+
},
64+
packA: {
65+
version: 1,
66+
id: "pack-a",
67+
},
68+
},
69+
};
70+
const right = {
71+
packs: {
72+
packA: {
73+
id: "pack-a",
74+
version: 1,
75+
},
76+
packB: {
77+
id: "pack-b",
78+
version: 1,
79+
},
80+
},
81+
variants: {
82+
alpha: {
83+
id: "alpha",
84+
status: "DRAFT",
85+
},
86+
beta: {
87+
id: "beta",
88+
status: "DRAFT",
89+
},
90+
},
91+
};
92+
93+
expect(stringifyJsonWithSortedKeys(left)).toBe(
94+
stringifyJsonWithSortedKeys(right),
95+
);
96+
});
97+
});

packages/excel-parser/src/cli-parse.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from "node:path";
44
import yargs from "yargs";
55
import { hideBin } from "yargs/helpers";
66
import { writeParseResultToConfigStore } from "./config-store-output";
7+
import { stringifyJsonWithSortedKeys } from "./json-output";
78
import { parseExcelFile } from "./parse-excel";
89

910
interface Arguments {
@@ -88,9 +89,10 @@ async function main() {
8889
const result = parseExcelFile(resolvedInput);
8990

9091
// Format the output
91-
const jsonOutput = pretty
92-
? JSON.stringify(result, null, 2)
93-
: JSON.stringify(result);
92+
const jsonOutput = stringifyJsonWithSortedKeys(
93+
result,
94+
pretty ? 2 : undefined,
95+
);
9496

9597
if (outputDir) {
9698
const resolvedOutputDir = path.isAbsolute(outputDir)

packages/excel-parser/src/config-store-output.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { mkdir, rm, writeFile } from "node:fs/promises";
22
import path from "node:path";
33

4+
import { stringifyJsonWithSortedKeys } from "./json-output";
45
import type { ParseResult } from "./parse-excel";
56

67
type ConfigStoreEntityDirectory =
@@ -140,7 +141,7 @@ export async function writeParseResultToConfigStore(
140141
entity,
141142
`${encodeRecordIdForFileName(id)}.json`,
142143
);
143-
const serialized = `${JSON.stringify(data, null, jsonSpacing)}\n`;
144+
const serialized = `${stringifyJsonWithSortedKeys(data, jsonSpacing)}\n`;
144145

145146
// eslint-disable-next-line security/detect-non-literal-fs-filename
146147
await writeFile(outputFile, serialized, "utf8");
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
function sortJsonValue(value: unknown): unknown {
2+
if (Array.isArray(value)) {
3+
return value.map((item) => sortJsonValue(item));
4+
}
5+
6+
if (value && typeof value === "object") {
7+
return Object.fromEntries(
8+
Object.entries(value)
9+
.toSorted(([left], [right]) => left.localeCompare(right))
10+
.map(([key, nestedValue]) => [key, sortJsonValue(nestedValue)]),
11+
);
12+
}
13+
14+
return value;
15+
}
16+
17+
export function stringifyJsonWithSortedKeys(
18+
value: unknown,
19+
spacing?: number,
20+
): string {
21+
return JSON.stringify(sortJsonValue(value), null, spacing);
22+
}

0 commit comments

Comments
 (0)