Skip to content

Support YAML and TOML config formats; add avenor verify command - #166

Open
sdougbrown wants to merge 6 commits into
mainfrom
feature/147-alternative-config-formats
Open

Support YAML and TOML config formats; add avenor verify command#166
sdougbrown wants to merge 6 commits into
mainfrom
feature/147-alternative-config-formats

Conversation

@sdougbrown

Copy link
Copy Markdown
Owner

Summary

Adds YAML and TOML support for config files (Team, Loop, Roster) alongside the existing JSON format, and introduces an avenor verify command for validating configs without starting a run.

Closes #147. Related: #165 (future os.Expand variable substitution pass).

New: internal/configfile package

A shared Load(path, dst) helper that:

  • Detects format by file extension: .json (default), .yaml/.yml, .toml
  • Normalizes non-JSON formats through a JSON intermediate (decode to anyjson.Marshal → strict json.Decoder) so that DisallowUnknownFields semantics and json struct tags apply uniformly across all formats
  • Rejects trailing data, multiple YAML documents, and empty documents consistently

Wired into the three existing config loaders:

  • rosterconfig.Load
  • teamrunner.LoadTeamConfigWithRoster
  • looprunner.LoadLoopConfigWithRoster

Each loader replaced its os.ReadFile + json.Unmarshal/json.NewDecoder with a single configfile.Load(path, &cfg) call. The validation and resolution logic downstream (Validate, ResolvePhaseFiles, LoadForConfig, validateRosterEntries) is format-agnostic and unchanged.

Behavior change: Team and loop configs now get DisallowUnknownFields (previously only roster enforced it). This is consistent with the codebase's stated intent — deferred/misspelled fields should not silently become no-ops. If existing configs have extra fields, they will need to be removed before this change.

The shared loader is designed so that future Workflow and Controller config loaders can call configfile.Load(path, &Config{}) with zero format-specific code.

New: avenor verify command

Validates config files without starting a run:

avenor verify --loop-file loop.json
avenor verify --team-file team.json --roster-file roster.json
avenor verify --roster-file roster.json --roster-entry planner
avenor verify --loop-file loop.yaml --dir configs/

Exercises the full validation path (format decoding, unknown-field rejection, mutual exclusions, prompt presence, roster entry references, prompt_file resolution) and recursively validates nested loop_file/team_file references that are only loaded on demand during a real run. Cycle detection via a seen map prevents infinite recursion.

Exit 0 with ok: messages on success; exit 1 with error: messages on failure.

Dependencies

  • gopkg.in/yaml.v3 v3.0.1
  • github.com/pelletier/go-toml/v2 v2.4.3

Both are stable, widely-used, and lightweight (no transitive dependency explosions).

Design notes

The JSON-intermediate approach was chosen over direct decoding into structs because:

  1. DisallowUnknownFields is preserved uniformly — YAML/TOML libraries don't have clean equivalents
  2. json struct tags work unchanged — no duplicate yaml:/toml: tags needed
  3. Trailing-data and multi-document rejection are handled in one place

The wire/JSON-only path (MCP tool args, SpawnParams, HTTP handlers) is untouched — alternate formats are purely a filesystem-loader concern. Configs passed via tool calls remain JSON by definition.

Motivating use case

The umpire-bot integration (~/Code/umpire-bot) currently has configs with ${MAX_ITERATIONS} and ${LLM_BASE_URL} template variables that are invalid JSON until substituted. The integrator works around this with raw-text strings.ReplaceAll plus a sentinel hack. Issue #165 tracks adding os.Expand-style variable substitution to configfile.Load, which would let the integrator pass template paths directly and delete its materialize* functions and sentinel workaround.

Test coverage

  • 22 unit tests in internal/configfile — format detection, JSON/YAML/TOML decode, unknown-field rejection per format, trailing data, multi-document YAML, empty YAML, comments-only YAML, invalid syntax, format equivalence, Decode entry point, file-not-found
  • 18 end-to-end tests for avenor verify — valid/invalid configs, roster entry lookup, nested loop/team files, combined configs, YAML/TOML formats, missing files, --roster-entry without --roster-file
  • Cross-format tests in rosterconfig, teamrunner, and looprunner — verify that JSON, YAML, and TOML produce identical decoded results
  • All 39 existing test packages pass

