From 279f32a8d272d074f0ca54e2e6222a6c956fb1ac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 22:51:29 +0000 Subject: [PATCH] deps: update ts/go deps; sort model JSON keys for byte-exact parity Update dependencies in both implementations: - Go: aontu/go -> v0.1.4 (release tag), multisource/go -> v0.3.1. - TS: memfs -> 4.57.8. (@types/node 26 is held back: it drops the worker_threads TransferListItem type that pino's thread-stream needs, which breaks the build.) Make the unified model output byte-for-byte identical across TS and Go. Go's encoding/json already sorts object keys, so the TS model producer now sorts them too (sortKeys in ts/src/producer/model.ts). Go disables encoding/json HTML escaping (marshalModel) so <, > and & are emitted literally as JSON.stringify does, and trims the encoder's trailing newline. Both use a two-space indent; arrays keep their order. Add parity tests in both languages (ts/test/parity.test.ts and go/parity_test.go) that assert the same expected byte string, and update AGENTS.md to record that the model JSON output is now byte-identical. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UgsNvRoZYcHm2YDovCUQTP --- AGENTS.md | 11 +++-- go/go.mod | 4 +- go/go.sum | 8 ++-- go/parity_test.go | 61 ++++++++++++++++++++++++++ go/producer.go | 25 +++++++++-- ts/dist-test/parity.test.js | 60 ++++++++++++++++++++++++++ ts/dist-test/parity.test.js.map | 1 + ts/dist/producer/model.js | 19 ++++++++- ts/dist/producer/model.js.map | 2 +- ts/package.json | 2 +- ts/src/producer/model.ts | 21 ++++++++- ts/test/p01/model/model.json | 4 +- ts/test/parity.test.ts | 71 ++++++++++++++++++++++++++++++ ts/test/sys01/model/model.json | 76 ++++++++++++++++----------------- 14 files changed, 308 insertions(+), 57 deletions(-) create mode 100644 go/parity_test.go create mode 100644 ts/dist-test/parity.test.js create mode 100644 ts/dist-test/parity.test.js.map create mode 100644 ts/test/parity.test.ts diff --git a/AGENTS.md b/AGENTS.md index 1918d10..3bb846e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,9 +134,14 @@ Other notes: so `AontuResolver` briefly `chdir`s to the model base (guarded by a mutex) so `@"..."` imports resolve. aontu/go does not report import deps, so the watcher tracks `*.jsonic` files under the base directory. -- **JSON key order** differs: Go's `encoding/json` sorts object keys; - TypeScript preserves insertion order. Content is otherwise equivalent — an - accepted cross-language difference. +- **Model JSON output is byte-for-byte identical** across the two + implementations. Go's `encoding/json` sorts object keys, so the TypeScript + model producer sorts them too (`ts/src/producer/model.ts: sortKeys`); both + use a two-space indent and emit HTML characters (`<`, `>`, `&`) literally + (Go disables `encoding/json`'s HTML escaping in `go/producer.go: + marshalModel`). Arrays keep their order. The byte parity is locked down by + `ts/test/parity.test.ts` and `go/parity_test.go`, which share one expected + string — keep them in step. - **`const Version`** lives in `go/model.go`; `make publish-go V=x.y.z` rewrites it and tags `go/vx.y.z`. - The Go port depends on **`aontu/go` only**; it does not use `util/go` (the diff --git a/go/go.mod b/go/go.mod index ac4d6d8..801b121 100644 --- a/go/go.mod +++ b/go/go.mod @@ -2,14 +2,14 @@ module github.com/voxgig/model/go go 1.24.7 -require github.com/rjrodger/aontu/go v0.1.4-0.20260622151248-c74b91f166cb +require github.com/rjrodger/aontu/go v0.1.4 require ( github.com/tabnas/directive/go v0.2.0 // indirect github.com/tabnas/expr/go v0.2.0 // indirect github.com/tabnas/json/go v0.2.0 // indirect github.com/tabnas/jsonic/go v0.2.0 // indirect - github.com/tabnas/multisource/go v0.3.0 // indirect + github.com/tabnas/multisource/go v0.3.1 // indirect github.com/tabnas/parser/go v0.2.0 // indirect github.com/tabnas/path/go v0.2.0 // indirect ) diff --git a/go/go.sum b/go/go.sum index 7483186..4677408 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,5 +1,5 @@ -github.com/rjrodger/aontu/go v0.1.4-0.20260622151248-c74b91f166cb h1:6KnptJbTI5YVUBF7TTaZpYpVnL49rorUchZjcN5mUlQ= -github.com/rjrodger/aontu/go v0.1.4-0.20260622151248-c74b91f166cb/go.mod h1:8Zr5tBVyW8/U+rwknohvMetWE54P1tRHixT8WEP+c1w= +github.com/rjrodger/aontu/go v0.1.4 h1:5qsPDSUPay6ENCQvRQxpu/e/tN3etCXWSNU6ZoYvIlU= +github.com/rjrodger/aontu/go v0.1.4/go.mod h1:uoXo3k3vKYCWBoa/UypKe60k3Cs0+cQ4WeP36zrPOoI= github.com/tabnas/debug/go v0.2.0 h1:7NvwsdMdv0h/AS0lqxe1Fj3i+GzC4CDsRCND222YYI8= github.com/tabnas/debug/go v0.2.0/go.mod h1:ScInhnXqJuXIiVuCCrLiX6R7sMhmviUk3535FJgnoi4= github.com/tabnas/directive/go v0.2.0 h1:d9tzwQG2fyJ3W1l4kTHPjWYmqLf9QzRndU+uQOV1icI= @@ -10,8 +10,8 @@ github.com/tabnas/json/go v0.2.0 h1:M2T6kMOHp7NGP48VoLKX9nvDNXibxgMHdvXgoD29jQw= github.com/tabnas/json/go v0.2.0/go.mod h1:YrCYGqqvz3RWi2Rq9nzuTRf8YGvvNTeEbhmTT0394io= github.com/tabnas/jsonic/go v0.2.0 h1:AHMrfPQ/NvB+2fxQT26byzNMTYC6a5C6fPoK8SuJLf8= github.com/tabnas/jsonic/go v0.2.0/go.mod h1:u8tWdcVasozUrbHddZrj5qMPq0WwQ3Q4aDbjxhaSkhE= -github.com/tabnas/multisource/go v0.3.0 h1:wHvr2IU9ZlP760TAyZpm7AfzTZHLpX5ryXaVZ+sMZEs= -github.com/tabnas/multisource/go v0.3.0/go.mod h1:1jwWdrNmfu2KqYgfP5gkQcj9G6i/2TTKZFL4MqqJfFo= +github.com/tabnas/multisource/go v0.3.1 h1:1xiT4usHGXJFoZqly7lyf4INjevgOKAmQPV6MHrkP60= +github.com/tabnas/multisource/go v0.3.1/go.mod h1:1jwWdrNmfu2KqYgfP5gkQcj9G6i/2TTKZFL4MqqJfFo= github.com/tabnas/parser/go v0.2.0 h1:jpnX0kXTCX/1EKnL242UYhVcNVCdFq8JN54BNrz8nTg= github.com/tabnas/parser/go v0.2.0/go.mod h1:WrlfEVyZ1QjEIowWiyEu3K1iHj7wxuPut5zoH9Trehk= github.com/tabnas/path/go v0.2.0 h1:WQ3Wep8tD5KHl5nu3M6zYE3zODRJxxaa+FbXAJF3Uz0= diff --git a/go/parity_test.go b/go/parity_test.go new file mode 100644 index 0000000..270f40f --- /dev/null +++ b/go/parity_test.go @@ -0,0 +1,61 @@ +/* Copyright © 2021-2026 Voxgig Ltd, MIT License. */ + +package model + +import ( + "os" + "path/filepath" + "testing" +) + +// parityExpected is the exact bytes both implementations must emit for +// parityModelSrc. Object keys are sorted alphabetically (a, b, html, list, +// nested; and a before z inside nested), arrays keep their order ([3,1,2]), +// the indent is two spaces, and HTML characters are written literally. The +// identical TypeScript expectation lives in ts/test/parity.test.ts — keep the +// two in step. +const parityExpected = `{ + "a": 1, + "b": 2, + "html": " & ", + "list": [ + 3, + 1, + 2 + ], + "nested": { + "a": "a", + "z": "z" + } +}` + +// Keys are deliberately out of alphabetical (insertion) order so the test fails +// if the producer ever stops sorting them. +const parityModelSrc = `b: 2 +a: 1 +nested: { z: "z", a: "a" } +list: [ 3, 1, 2 ] +html: " & " +` + +// ModelProducer output is byte-for-byte identical to the TypeScript producer: +// sorted keys, two-space indent, no HTML escaping, no trailing newline. +func TestModelProducerByteParity(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "model.jsonic") + writeFile(t, dir, "model.jsonic", parityModelSrc) + + b := NewBuild(BuildSpec{Path: path, Base: dir, + Res: []ProducerDef{{Path: "/", Build: ModelProducer}}}) + if br := b.Run(false); !br.OK { + t.Fatalf("build failed: %v", br.Errs) + } + + data, err := os.ReadFile(filepath.Join(dir, "model.json")) + if err != nil { + t.Fatal(err) + } + if string(data) != parityExpected { + t.Fatalf("model.json parity mismatch:\n--- got ---\n%s\n--- want ---\n%s", data, parityExpected) + } +} diff --git a/go/producer.go b/go/producer.go index b391de9..e185a3d 100644 --- a/go/producer.go +++ b/go/producer.go @@ -15,16 +15,17 @@ import ( // in the post phase only, and skips the write when the output is byte-for-byte // unchanged (avoiding mtime churn that would re-trigger watchers). // -// Go's encoding/json emits object keys in sorted order, where the TypeScript -// implementation preserves insertion order; the JSON content is otherwise -// equivalent. This is a known, accepted cross-language difference. +// The output is byte-for-byte identical to the TypeScript implementation: both +// emit object keys in sorted order (Go's encoding/json sorts map keys; the TS +// model producer sorts them explicitly) with a two-space indent and no HTML +// escaping. See marshalModel. func ModelProducer(b *Build, ctx *BuildContext) ProducerResult { pr := ProducerResult{OK: true, Name: "model", Step: ctx.Step, Active: true} if ctx.Step != StepPost { return pr } - data, err := json.MarshalIndent(b.Model, "", " ") + data, err := marshalModel(b.Model) if err != nil { return fail("model", ctx.Step, err) } @@ -166,6 +167,22 @@ func splitOrder(s string) []string { return out } +// marshalModel serializes the model to indented JSON that matches the +// TypeScript output byte-for-byte: object keys sorted (encoding/json sorts map +// keys), two-space indent, and HTML escaping disabled so characters like <, > +// and & are emitted literally as JSON.stringify does. json.Encoder appends a +// trailing newline, which JSON.stringify does not, so it is trimmed. +func marshalModel(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + return nil, err + } + return bytes.TrimRight(buf.Bytes(), "\n"), nil +} + func fail(name string, step Step, err error) ProducerResult { return ProducerResult{Name: name, Step: step, Active: true, OK: false, Errs: []error{err}} } diff --git a/ts/dist-test/parity.test.js b/ts/dist-test/parity.test.js new file mode 100644 index 0000000..3d9cdf0 --- /dev/null +++ b/ts/dist-test/parity.test.js @@ -0,0 +1,60 @@ +"use strict"; +/* Copyright © 2021-2026 Voxgig Ltd, MIT License. */ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const fs_1 = __importDefault(require("fs")); +const path_1 = __importDefault(require("path")); +const node_fs_1 = require("node:fs"); +const node_test_1 = require("node:test"); +const node_assert_1 = __importDefault(require("node:assert")); +const util_1 = require("@voxgig/util"); +const build_1 = require("../dist/build"); +const model_1 = require("../dist/producer/model"); +// The exact bytes both implementations must emit for SRC below. Object keys +// are sorted alphabetically (a, b, html, list, nested; and a before z inside +// nested), arrays keep their order ([3,1,2]), the indent is two spaces, and +// HTML characters are written literally. The identical Go expectation lives in +// go/parity_test.go — keep the two in step. +const EXPECTED = `{ + "a": 1, + "b": 2, + "html": " & ", + "list": [ + 3, + 1, + 2 + ], + "nested": { + "a": "a", + "z": "z" + } +}`; +// Source keys are deliberately out of alphabetical (insertion) order so the +// test fails if the producer ever stops sorting them. +const SRC = `b: 2 +a: 1 +nested: { z: "z", a: "a" } +list: [ 3, 1, 2 ] +html: " & " +`; +(0, node_test_1.describe)('parity', () => { + (0, node_test_1.test)('model-output-keys-sorted', async () => { + const base = path_1.default.join(__dirname, '..', 'test', '_gen', 'parity'); + (0, node_fs_1.mkdirSync)(base, { recursive: true }); + (0, node_fs_1.writeFileSync)(path_1.default.join(base, 'model.jsonic'), SRC); + const log = (0, util_1.prettyPino)('test', {}); + const b = (0, build_1.makeBuild)({ + fs: fs_1.default, + base, + path: path_1.default.join(base, 'model.jsonic'), + res: [{ path: '/', build: model_1.model_producer }], + }, log); + const r = await b.run({ watch: false }); + node_assert_1.default.ok(r.ok, 'build failed: ' + JSON.stringify(r.errs)); + const out = (0, node_fs_1.readFileSync)(path_1.default.join(base, 'model.json'), 'utf8'); + node_assert_1.default.strictEqual(out, EXPECTED); + }); +}); +//# sourceMappingURL=parity.test.js.map \ No newline at end of file diff --git a/ts/dist-test/parity.test.js.map b/ts/dist-test/parity.test.js.map new file mode 100644 index 0000000..ace71c1 --- /dev/null +++ b/ts/dist-test/parity.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"parity.test.js","sourceRoot":"","sources":["../test/parity.test.ts"],"names":[],"mappings":";AAAA,oDAAoD;;;;;AAEpD,4CAAmB;AACnB,gDAAuB;AAEvB,qCAAgE;AAChE,yCAA0C;AAC1C,8DAAgC;AAEhC,uCAAyC;AAIzC,yCAAyC;AACzC,kDAAuD;AAGvD,4EAA4E;AAC5E,6EAA6E;AAC7E,4EAA4E;AAC5E,+EAA+E;AAC/E,4CAA4C;AAC5C,MAAM,QAAQ,GAAG;;;;;;;;;;;;;EAaf,CAAA;AAEF,4EAA4E;AAC5E,sDAAsD;AACtD,MAAM,GAAG,GAAG;;;;;CAKX,CAAA;AAGD,IAAA,oBAAQ,EAAC,QAAQ,EAAE,GAAG,EAAE;IAEtB,IAAA,gBAAI,EAAC,0BAA0B,EAAE,KAAK,IAAI,EAAE;QAC1C,MAAM,IAAI,GAAG,cAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAA;QACjE,IAAA,mBAAS,EAAC,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;QACpC,IAAA,uBAAa,EAAC,cAAI,CAAC,IAAI,CAAC,IAAI,EAAE,cAAc,CAAC,EAAE,GAAG,CAAC,CAAA;QAEnD,MAAM,GAAG,GAAG,IAAA,iBAAU,EAAC,MAAM,EAAE,EAAE,CAAC,CAAA;QAElC,MAAM,CAAC,GAAG,IAAA,iBAAS,EAAC;YAClB,EAAE,EAAE,YAAE;YACN,IAAI;YACJ,IAAI,EAAE,cAAI,CAAC,IAAI,CAAC,IAAI,EAAE,cAAc,CAAC;YACrC,GAAG,EAAE,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,KAAK,EAAE,sBAAc,EAAE,CAAC;SAC5C,EAAE,GAAG,CAAC,CAAA;QAEP,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,GAAG,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAA;QACvC,qBAAM,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,gBAAgB,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAA;QAE1D,MAAM,GAAG,GAAG,IAAA,sBAAY,EAAC,cAAI,CAAC,IAAI,CAAC,IAAI,EAAE,YAAY,CAAC,EAAE,MAAM,CAAC,CAAA;QAC/D,qBAAM,CAAC,WAAW,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAA;IACnC,CAAC,CAAC,CAAA;AAEJ,CAAC,CAAC,CAAA"} \ No newline at end of file diff --git a/ts/dist/producer/model.js b/ts/dist/producer/model.js index 41e5ea0..33cfefa 100644 --- a/ts/dist/producer/model.js +++ b/ts/dist/producer/model.js @@ -5,6 +5,23 @@ var __importDefault = (this && this.__importDefault) || function (mod) { Object.defineProperty(exports, "__esModule", { value: true }); exports.model_producer = void 0; const path_1 = __importDefault(require("path")); +// Recursively sort object keys alphabetically so the serialized model output +// is byte-for-byte identical to the Go implementation, whose encoding/json +// emits object keys in sorted order. Arrays keep their order; only object keys +// are reordered. Returns a new value and does not mutate the input model. +function sortKeys(value) { + if (Array.isArray(value)) { + return value.map(sortKeys); + } + if (null != value && 'object' === typeof value) { + const out = {}; + for (const key of Object.keys(value).sort()) { + out[key] = sortKeys(value[key]); + } + return out; + } + return value; +} // Builds the main model file, after unification. const model_producer = async (build, ctx) => { let pr = { @@ -19,7 +36,7 @@ const model_producer = async (build, ctx) => { if ('post' !== ctx.step) { return pr; } - let json = JSON.stringify(build.model, null, 2); + let json = JSON.stringify(sortKeys(build.model), null, 2); let filename = path_1.default.basename(build.path); let filenameparts = filename.match(/^(.*)\.[^.]+$/); if (filenameparts) { diff --git a/ts/dist/producer/model.js.map b/ts/dist/producer/model.js.map index b5c4106..826e85d 100644 --- a/ts/dist/producer/model.js.map +++ b/ts/dist/producer/model.js.map @@ -1 +1 @@ -{"version":3,"file":"model.js","sourceRoot":"","sources":["../../src/producer/model.ts"],"names":[],"mappings":";;;;;;AACA,gDAAuB;AAKvB,iDAAiD;AACjD,MAAM,cAAc,GAAa,KAAK,EAAE,KAAY,EAAE,GAAiB,EAAE,EAAE;IACzE,IAAI,EAAE,GAAmB;QACvB,EAAE,EAAE,IAAI;QACR,IAAI,EAAE,OAAO;QACb,MAAM,EAAE,KAAK;QACb,IAAI,EAAE,GAAG,CAAC,IAAI;QACd,MAAM,EAAE,IAAI;QACZ,IAAI,EAAE,EAAE;QACR,MAAM,EAAE,EAAE;KACX,CAAA;IAED,IAAI,MAAM,KAAK,GAAG,CAAC,IAAI,EAAE,CAAC;QACxB,OAAO,EAAE,CAAA;IACX,CAAC;IAED,IAAI,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAAA;IAE/C,IAAI,QAAQ,GAAG,cAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IACxC,IAAI,aAAa,GAAG,QAAQ,CAAC,KAAK,CAAC,eAAe,CAAC,CAAA;IACnD,IAAI,aAAa,EAAE,CAAC;QAClB,QAAQ,GAAG,aAAa,CAAC,CAAC,CAAC,CAAA;IAC7B,CAAC;IAED,IAAI,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,GAAG,GAAG,GAAG,QAAQ,GAAG,OAAO,CAAA;IAErD,sEAAsE;IACtE,uDAAuD;IACvD,IAAI,QAA4B,CAAA;IAChC,IAAI,CAAC;QAAC,QAAQ,GAAG,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;IAAC,CAAC;IAAC,MAAM,CAAC,CAAC,CAAC;IAEhE,IAAI,QAAQ,KAAK,IAAI,EAAE,CAAC;QACtB,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC;YACd,KAAK,EAAE,kBAAkB;YACzB,IAAI,EAAE,IAAI;YACV,IAAI,EAAE,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,GAAG,CAAC,GAAG,cAAc;SACxD,CAAC,CAAA;QACF,OAAO,EAAE,CAAA;IACX,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC;QACb,KAAK,EAAE,aAAa;QACpB,IAAI,EAAE,IAAI;QACV,IAAI,EAAE,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,GAAG,CAAC;KACvC,CAAC,CAAA;IAEF,KAAK,CAAC,EAAE,CAAC,SAAS,CAAC,cAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IAC3D,KAAK,CAAC,EAAE,CAAC,aAAa,CAAC,IAAI,EAAE,IAAI,CAAC,CAAA;IAGlC,OAAO,EAAE,CAAA;AACX,CAAC,CAAA;AAGC,wCAAc"} \ No newline at end of file +{"version":3,"file":"model.js","sourceRoot":"","sources":["../../src/producer/model.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/ts/package.json b/ts/package.json index b09c807..c6dd523 100644 --- a/ts/package.json +++ b/ts/package.json @@ -46,7 +46,7 @@ "@tabnas/jsonic": "0.2.0", "aontu": "file:vendor/aontu-0.47.0.tgz", "chokidar": "5.0.0", - "memfs": "4.57.6", + "memfs": "4.57.8", "shape": "10.1.0" }, "peerDependencies": { diff --git a/ts/src/producer/model.ts b/ts/src/producer/model.ts index 6e41401..ecd7a35 100644 --- a/ts/src/producer/model.ts +++ b/ts/src/producer/model.ts @@ -4,6 +4,25 @@ import Path from 'path' import type { Build, Producer, BuildContext, ProducerResult } from '../types' +// Recursively sort object keys alphabetically so the serialized model output +// is byte-for-byte identical to the Go implementation, whose encoding/json +// emits object keys in sorted order. Arrays keep their order; only object keys +// are reordered. Returns a new value and does not mutate the input model. +function sortKeys(value: any): any { + if (Array.isArray(value)) { + return value.map(sortKeys) + } + if (null != value && 'object' === typeof value) { + const out: any = {} + for (const key of Object.keys(value).sort()) { + out[key] = sortKeys(value[key]) + } + return out + } + return value +} + + // Builds the main model file, after unification. const model_producer: Producer = async (build: Build, ctx: BuildContext) => { let pr: ProducerResult = { @@ -20,7 +39,7 @@ const model_producer: Producer = async (build: Build, ctx: BuildContext) => { return pr } - let json = JSON.stringify(build.model, null, 2) + let json = JSON.stringify(sortKeys(build.model), null, 2) let filename = Path.basename(build.path) let filenameparts = filename.match(/^(.*)\.[^.]+$/) diff --git a/ts/test/p01/model/model.json b/ts/test/p01/model/model.json index 689e68e..6711e47 100644 --- a/ts/test/p01/model/model.json +++ b/ts/test/p01/model/model.json @@ -1,4 +1,4 @@ { - "foo": 1, - "bar": 2 + "bar": 2, + "foo": 1 } \ No newline at end of file diff --git a/ts/test/parity.test.ts b/ts/test/parity.test.ts new file mode 100644 index 0000000..cb730dc --- /dev/null +++ b/ts/test/parity.test.ts @@ -0,0 +1,71 @@ +/* Copyright © 2021-2026 Voxgig Ltd, MIT License. */ + +import Fs from 'fs' +import Path from 'path' + +import { mkdirSync, writeFileSync, readFileSync } from 'node:fs' +import { test, describe } from 'node:test' +import assert from 'node:assert' + +import { prettyPino } from '@voxgig/util' + +import type { Build, BuildContext } from '../dist/types' + +import { makeBuild } from '../dist/build' +import { model_producer } from '../dist/producer/model' + + +// The exact bytes both implementations must emit for SRC below. Object keys +// are sorted alphabetically (a, b, html, list, nested; and a before z inside +// nested), arrays keep their order ([3,1,2]), the indent is two spaces, and +// HTML characters are written literally. The identical Go expectation lives in +// go/parity_test.go — keep the two in step. +const EXPECTED = `{ + "a": 1, + "b": 2, + "html": " & ", + "list": [ + 3, + 1, + 2 + ], + "nested": { + "a": "a", + "z": "z" + } +}` + +// Source keys are deliberately out of alphabetical (insertion) order so the +// test fails if the producer ever stops sorting them. +const SRC = `b: 2 +a: 1 +nested: { z: "z", a: "a" } +list: [ 3, 1, 2 ] +html: " & " +` + + +describe('parity', () => { + + test('model-output-keys-sorted', async () => { + const base = Path.join(__dirname, '..', 'test', '_gen', 'parity') + mkdirSync(base, { recursive: true }) + writeFileSync(Path.join(base, 'model.jsonic'), SRC) + + const log = prettyPino('test', {}) + + const b = makeBuild({ + fs: Fs, + base, + path: Path.join(base, 'model.jsonic'), + res: [{ path: '/', build: model_producer }], + }, log) + + const r = await b.run({ watch: false }) + assert.ok(r.ok, 'build failed: ' + JSON.stringify(r.errs)) + + const out = readFileSync(Path.join(base, 'model.json'), 'utf8') + assert.strictEqual(out, EXPECTED) + }) + +}) diff --git a/ts/test/sys01/model/model.json b/ts/test/sys01/model/model.json index cab1b47..6a64769 100644 --- a/ts/test/sys01/model/model.json +++ b/ts/test/sys01/model/model.json @@ -1,82 +1,82 @@ { + "color": { + "blue": { + "name": "blue", + "value": "00f" + }, + "green": { + "name": "green", + "value": "0f0" + }, + "red": { + "name": "red", + "value": "f00" + } + }, "main": { "srv": { - "foo": { - "in": {}, - "out": {}, - "deps": {}, + "bar": { "api": { "web": { "active": true, - "path": { - "prefix": "/api/" - }, - "method": "POST", "cors": { "active": false + }, + "method": "POST", + "path": { + "prefix": "/api/" } } }, + "deps": {}, "env": { "lambda": { - "active": false, - "timeout": 30, + "active": true, "handler": { "path": { "prefix": "src/handler/lambda/", "suffix": ".handler" } }, - "kind": "standard" + "kind": "standard", + "timeout": 30 } - } - }, - "bar": { + }, "in": {}, - "out": {}, - "deps": {}, + "out": {} + }, + "foo": { "api": { "web": { "active": true, - "path": { - "prefix": "/api/" - }, - "method": "POST", "cors": { "active": false + }, + "method": "POST", + "path": { + "prefix": "/api/" } } }, + "deps": {}, "env": { "lambda": { - "active": true, - "timeout": 30, + "active": false, "handler": { "path": { "prefix": "src/handler/lambda/", "suffix": ".handler" } }, - "kind": "standard" + "kind": "standard", + "timeout": 30 } - } + }, + "in": {}, + "out": {} } } }, - "color": { - "red": { - "value": "f00", - "name": "red" - }, - "green": { - "value": "0f0", - "name": "green" - }, - "blue": { - "value": "00f", - "name": "blue" - } - }, "pre": "OK", "sys": {} } \ No newline at end of file