From e63e8a272e48fadf21b0fa1023c98385bddb7c68 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 06:44:53 +0000 Subject: [PATCH 1/2] fix(cli): preserve example JSON key order in ledger publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace recursive stableStringify with JSON.stringify in the CLI's ledger publish path. The old stableStringify sorted ALL object keys at every nesting level, which reordered preformatted API example bodies (request/response JSON) alphabetically. Now only top-level manifest entry keys (apiDefinitionId) are sorted for deterministic hashing. Nested content — including API examples — preserves its original field order. Co-Authored-By: cbro --- .../fix-ledger-example-json-key-order.yml | 7 +++ .../src/__test__/buildLedgerInput.test.ts | 60 ++++++++++++++++++- .../src/publishDocsLedger.ts | 45 +++++++------- 3 files changed, 91 insertions(+), 21 deletions(-) create mode 100644 packages/cli/cli/changes/unreleased/fix-ledger-example-json-key-order.yml diff --git a/packages/cli/cli/changes/unreleased/fix-ledger-example-json-key-order.yml b/packages/cli/cli/changes/unreleased/fix-ledger-example-json-key-order.yml new file mode 100644 index 000000000000..767b5357f0e5 --- /dev/null +++ b/packages/cli/cli/changes/unreleased/fix-ledger-example-json-key-order.yml @@ -0,0 +1,7 @@ +- summary: | + Fix ledger publish reordering JSON keys in API example bodies. The + `stableStringify` serialization sorted all object keys recursively, + which reordered preformatted request/response example JSON. Now only + top-level manifest keys are sorted for determinism; nested content + (including examples) preserves its original field order. + type: fix diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/buildLedgerInput.test.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/buildLedgerInput.test.ts index d5d933b04242..15b183fa92b7 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/buildLedgerInput.test.ts +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/buildLedgerInput.test.ts @@ -267,7 +267,7 @@ describe("buildLedgerInput", () => { // Reproduces the docs-ledger deterministic-hash bug: `apiDefinitions` // is built by `Promise.all` of /api/register calls in CLI, so its Map // insertion order is whichever round-trip completed first. Without - // `stableStringify`, byte-identical content would hash differently + // top-level key sorting, byte-identical content would hash differently // across publishes and the docs-ledger "no-op republish" fast-path // would never fire. The two manifests below have identical entries // inserted in opposite orders and MUST produce the same blob hash. @@ -396,6 +396,64 @@ describe("buildLedgerInput", () => { expect(translatedParsed["api-1"].apiName).toBe("plant-api-zh"); }); + it("preserves nested key order in apiManifest blob (no recursive key sorting)", () => { + // API example bodies (request/response JSON) are preformatted user + // content — their field order is intentional and must not be reordered + // alphabetically. This test verifies that the serialized blob preserves + // the original key insertion order within each definition, including + // deeply nested objects. + const apiDefWithOrderedKeys: ApiDefinition = { + types: { + "type_plant:PlantResponse": { + name: "PlantResponse", + shape: { + type: "object", + extends: [], + properties: [ + { key: "name", valueType: { type: "primitive", value: { type: "string" } } }, + { key: "species", valueType: { type: "primitive", value: { type: "string" } } }, + { key: "id", valueType: { type: "primitive", value: { type: "string" } } } + ] + } + } + }, + subpackages: {}, + rootPackage: { + endpoints: [], + types: ["type_plant:PlantResponse"], + subpackages: [], + websockets: [], + webhooks: [] + }, + auth: undefined, + snippetsConfiguration: {}, + globalHeaders: [] + }; + + const apiDefinitions = new Map(); + apiDefinitions.set("api-1", apiDefWithOrderedKeys); + + const { localeEntry, blobs } = buildLedgerInput({ + docsDefinition: makeDocsDefinition(), + apiDefinitions + }); + + const manifestBuf = blobs.get(localeEntry.apiManifest?.hash ?? ""); + expect(manifestBuf).toBeDefined(); + const parsed = JSON.parse(manifestBuf?.toString("utf-8") ?? ""); + + // Verify nested key order is preserved (not sorted alphabetically). + // With the old stableStringify, "shape" would come after "name" is gone + // and properties array items would have keys sorted. + const typeDef = parsed["api-1"].types["type_plant:PlantResponse"]; + expect(Object.keys(typeDef)).toEqual(["name", "shape"]); + expect(Object.keys(typeDef.shape)).toEqual(["type", "extends", "properties"]); + + // Verify property key order within each property object is preserved. + const firstProp = typeDef.shape.properties[0]; + expect(Object.keys(firstProp)).toEqual(["key", "valueType"]); + }); + it("forwards fileManifest unchanged (file blobs are loaded lazily, not included in blob map)", () => { // Image entry — exercises width/height fields. const imageBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG header diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocsLedger.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocsLedger.ts index 6920c2b948ba..45e7f727618f 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocsLedger.ts +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocsLedger.ts @@ -74,27 +74,30 @@ interface BlobRef { } /** - * `JSON.stringify` with deterministic key ordering at every level. Arrays - * keep their original ordering (positions are meaningful); object keys are - * sorted lexicographically via the replacer. Two inputs that differ only by - * key insertion order serialize identically — required so the apiManifest - * blob hash is stable across publishes (cf. FDR `stableStringify`). + * Sort only the top-level keys of a plain object for deterministic + * serialization, without recursing into nested values. This avoids + * reordering keys inside API example JSON bodies (request/response), + * which are preformatted user content and must preserve their + * original field order. */ -function stableStringify(value: unknown): string { - return JSON.stringify(value, (_key, val) => { - if (val != null && typeof val === "object" && !Array.isArray(val)) { - return Object.fromEntries(Object.entries(val).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); - } - return val; - }); +function sortTopLevelKeys(obj: Record): Record { + return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); } /** - * Serializes a value to a JSON buffer using {@link stableStringify} and - * returns a BlobRef + the raw bytes, keyed by content hash for later upload. + * Serializes a value to a JSON buffer using standard `JSON.stringify` + * and returns a BlobRef + the raw bytes, keyed by content hash. + * + * Callers that need deterministic output for non-deterministic inputs + * (e.g. Map iteration order) should sort top-level keys via + * {@link sortTopLevelKeys} before calling this function. + * + * Unlike the previous `stableStringify` approach, this does NOT sort + * keys recursively — API example bodies are preformatted content + * whose field order must be preserved. */ function jsonBlobRef(value: unknown): { ref: BlobRef; hash: string; buf: Buffer } { - const buf = Buffer.from(stableStringify(value), "utf-8"); + const buf = Buffer.from(JSON.stringify(value), "utf-8"); const hash = sha256(buf); return { ref: { hash, contentType: "application/json", contentLength: buf.length }, @@ -187,9 +190,10 @@ export function buildLedgerInput({ // `apiDefinitions` is populated by the `registerApi` callback inside a // `Promise.all`, so the Map's insertion order reflects whichever HTTP // round-trip completed first — non-deterministic across publishes. - // {@link jsonBlobRef} uses {@link stableStringify}, which sorts object - // keys at every level, so the resulting bytes are stable regardless of - // Map iteration order. + // We sort the top-level keys (apiDefinitionId) for determinism, but + // use standard `JSON.stringify` to preserve nested key order — API + // example bodies (request/response JSON) are preformatted content + // whose field order must not be reordered. // // Determinism caveat: stable apiManifest bytes are necessary but not // sufficient for a deterministic deployment hash. Page bodies must also @@ -201,7 +205,8 @@ export function buildLedgerInput({ // request, so deployment-level dedup will not fire there. let apiManifestRef: BlobRef | null = null; if (apiDefinitions.size > 0) { - const manifestObj = Object.fromEntries(apiDefinitions); + const sortedEntries = [...apiDefinitions.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + const manifestObj = Object.fromEntries(sortedEntries); const manifestBlob = jsonBlobRef(manifestObj); blobs.set(manifestBlob.hash, manifestBlob.buf); apiManifestRef = manifestBlob.ref; @@ -211,7 +216,7 @@ export function buildLedgerInput({ // custom header/footer components resolved by DocsDefinitionResolver. let jsFilesRef: BlobRef | null = null; if (docsDefinition.jsFiles != null && Object.keys(docsDefinition.jsFiles).length > 0) { - const jsFilesBlob = jsonBlobRef(docsDefinition.jsFiles); + const jsFilesBlob = jsonBlobRef(sortTopLevelKeys(docsDefinition.jsFiles)); blobs.set(jsFilesBlob.hash, jsFilesBlob.buf); jsFilesRef = jsFilesBlob.ref; } From d6283706db17ffc9073ab2259f50ff5ec015bb2d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:13:18 +0000 Subject: [PATCH 2/2] fix(cli): use PREFORMATTED_KEYS to skip sorting example bodies in stableStringify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the approach: instead of replacing stableStringify entirely, teach it to skip recursive key-sorting for subtrees rooted at known preformatted keys (requestBody, responseBody, payload, body). Uses a WeakSet to track which objects belong to a preformatted subtree so deeply nested keys are also preserved. This is CLI-only — no FDR changes needed. Co-Authored-By: cbro --- .../fix-ledger-example-json-key-order.yml | 7 +- .../src/__test__/buildLedgerInput.test.ts | 99 +++++++++++-------- .../src/publishDocsLedger.ts | 80 ++++++++++----- 3 files changed, 119 insertions(+), 67 deletions(-) diff --git a/packages/cli/cli/changes/unreleased/fix-ledger-example-json-key-order.yml b/packages/cli/cli/changes/unreleased/fix-ledger-example-json-key-order.yml index 767b5357f0e5..d67a0e625652 100644 --- a/packages/cli/cli/changes/unreleased/fix-ledger-example-json-key-order.yml +++ b/packages/cli/cli/changes/unreleased/fix-ledger-example-json-key-order.yml @@ -1,7 +1,8 @@ - summary: | Fix ledger publish reordering JSON keys in API example bodies. The `stableStringify` serialization sorted all object keys recursively, - which reordered preformatted request/response example JSON. Now only - top-level manifest keys are sorted for determinism; nested content - (including examples) preserves its original field order. + which reordered preformatted request/response example JSON. Example + body fields (`requestBody`, `responseBody`, webhook `payload`, and + websocket message `body`) are now treated as preformatted content + and skipped by the key-sorting replacer. type: fix diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/buildLedgerInput.test.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/buildLedgerInput.test.ts index 15b183fa92b7..9a8d43814975 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/buildLedgerInput.test.ts +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/__test__/buildLedgerInput.test.ts @@ -266,11 +266,10 @@ describe("buildLedgerInput", () => { it("apiManifest blob hash is stable across Map insertion order (determinism guard)", () => { // Reproduces the docs-ledger deterministic-hash bug: `apiDefinitions` // is built by `Promise.all` of /api/register calls in CLI, so its Map - // insertion order is whichever round-trip completed first. Without - // top-level key sorting, byte-identical content would hash differently - // across publishes and the docs-ledger "no-op republish" fast-path - // would never fire. The two manifests below have identical entries - // inserted in opposite orders and MUST produce the same blob hash. + // insertion order is whichever round-trip completed first. `stableStringify` + // sorts object keys so two semantically identical payloads hash the same + // regardless of insertion order. The two manifests below have identical + // entries inserted in opposite orders and MUST produce the same blob hash. const minimalApiDefinition: ApiDefinition = { types: {}, subpackages: {}, @@ -396,31 +395,60 @@ describe("buildLedgerInput", () => { expect(translatedParsed["api-1"].apiName).toBe("plant-api-zh"); }); - it("preserves nested key order in apiManifest blob (no recursive key sorting)", () => { - // API example bodies (request/response JSON) are preformatted user - // content — their field order is intentional and must not be reordered - // alphabetically. This test verifies that the serialized blob preserves - // the original key insertion order within each definition, including - // deeply nested objects. - const apiDefWithOrderedKeys: ApiDefinition = { - types: { - "type_plant:PlantResponse": { - name: "PlantResponse", - shape: { - type: "object", - extends: [], - properties: [ - { key: "name", valueType: { type: "primitive", value: { type: "string" } } }, - { key: "species", valueType: { type: "primitive", value: { type: "string" } } }, - { key: "id", valueType: { type: "primitive", value: { type: "string" } } } - ] - } - } - }, + it("preserves example body key order in apiManifest blob (PREFORMATTED_KEYS skip)", () => { + // API example bodies (requestBody / responseBody) are preformatted + // user content — their field order is intentional. stableStringify + // skips recursive key-sorting for values under PREFORMATTED_KEYS, + // so the serialized blob preserves the original key order in example + // JSON while still sorting structural keys for determinism. + // + // We embed an endpoint with a responseBody whose keys are + // intentionally NOT in alphabetical order. After serialization, + // the blob must preserve that order. + const apiDefWithExample: ApiDefinition = { + types: {}, subpackages: {}, rootPackage: { - endpoints: [], - types: ["type_plant:PlantResponse"], + endpoints: [ + { + description: undefined, + availability: undefined, + id: "getPlant", + name: undefined, + path: { + parts: [], + pathParameters: [] + }, + queryParameters: [], + headers: [], + request: undefined, + response: undefined, + errors: undefined, + examples: [ + { + path: "/plants/plant-123", + pathParameters: {}, + queryParameters: {}, + headers: {}, + requestBody: undefined, + responseStatusCode: 200, + responseBody: { + name: "Monstera Deliciosa", + species: "Monstera", + id: "plant-123", + care: { + water: "weekly", + sunlight: "indirect", + difficulty: "easy" + } + } + } + ], + method: "GET", + environments: undefined + } + ], + types: [], subpackages: [], websockets: [], webhooks: [] @@ -431,7 +459,7 @@ describe("buildLedgerInput", () => { }; const apiDefinitions = new Map(); - apiDefinitions.set("api-1", apiDefWithOrderedKeys); + apiDefinitions.set("api-1", apiDefWithExample); const { localeEntry, blobs } = buildLedgerInput({ docsDefinition: makeDocsDefinition(), @@ -442,16 +470,9 @@ describe("buildLedgerInput", () => { expect(manifestBuf).toBeDefined(); const parsed = JSON.parse(manifestBuf?.toString("utf-8") ?? ""); - // Verify nested key order is preserved (not sorted alphabetically). - // With the old stableStringify, "shape" would come after "name" is gone - // and properties array items would have keys sorted. - const typeDef = parsed["api-1"].types["type_plant:PlantResponse"]; - expect(Object.keys(typeDef)).toEqual(["name", "shape"]); - expect(Object.keys(typeDef.shape)).toEqual(["type", "extends", "properties"]); - - // Verify property key order within each property object is preserved. - const firstProp = typeDef.shape.properties[0]; - expect(Object.keys(firstProp)).toEqual(["key", "valueType"]); + const responseBody = parsed["api-1"].rootPackage.endpoints[0].examples[0].responseBody; + expect(Object.keys(responseBody)).toEqual(["name", "species", "id", "care"]); + expect(Object.keys(responseBody.care)).toEqual(["water", "sunlight", "difficulty"]); }); it("forwards fileManifest unchanged (file blobs are loaded lazily, not included in blob map)", () => { diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocsLedger.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocsLedger.ts index 45e7f727618f..8ab7ebadd763 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocsLedger.ts +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/publishDocsLedger.ts @@ -74,30 +74,61 @@ interface BlobRef { } /** - * Sort only the top-level keys of a plain object for deterministic - * serialization, without recursing into nested values. This avoids - * reordering keys inside API example JSON bodies (request/response), - * which are preformatted user content and must preserve their - * original field order. + * Keys whose values are preformatted user content (e.g. API example + * request/response JSON) and must not have their object keys reordered. + * {@link stableStringify} skips recursive key-sorting for the entire + * subtree rooted at these values. + * + * Covers endpoint examples (`requestBody`, `responseBody`), webhook + * examples (`payload`), and websocket message examples (`body`). All + * are typed as `z.unknown()` in the fdr-sdk register contract. */ -function sortTopLevelKeys(obj: Record): Record { - return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); -} +const PREFORMATTED_KEYS: ReadonlySet = new Set(["requestBody", "responseBody", "payload", "body"]); /** - * Serializes a value to a JSON buffer using standard `JSON.stringify` - * and returns a BlobRef + the raw bytes, keyed by content hash. - * - * Callers that need deterministic output for non-deterministic inputs - * (e.g. Map iteration order) should sort top-level keys via - * {@link sortTopLevelKeys} before calling this function. + * `JSON.stringify` with deterministic key ordering at every level, except + * for subtrees rooted at {@link PREFORMATTED_KEYS} which are passed through + * as-is to preserve their original field order. Arrays keep their original + * ordering (positions are meaningful); all other object keys are sorted + * lexicographically via the replacer. Two inputs that differ only by key + * insertion order serialize identically — required so the apiManifest + * blob hash is stable across publishes (cf. FDR `stableStringify`). * - * Unlike the previous `stableStringify` approach, this does NOT sort - * keys recursively — API example bodies are preformatted content - * whose field order must be preserved. + * Uses a WeakSet to track which objects belong to a preformatted subtree + * so that deeply nested keys (e.g. `care.water` inside a `responseBody`) + * are also preserved. + */ +function stableStringify(value: unknown): string { + const preformatted = new WeakSet(); + return JSON.stringify(value, function (key, val) { + // When we encounter a preformatted key, mark its value and return + // immediately — the entire subtree should be serialized as-is. + if (PREFORMATTED_KEYS.has(key) && val != null && typeof val === "object") { + preformatted.add(val); + return val; + } + // `this` is the holder (parent) object for the current key. + // If the parent is in a preformatted subtree, propagate the mark + // to child objects and return the value without sorting. + if (this != null && typeof this === "object" && preformatted.has(this)) { + if (val != null && typeof val === "object") { + preformatted.add(val); + } + return val; + } + if (val != null && typeof val === "object" && !Array.isArray(val)) { + return Object.fromEntries(Object.entries(val).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); + } + return val; + }); +} + +/** + * Serializes a value to a JSON buffer using {@link stableStringify} and + * returns a BlobRef + the raw bytes, keyed by content hash for later upload. */ function jsonBlobRef(value: unknown): { ref: BlobRef; hash: string; buf: Buffer } { - const buf = Buffer.from(JSON.stringify(value), "utf-8"); + const buf = Buffer.from(stableStringify(value), "utf-8"); const hash = sha256(buf); return { ref: { hash, contentType: "application/json", contentLength: buf.length }, @@ -190,10 +221,10 @@ export function buildLedgerInput({ // `apiDefinitions` is populated by the `registerApi` callback inside a // `Promise.all`, so the Map's insertion order reflects whichever HTTP // round-trip completed first — non-deterministic across publishes. - // We sort the top-level keys (apiDefinitionId) for determinism, but - // use standard `JSON.stringify` to preserve nested key order — API - // example bodies (request/response JSON) are preformatted content - // whose field order must not be reordered. + // {@link jsonBlobRef} uses {@link stableStringify}, which sorts object + // keys at every level for deterministic hashing — except for values + // under {@link PREFORMATTED_KEYS} (example bodies), which are passed + // through as-is to preserve their original field order. // // Determinism caveat: stable apiManifest bytes are necessary but not // sufficient for a deterministic deployment hash. Page bodies must also @@ -205,8 +236,7 @@ export function buildLedgerInput({ // request, so deployment-level dedup will not fire there. let apiManifestRef: BlobRef | null = null; if (apiDefinitions.size > 0) { - const sortedEntries = [...apiDefinitions.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); - const manifestObj = Object.fromEntries(sortedEntries); + const manifestObj = Object.fromEntries(apiDefinitions); const manifestBlob = jsonBlobRef(manifestObj); blobs.set(manifestBlob.hash, manifestBlob.buf); apiManifestRef = manifestBlob.ref; @@ -216,7 +246,7 @@ export function buildLedgerInput({ // custom header/footer components resolved by DocsDefinitionResolver. let jsFilesRef: BlobRef | null = null; if (docsDefinition.jsFiles != null && Object.keys(docsDefinition.jsFiles).length > 0) { - const jsFilesBlob = jsonBlobRef(sortTopLevelKeys(docsDefinition.jsFiles)); + const jsFilesBlob = jsonBlobRef(docsDefinition.jsFiles); blobs.set(jsFilesBlob.hash, jsFilesBlob.buf); jsFilesRef = jsFilesBlob.ref; }