Commits

  1. feat: support YAML and TOML config files — configfile package + loader wiring + deps + tests
  2. feat: add avenor verify command — verify subcommand + tests
  3. docs: document verify command and multi-format config support — cli.md, loop.md, team.md
  4. fix: address review findings — unused param, YAML error handling, test improvements, new coverage

Add internal/configfile package with a shared Load(path, dst) helper that
detects format by file extension (.json default, .yaml/.yml, .toml) and
decodes into any Go struct. Non-JSON formats are normalized through a JSON
intermediate so that DisallowUnknownFields semantics and json struct tags
apply uniformly across all formats.

Wire configfile.Load into the three existing config loaders:
  - rosterconfig.Load
  - teamrunner.LoadTeamConfigWithRoster
  - looprunner.LoadLoopConfigWithRoster

This also extends DisallowUnknownFields to team and loop configs (previously
only roster enforced it), keeping deferred/misspelled fields from silently
becoming no-ops in all config types.

The shared loader is designed so that future Workflow and Controller config
loaders can use configfile.Load with zero format-specific code.

Related: #165 (os.Expand variable substitution pass)
Add a 'verify' subcommand that loads and validates config files without
starting a run. It exercises the full validation path (format decoding,
unknown-field rejection, mutual exclusions, prompt presence, roster entry
references, prompt_file resolution) and recursively validates nested
loop_file/team_file references that are only loaded on demand during a real
run.

Usage:
  avenor verify --loop-file loop.json
  avenor verify --team-file team.json --roster-file roster.json
  avenor verify --roster-file roster.json --roster-entry planner

Accepts the same --dir flag as run for resolving relative paths. Works with
JSON, YAML, and TOML configs via the shared configfile loader.

Exit code 0 with 'ok:' messages on success; exit code 1 with 'error:'
messages on failure.
Add avenor verify section to cli.md with flags, examples, and output
format. Update flag descriptions and roster/team/loop docs to mention
YAML and TOML support alongside JSON. Add verify usage tips to the
loop and team quick-start sections.
Must-fix:
- Remove unused parentPath parameter from checkNested
- Fix YAML multi-doc error handling: non-EOF parse errors are now wrapped
  with the underlying error instead of being mislabeled as 'multiple YAML
  documents'. The three-way check (EOF=ok, nil=multiple docs, error=parse
  error) preserves the multi-document rejection while surfacing real
  parse errors correctly.

Should-fix:
- Replace custom contains/indexOf/containsCount helpers with strings.Contains
  and strings.Count from the standard library
- Factor buildVerifyBinary to use sync.Once so the binary is compiled once
  instead of per-test (35s → 2.8s)
- Add explicit error for --roster-entry without --roster-file
- Improve TestVerifyNestedLoopFile to assert on file paths (outer.json,
  inner.json) instead of just counting 'ok: loop' occurrences
- Improve TestVerifyLoopWithRosterAndRosterEntryRef to assert on ok: output

New test coverage:
- TestVerifyRosterEntryWithoutRosterFile
- TestVerifyNestedTeamFile (team→team nesting)
- TestVerifyCombinedLoopTeamRoster (all three config types at once)
- TestLoadYAMLRejectsCommentsOnly
- TestLoadTOMLRejectsNestedUnknownFields
@sdougbrown
sdougbrown force-pushed the feature/147-alternative-config-formats branch from a461b49 to 5f24e04 Compare August 7, 2026 20:37

@umpire-bot umpire-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is marked... FOUL BALL. 👉

