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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ typings/

# Output of 'npm pack'
*.tgz
# ...except the vendored aontu build (tracks aontu GitHub main; not yet on npm)
!ts/vendor/*.tgz

# Yarn Integrity file
.yarn-integrity
Expand Down
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ Go **1.24+** is required (the `aontu/go` dependency declares `go 1.24.7`).
5. **Generated test fixtures go in `ts/test/_gen/`** (gitignored). Tests write
their own fixtures there at runtime; do not commit them.

6. **`aontu` tracks GitHub `main` via a vendored tarball.** The npm package
lives in a monorepo subdir (`rjrodger/aontu` → `ts/`), which npm cannot
install from git, and `main` is ahead of the npm release. So
`ts/vendor/aontu-<version>.tgz` is committed (whitelisted in `.gitignore`
against the global `*.tgz` rule) and referenced as
`"aontu": "file:vendor/aontu-<version>.tgz"`. To bump: `git clone` aontu at
the target commit, `npm pack` its `ts/`, drop the new `.tgz` in
`ts/vendor/` (remove the old one), update the `file:` ref, then
`npm install && npm run build && npm test`. aontu's parser is
`@tabnas/jsonic`; the CLI bin requires it directly (an explicit dep, since
it is no longer hoisted transitively).


## The Go port

Expand All @@ -106,6 +118,12 @@ semantics match TypeScript. Two things differ by necessity:
runtime, so the action *functions* are registered programmatically via
`ModelSpec.Actions` (`map[string]ActionDef`) and bound to the
config-declared names — see `go/producer.go`.
- **The config build is optional.** It is on by default; disable it with
`ModelSpec.config: false` (TS) / `ModelSpec.Config *bool` (Go, `nil` = on)
or the `--no-config` CLI flag. Disabled, the `Model` skips the config build
entirely (no `.model-config/` auto-creation, no actions) and builds the
model alone. Go's `Config` defaults to enabled via a `*bool` because the
bool zero value is `false`; keep TS and Go defaults in step.
- **Watching polls modification times** (`go/watch.go`) rather than using
chokidar.

Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ clean-go:
# Publish the Go module: make publish-go V=0.1.1
publish-go: vet-go test-go
@test -n "$(V)" || (echo "Usage: make publish-go V=x.y.z" && exit 1)
sed -i '' 's/^const Version = ".*"/const Version = "$(V)"/' go/model.go
# Portable in-place edit: GNU sed wants `-i`, BSD/macOS sed `-i ''`.
# A temp file plus mv sidesteps the difference.
sed 's/^const Version = ".*"/const Version = "$(V)"/' go/model.go > go/model.go.tmp \
&& mv go/model.go.tmp go/model.go
git add go/model.go
git commit -m "go: v$(V)"
git tag go/v$(V)
Expand Down
6 changes: 6 additions & 0 deletions docs/explanation.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,12 @@ An internal trigger producer on the config build kicks off the model build, so
the two stay in step. In watch mode both the model's sources and the config's
sources are watched, and a change to either rebuilds the model.

The config build is **optional**. When a `Model` is created with `config: false`
(or the CLI is run with `--no-config`), only the model build runs: nothing is
auto-created under `.model-config/`, no actions are declared or run, and the
model JSON is still written. This suits cases where you only want the unified
model — for example, generating `model.json` for another tool to consume.


## Watching and incremental rebuilds

Expand Down
16 changes: 15 additions & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ generated model JSON is written next to the root file.
| `--debug <level>` | `-g` | string | `info` | Log level: `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `silent`. |
| `--dryrun` | `-y` | boolean | `false` | Resolve and run the build, but redirect all file writes to an in-memory filesystem (nothing touches disk). |
| `--build <jsonic>` | `-b` | string | `''` | A [jsonic](https://github.com/jsonic-lang/jsonic) document of build arguments, exposed to actions as `build.args`. |
| `--no-config` | | boolean | `false` | Skip the `.model-config` build entirely: nothing is auto-created, no config-declared actions run, and the model is built on its own. |
| `--help` | `-h` | boolean | | Print usage and exit. |
| `--version` | `-v` | boolean | | Print version and exit. |

Expand All @@ -59,6 +60,9 @@ voxgig-model --dryrun model/model.jsonic

# Pass build arguments to actions
voxgig-model -b '{env:prod, region:eu-west-1}' model/model.jsonic

# Build the model alone, without the .model-config machinery
voxgig-model --no-config model/model.jsonic
```

### `init` — scaffold a new model
Expand Down Expand Up @@ -115,6 +119,12 @@ directory below the project root (e.g. `model/model.jsonic`).
during a build. If it does not exist, the `Model` constructor creates a minimal
one that imports the package's base config.

The config is **optional**. Pass `config: false` to `ModelSpec` (or `--no-config`
on the CLI) to skip it entirely: no `.model-config/` is created, no actions are
loaded or run, and the model is built on its own (the model JSON is still
written). In the Go API the field is `ModelSpec.Config *bool` — `nil` defaults to
enabled, a pointer to `false` disables it.

The config is itself a model, unified the same way as your main model. The
keys the tool reads:

Expand Down Expand Up @@ -289,6 +299,7 @@ interface ModelSpec {
buildargs?: any // build arguments, exposed as build.args
debug?: boolean | string // log level (string) or true => 'debug'
dryrun?: boolean // redirect writes to an in-memory fs
config?: boolean // resolve .model-config (default true); false skips it
idle?: number // watch debounce in ms (default 111)
fs?: any // a custom fs implementation (e.g. memfs)
log?: Log // a pre-built pino logger
Expand Down Expand Up @@ -476,7 +487,7 @@ A single build (`Build.run`) proceeds as:
5. **post phase** — run every producer with `ctx.step = 'post'`.
6. **result** — return a `BuildResult` with `ok`, `producers`, `errs`, `runlog`.

A `Model` orchestrates **two** builds:
A `Model` orchestrates up to **two** builds:

- The **config build** resolves `.model-config/model-config.jsonic` (writing
`model-config.json`) and, via an internal trigger producer, drives the main
Expand All @@ -487,6 +498,9 @@ A `Model` orchestrates **two** builds:
In watch mode both the model's source files and the config's source files are
watched; a change to either rebuilds the model.

When config is disabled (`config: false` / `--no-config`), the config build is
omitted: the model build runs directly and no config files are watched.

See [explanation](./explanation.md) for the reasoning and the caching and watch
designs in depth.

Expand Down
5 changes: 4 additions & 1 deletion go/cmd/voxgig-model/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ func run(args []string, stderr io.Writer) int {
watch := fs.Bool("w", false, "watch and rebuild on change")
dryrun := fs.Bool("y", false, "dry run (write nothing to disk)")
level := fs.String("g", "info", "log level: trace|debug|info|warn|error|silent")
noConfig := fs.Bool("no-config", false, "skip the .model-config build and run the model on its own")
fs.Usage = func() {
fmt.Fprintln(stderr, "usage: voxgig-model [-w] [-y] [-g level] <root-file>")
fmt.Fprintln(stderr, "usage: voxgig-model [-w] [-y] [-g level] [-no-config] <root-file>")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
Expand All @@ -60,10 +61,12 @@ func run(args []string, stderr io.Writer) int {
return 1
}

enableConfig := !*noConfig
m := model.New(model.ModelSpec{
Path: abs,
Base: filepath.Dir(abs),
Dryrun: *dryrun,
Config: &enableConfig,
Log: model.NewLog(*level),
})

Expand Down
17 changes: 17 additions & 0 deletions go/cmd/voxgig-model/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ func TestCLIWritesModel(t *testing.T) {
}
}

func TestCLINoConfigSkipsConfig(t *testing.T) {
dir := t.TempDir()
root := filepath.Join(dir, "m.jsonic")
write(t, root, "a: 1\n")

var out bytes.Buffer
if code := run([]string{"-no-config", "-g", "silent", root}, &out); code != 0 {
t.Fatalf("exit %d: %s", code, out.String())
}
if _, err := os.Stat(filepath.Join(dir, "m.json")); err != nil {
t.Fatalf("model JSON not written: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, ".model-config")); !os.IsNotExist(err) {
t.Fatal("-no-config should not create .model-config")
}
}

func TestCLIDryrunWritesNothing(t *testing.T) {
dir := t.TempDir()
root := filepath.Join(dir, "m.jsonic")
Expand Down
64 changes: 64 additions & 0 deletions go/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,70 @@ func TestConfigAutoCreated(t *testing.T) {
}
}

// With config disabled, New skips the .model-config build entirely: nothing is
// auto-created, Config() is nil, but the model is still written.
func TestConfigDisabled(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "model.jsonic", "x: 1\n")

disabled := false
m := New(ModelSpec{
Path: filepath.Join(dir, "model.jsonic"),
Base: dir,
Config: &disabled,
})
if br := m.Run(); !br.OK {
t.Fatalf("run failed: %v", br.Errs)
}
if _, err := os.Stat(filepath.Join(dir, ".model-config")); !os.IsNotExist(err) {
t.Fatalf(".model-config should not be created when config is disabled (err=%v)", err)
}
if _, err := os.Stat(filepath.Join(dir, "model.json")); err != nil {
t.Fatalf("model.json not written: %v", err)
}
if m.Config() != nil {
t.Fatal("Config() should be nil when config is disabled")
}
}

// With config disabled, the action order falls back to the spec's Order even
// when a .model-config file is present (it is ignored).
func TestConfigDisabledIgnoresFileUsesOrder(t *testing.T) {
dir := t.TempDir()
mdir := filepath.Join(dir, "model")
cdir := filepath.Join(mdir, ".model-config")
if err := os.MkdirAll(cdir, 0o755); err != nil {
t.Fatal(err)
}
writeFile(t, mdir, "model.jsonic", "x: 1\n")
writeFile(t, cdir, "model-config.jsonic",
"sys: model: action: { a: load: 'x', b: load: 'y' }\n"+
"sys: model: order: action: 'b,a'\n")

var order []string
mk := func(n string) ActionDef {
return ActionDef{Run: func(_ map[string]any, _ *Build, _ *BuildContext) ActionResult {
order = append(order, n)
return ActionResult{OK: true}
}}
}
disabled := false
m := New(ModelSpec{
Path: filepath.Join(mdir, "model.jsonic"),
Base: mdir,
Config: &disabled,
Actions: map[string]ActionDef{"a": mk("a"), "b": mk("b")},
Order: []string{"a", "b"},
})
if br := m.Run(); !br.OK {
t.Fatalf("run failed: %v", br.Errs)
}
// Spec Order wins (a,b); the config file's order (b,a) is ignored.
if strings.Join(order, ",") != "a,b" {
t.Fatalf("action order = %v, want [a b] (from spec Order, config ignored)", order)
}
}

// The config's sys.model.order.action drives the action run order, overriding
// the registry's default (sorted) order.
func TestConfigDrivesActionOrder(t *testing.T) {
Expand Down
14 changes: 8 additions & 6 deletions go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ module github.com/voxgig/model/go

go 1.24.7

require github.com/rjrodger/aontu/go v0.1.3
require github.com/rjrodger/aontu/go v0.1.4-0.20260622151248-c74b91f166cb

require (
github.com/jsonicjs/directive/go v0.1.4 // indirect
github.com/jsonicjs/expr/go v0.1.3 // indirect
github.com/jsonicjs/jsonic/go v0.1.22 // indirect
github.com/jsonicjs/multisource/go v0.1.6 // indirect
github.com/jsonicjs/path/go v0.1.2 // indirect
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/parser/go v0.2.0 // indirect
github.com/tabnas/path/go v0.2.0 // indirect
)
30 changes: 18 additions & 12 deletions go/go.sum
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
github.com/jsonicjs/directive/go v0.1.4 h1:K7ai2ccoh8d23mjCK8/YMjEDbzb6+jWcepOL3YubpKo=
github.com/jsonicjs/directive/go v0.1.4/go.mod h1:PMjJ3EulIhdo1R0o9bOUvSUOTCPvM/xCzo1O35VBiXs=
github.com/jsonicjs/expr/go v0.1.3 h1:D7x+5AIM/CLX6A6VUiHBzfvRxufuGzXYB6gmPb1lv+A=
github.com/jsonicjs/expr/go v0.1.3/go.mod h1:6kmd1o4p4/FL4DVuCxwYAqX5YRYjF20hbIrHQM81Qoc=
github.com/jsonicjs/jsonic/go v0.1.22 h1:sam238fTyjDq0nby9TYS+aCCHprLl91ArQPWLCg2O0Y=
github.com/jsonicjs/jsonic/go v0.1.22/go.mod h1:ObNKlCG7esWoi4AHCpdgkILvPINV8bpvkbCd4llGGUg=
github.com/jsonicjs/multisource/go v0.1.6 h1:FGOySI7hmFjsmcI/mbnBF04DFFOb5FqR2fOydOCoSuA=
github.com/jsonicjs/multisource/go v0.1.6/go.mod h1:XzgM6nuW2MOAsVySsGtpp7fWSx9PTCXd4eIRSCAA/6o=
github.com/jsonicjs/path/go v0.1.2 h1:Pk7PZIiRq64iodssEVtaJw+4jHiJe84aFtmKDUasXAo=
github.com/jsonicjs/path/go v0.1.2/go.mod h1:ffXuSMg950pdfU3wapeK6QnyvZrV5yHFSj4ifwmsgmU=
github.com/rjrodger/aontu/go v0.1.3 h1:jEMxKpN8C7ijCaFCmXCL8r3YI7wtvYkeyVGYSbO4pCw=
github.com/rjrodger/aontu/go v0.1.3/go.mod h1:wvCO27yvs2Rdr8bLD/hEn4e0+Qxz6rQImkhC3y8lgzc=
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/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=
github.com/tabnas/directive/go v0.2.0/go.mod h1:NCXUWyi24dYonJV0V8MUzCNNb46jaNNKRAah22AzCTc=
github.com/tabnas/expr/go v0.2.0 h1:cS/t6B8ieRYUYPjyqTXs9ou3gmiRnV31MuAZ81RXiCc=
github.com/tabnas/expr/go v0.2.0/go.mod h1:aDMV8YKvH8pkilgHVqtFWSrOc/YBfMN3dBOzrY63vDI=
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/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=
github.com/tabnas/path/go v0.2.0/go.mod h1:BX3CmO21Rhno8DxJnWMXNc9hlqoV1cmuG6efI7QZIio=
46 changes: 30 additions & 16 deletions go/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,16 @@ import (

// Version is the released version of the Go module. It is rewritten by
// `make publish-go V=x.y.z` to match the git tag (go/vx.y.z).
const Version = "0.1.2"
const Version = "0.1.3"

// DefaultIdle is the default watch debounce period.
const DefaultIdle = 111 * time.Millisecond

// Model unifies a .jsonic model and runs producers (the model writer and any
// registered actions) over it. It can build once or watch and rebuild, and
// resolves a .model-config/model-config.jsonic config (auto-created when
// missing) that declares the action order.
// optionally resolves a .model-config/model-config.jsonic config (auto-created
// when missing) that declares the action order. The config is enabled by
// default; ModelSpec.Config can disable it (see New).
type Model struct {
config *Config
build *Build
Expand All @@ -51,7 +52,12 @@ func New(spec ModelSpec) *Model {
idle = DefaultIdle
}

config := newConfig(base, spec, log)
// Config is optional: a nil spec.Config defaults to enabled. When disabled,
// the .model-config/ build is skipped and the model runs on its own.
var config *Config
if spec.Config == nil || *spec.Config {
config = newConfig(base, spec, log)
}

build := NewBuild(BuildSpec{
Name: "model",
Expand All @@ -70,7 +76,9 @@ func New(spec ModelSpec) *Model {
{Path: "/", Build: LocalProducer},
},
})
build.Use["config"] = config
if config != nil {
build.Use["config"] = config
}

m := &Model{
config: config,
Expand All @@ -80,28 +88,34 @@ func New(spec ModelSpec) *Model {
}

// Re-resolve the config on each watch rebuild so config edits are picked up.
m.watch.reload = func() {
config.build.InvalidateCache()
config.Run()
if config != nil {
m.watch.reload = func() {
config.build.InvalidateCache()
config.Run()
}
}

return m
}

// Run builds the config and then the model once, returning the model result.
// A failed config build is returned instead.
// Run builds the config (when enabled) and then the model once, returning the
// model result. A failed config build is returned instead.
func (m *Model) Run() *BuildResult {
if cr := m.config.Run(); !cr.OK {
return cr
if m.config != nil {
if cr := m.config.Run(); !cr.OK {
return cr
}
}
return m.watch.Run(false)
}

// Start builds once (config then model), then watches and rebuilds until Stop
// is called. It returns the initial model result.
// Start builds once (config when enabled, then model), then watches and
// rebuilds until Stop is called. It returns the initial model result.
func (m *Model) Start() *BuildResult {
if cr := m.config.Run(); !cr.OK {
return cr
if m.config != nil {
if cr := m.config.Run(); !cr.OK {
return cr
}
}
return m.watch.Start()
}
Expand Down
7 changes: 7 additions & 0 deletions go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,11 @@ type ModelSpec struct {
Idle time.Duration
Watch WatchModes
Log Log

// Config resolves a .model-config/model-config.jsonic (auto-created when
// missing) that declares the build action order. A nil pointer defaults to
// enabled; set it to a pointer-to-false to skip the config entirely and run
// the model on its own (action order then comes from Order, else the
// registered Actions).
Config *bool
}
Loading
Loading