Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
8 changes: 4 additions & 4 deletions go/go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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=
Expand Down
61 changes: 61 additions & 0 deletions go/parity_test.go
Original file line number Diff line number Diff line change
@@ -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": "<a> & </a>",
"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: "<a> & </a>"
`

// 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)
}
}
25 changes: 21 additions & 4 deletions go/producer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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}}
}
60 changes: 60 additions & 0 deletions ts/dist-test/parity.test.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions ts/dist-test/parity.test.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 18 additions & 1 deletion ts/dist/producer/model.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion ts/dist/producer/model.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
21 changes: 20 additions & 1 deletion ts/src/producer/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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(/^(.*)\.[^.]+$/)
Expand Down
4 changes: 2 additions & 2 deletions ts/test/p01/model/model.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"foo": 1,
"bar": 2
"bar": 2,
"foo": 1
}
71 changes: 71 additions & 0 deletions ts/test/parity.test.ts
Original file line number Diff line number Diff line change
@@ -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": "<a> & </a>",
"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: "<a> & </a>"
`


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)
})

})
Loading
Loading