Warning

  • T3 Duplication: format tests in looprunner, teamrunner, and rosterconfig follow an identical structure and should use a shared helper (internal/looprunner/config_format_test.go:11, internal/teamrunner/config_format_test.go:11, internal/rosterconfig/roster_format_test.go:11)
  • T1 Obscurity: TestVerifyCombinedLoopTeamRoster asserts on multiple output substrings without labeled sub-tests, making failure localization difficult (cmd/avenor/verify_test.go:379)
  • T1 Obscurity: The 'contains' helper lacks context or labels, allowing tests to pass silently on unrelated successful output (cmd/avenor/verify_test.go:67)
  • Documentation: The test for null JSON decodes to a zero-value struct without error; it should assert specific field values to better document behavior (internal/configfile/configfile_test.go:62)

Prior Findings Review

Fixed (5)

Prior findings that have been resolved.
5 prior findings have been resolved in the current code.


Comment thread cmd/avenor/verify.go
}
}

func yamlToJSON(path string, data []byte) ([]byte, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yamlToJSON() error paths (empty YAML, multi-document, comments-only) have no direct unit test of the function itself; they are only exercised indirectly through Load-level tests (TestLoadYAMLRejectsEmptyDocument, TestLoadYAMLRejectsMultipleDocuments, TestLoadYAMLRejectsCommentsOnly). A direct unit test of yamlToJSON would localize failures in the normalization path (e.g. json.Marshal errors on unusual YAML nodes) instead of surfacing them via the full load flow.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👾 AI Agent

Accepted as-is. The yamlToJSON error paths (empty YAML, multi-document, comments-only) are exercised through the public Load API via TestLoadYAMLRejectsEmptyDocument, TestLoadYAMLRejectsMultipleDocuments, and TestLoadYAMLRejectsCommentsOnly. These tests exercise the full normalization + decode path, which is the surface callers actually use. yamlToJSON is an unexported implementation detail; adding direct unit tests would couple tests to internal structure without improving coverage. Additionally, yaml.v3 produces values that json.Marshal always handles successfully — the json.Marshal error path is unreachable in practice.

Comment thread cmd/avenor/verify_test.go
Comment thread cmd/avenor/verify_test.go
Comment thread internal/configfile/configfile.go
Comment thread cmd/avenor/verify_test.go Outdated
Comment thread cmd/avenor/verify_test.go
}
}

