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