func TestVerifyRosterWithEntryLookup(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TestVerifyRosterWithEntryLookup (line 108) and TestVerifyRosterEntryNotFound (line 125) are near-identical: both build the same single-entry roster, invoke the built binary with --roster-file roster.json --roster-entry <name>, and assert on output, differing only in the entry name and the success/error assertion. Consider folding the negative case into a table-driven test (success/not-found rows) to remove the duplication.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👾 AI Agent

Accepted as-is. The two tests have different semantics — one asserts a success path (exit 0, ok: output, entry found) and the other asserts an error path (exit 1, error: output, entry not found). Folding them into a table-driven test would obscure the success/error distinction for minimal deduplication gain (the shared setup is ~5 lines). The current separate form is clearer for readers and easier to extend independently.

PR review fixes:
- Print errors before oks so failures surface first in merged stdout/stderr
- Add output assertion to TestVerifyEmptyPhaseName
- Add nonexistent.json assertion to TestVerifyFileNotFound
- Add TestDecodeInvalidTOMLPreservesParseError for TOML error wrapping
- Add TestDecodeInvalidYAMLPreservesParseError for YAML error wrapping
- Remove dead containsCount helper
- Tighten TestVerifyNestedLoopFileInvalid to assert on inner.json

Regression review fixes:
- Add defensive nil check in tomlToJSON for empty TOML (go-toml/v2
  returns empty map not nil, so the check is defensive only; downstream
  validation catches empty configs via Validate())
@sdougbrown

Copy link
Copy Markdown
Owner Author

👾 AI Agent

Addressed 7 comments across 4 files:

  • Fixed (5): error ordering before oks (verify.go), TestVerifyEmptyPhaseName output assertion, TestVerifyFileNotFound file-name assertion, TestDecodeInvalidTOMLPreservesParseError + TestDecodeInvalidYAMLPreservesParseError for Decode-level error wrapping, removed dead containsCount helper
  • Accepted as is (2): yamlToJSON direct unit test (Load-level tests cover same paths through public API; yamlToJSON is unexported impl detail), fold entry lookup/not-found into table-driven test (different semantics, clearer separate)

Review loop: 2 iterations. Regression review found one additional must-fix (empty TOML silently accepted) — added defensive nil check in tomlToJSON; go-toml/v2 returns empty map (not nil) for empty input so the check is defensive only. Downstream Validate() catches empty configs via required-field checks. Also tightened TestVerifyNestedLoopFileInvalid to assert on inner.json path. All 39 test packages pass.

1 similar comment
@sdougbrown

Copy link
Copy Markdown
Owner Author

👾 AI Agent

Addressed 7 comments across 4 files:

  • Fixed (5): error ordering before oks (verify.go), TestVerifyEmptyPhaseName output assertion, TestVerifyFileNotFound file-name assertion, TestDecodeInvalidTOMLPreservesParseError + TestDecodeInvalidYAMLPreservesParseError for Decode-level error wrapping, removed dead containsCount helper
  • Accepted as is (2): yamlToJSON direct unit test (Load-level tests cover same paths through public API; yamlToJSON is unexported impl detail), fold entry lookup/not-found into table-driven test (different semantics, clearer separate)

Review loop: 2 iterations. Regression review found one additional must-fix (empty TOML silently accepted) — added defensive nil check in tomlToJSON; go-toml/v2 returns empty map (not nil) for empty input so the check is defensive only. Downstream Validate() catches empty configs via required-field checks. Also tightened TestVerifyNestedLoopFileInvalid to assert on inner.json path. All 39 test packages pass.

@sdougbrown

Copy link
Copy Markdown
Owner Author

@umpire-bot review again

Comment thread cmd/avenor/verify.go
}
v.seen[abs] = true

fallback := v.resolve(rosterFallback)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T5 Coverage Illusion: when a loop config has a roster_file field but no --roster-file flag is passed, addLoop resolves the fallback (line 113) and calls LoadLoopConfigWithRoster with that fallback (line 114). No test exercises the case where a loop config declares roster_file: roster.json and verify picks it up as the roster source. Add a test with a loop config containing roster_file and a roster with a valid entry reference in a loop phase.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👾 AI Agent

Incorrect. TestVerifyLoopWithRosterAndRosterEntryRef already covers this: it invokes verify with only --loop-file, while that loop config declares roster_file: roster.json and a phase uses the declared roster entry.

Comment thread cmd/avenor/verify_test.go
Comment thread internal/rosterconfig/roster.go
// detection. This is useful when the caller has already read the file (for
// example to apply a pre-processing step) or is working with embedded data.
func Decode(path string, data []byte, dst any) error {
jsonBytes, err := normalizeToJSON(path, data)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decode wraps errors from yamlToJSON/tomlToJSON with fmt.Errorf("decode config %s: %w", path, err), but those functions already wrap with the same prefix via %w, causing double-wrapping (e.g., "decode config X: decode config X: ...").

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👾 AI Agent

Incorrect. Decode returns the normalizeToJSON error directly in internal/configfile/configfile.go (the if err != nil { return err } branch), so YAML/TOML decode errors receive one decode config prefix from their helper rather than a duplicate prefix.

Comment thread cmd/avenor/verify.go Outdated
@sdougbrown

Copy link
Copy Markdown
Owner Author

👾 AI Agent

Addressed 5 comments across 3 files:

  • Fixed (3): exact successful verify-output assertion (3c3df52); roster-load error context with preserved cause (3c3df52); removed the unused verifier hook (3c3df52).
  • Accepted as is (0).
  • Dismissed (2): the declared-roster verification case is already covered; normalization errors are returned directly, so no duplicate decode prefix exists.

Review loop: 1 iteration. go test ./... passes; no ≥80-confidence findings in the changed diff.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🤲 Alternative Definition Formats?

1 participant