Compare commits

...

8 Commits

14 changed files with 3796 additions and 656 deletions

View File

@@ -1,642 +0,0 @@
# CLI and Configuration Contract Test Plan
This plan adds deliberate contract and risk-based test coverage for
`internal/cli` and `internal/core/config`. It does not pursue a statement
coverage target. Coverage reports are diagnostic input: they may reveal an
important untested branch, but a percentage alone does not justify a test.
The audience is a relatively small LLM coding agent. Implement one stage per
prompt, in order. Each stage must leave the repository compiling and passing
all tests.
## Scope and fixed decisions
Tests added by this plan protect:
- external contracts documented in `docs/cli.md`, `docs/config.md`,
`docs/operations.md`, and maintained examples;
- internal contracts around configuration loading, precedence, validation,
resolution, cloning, and redaction;
- high-risk CLI composition paths involving run selection, references, LLM
profiles, output, cache, resume, and debug state; and
- production composition and the shortest maintained end-to-end workflows.
The following decisions are fixed:
- Do not set or enforce a repository-wide coverage percentage.
- Do not restore deleted test files wholesale. The former suite predates
ADR-0006 and includes obsolete workspace and diagnostics behavior. Historical
tests immediately before commit `26142f0` may be consulted for fixture and
harness ideas only; every restored assertion must be checked against current
code and canonical documentation.
- Prefer behavior tests at the narrowest useful boundary. Pure configuration
rules belong in `internal/core/config`; CLI tests should prove parsing,
composition, process-facing behavior, and cross-component wiring rather than
repeat every configuration permutation.
- Test stable observable facts: exit code, output stream, selected collaborator,
logical artifact, path, or resolved field. Avoid exact whole-error strings,
entire JSON snapshots, timestamps, random identifiers, and internal call
sequences unless those are the contract under test.
- Tests must be deterministic, offline, and secret-free. Inject catalogs,
registries, clocks, run-ID generators, cache collaborators, and fake LLM
clients. Use `t.TempDir`, `t.Setenv`, and `t.Chdir` where appropriate. Never
use a real provider, credential, user cache directory, or shared output root.
- Do not weaken a test to match an apparent defect. If current behavior
conflicts with a canonical contract, stop the stage and report the conflict.
Production fixes are outside this test-coverage plan unless separately
authorized.
- Preserve the focused ADR-0006 state tests already present. Consolidate
helpers only when it materially reduces duplication and does not obscure the
contract being tested.
- Checkpoint wire compatibility is already owned by
`internal/framework/checkpoint` tests. Do not add a second legacy checkpoint
fixture in CLI tests unless a distinct CLI compatibility boundary is found.
## Execution rules for every stage
Before editing:
1. Read `docs/development.md` and follow its task-specific reading guide.
2. Read both files under `docs/policy/`.
3. Read this plan and all current source, tests, and canonical documents named
by the stage.
4. Run `git status --short`; preserve all existing changes.
5. Run the stage's package tests once to establish a baseline.
While editing:
- Add tests only for the cases listed in the stage. Do not expand into adjacent
subsystem redesign.
- Use table-driven tests for validation and syntax matrices when the setup and
assertion are genuinely shared.
- Give failures enough context to identify the contract case.
- Assert both the positive outcome and important negative side effects, such as
a root not being resolved or a file not being created.
- Do not run environment- or working-directory-mutating tests in parallel.
At the end of each stage:
1. Run the focused tests named by that stage.
2. Run `go test ./...`.
3. Run `go vet ./...`.
4. Run `go build ./cmd/notarius`.
5. Run `git diff --check`.
6. Review the diff for assertions tied to obsolete implementation details.
Do not mark a stage complete until all checks pass.
## Stage 1: File schema, defaults, and decoding contracts
**Status:** Not started
### Objective
Protect the version 3 file format and the translation from YAML into the
runtime configuration model.
### Read first
- `docs/config.md`, through **Module Bindings**
- `internal/core/config/config.go`
- `internal/core/config/file_config.go`
- `internal/core/config/v3_test.go`
### Implement
Add focused tests in `internal/core/config/file_config_contract_test.go`. Reuse
small helper constructors within that file; do not reproduce complete example
files for unit cases.
Cover these contracts:
1. `Default` returns the documented concurrency, output, cache-family, debug,
and empty-pipeline defaults. Mutating maps in one returned configuration
must not affect a later `Default` result.
2. A minimal `version: 3` file parses and applies over defaults.
3. Missing, version 2, and unsupported versions fail with actionable version
context before ordinary field decoding.
4. Unknown top-level, pipeline, lane, and module-binding fields are rejected.
Include removed `workspace`, `diagnostics`, and `llm_profiles` fields as
representative compatibility failures, without testing every old field.
5. Shorthand and object module bindings preserve module, profile, retries,
options, references, and the distinction between omitted validators and an
explicit empty validator chain.
6. Pipeline, target-local, and lane compatibility reference maps apply with the
documented precedence, including `extract.references` overriding the
lane-level alias.
7. Chunk, extract, merge, and normalize stage-local validator bindings retain
configured order and fields.
8. Scriptorium source, concurrency, output, chunk-plan cache, checkpoint cache,
and debug sections apply without coupling their roots.
9. Keys that collide after trimming—pipeline IDs, lane IDs, and representative
reference slots—are rejected. Do not enumerate the same normalization rule
at every possible reference location.
10. `LoadFileConfig` reports both missing-file and malformed-YAML context.
Where `v3_test.go` already proves a case completely, either leave it there or
move it without duplicating it.
### Validation
```sh
go test ./internal/core/config -run 'Test(FileConfig|Default|Version3)'
```
### Exit criteria
The maintained version 3 schema, defaults, binding forms, state sections, and
normalization rules can be refactored without silently changing their contract.
## Stage 2: Precedence and validation matrices
**Status:** Not started
### Objective
Protect configuration precedence and high-risk invalid combinations after all
sources have been applied.
### Read first
- `docs/config.md`, especially **Discovery**, **Environment Overrides**,
**Concurrency**, **Module Bindings**, and **State Surfaces**
- `internal/core/config/env.go`
- `internal/core/config/validation.go`
- current tests from Stage 1
### Implement
Add `internal/core/config/env_contract_test.go` and
`internal/core/config/validation_contract_test.go`.
Precedence tests must prove:
1. File values override built-in defaults.
2. Every current `NOTARIUS_*` operational variable overrides the corresponding
file value: total LLM concurrency, extract workers, output directory,
chunk-plan mode and directory, checkpoint directory, and debug directory.
3. Extract workers default to the final effective total when not explicitly
configured, but an explicit file or environment worker value is retained.
4. Empty cache-directory fields in a file remain valid and select deferred
per-user defaults; empty directory environment overrides are errors.
5. Invalid integers and chunk-cache modes report the responsible environment
variable and do not panic.
6. Removed provider environment variables are ignored. Do not place credential
values in assertions or failure output.
Validation tables must cover one representative case for each rule family:
- non-positive total concurrency; extract worker below one, above total, and at
both valid boundaries; unknown and blank worker keys;
- mutually exclusive Scriptorium profile sources;
- blank required output/debug roots, NUL in every physical root family, and an
invalid chunk-plan mode;
- empty and trim-duplicated pipeline/lane/reference identifiers;
- negative retries and whitespace-only explicit LLM profiles;
- references on unsupported input/output bindings;
- invalid validator bindings: empty module, retries, references, nested
validators, and validator chains on unsupported stages; and
- deprecated non-empty lane-level validators rejected while stage-local omitted,
empty, and non-empty overrides remain valid structurally.
Assert stable contextual fragments rather than complete error text.
### Validation
```sh
go test ./internal/core/config -run 'Test(Env|Precedence|Validate)'
```
### Exit criteria
Every documented precedence edge and every materially different validation
rule family has a direct, readable regression test.
## Stage 3: Effective configuration, cloning, and redaction
**Status:** Not started
### Objective
Protect the internal contract that validated configuration resolves into an
isolated, auditable pipeline without leaking sensitive options.
### Read first
- `docs/policy/architecture.md`, especially **Pipeline Composition And
Ownership**, **Configuration And Provenance**, and **State, Output, And
Safety**
- `internal/core/config/effective_config.go`
- `internal/core/config/redaction.go`
- `internal/core/config/redaction_test.go`
- `internal/framework/pipeline/profile.go`
### Implement
Add `internal/core/config/effective_config_contract_test.go` and extend
`redaction_test.go` only for missing cases.
Use a minimal fake `pipeline.ModuleCatalog` with enough variants and
capabilities to make each resolution outcome explicit. Cover:
1. Empty and unknown pipeline IDs fail; a map key with harmless surrounding
whitespace resolves to its normalized ID.
2. `Only` selects exactly the requested lanes, rejects unknown lanes, and does
not mutate the source configuration.
3. Default chunk, merge, normalize, and output modules are materialized through
the catalog.
4. Unknown modules, missing capabilities, missing artifact variants, invalid
module options, and invalid validator options retain pipeline/lane/stage
context.
5. An LLM-profile override applies to LLM-capable pipeline module bindings
before digest calculation, does not override validator-specific profiles,
and changes the digest when effective behavior changes.
6. Omitted, explicitly empty, and configured validator chains resolve
distinctly and preserve configured order.
7. Returned `EffectiveConfig`, resolved bindings, nested options, reference
slices/content, and validator chains do not alias the input configuration or
resolution inputs.
8. Both redacted summary payloads cover every binding and recursively redact
sensitive keys in untyped and typed/aliased option containers while
preserving safe neighbors and excluding materialized reference content.
Do not duplicate detailed reference-selector behavior owned by Stage 6.
### Validation
```sh
go test ./internal/core/config -run 'Test(Resolve|Effective|Redacted)'
```
### Exit criteria
Resolution defaults, selection, compatibility failures, profile overrides,
digests, deep-copy isolation, and redaction are directly protected.
## Stage 4: CLI command and configuration-loading contracts
**Status:** Not started
### Objective
Protect the public command surface, exit-code classification, configuration
discovery, validation command, and pipeline listing.
### Read first
- `docs/cli.md`
- `docs/config.md#discovery`
- command dispatch, `runConfigValidate`, `runPipelinesList`, and configuration
loading in `internal/cli/run.go`
- `internal/cli/state_surfaces_test.go`
### Implement
Add `internal/cli/command_contract_test.go`. Use injected options and temporary
files; no case may require production credentials or provider access.
Cover:
1. No arguments and each documented help spelling write usage to stdout and
return `0`.
2. Unknown commands/subcommands and malformed command syntax write to stderr
and return `2`.
3. Configuration discovery precedence is explicit `--config`, then non-empty
`NOTARIUS_CONFIG`, then the compiled default path. Test the first two with
temporary files. For the compiled path, inspect its host state first: assert
selection when it is a regular file, or the documented not-found error when
it is absent. Never create, replace, or remove the system path.
4. Missing and malformed configuration files return `1` with actionable path
or parse context.
5. `config validate` succeeds for a valid file, resolves a requested pipeline
against an injected catalog, rejects an unknown pipeline/lane, requires
`--pipeline` with `--only`, and returns `2` for malformed `--only` syntax.
6. `pipelines list` sorts normalized IDs in text and JSON forms; JSON is decoded
and compared structurally rather than as raw formatting.
7. Removed structural/state flags such as `--diagnostics-dir` are rejected with
exit `2` instead of being silently ignored.
8. Representative malformed run flags and valid-runtime failures establish the
documented `2` versus `1` boundary.
### Validation
```sh
go test ./internal/cli -run 'Test(Command|Help|ConfigDiscovery|ConfigValidate|PipelinesList|ExitCode)'
```
### Exit criteria
The documented command grammar, streams, discovery order, listing behavior,
and exit-code classes are protected without invoking a real pipeline.
## Stage 5: Run controls, profile selection, and process-facing results
**Status:** Not started
### Objective
Protect the public run controls and the CLI-to-pipeline composition boundary.
### Read first
- `docs/cli.md#run`
- `internal/cli/run.go`
- `internal/cli/scriptorium_profiles.go`
- `internal/cli/state_hardening_test.go` and its fake harness
### Implement
Add `internal/cli/run_contract_test.go`. Reuse or minimally generalize the
existing deterministic state-test harness. Keep helper implementations at the
bottom of a test file or in one clearly named `test_helpers_test.go` file.
Cover:
1. Missing pipeline ID, missing `--input`, unknown flags, blank values, and
multiple positional IDs return `2` without allocating output or debug state.
2. Unknown pipeline/lane and unreadable input are valid invocations that return
`1`; when debug is requested after allocation, its path is reported.
3. A successful run writes the logical output files, reports normalized and
rejected counts, and passes the same deterministic run ID and start time to
the manifest and debug bundle.
4. `--only` executes and reports only selected lanes.
5. `--output-dir` and `--debug-dir` override environment, file, and default
roots; `--debug-dir` without `--debug` returns `2` and creates nothing.
6. `--llm-profile` reaches every effective LLM-capable pipeline module binding,
leaves validator-specific profiles unchanged, selects the expected factory
profile when exactly one is effective, and validates explicit Scriptorium
profile IDs without making provider calls.
7. `effectiveLLMProfileIDs` ignores deterministic stages and returns stable,
deduplicated ordering across chunk, lane stages, and LLM-backed validators.
8. `--session-id` rejects missing/blank values and passes a trimmed explicit
value through prompt-facing requests. With no override, the parsed source
document ID is used as the session identifier.
9. LLM factory, preparation, pipeline execution, output persistence, and debug
persistence failures return `1`, preserve the primary error, and do not print
a success message.
10. Successful warnings remain exit `0`, are counted on stderr, and appear in
durable output and requested debug summary.
Do not repeat the output-collision and terminalization matrices already covered
by `run_id_test.go` and `state_hardening_test.go`.
### Validation
```sh
go test ./internal/cli -run 'TestRun'
```
### Exit criteria
Every public non-reference run flag and every major CLI-to-runner handoff has a
contract test, including representative failures.
## Stage 6: CLI reference selector and override contracts
**Status:** Not started
### Objective
Protect the high-risk selector grammar and target-resolution behavior without
duplicating reference materialization internals.
### Read first
- reference sections of `docs/cli.md` and `docs/config.md`
- reference parsing and resolution helpers in `internal/cli/run.go`
- reference resolution/materialization tests under `internal/framework/pipeline`
### Implement
Add `internal/cli/reference_contract_test.go`. Build a compact catalog with
chunk, extractor, merger, and normalizer specs declaring distinct, shared,
optional, and required slots, including at least two artifact kinds where
variant lookup matters.
Cover:
1. Parse and apply every documented selector form: flat, `chunk.slot`,
`merge.slot`, `lane.slot`, and explicit lane extract/merge/normalize.
2. Flat and lane selectors succeed only with one eligible selected target.
Ambiguity errors identify viable explicit selectors.
3. Selected lanes constrain reference discovery; selectors for unselected or
unknown lanes fail before reference file materialization.
4. Malformed bindings—missing selector, path, separator, or excess selector
segments—and malformed unbind selectors return `2`.
5. Repeated overrides use the final explicit binding for that exact target;
target-specific overrides do not alter same-named slots on other targets.
6. `--without-reference` removes optional configured bindings and fails when a
required chunk, extract, merge, or normalize slot would remain unbound.
7. CLI paths resolve from the working directory while config paths resolve from
the config file directory. Assert recorded provenance, not private helper
call order.
8. Target lookup uses the selected artifact-kind variant and returns useful
context when the requested merger/normalizer/extractor variant is absent.
Use small UTF-8 temporary reference files. Media-type and malformed-content
details already proven by pipeline materialization tests need only one CLI
smoke case.
### Validation
```sh
go test ./internal/cli -run 'Test.*Reference'
```
### Exit criteria
All public selector forms, ambiguity boundaries, lane selection, binding
precedence, unbinding, and path-origin rules are protected.
## Stage 7: Cache, resume, output, and debug integration risks
**Status:** Not started
### Objective
Fill state-integration gaps without duplicating the focused ADR-0005 and
ADR-0006 tests already present.
### Read first
- `docs/operations.md`
- `docs/internal/state.md`
- ADR-0005 and ADR-0006
- `internal/cli/run_id_test.go`
- `internal/cli/state_surfaces_test.go`
- `internal/cli/state_hardening_test.go`
- tests under `internal/framework/chunkplan` and
`internal/framework/checkpoint`
### Implement
Add `internal/cli/cache_contract_test.go` only for uncovered CLI composition
contracts. Extend an existing state test when it already owns the behavior.
Cover or confirm existing coverage for:
1. Chunk-plan mode precedence: CLI flag, environment, file, default.
Invalid CLI syntax returns `2`; invalid environment/file modes return `1`.
2. An explicit chunk-plan root is passed through exactly. An empty configured
root selects `<UserCacheDir>/notarius/chunk-plans`. `bypass` never calls
`UserCacheDir`, constructs a store, or creates a root.
3. Root-resolution and store-construction failures preserve context and create
no durable output.
4. `auto` reuses a plan across independent invocations with the same source
digest even when pipeline, chunk module settings, references, or selected
lanes differ. `refresh` replaces only after valid chunking; `bypass` does no
cache I/O. Reuse details below the CLI remain owned by chunk-plan package
tests.
5. `--resume` alone constructs checkpoint loader and recorder under the exact
configured or per-user checkpoint root. Without it, neither root resolution
nor checkpoint I/O occurs.
6. Chunk-plan reuse and checkpoint resume remain independent across the
relevant mode matrix. Reuse a compact table rather than duplicating every
existing state-surface assertion.
7. Existing tests continue to prove exclusive output allocation, atomic files,
retained new partial output, shared run identity, debug opt-in, restrictive
debug permissions, redacted summaries, and exactly-once terminal reports.
Add a case only if one of these facts is not directly asserted.
8. Config-oriented commands do not resolve or create output, cache, or debug
state.
Do not introduce automatic cleanup tests: Notarius intentionally retains output
and requested debug bundles.
### Validation
```sh
go test ./internal/cli -run 'Test.*(Cache|ChunkPlan|Checkpoint|Resume|State|Output|Debug|RunID)'
go test ./internal/framework/chunkplan ./internal/framework/checkpoint
```
### Exit criteria
CLI state-root selection, opt-in I/O, cache mode precedence, cross-run reuse,
and cache-family independence are covered at their composition boundary.
## Stage 8: Production composition and maintained examples
**Status:** Not started
### Objective
Protect the small number of production and end-to-end contracts most likely to
break while unit tests continue to pass.
### Read first
- `README.md`
- maintained files under `examples/`
- `docs/config.md#implemented-production-modules`
- `internal/cli/catalog.go`
- `internal/modules/integration` tests and helpers
### Implement
Add `internal/cli/production_contract_test.go` and
`internal/cli/example_contract_test.go`. Reuse production registries and prompt
assets, but always inject a deterministic structured fake LLM client.
Production composition tests must prove:
1. The production catalog exposes every module, artifact codec, validator,
default validator chain, and prompt asset named by current configuration and
maintained examples.
2. Catalog/registry conversion preserves typed artifact codec variants and
validator-chain registrations.
3. Production prompt assets allow the configured D&D scene chunker and spell
extractor to prepare without reading provider credentials.
4. `config validate --pipeline` accepts the maintained production module graph
and rejects representative unknown modules, unknown validators, invalid
artifact variants, and deterministic validators with LLM profiles.
5. A small test configuration selecting `dnd/scenes` records that chunker and
its expected warning/provenance fields during a fake-LLM run.
Example tests must prove:
1. Every maintained example configuration parses, validates, resolves its
documented pipeline, and appears correctly in `pipelines list`.
2. The README/minimal example invocation runs with a fake LLM and produces the
documented logical JSON bundle. Decode manifest, index, warning, rejection,
and artifact files and assert contract fields; do not snapshot whitespace or
incidental timestamps.
3. The documented `--only spells` invocation selects exactly the `spells` lane.
4. One malformed input fixture returns `1`, produces no successful output
bundle, and records a failure report only when debug is explicitly enabled.
If an example requires a provider profile, supply a test-only fake through
`cli.Options`; never modify the example to embed credentials.
### Validation
```sh
go test ./internal/cli -run 'Test(Production|Example|Maintained)'
go test ./internal/modules/integration
```
### Exit criteria
Production registration, prompts, maintained configuration, and the shortest
end-to-end user workflow are protected by offline tests.
## Stage 9: Coverage audit and plan closure
**Status:** Not started
### Objective
Confirm that contract and high-risk coverage is complete without converting
coverage percentage into a goal.
### Read first
- all tests added by Stages 18
- current canonical CLI, configuration, operations, integration, and internal
state documents
- `docs/policy/documentation.md`
### Implement
1. Build a temporary checklist mapping every current CLI command/flag and every
current configuration section/source to at least one owning test. Do not
commit the checklist if the test names themselves make ownership clear.
2. Run statement coverage for `internal/cli` and `internal/core/config`. Inspect
uncovered functions and branches. Add a test only when the uncovered code is
a documented contract, safety boundary, destructive/stateful path, complex
selector/precedence branch, or failure path likely to regress.
3. Remove redundant cases that prove no additional contract or risk. Keep a
small end-to-end layer and more numerous narrow unit tests.
4. Confirm tests do not access the network, depend on host credentials, use the
real user cache directory, write outside temporary roots, or depend on test
ordering.
5. Run race-enabled tests for the two changed packages. Fix test races; report
production races separately unless authorized to change production code.
6. Update `docs/development.md` only if a stable testing procedure beyond its
existing validation commands is now necessary. Do not document a coverage
threshold.
7. Once all checks pass, delete this completed implementation plan. The tests
and canonical current-behavior documents remain authoritative.
### Validation
```sh
go test -coverprofile=/tmp/notarius-cli.cover ./internal/cli
go test -coverprofile=/tmp/notarius-config.cover ./internal/core/config
go tool cover -func=/tmp/notarius-cli.cover
go tool cover -func=/tmp/notarius-config.cover
go test -race ./internal/cli ./internal/core/config
go test ./...
go vet ./...
go build ./cmd/notarius
git diff --check
```
The `/tmp` coverage profiles are inspection artifacts and must not be committed.
### Exit criteria
Every current CLI and configuration contract has an identifiable owning test;
high-risk state, safety, precedence, selector, and failure paths are covered;
the suite remains deterministic and maintainable; and no numeric coverage target
has been introduced.

View File

@@ -0,0 +1,364 @@
package cli
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkplan"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestRunChunkPlanModePrecedenceAndValidation(t *testing.T) {
tests := []struct {
name string
fileMode string
envMode string
cliMode string
wantStores int
}{
{name: "default", wantStores: 1},
{name: "file", fileMode: "bypass"},
{name: "environment", envMode: "bypass"},
{name: "cli", envMode: "refresh", cliMode: "bypass"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
roots := newStateTestRoots(t)
if tt.name == "default" {
removeStateTestConfigLine(t, roots.config, " mode: auto\n")
} else if tt.fileMode != "" {
replaceStateTestConfigLine(t, roots.config, " mode: auto\n", " mode: "+tt.fileMode+"\n")
}
var stores []string
opts := newStateTestHarness().options()
opts.LookupEnv = func(name string) (string, bool) {
if name == "NOTARIUS_CACHE_CHUNK_PLANS_MODE" && tt.envMode != "" {
return tt.envMode, true
}
return "", false
}
opts.ChunkPlanStoreFactory = func(root string) (pipeline.ChunkPlanStore, error) {
stores = append(stores, root)
return chunkplan.NewFilesystemStore(root)
}
args := []string{"run", "sample", "--config", roots.config, "--input", roots.input}
if tt.cliMode != "" {
args = append(args, "--chunk_cache", tt.cliMode)
}
var stdout, stderr bytes.Buffer
if code := RunWithOptions(args, &stdout, &stderr, opts); code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
assertStateTestOutput(t, roots.output)
if len(stores) != tt.wantStores {
t.Fatalf("chunk plan store roots = %v, want %d stores", stores, tt.wantStores)
}
if tt.wantStores == 1 && stores[0] != roots.plans {
t.Fatalf("chunk plan store root = %q, want %q", stores[0], roots.plans)
}
if tt.wantStores == 0 {
assertAbsent(t, roots.plans)
}
})
}
t.Run("invalid cli syntax is a usage error", func(t *testing.T) {
roots := newStateTestRoots(t)
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "invalid"}, &stdout, &stderr, newStateTestHarness().options())
if code != 2 || stdout.Len() != 0 || stderr.Len() == 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
assertNoRunState(t, roots)
})
for _, tt := range []struct {
name string
fileConfig bool
}{
{name: "invalid environment mode"},
{name: "invalid file mode", fileConfig: true},
} {
t.Run(tt.name, func(t *testing.T) {
roots := newStateTestRoots(t)
opts := newStateTestHarness().options()
if tt.fileConfig {
replaceStateTestConfigLine(t, roots.config, " mode: auto\n", " mode: invalid\n")
} else {
opts.LookupEnv = func(name string) (string, bool) {
if name == "NOTARIUS_CACHE_CHUNK_PLANS_MODE" {
return "invalid", true
}
return "", false
}
}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &stdout, &stderr, opts)
if code != 1 || stdout.Len() != 0 || stderr.Len() == 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
assertNoRunState(t, roots)
})
}
}
func TestRunChunkPlanRootSelectionAndFailures(t *testing.T) {
t.Run("empty configured root uses the per-user cache root", func(t *testing.T) {
roots := newStateTestRoots(t)
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.plans))
userCache := filepath.Join(t.TempDir(), "user-cache")
var stores []string
opts := newStateTestHarness().options()
opts.UserCacheDir = func() (string, error) { return userCache, nil }
opts.ChunkPlanStoreFactory = func(root string) (pipeline.ChunkPlanStore, error) {
stores = append(stores, root)
return chunkplan.NewFilesystemStore(root)
}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &stdout, &stderr, opts)
if code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
wantRoot := filepath.Join(userCache, "notarius", "chunk-plans")
if len(stores) != 1 || stores[0] != wantRoot {
t.Fatalf("chunk plan store roots = %v, want [%q]", stores, wantRoot)
}
assertFile(t, filepath.Join(wantRoot, strings.TrimPrefix(stateTestDigest, "sha256:"), "plan.json"))
assertAbsent(t, roots.plans)
})
t.Run("bypass avoids default cache dependencies", func(t *testing.T) {
roots := newStateTestRoots(t)
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.plans))
userCacheCalls := 0
storeCalls := 0
opts := newStateTestHarness().options()
opts.UserCacheDir = func() (string, error) {
userCacheCalls++
return "", errors.New("user cache must not be resolved")
}
opts.ChunkPlanStoreFactory = func(string) (pipeline.ChunkPlanStore, error) {
storeCalls++
return nil, errors.New("chunk plan store must not be constructed")
}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}, &stdout, &stderr, opts)
if code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if userCacheCalls != 0 || storeCalls != 0 {
t.Fatalf("user cache calls=%d store calls=%d, want none", userCacheCalls, storeCalls)
}
assertStateTestOutput(t, roots.output)
assertAbsent(t, roots.plans)
})
t.Run("user cache resolution failure has context and no output", func(t *testing.T) {
roots := newStateTestRoots(t)
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.plans))
opts := newStateTestHarness().options()
opts.UserCacheDir = func() (string, error) { return "", errors.New("cache home unavailable") }
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &stdout, &stderr, opts)
if code != 1 || !strings.Contains(stderr.String(), "resolve chunk plan root") || !strings.Contains(stderr.String(), "cache home unavailable") {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
assertNoRunState(t, roots)
})
t.Run("store construction failure has context and no output", func(t *testing.T) {
roots := newStateTestRoots(t)
opts := newStateTestHarness().options()
opts.ChunkPlanStoreFactory = func(root string) (pipeline.ChunkPlanStore, error) {
return nil, fmt.Errorf("store unavailable")
}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &stdout, &stderr, opts)
want := fmt.Sprintf("create chunk plan store at %q", roots.plans)
if code != 1 || !strings.Contains(stderr.String(), want) || !strings.Contains(stderr.String(), "store unavailable") {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
assertNoRunState(t, roots)
})
t.Run("checkpoint root resolution failure has context and no output", func(t *testing.T) {
roots := newStateTestRoots(t)
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.checkpoints))
opts := newStateTestHarness().options()
opts.UserCacheDir = func() (string, error) { return "", errors.New("checkpoint cache unavailable") }
result := runStateTest(t, roots, opts, false, true, "bypass")
if result.code != 1 || !strings.Contains(result.stderr, "resolve checkpoint root") || !strings.Contains(result.stderr, "checkpoint cache unavailable") {
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
}
assertNoRunState(t, roots)
})
}
func TestRunAutoReusesPlanWhenRunInputsChange(t *testing.T) {
roots := newStateTestRoots(t)
data, err := os.ReadFile(roots.config)
if err != nil {
t.Fatal(err)
}
configText := strings.Replace(string(data), " chunk: test/chunk\n", ` chunk:
module: test/chunk
options:
strategy: first
`, 1)
configText = strings.Replace(configText, " output: test/output\n", ` other:
extract: test/extract
merge: test/merge
normalize: test/normalize
output: test/output
`, 1)
if err := os.WriteFile(roots.config, []byte(configText), 0o600); err != nil {
t.Fatal(err)
}
referencePath := filepath.Join(filepath.Dir(roots.input), "reference.txt")
if err := os.WriteFile(referencePath, []byte("reference content"), 0o600); err != nil {
t.Fatal(err)
}
harness := newStateTestHarness()
var firstStdout, firstStderr bytes.Buffer
first := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &firstStdout, &firstStderr, harness.options())
if first != 0 {
t.Fatalf("first run code=%d stdout=%q stderr=%q", first, firstStdout.String(), firstStderr.String())
}
configData, err := os.ReadFile(roots.config)
if err != nil {
t.Fatal(err)
}
configText = strings.Replace(string(configData), "strategy: first", "strategy: second", 1)
if err := os.WriteFile(roots.config, []byte(configText), 0o600); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
second := RunWithOptions([]string{
"run", "sample", "--config", roots.config, "--input", roots.input,
"--only", "items", "--reference", "chunk.cache-reference=" + referencePath,
}, &stdout, &stderr, harness.options())
if second != 0 {
t.Fatalf("second run code=%d stdout=%q stderr=%q", second, stdout.String(), stderr.String())
}
harness.mu.Lock()
chunkCalls := harness.chunkCalls
harness.mu.Unlock()
if chunkCalls != 1 {
t.Fatalf("chunk calls across changed run inputs = %d, want 1", chunkCalls)
}
assertFile(t, filepath.Join(roots.plans, strings.TrimPrefix(stateTestDigest, "sha256:"), "plan.json"))
assertAnyFile(t, roots.output)
}
func TestRunResumeSelectsConfiguredOrPerUserCheckpointRoot(t *testing.T) {
for _, configured := range []bool{true, false} {
name := "per-user root"
if configured {
name = "configured root"
}
t.Run(name, func(t *testing.T) {
roots := newStateTestRoots(t)
if !configured {
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.checkpoints))
}
userCache := filepath.Join(t.TempDir(), "user-cache")
userCacheCalls := 0
opts := newStateTestHarness().options()
opts.UserCacheDir = func() (string, error) {
userCacheCalls++
return userCache, nil
}
result := runStateTest(t, roots, opts, false, true, "bypass")
if result.code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
}
wantRoot := roots.checkpoints
wantCalls := 0
if !configured {
wantRoot = filepath.Join(userCache, "notarius", "checkpoints")
wantCalls = 1
}
if userCacheCalls != wantCalls {
t.Fatalf("user cache calls = %d, want %d", userCacheCalls, wantCalls)
}
assertAnyFile(t, wantRoot)
if !configured {
assertAbsent(t, roots.checkpoints)
}
assertStateTestOutput(t, roots.output)
})
}
t.Run("without resume avoids checkpoint root resolution", func(t *testing.T) {
roots := newStateTestRoots(t)
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.checkpoints))
opts := newStateTestHarness().options()
opts.UserCacheDir = func() (string, error) { return "", errors.New("checkpoint cache must not be resolved") }
result := runStateTest(t, roots, opts, false, false, "bypass")
if result.code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
}
assertStateTestOutput(t, roots.output)
assertAbsent(t, roots.checkpoints)
})
}
func TestConfigCommandsDoNotResolveRunState(t *testing.T) {
for _, args := range [][]string{
{"config", "validate", "--config"},
{"pipelines", "list", "--config"},
} {
name := strings.Join(args[:2], "-")
t.Run(name, func(t *testing.T) {
roots := newStateTestRoots(t)
opts := newStateTestHarness().options()
opts.UserCacheDir = func() (string, error) { return "", errors.New("state root must not be resolved") }
opts.ChunkPlanStoreFactory = func(string) (pipeline.ChunkPlanStore, error) {
return nil, errors.New("chunk plan store must not be constructed")
}
command := append([]string(nil), args...)
command = append(command, roots.config)
var stdout, stderr bytes.Buffer
code := RunWithOptions(command, &stdout, &stderr, opts)
if code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
assertNoRunState(t, roots)
})
}
}
func replaceStateTestConfigLine(t *testing.T, path, old, new string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
text := string(data)
if !strings.Contains(text, old) {
t.Fatalf("config %q does not contain %q", path, old)
}
text = strings.Replace(text, old, new, 1)
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
t.Fatal(err)
}
}
func removeStateTestConfigLine(t *testing.T, path, line string) {
replaceStateTestConfigLine(t, path, line, "")
}
func assertNoRunState(t *testing.T, roots stateTestRoots) {
t.Helper()
assertAbsent(t, roots.output)
assertAbsent(t, roots.plans)
assertAbsent(t, roots.checkpoints)
assertAbsent(t, roots.debug)
}

View File

@@ -0,0 +1,240 @@
package cli
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCommandHelpSpellingsWriteUsageToStdout(t *testing.T) {
tests := [][]string{nil, {"help"}, {"--help"}, {"-h"}}
for _, args := range tests {
name := "no arguments"
if len(args) > 0 {
name = args[0]
}
t.Run(name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
code := RunWithOptions(args, &stdout, &stderr, commandContractOptions(t))
if code != 0 || !strings.Contains(stdout.String(), "Usage:") || stderr.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
})
}
}
func TestCommandSyntaxErrorsUseStderrAndExitTwo(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "unknown command", args: []string{"unknown"}, want: "unknown command"},
{name: "missing config subcommand", args: []string{"config"}, want: "config requires a subcommand"},
{name: "unknown pipelines subcommand", args: []string{"pipelines", "unknown"}, want: "unknown pipelines subcommand"},
{name: "malformed run flag", args: []string{"run", "demo", "--chunk_cache", "invalid"}, want: "not supported"},
{name: "unknown flag", args: []string{"config", "validate", "--unknown"}, want: "flag provided but not defined"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
code := RunWithOptions(tt.args, &stdout, &stderr, commandContractOptions(t))
if code != 2 || !strings.Contains(stderr.String(), tt.want) || stdout.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
})
}
}
func TestConfigDiscoveryPrefersExplicitPathThenEnvironment(t *testing.T) {
explicit := writeCommandConfig(t, "explicit", "alpha")
environment := writeCommandConfig(t, "environment", "beta")
lookup := func(name string) (string, bool) {
if name == "NOTARIUS_CONFIG" {
return environment, true
}
return "", false
}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"pipelines", "list", "--config", explicit}, &stdout, &stderr, commandContractOptionsWithLookup(t, lookup))
if code != 0 || stdout.String() != "alpha\nexplicit\n" || stderr.Len() != 0 {
t.Fatalf("explicit config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
stdout.Reset()
stderr.Reset()
code = RunWithOptions([]string{"pipelines", "list"}, &stdout, &stderr, commandContractOptionsWithLookup(t, lookup))
if code != 0 || stdout.String() != "beta\nenvironment\n" || stderr.Len() != 0 {
t.Fatalf("environment config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
}
func TestConfigDiscoveryUsesCompiledDefaultOnlyWhenAvailable(t *testing.T) {
info, statErr := os.Stat(defaultConfigPath)
if statErr != nil && !os.IsNotExist(statErr) {
t.Fatalf("stat compiled default config: %v", statErr)
}
if statErr == nil && !info.Mode().IsRegular() {
t.Skipf("compiled default config has unexpected host state: %s", info.Mode())
}
path, err := discoverConfigPath("", commandContractOptions(t))
if statErr == nil {
if err != nil || path != defaultConfigPath {
t.Fatalf("discoverConfigPath() = %q, %v; want compiled default", path, err)
}
return
}
if err == nil || !strings.Contains(err.Error(), "config file not found") {
t.Fatalf("discoverConfigPath() error = %v, want documented not-found context", err)
}
}
func TestConfigLoadingFailuresReturnOneWithPathContext(t *testing.T) {
missing := filepath.Join(t.TempDir(), "missing.yml")
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"config", "validate", "--config", missing}, &stdout, &stderr, commandContractOptions(t))
if code != 1 || !strings.Contains(stderr.String(), missing) || stdout.Len() != 0 {
t.Fatalf("missing config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
malformed := filepath.Join(t.TempDir(), "malformed.yml")
if err := os.WriteFile(malformed, []byte("version: [\n"), 0o600); err != nil {
t.Fatal(err)
}
stdout.Reset()
stderr.Reset()
code = RunWithOptions([]string{"config", "validate", "--config", malformed}, &stdout, &stderr, commandContractOptions(t))
if code != 1 || !strings.Contains(stderr.String(), malformed) || !strings.Contains(stderr.String(), "parse config file") || stdout.Len() != 0 {
t.Fatalf("malformed config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
}
func TestConfigValidateResolvesPipelineAndChecksSelection(t *testing.T) {
configPath := writeResolvableCommandConfig(t)
options := commandContractOptions(t)
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo", "--only", "spells"}, &stdout, &stderr, options)
if code != 0 || !strings.Contains(stdout.String(), "valid for pipeline \"demo\"") || stderr.Len() != 0 {
t.Fatalf("valid resolution: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
stdout.Reset()
stderr.Reset()
code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "missing"}, &stdout, &stderr, options)
if code != 1 || !strings.Contains(stderr.String(), "pipeline \"missing\"") {
t.Fatalf("unknown pipeline: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
stdout.Reset()
stderr.Reset()
code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo", "--only", "missing"}, &stdout, &stderr, options)
if code != 1 || !strings.Contains(stderr.String(), "lane \"missing\"") {
t.Fatalf("unknown lane: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
stdout.Reset()
stderr.Reset()
code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--only", "spells"}, &stdout, &stderr, options)
if code != 2 || !strings.Contains(stderr.String(), "--only requires --pipeline") {
t.Fatalf("missing pipeline for only: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
stdout.Reset()
stderr.Reset()
code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo", "--only", "spells,,other"}, &stdout, &stderr, options)
if code != 2 || !strings.Contains(stderr.String(), "--only must contain") {
t.Fatalf("malformed only: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
}
func TestPipelinesListSortsNormalizedIDsInTextAndJSON(t *testing.T) {
configPath := writeCommandConfig(t, " zeta ", "alpha")
options := commandContractOptions(t)
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"pipelines", "list", "--config", configPath}, &stdout, &stderr, options)
if code != 0 || stdout.String() != "alpha\nzeta\n" || stderr.Len() != 0 {
t.Fatalf("text list: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
stdout.Reset()
stderr.Reset()
code = RunWithOptions([]string{"pipelines", "list", "--config", configPath, "--json"}, &stdout, &stderr, options)
var payload struct {
Pipelines []string `json:"pipelines"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("JSON list = %q: %v", stdout.String(), err)
}
if code != 0 || len(payload.Pipelines) != 2 || payload.Pipelines[0] != "alpha" || payload.Pipelines[1] != "zeta" || stderr.Len() != 0 {
t.Fatalf("JSON list: code=%d payload=%#v stderr=%q", code, payload, stderr.String())
}
}
func TestRemovedStructuralFlagsAndRuntimeFailuresKeepExitClasses(t *testing.T) {
configPath := writeResolvableCommandConfig(t)
options := commandContractOptions(t)
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "demo", "--input", "missing-input", "--config", configPath, "--diagnostics-dir", t.TempDir()}, &stdout, &stderr, options)
if code != 2 || !strings.Contains(stderr.String(), "flag provided but not defined") {
t.Fatalf("removed flag: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
stdout.Reset()
stderr.Reset()
code = RunWithOptions([]string{"run", "missing", "--input", "missing-input", "--config", configPath, "--chunk_cache", "bypass"}, &stdout, &stderr, options)
if code != 1 || !strings.Contains(stderr.String(), "pipeline \"missing\"") || stdout.Len() != 0 {
t.Fatalf("valid-runtime failure: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
}
func commandContractOptions(t *testing.T) Options {
return commandContractOptionsWithLookup(t, emptyLookup)
}
func commandContractOptionsWithLookup(t *testing.T, lookup func(string) (string, bool)) Options {
t.Helper()
components, err := newProductionComponents()
if err != nil {
t.Fatal(err)
}
return Options{
Catalog: catalogFromRegistries(components.registries),
Registries: components.registries,
LookupEnv: lookup,
}
}
func writeCommandConfig(t *testing.T, firstID, secondID string) string {
t.Helper()
content := fmt.Sprintf("version: 3\npipelines:\n %q:\n input: seriatim\n %q:\n input: seriatim\n", firstID, secondID)
return writeCommandConfigContent(t, content)
}
func writeResolvableCommandConfig(t *testing.T) string {
t.Helper()
return writeCommandConfigContent(t, `version: 3
pipelines:
demo:
input: seriatim
artifacts:
spells:
extract: dnd/spells
`)
}
func writeCommandConfigContent(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}

View File

@@ -0,0 +1,149 @@
package cli
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/core/debugbundle"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
)
func TestMaintainedExamplesLoadResolveAndList(t *testing.T) {
components := productionTestComponents(t)
for _, example := range maintainedExampleFiles(t) {
t.Run(example.name, func(t *testing.T) {
cfg := loadMaintainedExample(t, example.path)
if _, err := cfg.Resolve(resolveInputForMaintainedExample(components, "dnd-session")); err != nil {
t.Fatalf("resolve maintained example: %v", err)
}
var stdout, stderr strings.Builder
code := RunWithOptions([]string{"pipelines", "list", "--config", example.path}, &stdout, &stderr, productionOptionsFromComponents(components))
if code != 0 || stdout.String() != "dnd-session\n" || stderr.Len() != 0 {
t.Fatalf("pipelines list: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
})
}
}
func TestMaintainedMinimalInvocationProducesJSONBundle(t *testing.T) {
outputRoot := filepath.Join(t.TempDir(), "output")
fake := &productionFakeLLMClient{}
options := productionRunOptions(t, fake)
var stdout, stderr strings.Builder
code := RunWithOptions([]string{
"run", "dnd-session",
"--config", repositoryPath("examples", "dnd-spells.config.yml"),
"--input", repositoryPath("examples", "seriatim-minimal-transcript.json"),
"--only", "spells", "--chunk_cache", "bypass", "--output-dir", outputRoot,
}, &stdout, &stderr, options)
if code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if !strings.Contains(stdout.String(), `pipeline "dnd-session"`) || !strings.Contains(stdout.String(), "outputs=1 rejected=0") {
t.Fatalf("stdout=%q, want completed pipeline and counts", stdout.String())
}
runRoot := filepath.Join(outputRoot, productionRunID)
index := readProductionJSON[exampleOutputIndex](t, filepath.Join(runRoot, "index.json"))
if index.ManifestFile != "manifest.json" || index.RejectedFile != "rejected.json" || index.WarningsFile != "warnings.json" || len(index.OutputFiles) != 1 {
t.Fatalf("index = %#v, want one spells output and fixed companion files", index)
}
entry := index.OutputFiles[0]
if entry.LaneID != "spells" || entry.File != "lanes/spells.json" || entry.MediaType != "application/json" || entry.SchemaID != "notarius.dnd.spells" || entry.SchemaVersion != "v1" {
t.Fatalf("index output entry = %#v, want spells JSON contract", entry)
}
manifest := readProductionJSON[artifacts.RunManifest](t, filepath.Join(runRoot, "manifest.json"))
if manifest.PipelineID != "dnd-session" || manifest.InputModule != "seriatim" || manifest.Chunker != "generic" || manifest.OutputEncoder != "json" || manifest.ValidationStatus != "approved" || manifest.ChunkPlan == nil || manifest.ChunkPlan.Action != "bypassed" {
t.Fatalf("manifest = %#v, want approved minimal run", manifest)
}
if len(manifest.ArtifactLanes) != 1 {
t.Fatalf("manifest lanes = %#v, want exactly spells", manifest.ArtifactLanes)
}
lane := manifest.ArtifactLanes[0]
if lane.ID != "spells" || lane.Extractor != "dnd/spells" || lane.Merger != "appendorder" || lane.Normalizer != "noop" {
t.Fatalf("manifest lane = %#v, want production spells composition", lane)
}
artifact := readProductionJSON[dnd.SpellList](t, filepath.Join(runRoot, entry.File))
if len(artifact.SpellCasts) != 1 || artifact.SpellCasts[0].Spell != "Cure Wounds" || artifact.SpellCasts[0].SourceRefs[0].SourceID != "session-alpha" {
t.Fatalf("artifact = %#v, want one source-linked Cure Wounds cast", artifact)
}
rejected := readProductionJSON[struct {
Rejected []json.RawMessage `json:"rejected"`
}](t, filepath.Join(runRoot, "rejected.json"))
if len(rejected.Rejected) != 0 {
t.Fatalf("rejected = %#v, want empty rejection list", rejected.Rejected)
}
warnings := readProductionJSON[struct {
Warnings []json.RawMessage `json:"warnings"`
}](t, filepath.Join(runRoot, "warnings.json"))
if len(warnings.Warnings) != 0 {
t.Fatalf("warnings = %#v, want empty warning list", warnings.Warnings)
}
}
func TestMaintainedMalformedInputOnlyRecordsDebugFailureWhenRequested(t *testing.T) {
malformed := filepath.Join(t.TempDir(), "malformed.json")
if err := os.WriteFile(malformed, []byte("{not valid json"), 0o600); err != nil {
t.Fatal(err)
}
for _, debug := range []bool{false, true} {
name := "without debug"
if debug {
name = "with debug"
}
t.Run(name, func(t *testing.T) {
outputRoot := filepath.Join(t.TempDir(), "output")
debugRoot := filepath.Join(t.TempDir(), "debug")
options := productionRunOptions(t, &productionFakeLLMClient{})
args := []string{
"run", "dnd-session",
"--config", repositoryPath("examples", "dnd-spells.config.yml"),
"--input", malformed, "--chunk_cache", "bypass", "--output-dir", outputRoot,
}
if debug {
args = append(args, "--debug", "--debug-dir", debugRoot)
}
var stdout, stderr strings.Builder
code := RunWithOptions(args, &stdout, &stderr, options)
if code != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "parse input") {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
assertAbsent(t, outputRoot)
if !debug {
assertAbsent(t, debugRoot)
return
}
bundle := onlyChildDir(t, debugRoot)
report := readProductionJSON[debugbundle.RunReport](t, filepath.Join(bundle, "summary", "run-report.json"))
if report.Succeeded || report.PipelineID != "dnd-session" {
t.Fatalf("failure report = %#v, want failed dnd-session report", report)
}
})
}
}
type exampleOutputIndex struct {
ManifestFile string `json:"manifest_file"`
OutputFiles []exampleOutputIndexEntry `json:"output_files"`
RejectedFile string `json:"rejected_file"`
WarningsFile string `json:"warnings_file"`
}
type exampleOutputIndexEntry struct {
LaneID string `json:"lane_id"`
MediaType string `json:"media_type"`
File string `json:"file"`
SchemaID string `json:"schema_id"`
SchemaVersion string `json:"schema_version"`
}
func resolveInputForMaintainedExample(components productionComponents, pipelineID string) config.ResolveInput {
return config.ResolveInput{PipelineID: pipelineID, Catalog: catalogFromRegistries(components.registries)}
}

View File

@@ -0,0 +1,467 @@
package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"reflect"
"runtime"
"sort"
"strings"
"sync"
"testing"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
)
func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
components := productionTestComponents(t)
registries := components.registries
assertProductionKeys(t, "inputs", registries.Inputs.RegisteredKeys(), []string{"seriatim"})
assertProductionKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes", "generic"})
assertProductionKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells"})
assertProductionKeys(t, "mergers", registries.Mergers.RegisteredKeys(), []string{"appendorder"})
assertProductionKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop"})
assertProductionKeys(t, "outputs", registries.Outputs.RegisteredKeys(), []string{"json"})
assertProductionKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{
"extract/dnd/spells/shape",
"extract/dnd/spells/source_refs",
"extract/dnd/spells/source_relatedness",
"generic/always_accept",
"generic/always_reject",
"generic/valid_json",
"generic/valid_json_schema",
})
if got := registries.ArtifactCodecs.RegisteredKinds(); !reflect.DeepEqual(got, []contracts.ArtifactKind{dnd.SpellListKind}) {
t.Fatalf("artifact codec kinds = %#v, want [%q]", got, dnd.SpellListKind)
}
if got := registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule); !reflect.DeepEqual(got, []contracts.ArtifactKind{dnd.SpellListKind}) {
t.Fatalf("merger variants = %#v, want [%q]", got, dnd.SpellListKind)
}
if got := registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule); !reflect.DeepEqual(got, []contracts.ArtifactKind{dnd.SpellListKind}) {
t.Fatalf("normalizer variants = %#v, want [%q]", got, dnd.SpellListKind)
}
wantChain := []pipeline.ModuleBinding{
pipeline.Binding("generic/valid_json"),
pipeline.Binding("generic/valid_json_schema"),
pipeline.Binding("extract/dnd/spells/shape"),
pipeline.Binding("extract/dnd/spells/source_refs"),
pipeline.Binding("extract/dnd/spells/source_relatedness"),
}
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) {
t.Fatalf("spell validator chain = %#v, want %#v", got, wantChain)
}
assetNames := productionAssetNames(t, components.assets.PromptFS)
wantAssets := []string{
"dnd.scenes/dnd.scenes.yaml",
"dnd.scenes/instructions.md",
"dnd.scenes/sharedassets/common-dnd-references.md",
"dnd.scenes/sharedassets/common-dnd-system.md",
"dnd.scenes/sharedassets/common-dnd-transcript.md",
"dnd.scenes/task.md",
"dnd.spells/dnd.spells.yaml",
"dnd.spells/instructions.md",
"dnd.spells/sharedassets/common-dnd-references.md",
"dnd.spells/sharedassets/common-dnd-system.md",
"dnd.spells/sharedassets/common-dnd-transcript.md",
"dnd.spells/task.md",
}
if !reflect.DeepEqual(assetNames, wantAssets) {
t.Fatalf("production prompt assets = %#v, want %#v", assetNames, wantAssets)
}
catalog := catalogFromRegistries(registries)
converted := registriesFromCatalog(catalog)
if converted.ArtifactCodecs != registries.ArtifactCodecs || converted.ValidatorChains != registries.ValidatorChains {
t.Fatal("catalog/registry conversion did not preserve codec and validator-chain registries")
}
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.SpellListKind)
if !ok || codecSpec.Kind != dnd.SpellListKind || codecSpec.Schema.ID != spellcodec.SchemaID {
t.Fatalf("catalog codec spec = %#v, ok=%t, want typed D&D spell codec", codecSpec, ok)
}
if got := catalog.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) {
t.Fatalf("catalog validator chain = %#v, want %#v", got, wantChain)
}
for _, example := range maintainedExampleFiles(t) {
cfg := loadMaintainedExample(t, example.path)
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-session", Catalog: catalog})
if err != nil {
t.Fatalf("resolve %s: %v", example.name, err)
}
if effective.ResolvedPipeline.Input.Module != "seriatim" || len(effective.ResolvedPipeline.ArtifactLanes) != 1 || effective.ResolvedPipeline.ArtifactLanes[0].ID != "spells" {
t.Fatalf("resolved %s pipeline = %#v, want seriatim and spells", example.name, effective.ResolvedPipeline)
}
}
}
func TestDefaultCLICompositionResolvesMaintainedConfigurations(t *testing.T) {
catalog, err := effectiveCatalog(Options{})
if err != nil {
t.Fatalf("resolve default catalog: %v", err)
}
if isEmptyCatalog(catalog) {
t.Fatal("default catalog is empty")
}
registries, err := effectiveRegistries(Options{})
if err != nil {
t.Fatalf("resolve default registries: %v", err)
}
if isEmptyRegistries(registries) {
t.Fatal("default registries are empty")
}
for _, example := range maintainedExampleFiles(t) {
var stdout, stderr strings.Builder
code := RunWithOptions([]string{"config", "validate", "--config", example.path, "--pipeline", "dnd-session"}, &stdout, &stderr, Options{LookupEnv: emptyLookup})
if code != 0 || stderr.Len() != 0 {
t.Fatalf("validate maintained %s config with defaults: code=%d stdout=%q stderr=%q", example.name, code, stdout.String(), stderr.String())
}
}
}
func TestProductionPromptAssetsPrepareWithoutProviderCredentials(t *testing.T) {
components := productionTestComponents(t)
cfg := config.Default()
cfg.Pipelines["dnd-scenes"] = pipeline.PipelineProfile{
ID: "dnd-scenes",
Input: pipeline.Binding("seriatim"),
Chunk: pipeline.Binding("dnd/scenes"),
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"spells": {Extract: pipeline.Binding("dnd/spells")},
},
}
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-scenes", Catalog: catalogFromRegistries(components.registries)})
if err != nil {
t.Fatalf("resolve production scene pipeline: %v", err)
}
if _, err := pipeline.Prepare(effective.ResolvedPipeline, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err != nil {
t.Fatalf("prepare production scene and spell modules: %v", err)
}
}
func TestProductionConfigValidationCoversModuleAndVariantFailures(t *testing.T) {
base := string(readRepositoryFile(t, "examples", "dnd-spells.config.yml"))
validPath := writeProductionContractConfig(t, base)
options := productionCLIOptions(t)
var stdout, stderr strings.Builder
if code := RunWithOptions([]string{"config", "validate", "--config", validPath, "--pipeline", "dnd-session"}, &stdout, &stderr, options); code != 0 {
t.Fatalf("valid production config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
for _, example := range maintainedExampleFiles(t) {
var exampleStdout, exampleStderr strings.Builder
code := RunWithOptions([]string{"config", "validate", "--config", example.path, "--pipeline", "dnd-session"}, &exampleStdout, &exampleStderr, options)
if code != 0 {
t.Fatalf("validate maintained %s config: code=%d stdout=%q stderr=%q", example.name, code, exampleStdout.String(), exampleStderr.String())
}
}
tests := []struct {
name string
content string
options Options
fragments []string
}{
{
name: "unknown module",
content: strings.Replace(base, " input: seriatim\n", " input: missing/input\n", 1),
options: productionCLIOptions(t),
fragments: []string{"pipeline \"dnd-session\"", "input", "missing/input"},
},
{
name: "unknown validator",
content: strings.Replace(base, " extract: dnd/spells\n", " extract:\n module: dnd/spells\n validators:\n - module: missing/validator\n", 1),
options: productionCLIOptions(t),
fragments: []string{"validator", "missing/validator"},
},
{
name: "invalid artifact variant",
content: base,
options: productionCLIOptionsWithoutSpellNormalizer(t),
fragments: []string{"normalizer", "noop", string(dnd.SpellListKind), "variant"},
},
{
name: "deterministic validator with profile",
content: strings.Replace(base, " extract: dnd/spells\n", " extract:\n module: dnd/spells\n validators:\n - module: generic/valid_json\n llm_profile: forbidden-profile\n", 1),
options: productionCLIOptions(t),
fragments: []string{"deterministic validator", "llm_profile"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := writeProductionContractConfig(t, tt.content)
var stdout, stderr strings.Builder
code := RunWithOptions([]string{"config", "validate", "--config", path, "--pipeline", "dnd-session"}, &stdout, &stderr, tt.options)
if code != 1 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
for _, fragment := range tt.fragments {
if !strings.Contains(stderr.String(), fragment) {
t.Fatalf("stderr=%q, want %q", stderr.String(), fragment)
}
}
})
}
}
func TestProductionSceneRunRecordsChunkerWarningsAndProvenance(t *testing.T) {
outputRoot := filepath.Join(t.TempDir(), "output")
configPath := writeProductionContractConfig(t, productionRunConfig(outputRoot, "dnd/scenes"))
fake := &productionFakeLLMClient{}
options := productionRunOptions(t, fake)
var stdout, stderr strings.Builder
code := RunWithOptions([]string{
"run", "dnd-session", "--config", configPath,
"--input", repositoryPath("examples", "seriatim-minimal-transcript.json"),
"--chunk_cache", "bypass", "--session-id", "offline-session",
}, &stdout, &stderr, options)
if code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
manifest := readProductionJSON[artifacts.RunManifest](t, filepath.Join(outputRoot, productionRunID, "manifest.json"))
if manifest.Chunker != scenes.Key || manifest.ChunkPlan == nil || manifest.ChunkPlan.Action != "bypassed" || manifest.ChunkPlan.ProducerModule != scenes.Key {
t.Fatalf("chunk manifest = %#v, want dnd scene producer", manifest.ChunkPlan)
}
if got := manifest.ModuleMetadata["chunker"]["prompt_id"]; got != scenes.PromptID {
t.Fatalf("chunker prompt metadata = %#v, want %q", got, scenes.PromptID)
}
if got := manifest.ChunkPlan.ProducerMetadata["response_schema_id"]; got != scenes.ResponseSchemaID {
t.Fatalf("chunk producer schema metadata = %#v, want %q", got, scenes.ResponseSchemaID)
}
warnings := readProductionJSON[struct {
Warnings []contracts.Warning `json:"warnings"`
}](t, filepath.Join(outputRoot, productionRunID, "warnings.json"))
if len(warnings.Warnings) != 1 || warnings.Warnings[0].ReasonCode != "scene_boundary_caveat" {
t.Fatalf("warnings = %#v, want one scene boundary warning", warnings.Warnings)
}
if len(fake.requestsFor(scenes.PromptID)) != 1 || len(fake.requestsFor(spells.PromptID)) != 1 {
t.Fatalf("fake prompt requests = %#v, want one scene and one spell request", fake.requestPrompts())
}
}
type maintainedExample struct {
name string
path string
}
func maintainedExampleFiles(t *testing.T) []maintainedExample {
t.Helper()
return []maintainedExample{
{name: "minimal", path: repositoryPath("examples", "dnd-spells.config.yml")},
{name: "production", path: repositoryPath("examples", "dnd-spells-production.config.yml")},
}
}
func loadMaintainedExample(t *testing.T, path string) config.Config {
t.Helper()
fileConfig, err := config.LoadFileConfig(path)
if err != nil {
t.Fatalf("load maintained config %q: %v", path, err)
}
cfg := config.Default()
if err := cfg.ApplyFileConfig(fileConfig); err != nil {
t.Fatalf("apply maintained config %q: %v", path, err)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("validate maintained config %q: %v", path, err)
}
return cfg
}
func productionTestComponents(t *testing.T) productionComponents {
t.Helper()
components, err := newProductionComponents()
if err != nil {
t.Fatalf("new production components: %v", err)
}
return components
}
func productionCLIOptions(t *testing.T) Options {
t.Helper()
components := productionTestComponents(t)
return productionOptionsFromComponents(components)
}
func productionOptionsFromComponents(components productionComponents) Options {
return Options{
Catalog: catalogFromRegistries(components.registries),
Registries: components.registries,
LookupEnv: emptyLookup,
}
}
func productionCLIOptionsWithoutSpellNormalizer(t *testing.T) Options {
t.Helper()
components := productionTestComponents(t)
registries := components.registries
registries.Normalizers = pipeline.NewNormalizerRegistry()
if err := noop.RegisterTyped[dnd.SpellList](registries.Normalizers, contracts.ArtifactKind("test/other")); err != nil {
t.Fatalf("register mismatched normalizer: %v", err)
}
return productionOptionsFromComponents(productionComponents{registries: registries, assets: components.assets})
}
const productionRunID = "run-1700000000000000000-0123456789abcdef0123456789abcdef"
func productionRunOptions(t *testing.T, fake *productionFakeLLMClient) Options {
t.Helper()
options := productionCLIOptions(t)
options.Now = func() time.Time { return time.Unix(1700000000, 0).UTC() }
options.RunIDGenerator = func(time.Time) (string, error) { return productionRunID, nil }
options.UserCacheDir = func() (string, error) { return "", errors.New("user cache must not be used") }
options.LLMClientFactory = func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
return fake, nil, nil
}
return options
}
func productionRunConfig(outputRoot, chunkModule string) string {
return fmt.Sprintf(`version: 3
output:
directory: %q
cache:
chunk_plans:
mode: bypass
checkpoints: {}
debug:
directory: %q
pipelines:
dnd-session:
input: seriatim
chunk: %s
artifacts:
spells:
extract: dnd/spells
`, outputRoot, filepath.Join(filepath.Dir(outputRoot), "debug"), chunkModule)
}
func writeProductionContractConfig(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func productionAssetNames(t *testing.T, getFS func() (fs.FS, error)) []string {
t.Helper()
fileSystem, err := getFS()
if err != nil {
t.Fatalf("load production prompt assets: %v", err)
}
var names []string
if err := fs.WalkDir(fileSystem, ".", func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if !entry.IsDir() {
names = append(names, path)
}
return nil
}); err != nil {
t.Fatalf("walk production prompt assets: %v", err)
}
sort.Strings(names)
return names
}
func assertProductionKeys(t *testing.T, name string, got, want []string) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Fatalf("%s = %#v, want %#v", name, got, want)
}
}
func readProductionJSON[T any](t *testing.T, path string) T {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var value T
if err := json.Unmarshal(data, &value); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return value
}
type productionFakeLLMClient struct {
mu sync.Mutex
requests []contracts.StructuredCompletionRequest
}
func (client *productionFakeLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
if err := ctx.Err(); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
var content []byte
switch req.PromptID {
case scenes.PromptID:
content = []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":2,"short_title":"Opening scene","primary_mode":"Narrative","main_participants":["Aria"],"summary":"The session opens.","boundary_note":"The opening covers the available transcript.","boundary_confidence":"High"}],"boundary_caveats":["The opening boundary is inferred from the short transcript."]}`)
case spells.PromptID:
content = []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"Heals an injured ally.","narrative_description":"Aria restores the fighter after the fight.","source_refs":[{"source_id":"session-alpha","start_unit_id":1,"end_unit_id":1}]}]}`)
default:
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID)
}
if err := json.Unmarshal(content, out); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate fake structured target: %w", err)
}
client.mu.Lock()
client.requests = append(client.requests, req)
client.mu.Unlock()
return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "deterministic", ProfileID: req.ProfileID}, nil
}
func (client *productionFakeLLMClient) requestsFor(promptID string) []contracts.StructuredCompletionRequest {
client.mu.Lock()
defer client.mu.Unlock()
var requests []contracts.StructuredCompletionRequest
for _, req := range client.requests {
if req.PromptID == promptID {
requests = append(requests, req)
}
}
return requests
}
func (client *productionFakeLLMClient) requestPrompts() []string {
client.mu.Lock()
defer client.mu.Unlock()
prompts := make([]string, 0, len(client.requests))
for _, req := range client.requests {
prompts = append(prompts, req.PromptID)
}
return prompts
}
func repositoryPath(parts ...string) string {
_, file, _, _ := runtime.Caller(0)
return filepath.Join(append([]string{filepath.Dir(file), "..", ".."}, parts...)...)
}
func readRepositoryFile(t *testing.T, parts ...string) []byte {
t.Helper()
data, err := os.ReadFile(repositoryPath(parts...))
if err != nil {
t.Fatal(err)
}
return data
}

View File

@@ -0,0 +1,454 @@
package cli
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestReferenceSelectorsParseAndApplyAllDocumentedForms(t *testing.T) {
tests := []struct {
name string
selector string
only []string
wantStage pipeline.ModuleStage
wantLane string
wantSlot string
}{
{name: "flat", selector: "alpha-slot", wantStage: pipeline.StageExtract, wantLane: "alpha", wantSlot: "alpha-slot"},
{name: "chunk", selector: "chunk.chunk-slot", wantStage: pipeline.StageChunk, wantSlot: "chunk-slot"},
{name: "merge", selector: "merge.alpha-merge", only: []string{"alpha"}, wantStage: pipeline.StageMerge, wantLane: "alpha", wantSlot: "alpha-merge"},
{name: "lane", selector: "alpha.alpha-slot", wantStage: pipeline.StageExtract, wantLane: "alpha", wantSlot: "alpha-slot"},
{name: "lane extract", selector: "alpha.extract.alpha-slot", wantStage: pipeline.StageExtract, wantLane: "alpha", wantSlot: "alpha-slot"},
{name: "lane merge", selector: "alpha.merge.alpha-merge", wantStage: pipeline.StageMerge, wantLane: "alpha", wantSlot: "alpha-merge"},
{name: "lane normalize", selector: "alpha.normalize.alpha-normalize", wantStage: pipeline.StageNormalize, wantLane: "alpha", wantSlot: "alpha-normalize"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := referenceContractConfig()
catalog := referenceContractCatalog(t, true, true)
selector, err := parseReferenceSelector(tt.selector, "--reference")
if err != nil {
t.Fatal(err)
}
overrides, _, err := resolveCLIReferenceRequests(cfg, "demo", tt.only, catalog, []cliReferenceRequest{{Selector: selector, Source: "reference.txt"}}, nil)
if err != nil {
t.Fatalf("resolve selector: %v", err)
}
if len(overrides) != 1 {
t.Fatalf("overrides = %#v, want one binding", overrides)
}
got := overrides[0]
if got.Stage != tt.wantStage || got.LaneID != tt.wantLane || got.SlotName != tt.wantSlot || got.BindingSource != contracts.ReferenceBindingSourceCLI {
t.Fatalf("binding = %#v, want %s/%s/%s from CLI", got, tt.wantStage, tt.wantLane, tt.wantSlot)
}
})
}
}
func TestReferenceSelectorsRejectAmbiguityWithSpecificSuggestions(t *testing.T) {
cfg := referenceContractConfig()
catalog := referenceContractCatalog(t, true, true)
for _, tt := range []struct {
name string
selector string
want []string
}{
{name: "flat shared slot", selector: "shared", want: []string{"alpha.extract.shared", "beta.extract.shared"}},
{name: "lane shared slot", selector: "alpha.shared", want: []string{"alpha.extract.shared", "alpha.merge.shared", "alpha.normalize.shared"}},
{name: "all mergers", selector: "merge.shared", want: []string{"alpha.merge.shared", "beta.merge.shared"}},
} {
t.Run(tt.name, func(t *testing.T) {
selector, err := parseReferenceSelector(tt.selector, "--reference")
if err != nil {
t.Fatal(err)
}
_, _, err = resolveCLIReferenceRequests(cfg, "demo", nil, catalog, []cliReferenceRequest{{Selector: selector, Source: "reference.txt"}}, nil)
if err == nil {
t.Fatal("resolve selector succeeded, want ambiguity error")
}
for _, fragment := range tt.want {
if !strings.Contains(err.Error(), fragment) {
t.Fatalf("error = %q, want suggestion %q", err, fragment)
}
}
})
}
}
func TestReferenceSelectorsRespectSelectedLanesBeforeMaterialization(t *testing.T) {
cfg := referenceContractConfig()
catalog := referenceContractCatalog(t, true, true)
for _, tt := range []struct {
name string
selector string
want string
}{
{name: "unselected lane", selector: "beta.extract.beta-slot", want: `reference lane "beta" is not selected`},
{name: "unknown lane", selector: "missing.extract.beta-slot", want: `reference lane "missing" is not selected`},
} {
t.Run(tt.name, func(t *testing.T) {
selector, err := parseReferenceSelector(tt.selector, "--reference")
if err != nil {
t.Fatal(err)
}
_, _, err = resolveCLIReferenceRequests(cfg, "demo", []string{"alpha"}, catalog, []cliReferenceRequest{{Selector: selector, Source: filepath.Join(t.TempDir(), "missing.txt")}}, nil)
if err == nil || !strings.Contains(err.Error(), tt.want) || strings.Contains(err.Error(), "missing.txt") {
t.Fatalf("error = %v, want selection failure before file access", err)
}
})
}
}
func TestReferenceSyntaxErrorsReturnTwo(t *testing.T) {
tests := []struct {
name string
args []string
}{
{name: "reference missing value", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference"}},
{name: "reference missing selector", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "=path.txt"}},
{name: "reference missing separator", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "slot"}},
{name: "reference missing path", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "slot="}},
{name: "reference excess segments", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "a.b.c.d=path.txt"}},
{name: "unbind with path", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--without-reference", "slot=path.txt"}},
{name: "unbind excess segments", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--without-reference", "a.b.c.d"}},
{name: "unbind missing value", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--without-reference"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
code := RunWithOptions(tt.args, &stdout, &stderr, Options{LookupEnv: emptyLookup})
if code != 2 || stdout.Len() != 0 || stderr.Len() == 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
})
}
}
func TestReferenceOverridesUseFinalExactTargetBinding(t *testing.T) {
cfg := referenceContractConfig()
catalog := referenceContractCatalog(t, true, true)
alphaShared, err := parseReferenceSelector("alpha.extract.shared", "--reference")
if err != nil {
t.Fatal(err)
}
betaShared, err := parseReferenceSelector("beta.extract.shared", "--reference")
if err != nil {
t.Fatal(err)
}
overrides, unbinds, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, []cliReferenceRequest{
{Selector: alphaShared, Source: "alpha-first.txt"},
{Selector: alphaShared, Source: "alpha-final.txt"},
{Selector: betaShared, Source: "beta-only.txt"},
}, nil)
if err != nil {
t.Fatal(err)
}
if len(unbinds) != 0 {
t.Fatalf("unbinds = %#v, want none", unbinds)
}
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceOverrides: overrides})
if err != nil {
t.Fatalf("resolve pipeline: %v", err)
}
alpha := referenceContractLane(t, effective.ResolvedPipeline, "alpha")
beta := referenceContractLane(t, effective.ResolvedPipeline, "beta")
if source := referenceContractBindingSource(alpha.ExtractReferences.Bindings, "shared"); source != "alpha-final.txt" {
t.Fatalf("alpha shared source = %q, want final exact-target override", source)
}
if source := referenceContractBindingSource(beta.ExtractReferences.Bindings, "shared"); source != "beta-only.txt" {
t.Fatalf("beta shared source = %q, want target-specific override", source)
}
}
func TestReferenceUnbindsRemoveOptionalAndProtectRequiredSlots(t *testing.T) {
cfg := referenceContractConfig()
catalog := referenceContractCatalog(t, true, true)
optional, err := parseReferenceSelector("alpha.extract.alpha-slot", "--without-reference")
if err != nil {
t.Fatal(err)
}
_, without, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, nil, []cliReferenceUnbindRequest{{Selector: optional}})
if err != nil {
t.Fatal(err)
}
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceUnbinds: without})
if err != nil {
t.Fatalf("optional unbind: %v", err)
}
if binding := referenceContractFindBinding(referenceContractLane(t, effective.ResolvedPipeline, "alpha").ExtractReferences.Bindings, "alpha-slot"); binding != nil {
t.Fatalf("optional binding after unbind = %#v, want absent", binding)
}
for _, tt := range []struct {
name string
selector string
}{
{name: "chunk", selector: "chunk.required-chunk"},
{name: "extract", selector: "alpha.extract.required-extract"},
{name: "merge", selector: "alpha.merge.required-merge"},
{name: "normalize", selector: "alpha.normalize.required-normalize"},
} {
t.Run(tt.name, func(t *testing.T) {
selector, err := parseReferenceSelector(tt.selector, "--without-reference")
if err != nil {
t.Fatal(err)
}
_, unbinds, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, nil, []cliReferenceUnbindRequest{{Selector: selector}})
if err != nil {
t.Fatal(err)
}
_, err = cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceUnbinds: unbinds})
if err == nil || !strings.Contains(err.Error(), "required reference slot") {
t.Fatalf("resolve error = %v, want required-slot failure", err)
}
})
}
}
func TestReferenceMaterializationSeparatesCLIAndConfigPathOrigins(t *testing.T) {
configDir := t.TempDir()
workingDir := t.TempDir()
cfg := referenceContractConfig()
configPath := filepath.Join(configDir, "config.yml")
if err := os.WriteFile(configPath, []byte("version: 3\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(configDir, "required.txt"), []byte("config reference"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(configDir, "optional.txt"), []byte("optional reference"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(workingDir, "cli-reference.txt"), []byte("CLI reference"), 0o600); err != nil {
t.Fatal(err)
}
catalog := referenceContractCatalog(t, true, true)
selector, err := parseReferenceSelector("alpha.extract.alpha-slot", "--reference")
if err != nil {
t.Fatal(err)
}
overrides, unbinds, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, []cliReferenceRequest{{Selector: selector, Source: "cli-reference.txt"}}, nil)
if err != nil {
t.Fatal(err)
}
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceOverrides: overrides, ReferenceUnbinds: unbinds})
if err != nil {
t.Fatalf("resolve pipeline: %v", err)
}
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{ConfigPath: configPath, WorkingDir: workingDir})
if err != nil {
t.Fatalf("materialize references: %v", err)
}
alpha := referenceContractLane(t, materialized, "alpha")
cliItem := alpha.ExtractReferences.ReferenceSet.Slots["alpha-slot"].Items[0]
if string(cliItem.Content) != "CLI reference" || cliItem.BindingSource != contracts.ReferenceBindingSourceCLI || cliItem.Origin.URI != referenceContractFileURI(filepath.Join(workingDir, "cli-reference.txt")) {
t.Fatalf("CLI materialization = %#v, want working-directory provenance", cliItem)
}
configItem := alpha.ExtractReferences.ReferenceSet.Slots["required-extract"].Items[0]
if string(configItem.Content) != "config reference" || configItem.BindingSource != contracts.ReferenceBindingSourceConfig || configItem.Origin.URI != referenceContractFileURI(filepath.Join(configDir, "required.txt")) {
t.Fatalf("config materialization = %#v, want config-directory provenance", configItem)
}
}
func TestReferenceTargetLookupUsesArtifactVariantsAndReportsMissingContext(t *testing.T) {
cfg := referenceContractConfig()
full := referenceContractCatalog(t, true, true)
targets, err := selectedReferenceTargets(cfg, "demo", nil, full)
if err != nil {
t.Fatalf("select reference targets: %v", err)
}
var alphaMerge, betaMerge selectedReferenceTarget
for _, target := range targets {
if target.stage == pipeline.StageMerge && target.laneID == "alpha" {
alphaMerge = target
}
if target.stage == pipeline.StageMerge && target.laneID == "beta" {
betaMerge = target
}
}
if _, ok := alphaMerge.slots["alpha-merge"]; !ok {
t.Fatalf("alpha merger slots = %#v, want alpha artifact variant", alphaMerge.slots)
}
if _, ok := betaMerge.slots["beta-merge"]; !ok {
t.Fatalf("beta merger slots = %#v, want beta artifact variant", betaMerge.slots)
}
if _, ok := betaMerge.slots["alpha-merge"]; ok {
t.Fatalf("beta merger slots = %#v, must not use alpha variant", betaMerge.slots)
}
missingMerger := referenceContractCatalog(t, false, true)
_, err = selectedReferenceTargets(cfg, "demo", nil, missingMerger)
if err == nil || !strings.Contains(err.Error(), "merger") || !strings.Contains(err.Error(), string(referenceContractKindBeta)) {
t.Fatalf("missing merger error = %v, want artifact variant context", err)
}
missingNormalizer := referenceContractCatalog(t, true, false)
_, err = selectedReferenceTargets(cfg, "demo", nil, missingNormalizer)
if err == nil || !strings.Contains(err.Error(), "normalizer") || !strings.Contains(err.Error(), string(referenceContractKindBeta)) {
t.Fatalf("missing normalizer error = %v, want artifact variant context", err)
}
missingExtractor := referenceContractCatalog(t, true, true)
missingExtractor.Extractors = pipeline.NewExtractorRegistry()
_, err = selectedReferenceTargets(cfg, "demo", nil, missingExtractor)
if err == nil || !strings.Contains(err.Error(), `lane "alpha" extract module`) || !strings.Contains(err.Error(), "not registered") {
t.Fatalf("missing extractor error = %v, want lane/module context", err)
}
}
const (
referenceContractKindAlpha contracts.ArtifactKind = "reference/alpha"
referenceContractKindBeta contracts.ArtifactKind = "reference/beta"
)
func referenceContractConfig() config.Config {
cfg := config.Default()
cfg.Pipelines = map[string]pipeline.PipelineProfile{
"demo": {
ID: "demo",
Input: pipeline.Binding("reference/input"),
Chunk: pipeline.Binding("reference/chunk"),
Output: pipeline.Binding("reference/output"),
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"alpha": {
Extract: pipeline.Binding("reference/extract-alpha"),
Merge: pipeline.Binding("reference/shared-merge"),
Normalize: pipeline.Binding("reference/shared-normalize"),
References: map[string]string{"required-extract": "required.txt"},
},
"beta": {
Extract: pipeline.Binding("reference/extract-beta"),
Merge: pipeline.Binding("reference/shared-merge"),
Normalize: pipeline.Binding("reference/shared-normalize"),
References: map[string]string{"required-extract": "required.txt"},
},
},
},
}
profile := cfg.Pipelines["demo"]
profile.Chunk.References = map[string]string{"required-chunk": "required.txt"}
alpha := profile.Artifacts["alpha"]
alpha.Extract.References = map[string]string{"required-extract": "required.txt", "alpha-slot": "optional.txt"}
alpha.Merge.References = map[string]string{"required-merge": "required.txt"}
alpha.Normalize.References = map[string]string{"required-normalize": "required.txt"}
profile.Artifacts["alpha"] = alpha
beta := profile.Artifacts["beta"]
beta.Extract.References = map[string]string{"required-extract": "required.txt"}
beta.Merge.References = map[string]string{"required-merge": "required.txt"}
beta.Normalize.References = map[string]string{"required-normalize": "required.txt"}
profile.Artifacts["beta"] = beta
cfg.Pipelines["demo"] = profile
return cfg
}
func referenceContractCatalog(t *testing.T, includeBetaMerger, includeBetaNormalizer bool) pipeline.ModuleCatalog {
t.Helper()
registries := pipeline.Registries{
Inputs: pipeline.NewInputAdapterRegistry(),
Chunkers: pipeline.NewChunkerRegistry(),
ArtifactCodecs: pipeline.NewArtifactCodecRegistry(),
Extractors: pipeline.NewExtractorRegistry(),
Mergers: pipeline.NewMergerRegistry(),
Normalizers: pipeline.NewNormalizerRegistry(),
Validators: pipeline.NewValidatorRegistry(),
ValidatorChains: pipeline.NewValidatorChainRegistry(),
Outputs: pipeline.NewOutputEncoderRegistry(),
}
register := func(err error) {
if err != nil {
t.Fatal(err)
}
}
register(registries.Inputs.RegisterBuilderWithSpec(pipeline.ModuleSpec{Key: "reference/input", Stage: pipeline.StageInput, Provides: []string{"source"}}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.InputAdapter, error) { return stateTestInput{}, nil }))
register(registries.Chunkers.RegisterWithSpec(pipeline.ModuleSpec{Key: "reference/chunk", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}, ReferenceSlots: []contracts.ReferenceSlot{{Name: "chunk-slot"}, {Name: "required-chunk", Required: true}}}, func() (contracts.Chunker, error) { return stateTestChunker{}, nil }))
register(pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, referenceContractCodecA{}))
register(pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, referenceContractCodecB{}))
register(pipeline.RegisterExtractor(registries.Extractors, pipeline.ModuleSpec{Key: "reference/extract-alpha", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: referenceContractKindAlpha, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "alpha-slot"}, {Name: "required-extract", Required: true}}}, func() (contracts.Extractor[stateTestArtifact], error) { return stateTestExtractor{}, nil }))
register(pipeline.RegisterExtractor(registries.Extractors, pipeline.ModuleSpec{Key: "reference/extract-beta", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: referenceContractKindBeta, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "beta-slot"}, {Name: "required-extract", Required: true}}}, func() (contracts.Extractor[stateTestArtifact], error) { return stateTestExtractor{}, nil }))
register(pipeline.RegisterMerger(registries.Mergers, pipeline.ModuleSpec{Key: "reference/shared-merge", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}, ArtifactKind: referenceContractKindAlpha, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "alpha-merge"}, {Name: "required-merge", Required: true}}}, func() (contracts.Merger[stateTestArtifact], error) { return stateTestMerger{}, nil }))
if includeBetaMerger {
register(pipeline.RegisterMerger(registries.Mergers, pipeline.ModuleSpec{Key: "reference/shared-merge", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}, ArtifactKind: referenceContractKindBeta, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "beta-merge"}, {Name: "required-merge", Required: true}}}, func() (contracts.Merger[stateTestArtifact], error) { return stateTestMerger{}, nil }))
}
register(pipeline.RegisterNormalizer(registries.Normalizers, pipeline.ModuleSpec{Key: "reference/shared-normalize", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: referenceContractKindAlpha, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "alpha-normalize"}, {Name: "required-normalize", Required: true}}}, func() (contracts.Normalizer[stateTestArtifact], error) { return stateTestNormalizer{}, nil }))
if includeBetaNormalizer {
register(pipeline.RegisterNormalizer(registries.Normalizers, pipeline.ModuleSpec{Key: "reference/shared-normalize", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: referenceContractKindBeta, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "beta-normalize"}, {Name: "required-normalize", Required: true}}}, func() (contracts.Normalizer[stateTestArtifact], error) { return stateTestNormalizer{}, nil }))
}
register(registries.Outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "reference/output", Stage: pipeline.StageOutput, Requires: []string{"normalized"}, Provides: []string{"output"}}, func() (contracts.OutputEncoder, error) { return stateTestOutput{}, nil }))
return catalogFromRegistries(registries)
}
type referenceContractCodecB struct{}
type referenceContractCodecA struct{}
func (referenceContractCodecA) Kind() contracts.ArtifactKind { return referenceContractKindAlpha }
func (referenceContractCodecA) Schema() contracts.ArtifactSchema {
return contracts.ArtifactSchema{ID: "reference.alpha", Name: "reference_alpha", Version: "v1", JSONSchema: []byte(`{"type":"object"}`)}
}
func (referenceContractCodecA) MediaType() string { return "application/json" }
func (referenceContractCodecA) EncodeCandidate(stateTestArtifact) ([]byte, error) {
return []byte(`{"value":"ok"}`), nil
}
func (referenceContractCodecA) Encode(stateTestArtifact) ([]byte, error) {
return []byte(`{"value":"ok"}`), nil
}
func (referenceContractCodecA) Decode([]byte) (stateTestArtifact, error) {
return stateTestArtifact{Value: "ok"}, nil
}
func (referenceContractCodecB) Kind() contracts.ArtifactKind { return referenceContractKindBeta }
func (referenceContractCodecB) Schema() contracts.ArtifactSchema {
return contracts.ArtifactSchema{ID: "reference.beta", Name: "reference_beta", Version: "v1", JSONSchema: []byte(`{"type":"object"}`)}
}
func (referenceContractCodecB) MediaType() string { return "application/json" }
func (referenceContractCodecB) EncodeCandidate(stateTestArtifact) ([]byte, error) {
return []byte(`{"value":"ok"}`), nil
}
func (referenceContractCodecB) Encode(stateTestArtifact) ([]byte, error) {
return []byte(`{"value":"ok"}`), nil
}
func (referenceContractCodecB) Decode([]byte) (stateTestArtifact, error) {
return stateTestArtifact{Value: "ok"}, nil
}
func referenceContractLane(t *testing.T, resolved pipeline.ResolvedPipeline, id string) pipeline.ResolvedArtifactLane {
t.Helper()
for _, lane := range resolved.ArtifactLanes {
if lane.ID == id {
return lane
}
}
t.Fatalf("lane %q not found", id)
return pipeline.ResolvedArtifactLane{}
}
func referenceContractBindingSource(bindings []pipeline.ReferenceBinding, slot string) string {
for _, binding := range bindings {
if binding.SlotName == slot {
return binding.Source
}
}
return ""
}
func referenceContractFindBinding(bindings []pipeline.ReferenceBinding, slot string) *pipeline.ReferenceBinding {
for i := range bindings {
if bindings[i].SlotName == slot {
return &bindings[i]
}
}
return nil
}
func referenceContractFileURI(path string) string {
absolute, err := filepath.Abs(path)
if err != nil {
absolute = path
}
return "file://" + filepath.ToSlash(absolute)
}

View File

@@ -0,0 +1,444 @@
package cli
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestRunControlsRejectSyntaxWithoutAllocatingState(t *testing.T) {
tests := []struct {
name string
args func(stateTestRoots) []string
}{
{name: "missing pipeline", args: func(roots stateTestRoots) []string {
return []string{"run", "--config", roots.config, "--input", roots.input}
}},
{name: "missing input", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config}
}},
{name: "unknown flag", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--unknown"}
}},
{name: "blank output directory", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--output-dir", ""}
}},
{name: "blank debug directory", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--debug-dir", ""}
}},
{name: "debug directory without debug", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--debug-dir", filepath.Join(filepath.Dir(roots.debug), "requested-debug")}
}},
{name: "blank session ID", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--session-id", ""}
}},
{name: "multiple pipeline IDs", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "extra", "--config", roots.config, "--input", roots.input}
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
roots := newStateTestRoots(t)
var stdout, stderr bytes.Buffer
code := RunWithOptions(tt.args(roots), &stdout, &stderr, newStateTestHarness().options())
if code != 2 || stdout.Len() != 0 || stderr.Len() == 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
assertAbsent(t, roots.output)
assertAbsent(t, roots.debug)
})
}
}
func TestRunValidFailuresClassifyAndReportDebug(t *testing.T) {
tests := []struct {
name string
args func(stateTestRoots) []string
wantError string
wantDebug bool
}{
{name: "unknown pipeline", args: func(roots stateTestRoots) []string {
return []string{"run", "missing", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}
}, wantError: `pipeline "missing"`},
{name: "unknown lane", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--only", "missing", "--chunk_cache", "bypass", "--debug"}
}, wantError: `lane "missing"`, wantDebug: true},
{name: "unreadable input", args: func(roots stateTestRoots) []string {
return []string{"run", "sample", "--config", roots.config, "--input", filepath.Join(filepath.Dir(roots.input), "unreadable.txt"), "--chunk_cache", "bypass", "--debug"}
}, wantError: "read input", wantDebug: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
roots := newStateTestRoots(t)
var stdout, stderr bytes.Buffer
code := RunWithOptions(tt.args(roots), &stdout, &stderr, newStateTestHarness().options())
if code != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), tt.wantError) {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if tt.wantDebug {
if !strings.Contains(stderr.String(), "debug=") {
t.Fatalf("stderr=%q, want debug path", stderr.String())
}
onlyChildDir(t, roots.debug)
} else {
assertAbsent(t, roots.debug)
}
assertAbsent(t, roots.output)
})
}
}
func TestRunOnlyExecutesSelectedLanes(t *testing.T) {
roots := newStateTestRoots(t)
data, err := os.ReadFile(roots.config)
if err != nil {
t.Fatal(err)
}
data = bytes.Replace(data, []byte(" output: test/output\n"), []byte(" other:\n extract: test/extract\n output: test/output\n"), 1)
if err := os.WriteFile(roots.config, data, 0o600); err != nil {
t.Fatal(err)
}
harness := newStateTestHarness()
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--only", "items", "--chunk_cache", "bypass"}, &stdout, &stderr, harness.options())
if code != 0 || !strings.Contains(stdout.String(), "outputs=1") || stderr.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
harness.mu.Lock()
extractCalls := harness.extractCalls
harness.mu.Unlock()
if extractCalls != 1 {
t.Fatalf("extract calls = %d, want only the selected lane", extractCalls)
}
}
func TestRunStateRootsHonorEnvironmentFlagsAndDefaults(t *testing.T) {
t.Run("environment roots", func(t *testing.T) {
roots := newStateTestRoots(t)
environmentOutput := filepath.Join(t.TempDir(), "environment-output")
environmentDebug := filepath.Join(t.TempDir(), "environment-debug")
opts := newStateTestHarness().options()
opts.LookupEnv = lookupRunContractEnv(map[string]string{
"NOTARIUS_OUTPUT_DIR": environmentOutput,
"NOTARIUS_DEBUG_DIR": environmentDebug,
})
result := runWithStateRoots(t, roots, opts, nil)
if result.code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
}
assertFile(t, filepath.Join(environmentOutput, filepath.Base(onlyChildDir(t, environmentOutput)), "result.json"))
onlyChildDir(t, environmentDebug)
assertAbsent(t, roots.output)
assertAbsent(t, roots.debug)
})
t.Run("command flags override environment", func(t *testing.T) {
roots := newStateTestRoots(t)
environmentOutput := filepath.Join(t.TempDir(), "environment-output")
environmentDebug := filepath.Join(t.TempDir(), "environment-debug")
flagOutput := filepath.Join(t.TempDir(), "flag-output")
flagDebug := filepath.Join(t.TempDir(), "flag-debug")
opts := newStateTestHarness().options()
opts.LookupEnv = lookupRunContractEnv(map[string]string{
"NOTARIUS_OUTPUT_DIR": environmentOutput,
"NOTARIUS_DEBUG_DIR": environmentDebug,
})
result := runWithStateRoots(t, roots, opts, []string{"--output-dir", flagOutput, "--debug-dir", flagDebug})
if result.code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
}
assertFile(t, filepath.Join(flagOutput, filepath.Base(onlyChildDir(t, flagOutput)), "result.json"))
onlyChildDir(t, flagDebug)
assertAbsent(t, environmentOutput)
assertAbsent(t, environmentDebug)
})
t.Run("built-in roots", func(t *testing.T) {
roots := newStateTestRoots(t)
data, err := os.ReadFile(roots.config)
if err != nil {
t.Fatal(err)
}
text := string(data)
text = strings.Replace(text, fmt.Sprintf(" directory: %q\n", roots.output), "", 1)
text = strings.Replace(text, fmt.Sprintf(" directory: %q\n", roots.debug), "", 1)
if err := os.WriteFile(roots.config, []byte(text), 0o600); err != nil {
t.Fatal(err)
}
workDir := t.TempDir()
t.Chdir(workDir)
opts := newStateTestHarness().options()
result := runWithStateRoots(t, roots, opts, nil)
if result.code != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
}
assertFile(t, filepath.Join(workDir, "notarius-output", filepath.Base(onlyChildDir(t, filepath.Join(workDir, "notarius-output"))), "result.json"))
onlyChildDir(t, filepath.Join(workDir, "notarius-debug"))
})
}
func TestRunLLMProfileOverrideAndValidationUseInjectedBoundaries(t *testing.T) {
t.Run("one effective profile reaches the factory and modules", func(t *testing.T) {
roots := newStateTestRoots(t)
profileDir := writeRunContractProfiles(t, "override-profile")
prependRunContractConfig(t, roots, fmt.Sprintf("scriptorium:\n profile_dir: %q\n", profileDir))
harness := newStateTestHarness()
var factoryProfiles []string
opts := harness.options()
opts.LLMClientFactory = func(_ context.Context, _ config.Config, profileID string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
factoryProfiles = append(factoryProfiles, profileID)
return nil, nil, nil
}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--llm-profile", "override-profile"}, &stdout, &stderr, opts)
if code != 0 || stderr.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if len(factoryProfiles) != 1 || factoryProfiles[0] != "override-profile" {
t.Fatalf("factory profiles = %#v, want one override profile", factoryProfiles)
}
harness.mu.Lock()
profiles := append([]string(nil), harness.moduleProfiles...)
harness.mu.Unlock()
if len(profiles) < 4 {
t.Fatalf("module profiles = %#v, want chunk and lane stage requests", profiles)
}
for _, profile := range profiles {
if profile != "override-profile" {
t.Fatalf("module profiles = %#v, want override on every request", profiles)
}
}
})
t.Run("validator profile remains distinct", func(t *testing.T) {
roots := newStateTestRoots(t)
profileDir := writeRunContractProfiles(t, "override-profile", "validator-profile")
prependRunContractConfig(t, roots, fmt.Sprintf("scriptorium:\n profile_dir: %q\n", profileDir))
harness := newStateTestHarness()
var validatorProfiles []string
opts := harness.options()
registerRunContractValidator(t, &opts, &validatorProfiles)
factoryProfiles := []string{}
opts.LLMClientFactory = func(_ context.Context, _ config.Config, profileID string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
factoryProfiles = append(factoryProfiles, profileID)
return nil, nil, nil
}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--llm-profile", "override-profile"}, &stdout, &stderr, opts)
if code != 0 || stderr.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if len(factoryProfiles) != 1 || factoryProfiles[0] != "" {
t.Fatalf("factory profiles = %#v, want one call without a unique profile", factoryProfiles)
}
if len(validatorProfiles) != 1 || validatorProfiles[0] != "validator-profile" {
t.Fatalf("validator profiles = %#v, want configured validator profile", validatorProfiles)
}
})
t.Run("unknown profile is rejected without factory access", func(t *testing.T) {
roots := newStateTestRoots(t)
profileDir := writeRunContractProfiles(t, "override-profile")
prependRunContractConfig(t, roots, fmt.Sprintf("scriptorium:\n profile_dir: %q\n", profileDir))
factoryCalls := 0
opts := newStateTestHarness().options()
opts.LLMClientFactory = func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
factoryCalls++
return nil, nil, nil
}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--llm-profile", "missing-profile"}, &stdout, &stderr, opts)
if code != 1 || !strings.Contains(stderr.String(), "not configured") || factoryCalls != 0 || stdout.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q factoryCalls=%d", code, stdout.String(), stderr.String(), factoryCalls)
}
})
}
func TestEffectiveLLMProfileIDsAreSortedDeduplicatedAndLLMOnly(t *testing.T) {
resolved := pipeline.ResolvedPipeline{
Chunk: pipeline.ModuleBinding{LLMProfile: " zeta "},
ArtifactLanes: []pipeline.ResolvedArtifactLane{
{Extract: pipeline.ModuleBinding{LLMProfile: "alpha"}, Merge: pipeline.ModuleBinding{LLMProfile: "zeta"}},
},
ValidatorChains: []pipeline.ResolvedValidatorChain{{Validators: []pipeline.ResolvedValidator{
{Binding: pipeline.ModuleBinding{LLMProfile: "deterministic-profile"}, ExecutionClass: contracts.ExecutionClassDeterministic},
{Binding: pipeline.ModuleBinding{LLMProfile: "beta"}, ExecutionClass: contracts.ExecutionClassLLMBacked},
}}},
}
got := effectiveLLMProfileIDs(resolved)
want := []string{"alpha", "beta", "zeta"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("effective profiles = %#v, want %#v", got, want)
}
}
func TestRunSessionIDUsesExplicitValueOrSourceDocumentID(t *testing.T) {
for _, tt := range []struct {
name string
args []string
want string
}{
{name: "source document", want: "source"},
{name: "explicit trimmed value", args: []string{"--session-id", " explicit-session "}, want: "explicit-session"},
} {
t.Run(tt.name, func(t *testing.T) {
roots := newStateTestRoots(t)
harness := newStateTestHarness()
args := append([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}, tt.args...)
var stdout, stderr bytes.Buffer
code := RunWithOptions(args, &stdout, &stderr, harness.options())
if code != 0 || stderr.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
harness.mu.Lock()
sessions := append([]string(nil), harness.sessionIDs...)
harness.mu.Unlock()
if len(sessions) < 4 {
t.Fatalf("session IDs = %#v, want all prompt-facing module requests", sessions)
}
for _, session := range sessions {
if session != tt.want {
t.Fatalf("session IDs = %#v, want %q", sessions, tt.want)
}
}
})
}
}
func TestRunFactoryAndPreparationFailuresAreProcessFailures(t *testing.T) {
t.Run("LLM factory", func(t *testing.T) {
roots := newStateTestRoots(t)
opts := newStateTestHarness().options()
opts.LLMClientFactory = func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
return nil, nil, errors.New("injected LLM factory failure")
}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}, &stdout, &stderr, opts)
if code != 1 || !strings.Contains(stderr.String(), "injected LLM factory failure") || stdout.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
})
t.Run("pipeline preparation", func(t *testing.T) {
roots := newStateTestRoots(t)
data, err := os.ReadFile(roots.config)
if err != nil {
t.Fatal(err)
}
data = bytes.Replace(data, []byte("extract: test/extract"), []byte("extract: test/failing-extract"), 1)
if err := os.WriteFile(roots.config, data, 0o600); err != nil {
t.Fatal(err)
}
opts := newStateTestHarness().options()
if err := pipeline.RegisterExtractorBuilder(opts.Registries.Extractors, pipeline.ModuleSpec{Key: "test/failing-extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: stateTestArtifactKind}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.Extractor[stateTestArtifact], error) {
return nil, errors.New("injected extractor construction failure")
}); err != nil {
t.Fatal(err)
}
opts.Catalog = catalogFromRegistries(opts.Registries)
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}, &stdout, &stderr, opts)
if code != 1 || !strings.Contains(stderr.String(), "injected extractor construction failure") || stdout.Len() != 0 {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
})
}
func TestRunWarningsRemainSuccessfulAndReachDurableSurfaces(t *testing.T) {
roots := newStateTestRoots(t)
harness := newStateTestHarness()
harness.includeWarnings = true
harness.chunkWarnings = []contracts.Warning{{Scope: "chunk", ReasonCode: "contract-warning", Message: "warning retained"}}
var stdout, stderr bytes.Buffer
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--debug"}, &stdout, &stderr, harness.options())
if code != 0 || !strings.Contains(stdout.String(), "outputs=1") || !strings.Contains(stderr.String(), "1 warning(s)") {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
outputPath := filepath.Join(onlyChildDir(t, roots.output), "result.json")
output, err := os.ReadFile(outputPath)
if err != nil || !strings.Contains(string(output), "contract-warning") {
t.Fatalf("durable output = %q, %v", output, err)
}
bundle := onlyChildDir(t, roots.debug)
var warnings []contracts.Warning
readStateTestSummaryJSON(t, bundle, "warnings.json", &warnings)
if len(warnings) != 1 || warnings[0].ReasonCode != "contract-warning" {
t.Fatalf("debug warnings = %#v", warnings)
}
}
func runWithStateRoots(t *testing.T, roots stateTestRoots, opts Options, extra []string) stateTestResult {
t.Helper()
args := []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--debug"}
args = append(args, extra...)
var stdout, stderr bytes.Buffer
return stateTestResult{code: RunWithOptions(args, &stdout, &stderr, opts), stdout: stdout.String(), stderr: stderr.String()}
}
func lookupRunContractEnv(values map[string]string) func(string) (string, bool) {
return func(name string) (string, bool) {
value, ok := values[name]
return value, ok
}
}
func prependRunContractConfig(t *testing.T, roots stateTestRoots, prefix string) {
t.Helper()
data, err := os.ReadFile(roots.config)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(roots.config, append([]byte(prefix), data...), 0o600); err != nil {
t.Fatal(err)
}
}
func writeRunContractProfiles(t *testing.T, ids ...string) string {
t.Helper()
dir := t.TempDir()
for _, id := range ids {
profile := fmt.Sprintf("id: %s\nendpoint: http://127.0.0.1:1/v1\nmodel: %s-model\n", id, id)
if err := os.WriteFile(filepath.Join(dir, id+".yaml"), []byte(profile), 0o600); err != nil {
t.Fatal(err)
}
}
return dir
}
func registerRunContractValidator(t *testing.T, opts *Options, profiles *[]string) {
t.Helper()
if err := pipeline.RegisterTypedValidatorBuilder(opts.Registries.Validators, stateTestArtifactKind, pipeline.ValidatorSpec{Key: "run-contract-validator", ExecutionClass: contracts.ExecutionClassLLMBacked}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.TypedValidator[stateTestArtifact], error) {
return runContractValidator{profiles: profiles}, nil
}); err != nil {
t.Fatal(err)
}
if err := opts.Registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{Stage: pipeline.StageExtract, Module: "test/extract", Validators: []pipeline.ModuleBinding{{Module: "run-contract-validator", LLMProfile: "validator-profile"}}}); err != nil {
t.Fatal(err)
}
opts.Catalog = catalogFromRegistries(opts.Registries)
}
type runContractValidator struct {
profiles *[]string
}
func (v runContractValidator) Name() string { return "run-contract-validator" }
func (v runContractValidator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassLLMBacked
}
func (v runContractValidator) Validate(_ context.Context, req contracts.TypedValidationRequest[stateTestArtifact]) (contracts.ValidationResult, error) {
*v.profiles = append(*v.profiles, req.LLMProfile)
return contracts.ValidationResult{Approved: true}, nil
}

View File

@@ -322,6 +322,15 @@ func TestRunUsesOneInjectedIdentityForDebugOutputAndManifest(t *testing.T) {
if manifest.RunID != runID { if manifest.RunID != runID {
t.Fatalf("manifest run ID = %q, want %q", manifest.RunID, runID) t.Fatalf("manifest run ID = %q, want %q", manifest.RunID, runID)
} }
wantStartedAt := time.Unix(1, 0).UTC()
if manifest.StartedAt == nil || !manifest.StartedAt.Equal(wantStartedAt) {
t.Fatalf("manifest started at = %v, want %v", manifest.StartedAt, wantStartedAt)
}
var invocation debugbundle.Invocation
readStateTestSummaryJSON(t, debugPath, "invocation.json", &invocation)
if invocation.RunID != runID || !invocation.StartedAt.Equal(wantStartedAt) {
t.Fatalf("debug invocation identity = %#v, want run %q at %v", invocation, runID, wantStartedAt)
}
report := readStateTestRunReport(t, debugPath) report := readStateTestRunReport(t, debugPath)
if !report.Succeeded || report.RunID != runID || report.PipelineID != "sample" || report.OutputPath != outputPath || report.DebugPath != debugPath || report.OutputCount != 1 || report.RejectedCount != 0 || report.WarningCount != 0 || report.ValidationStatus != "approved" { if !report.Succeeded || report.RunID != runID || report.PipelineID != "sample" || report.OutputPath != outputPath || report.DebugPath != debugPath || report.OutputCount != 1 || report.RejectedCount != 0 || report.WarningCount != 0 || report.ValidationStatus != "approved" {
t.Fatalf("success report = %#v", report) t.Fatalf("success report = %#v", report)
@@ -799,30 +808,36 @@ type stateTestHarness struct {
runIDCalls uint64 runIDCalls uint64
extractErr error extractErr error
chunkWarnings []contracts.Warning chunkWarnings []contracts.Warning
moduleProfiles []string
sessionIDs []string
outputWarnings []contracts.Warning
includeWarnings bool
} }
func newStateTestHarness() *stateTestHarness { return &stateTestHarness{} } func newStateTestHarness() *stateTestHarness { return &stateTestHarness{} }
func (h *stateTestHarness) options() Options { func (h *stateTestHarness) options() Options {
registries := pipeline.Registries{Inputs: pipeline.NewInputAdapterRegistry(), Chunkers: pipeline.NewChunkerRegistry(), ArtifactCodecs: pipeline.NewArtifactCodecRegistry(), Extractors: pipeline.NewExtractorRegistry(), Mergers: pipeline.NewMergerRegistry(), Normalizers: pipeline.NewNormalizerRegistry(), Outputs: pipeline.NewOutputEncoderRegistry()} registries := pipeline.Registries{Inputs: pipeline.NewInputAdapterRegistry(), Chunkers: pipeline.NewChunkerRegistry(), ArtifactCodecs: pipeline.NewArtifactCodecRegistry(), Extractors: pipeline.NewExtractorRegistry(), Mergers: pipeline.NewMergerRegistry(), Normalizers: pipeline.NewNormalizerRegistry(), Validators: pipeline.NewValidatorRegistry(), ValidatorChains: pipeline.NewValidatorChainRegistry(), Outputs: pipeline.NewOutputEncoderRegistry()}
if err := pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, stateTestCodec{}); err != nil { if err := pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, stateTestCodec{}); err != nil {
panic(err) panic(err)
} }
if err := registries.Inputs.RegisterBuilderWithSpec(pipeline.ModuleSpec{Key: "test/input", Stage: pipeline.StageInput, Provides: []string{"source"}}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.InputAdapter, error) { return stateTestInput{}, nil }); err != nil { if err := registries.Inputs.RegisterBuilderWithSpec(pipeline.ModuleSpec{Key: "test/input", Stage: pipeline.StageInput, Provides: []string{"source"}}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.InputAdapter, error) { return stateTestInput{}, nil }); err != nil {
panic(err) panic(err)
} }
if err := registries.Chunkers.RegisterWithSpec(pipeline.ModuleSpec{Key: "test/chunk", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}}, func() (contracts.Chunker, error) { return stateTestChunker{h}, nil }); err != nil { if err := registries.Chunkers.RegisterBuilderWithSpec(pipeline.ModuleSpec{Key: "test/chunk", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}, ReferenceSlots: []contracts.ReferenceSlot{{Name: "cache-reference"}}}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.Chunker, error) { return stateTestChunker{h}, nil }); err != nil {
panic(err) panic(err)
} }
if err := pipeline.RegisterExtractor(registries.Extractors, pipeline.ModuleSpec{Key: "test/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: stateTestArtifactKind}, func() (contracts.Extractor[stateTestArtifact], error) { return stateTestExtractor{h}, nil }); err != nil { if err := pipeline.RegisterExtractor(registries.Extractors, pipeline.ModuleSpec{Key: "test/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: stateTestArtifactKind}, func() (contracts.Extractor[stateTestArtifact], error) { return stateTestExtractor{h}, nil }); err != nil {
panic(err) panic(err)
} }
if err := pipeline.RegisterMerger(registries.Mergers, pipeline.ModuleSpec{Key: "test/merge", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}, ArtifactKind: stateTestArtifactKind}, func() (contracts.Merger[stateTestArtifact], error) { return stateTestMerger{}, nil }); err != nil { if err := pipeline.RegisterMerger(registries.Mergers, pipeline.ModuleSpec{Key: "test/merge", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}, ArtifactKind: stateTestArtifactKind}, func() (contracts.Merger[stateTestArtifact], error) { return stateTestMerger{harness: h}, nil }); err != nil {
panic(err) panic(err)
} }
if err := pipeline.RegisterNormalizer(registries.Normalizers, pipeline.ModuleSpec{Key: "test/normalize", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: stateTestArtifactKind}, func() (contracts.Normalizer[stateTestArtifact], error) { return stateTestNormalizer{}, nil }); err != nil { if err := pipeline.RegisterNormalizer(registries.Normalizers, pipeline.ModuleSpec{Key: "test/normalize", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: stateTestArtifactKind}, func() (contracts.Normalizer[stateTestArtifact], error) { return stateTestNormalizer{harness: h}, nil }); err != nil {
panic(err) panic(err)
} }
if err := registries.Outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "test/output", Stage: pipeline.StageOutput, Requires: []string{"normalized"}, Provides: []string{"output"}}, func() (contracts.OutputEncoder, error) { return stateTestOutput{}, nil }); err != nil { if err := registries.Outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "test/output", Stage: pipeline.StageOutput, Requires: []string{"normalized"}, Provides: []string{"output"}}, func() (contracts.OutputEncoder, error) {
return stateTestOutput{harness: h, includeWarnings: h.includeWarnings}, nil
}); err != nil {
panic(err) panic(err)
} }
return Options{Catalog: catalogFromRegistries(registries), Registries: registries, LookupEnv: emptyLookup, Now: func() time.Time { return time.Unix(1, 0) }, RunIDGenerator: func(startedAt time.Time) (string, error) { return Options{Catalog: catalogFromRegistries(registries), Registries: registries, LookupEnv: emptyLookup, Now: func() time.Time { return time.Unix(1, 0) }, RunIDGenerator: func(startedAt time.Time) (string, error) {
@@ -847,6 +862,10 @@ type stateTestChunker struct{ harness *stateTestHarness }
func (stateTestChunker) Key() string { return "test/chunk" } func (stateTestChunker) Key() string { return "test/chunk" }
func (stateTestChunker) ReferenceSlots() []contracts.ReferenceSlot { return nil } func (stateTestChunker) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (c stateTestChunker) Plan(_ context.Context, req contracts.ChunkRequest) (contracts.ChunkPlanResult, error) { func (c stateTestChunker) Plan(_ context.Context, req contracts.ChunkRequest) (contracts.ChunkPlanResult, error) {
c.harness.mu.Lock()
c.harness.moduleProfiles = append(c.harness.moduleProfiles, req.LLMProfile)
c.harness.sessionIDs = append(c.harness.sessionIDs, req.SessionID)
c.harness.mu.Unlock()
c.harness.mu.Lock() c.harness.mu.Lock()
c.harness.chunkCalls++ c.harness.chunkCalls++
c.harness.mu.Unlock() c.harness.mu.Unlock()
@@ -879,36 +898,56 @@ type stateTestExtractor struct{ harness *stateTestHarness }
func (stateTestExtractor) Key() string { return "test/extract" } func (stateTestExtractor) Key() string { return "test/extract" }
func (stateTestExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil } func (stateTestExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (e stateTestExtractor) Extract(context.Context, contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[stateTestArtifact], error) { func (e stateTestExtractor) Extract(_ context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[stateTestArtifact], error) {
e.harness.mu.Lock() e.harness.mu.Lock()
defer e.harness.mu.Unlock() defer e.harness.mu.Unlock()
e.harness.extractCalls++ e.harness.extractCalls++
e.harness.moduleProfiles = append(e.harness.moduleProfiles, req.LLMProfile)
e.harness.sessionIDs = append(e.harness.sessionIDs, req.SessionID)
if e.harness.extractErr != nil { if e.harness.extractErr != nil {
return contracts.TypedExtractionResult[stateTestArtifact]{}, e.harness.extractErr return contracts.TypedExtractionResult[stateTestArtifact]{}, e.harness.extractErr
} }
return contracts.TypedExtractionResult[stateTestArtifact]{Value: stateTestArtifact{Value: "ok"}}, nil return contracts.TypedExtractionResult[stateTestArtifact]{Value: stateTestArtifact{Value: "ok"}}, nil
} }
type stateTestMerger struct{} type stateTestMerger struct{ harness *stateTestHarness }
func (stateTestMerger) Key() string { return "test/merge" } func (stateTestMerger) Key() string { return "test/merge" }
func (stateTestMerger) Merge(_ context.Context, req contracts.TypedMergeRequest[stateTestArtifact]) (contracts.TypedMergeResult[stateTestArtifact], error) { func (m stateTestMerger) Merge(_ context.Context, req contracts.TypedMergeRequest[stateTestArtifact]) (contracts.TypedMergeResult[stateTestArtifact], error) {
m.harness.mu.Lock()
m.harness.moduleProfiles = append(m.harness.moduleProfiles, req.LLMProfile)
m.harness.sessionIDs = append(m.harness.sessionIDs, req.SessionID)
m.harness.mu.Unlock()
return contracts.TypedMergeResult[stateTestArtifact]{Value: req.ExtractOutputs[0].Value}, nil return contracts.TypedMergeResult[stateTestArtifact]{Value: req.ExtractOutputs[0].Value}, nil
} }
type stateTestNormalizer struct{} type stateTestNormalizer struct{ harness *stateTestHarness }
func (stateTestNormalizer) Key() string { return "test/normalize" } func (stateTestNormalizer) Key() string { return "test/normalize" }
func (stateTestNormalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil } func (stateTestNormalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (stateTestNormalizer) Normalize(_ context.Context, req contracts.TypedNormalizeRequest[stateTestArtifact]) (contracts.TypedNormalizeResult[stateTestArtifact], error) { func (n stateTestNormalizer) Normalize(_ context.Context, req contracts.TypedNormalizeRequest[stateTestArtifact]) (contracts.TypedNormalizeResult[stateTestArtifact], error) {
n.harness.mu.Lock()
n.harness.moduleProfiles = append(n.harness.moduleProfiles, req.LLMProfile)
n.harness.sessionIDs = append(n.harness.sessionIDs, req.SessionID)
n.harness.mu.Unlock()
return contracts.TypedNormalizeResult[stateTestArtifact]{Value: req.MergeOutput.Value}, nil return contracts.TypedNormalizeResult[stateTestArtifact]{Value: req.MergeOutput.Value}, nil
} }
type stateTestOutput struct{} type stateTestOutput struct {
harness *stateTestHarness
includeWarnings bool
}
func (stateTestOutput) Key() string { return "test/output" } func (o stateTestOutput) Key() string { return "test/output" }
func (stateTestOutput) Encode(context.Context, contracts.OutputRequest) (contracts.OutputResult, error) { func (o stateTestOutput) Encode(_ context.Context, req contracts.OutputRequest) (contracts.OutputResult, error) {
return contracts.OutputResult{Files: []contracts.OutputFile{{Name: "result.json", Bytes: []byte("{\"ok\":true}\n")}}}, nil o.harness.mu.Lock()
o.harness.outputWarnings = append([]contracts.Warning(nil), req.Warnings...)
o.harness.mu.Unlock()
data := []byte("{\"ok\":true}\n")
if o.includeWarnings && len(req.Warnings) > 0 {
data = []byte(fmt.Sprintf("{\"ok\":true,\"warnings\":%q}\n", req.Warnings[0].ReasonCode))
}
return contracts.OutputResult{Files: []contracts.OutputFile{{Name: "result.json", Bytes: data}}}, nil
} }
type failingDebugRecorder struct{} type failingDebugRecorder struct{}

View File

@@ -0,0 +1,514 @@
package config
import (
"context"
"encoding/json"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestEffectiveConfigRejectsEmptyAndUnknownPipelineIDs(t *testing.T) {
cfg := configForEffectiveTests(t, effectiveProfile())
for _, pipelineID := range []string{"", "missing"} {
name := pipelineID
if name == "" {
name = "empty"
}
t.Run(name, func(t *testing.T) {
_, err := cfg.Resolve(ResolveInput{PipelineID: pipelineID, Catalog: effectiveCatalog(t)})
if err == nil || !strings.Contains(err.Error(), "pipeline") {
t.Fatalf("Resolve(%q) error = %v, want pipeline context", pipelineID, err)
}
})
}
}
func TestEffectiveConfigResolvesTrimmedPipelineMapKeys(t *testing.T) {
profile := effectiveProfile()
profile.ID = " main "
cfg := Default()
cfg.Pipelines = map[string]pipeline.PipelineProfile{" main ": profile}
effective, err := cfg.Resolve(ResolveInput{PipelineID: "main", Catalog: effectiveCatalog(t)})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if effective.PipelineID != "main" || effective.ResolvedPipeline.ID != "main" {
t.Fatalf("resolved IDs = %q, %q", effective.PipelineID, effective.ResolvedPipeline.ID)
}
}
func TestEffectiveConfigOnlySelectsRequestedLanesWithoutMutatingSource(t *testing.T) {
profile := effectiveProfile()
profile.Artifacts["other"] = pipeline.ArtifactLaneProfile{Extract: pipeline.Binding("extract")}
cfg := configForEffectiveTests(t, profile)
effective, err := cfg.Resolve(ResolveInput{
PipelineID: "main",
Only: []string{"other"},
Catalog: effectiveCatalog(t),
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if len(effective.ResolvedPipeline.ArtifactLanes) != 1 || effective.ResolvedPipeline.ArtifactLanes[0].ID != "other" {
t.Fatalf("resolved lanes = %#v", effective.ResolvedPipeline.ArtifactLanes)
}
if len(cfg.Pipelines["main"].Artifacts) != 2 {
t.Fatalf("source lanes were mutated: %#v", cfg.Pipelines["main"].Artifacts)
}
_, err = cfg.Resolve(ResolveInput{
PipelineID: "main",
Only: []string{"missing"},
Catalog: effectiveCatalog(t),
})
if err == nil || !strings.Contains(err.Error(), "lane \"missing\"") {
t.Fatalf("unknown lane error = %v", err)
}
}
func TestEffectiveConfigMaterializesDefaultBindingsThroughCatalog(t *testing.T) {
effective, err := resolveEffectiveProfile(t, effectiveProfile(), ResolveInput{})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
resolved := effective.ResolvedPipeline
if resolved.Chunk.Module != pipeline.DefaultChunkModule || resolved.Output.Module != pipeline.DefaultOutputModule {
t.Fatalf("default pipeline bindings = %#v, %#v", resolved.Chunk, resolved.Output)
}
if len(resolved.ArtifactLanes) != 1 || resolved.ArtifactLanes[0].Merge.Module != pipeline.DefaultMergeModule || resolved.ArtifactLanes[0].Normalize.Module != pipeline.DefaultNormalizeModule {
t.Fatalf("default lane bindings = %#v", resolved.ArtifactLanes)
}
}
func TestEffectiveConfigResolutionFailuresRetainContext(t *testing.T) {
tests := []struct {
name string
mutate func(*pipeline.PipelineProfile)
want []string
}{
{
name: "unknown module",
mutate: func(profile *pipeline.PipelineProfile) {
profile.Input.Module = "missing-input"
},
want: []string{"pipeline \"main\"", "input"},
},
{
name: "missing capability",
mutate: func(profile *pipeline.PipelineProfile) {
profile.Chunk.Module = "needs-capability"
},
want: []string{"pipeline \"main\"", "chunk"},
},
{
name: "missing artifact variant",
mutate: func(profile *pipeline.PipelineProfile) {
profile.Artifacts["lane"] = pipeline.ArtifactLaneProfile{
Extract: pipeline.Binding("extract"),
Merge: pipeline.Binding("other-merge"),
}
},
want: []string{"pipeline \"main\"", "lane \"lane\"", "merge"},
},
{
name: "invalid module options",
mutate: func(profile *pipeline.PipelineProfile) {
profile.Chunk = pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"unknown": true}}
},
want: []string{"pipeline \"main\"", "chunk", "generic", "options"},
},
{
name: "invalid validator options",
mutate: func(profile *pipeline.PipelineProfile) {
lane := profile.Artifacts["lane"]
lane.Extract.Validators = pipeline.ValidatorOverride{
Set: true,
Validators: []pipeline.ModuleBinding{{
Module: "option-validator",
Options: map[string]any{"invalid": true},
}},
}
profile.Artifacts["lane"] = lane
},
want: []string{"pipeline \"main\"", "lane \"lane\"", "extract", "option-validator", "options"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
profile := effectiveProfile()
tt.mutate(&profile)
_, err := resolveEffectiveProfile(t, profile, ResolveInput{})
if err == nil {
t.Fatal("Resolve() error = nil, want failure")
}
for _, fragment := range tt.want {
if !strings.Contains(err.Error(), fragment) {
t.Fatalf("Resolve() error = %v, want context %q", err, fragment)
}
}
})
}
}
func TestEffectiveConfigLLMProfileOverrideChangesDigestWithoutOverridingValidators(t *testing.T) {
profile := effectiveProfile()
profile.Chunk.LLMProfile = "chunk-profile"
lane := profile.Artifacts["lane"]
lane.Extract.LLMProfile = "extract-profile"
lane.Merge.LLMProfile = "merge-profile"
lane.Normalize.LLMProfile = "normalize-profile"
lane.Extract.Validators = pipeline.ValidatorOverride{
Set: true,
Validators: []pipeline.ModuleBinding{{
Module: "llm-validator",
LLMProfile: "validator-profile",
}},
}
profile.Artifacts["lane"] = lane
base, err := resolveEffectiveProfile(t, profile, ResolveInput{})
if err != nil {
t.Fatalf("base Resolve() error = %v", err)
}
overridden, err := resolveEffectiveProfile(t, profile, ResolveInput{LLMProfileOverride: "override-profile"})
if err != nil {
t.Fatalf("overridden Resolve() error = %v", err)
}
if base.ResolvedPipeline.Digest == overridden.ResolvedPipeline.Digest {
t.Fatal("LLM profile override did not change the pipeline digest")
}
resolved := overridden.ResolvedPipeline
if resolved.Chunk.LLMProfile != "override-profile" || resolved.ArtifactLanes[0].Extract.LLMProfile != "override-profile" ||
resolved.ArtifactLanes[0].Merge.LLMProfile != "override-profile" || resolved.ArtifactLanes[0].Normalize.LLMProfile != "override-profile" {
t.Fatalf("pipeline profile override was not applied: %#v", resolved)
}
validators := findEffectiveValidatorChain(resolved, pipeline.StageExtract, "lane")
if len(validators.Validators) != 1 || validators.Validators[0].Binding.LLMProfile != "validator-profile" {
t.Fatalf("validator profile was overridden: %#v", validators)
}
}
func TestEffectiveConfigValidatorOverridesRemainDistinctAndOrdered(t *testing.T) {
tests := []struct {
name string
value pipeline.ValidatorOverride
want []string
}{
{
name: "omitted uses default",
want: []string{"default-validator"},
},
{
name: "explicit empty",
value: pipeline.ValidatorOverride{Set: true},
want: nil,
},
{
name: "configured order",
value: pipeline.ValidatorOverride{
Set: true,
Validators: []pipeline.ModuleBinding{
pipeline.Binding("configured-a"),
pipeline.Binding("configured-b"),
},
},
want: []string{"configured-a", "configured-b"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
profile := effectiveProfile()
lane := profile.Artifacts["lane"]
lane.Extract.Validators = tt.value
profile.Artifacts["lane"] = lane
effective, err := resolveEffectiveProfile(t, profile, ResolveInput{})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
chain := findEffectiveValidatorChain(effective.ResolvedPipeline, pipeline.StageExtract, "lane")
got := make([]string, len(chain.Validators))
for i, validator := range chain.Validators {
got[i] = validator.Binding.Module
}
if len(got) != len(tt.want) {
t.Fatalf("validator chain = %#v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("validator chain = %#v, want %v", got, tt.want)
}
}
})
}
}
func TestEffectiveConfigAndResolutionInputsDoNotAliasSource(t *testing.T) {
profile := effectiveProfile()
profile.Chunk.Options = map[string]any{"nested": map[string]any{"safe": "source"}}
profile.Chunk.References = map[string]string{"chunk-ref": "chunk.txt"}
lane := profile.Artifacts["lane"]
lane.Extract.Validators = pipeline.ValidatorOverride{
Set: true,
Validators: []pipeline.ModuleBinding{{
Module: "configured-a",
Options: map[string]any{"nested": map[string]any{"safe": "validator-source"}},
}},
}
profile.Artifacts["lane"] = lane
cfg := configForEffectiveTests(t, profile)
only := []string{"lane"}
overrides := []pipeline.ReferenceBinding{{Stage: pipeline.StageChunk, SlotName: "chunk-ref", Source: "source.txt"}}
effective, err := cfg.Resolve(ResolveInput{
PipelineID: "main",
Only: only,
ReferenceOverrides: overrides,
Catalog: effectiveCatalog(t),
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
effective.Config.Pipelines["main"].Chunk.Options["nested"].(map[string]any)["safe"] = "effective-config"
effective.ResolvedPipeline.Chunk.Options["nested"].(map[string]any)["safe"] = "resolved-pipeline"
effective.ResolvedPipeline.ChunkReferences.Bindings[0].Source = "resolved-reference"
effective.ResolvedPipeline.ValidatorChains[1].Validators[0].Binding.Options["nested"].(map[string]any)["safe"] = "resolved-validator"
effective.Only[0] = "mutated-only"
effective.ReferenceOverrides[0].Source = "mutated-override"
if got := cfg.Pipelines["main"].Chunk.Options["nested"].(map[string]any)["safe"]; got != "source" {
t.Fatalf("source config option was aliased: %v", got)
}
if got := cfg.Pipelines["main"].Chunk.References["chunk-ref"]; got != "chunk.txt" {
t.Fatalf("source config references were aliased: %v", got)
}
if only[0] != "lane" || overrides[0].Source != "source.txt" {
t.Fatal("resolution inputs were aliased")
}
}
func effectiveProfile() pipeline.PipelineProfile {
return pipeline.PipelineProfile{
ID: "main",
Input: pipeline.Binding("input"),
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"lane": {Extract: pipeline.Binding("extract")},
},
}
}
func configForEffectiveTests(t *testing.T, profile pipeline.PipelineProfile) Config {
t.Helper()
cfg := Default()
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
return cfg
}
func resolveEffectiveProfile(t *testing.T, profile pipeline.PipelineProfile, input ResolveInput) (EffectiveConfig, error) {
t.Helper()
cfg := configForEffectiveTests(t, profile)
if input.PipelineID == "" {
input.PipelineID = "main"
}
if input.Catalog.Inputs == nil {
input.Catalog = effectiveCatalog(t)
}
return cfg.Resolve(input)
}
func findEffectiveValidatorChain(resolved pipeline.ResolvedPipeline, stage pipeline.ModuleStage, laneID string) pipeline.ResolvedValidatorChain {
for _, chain := range resolved.ValidatorChains {
if chain.Stage == stage && chain.LaneID == laneID {
return chain
}
}
return pipeline.ResolvedValidatorChain{}
}
type effectiveArtifact struct {
Value string `json:"value"`
}
const effectiveArtifactKind contracts.ArtifactKind = "test/effective"
type effectiveCodec struct{}
func (effectiveCodec) Kind() contracts.ArtifactKind { return effectiveArtifactKind }
func (effectiveCodec) Schema() contracts.ArtifactSchema {
return contracts.ArtifactSchema{
ID: "effective-schema",
Name: "Effective artifact",
Version: "1",
JSONSchema: []byte(`{"type":"object"}`),
}
}
func (effectiveCodec) MediaType() string { return "application/json" }
func (effectiveCodec) EncodeCandidate(value effectiveArtifact) ([]byte, error) {
return json.Marshal(value)
}
func (effectiveCodec) Encode(value effectiveArtifact) ([]byte, error) {
return json.Marshal(value)
}
func (effectiveCodec) Decode(content []byte) (effectiveArtifact, error) {
var value effectiveArtifact
err := json.Unmarshal(content, &value)
return value, err
}
type effectiveInput struct{ key string }
func (m effectiveInput) Key() string { return m.key }
func (m effectiveInput) Parse(context.Context, contracts.ParseRequest) (*source.SourceDocument, error) {
return &source.SourceDocument{}, nil
}
type effectiveChunker struct{ key string }
func (m effectiveChunker) Key() string { return m.key }
func (m effectiveChunker) ReferenceSlots() []contracts.ReferenceSlot {
if m.key == pipeline.DefaultChunkModule {
return []contracts.ReferenceSlot{{Name: "chunk-ref"}}
}
return nil
}
func (m effectiveChunker) Plan(context.Context, contracts.ChunkRequest) (contracts.ChunkPlanResult, error) {
return contracts.ChunkPlanResult{}, nil
}
type effectiveExtractor struct{ key string }
func (m effectiveExtractor) Key() string { return m.key }
func (m effectiveExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (m effectiveExtractor) Extract(context.Context, contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[effectiveArtifact], error) {
return contracts.TypedExtractionResult[effectiveArtifact]{}, nil
}
type effectiveMerger struct{ key string }
func (m effectiveMerger) Key() string { return m.key }
func (m effectiveMerger) Merge(context.Context, contracts.TypedMergeRequest[effectiveArtifact]) (contracts.TypedMergeResult[effectiveArtifact], error) {
return contracts.TypedMergeResult[effectiveArtifact]{}, nil
}
type effectiveNormalizer struct{ key string }
func (m effectiveNormalizer) Key() string { return m.key }
func (m effectiveNormalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (m effectiveNormalizer) Normalize(context.Context, contracts.TypedNormalizeRequest[effectiveArtifact]) (contracts.TypedNormalizeResult[effectiveArtifact], error) {
return contracts.TypedNormalizeResult[effectiveArtifact]{}, nil
}
type effectiveOutput struct{ key string }
func (m effectiveOutput) Key() string { return m.key }
func (m effectiveOutput) Encode(context.Context, contracts.OutputRequest) (contracts.OutputResult, error) {
return contracts.OutputResult{}, nil
}
type effectiveValidator struct {
name string
class contracts.ExecutionClass
}
func (v effectiveValidator) Name() string { return v.name }
func (v effectiveValidator) ExecutionClass() contracts.ExecutionClass { return v.class }
func (v effectiveValidator) Validate(context.Context, contracts.TypedValidationRequest[effectiveArtifact]) (contracts.ValidationResult, error) {
return contracts.ValidationResult{Approved: true}, nil
}
func effectiveCatalog(t *testing.T) pipeline.ModuleCatalog {
t.Helper()
catalog := pipeline.ModuleCatalog{
Inputs: pipeline.NewInputAdapterRegistry(),
Chunkers: pipeline.NewChunkerRegistry(),
ArtifactCodecs: pipeline.NewArtifactCodecRegistry(),
Extractors: pipeline.NewExtractorRegistry(),
Mergers: pipeline.NewMergerRegistry(),
Normalizers: pipeline.NewNormalizerRegistry(),
Validators: pipeline.NewValidatorRegistry(),
ValidatorChains: pipeline.NewValidatorChainRegistry(),
Outputs: pipeline.NewOutputEncoderRegistry(),
}
if err := pipeline.RegisterArtifactCodec(catalog.ArtifactCodecs, effectiveCodec{}); err != nil {
t.Fatal(err)
}
if err := catalog.Inputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "input", Stage: pipeline.StageInput, Provides: []string{"source"}}, func() (contracts.InputAdapter, error) {
return effectiveInput{key: "input"}, nil
}); err != nil {
t.Fatal(err)
}
chunkSpec := pipeline.ModuleSpec{
Key: pipeline.DefaultChunkModule,
Stage: pipeline.StageChunk,
Requires: []string{"source"},
Provides: []string{"chunk"},
ReferenceSlots: []contracts.ReferenceSlot{{Name: "chunk-ref"}},
}
chunkOptions := func(options map[string]any) error { return pipeline.RejectUnknownOptions(options, "size", "nested") }
if err := catalog.Chunkers.RegisterBuilderWithSpec(chunkSpec, chunkOptions, func(pipeline.BuildRequest) (contracts.Chunker, error) {
return effectiveChunker{key: pipeline.DefaultChunkModule}, nil
}); err != nil {
t.Fatal(err)
}
if err := catalog.Chunkers.RegisterBuilderWithSpec(pipeline.ModuleSpec{Key: "needs-capability", Stage: pipeline.StageChunk, Requires: []string{"missing"}}, chunkOptions, func(pipeline.BuildRequest) (contracts.Chunker, error) {
return effectiveChunker{key: "needs-capability"}, nil
}); err != nil {
t.Fatal(err)
}
if err := pipeline.RegisterExtractor(catalog.Extractors, pipeline.ModuleSpec{Key: "extract", Stage: pipeline.StageExtract, ArtifactKind: effectiveArtifactKind, Requires: []string{"chunk"}, Provides: []string{"candidate"}}, func() (contracts.Extractor[effectiveArtifact], error) {
return effectiveExtractor{key: "extract"}, nil
}); err != nil {
t.Fatal(err)
}
if err := pipeline.RegisterMerger(catalog.Mergers, pipeline.ModuleSpec{Key: pipeline.DefaultMergeModule, Stage: pipeline.StageMerge, ArtifactKind: effectiveArtifactKind, Requires: []string{"candidate"}, Provides: []string{"merged"}}, func() (contracts.Merger[effectiveArtifact], error) {
return effectiveMerger{key: pipeline.DefaultMergeModule}, nil
}); err != nil {
t.Fatal(err)
}
if err := pipeline.RegisterMerger(catalog.Mergers, pipeline.ModuleSpec{Key: "other-merge", Stage: pipeline.StageMerge, ArtifactKind: "other-kind", Requires: []string{"candidate"}, Provides: []string{"merged"}}, func() (contracts.Merger[effectiveArtifact], error) {
return effectiveMerger{key: "other-merge"}, nil
}); err != nil {
t.Fatal(err)
}
if err := pipeline.RegisterNormalizer(catalog.Normalizers, pipeline.ModuleSpec{Key: pipeline.DefaultNormalizeModule, Stage: pipeline.StageNormalize, ArtifactKind: effectiveArtifactKind, Requires: []string{"merged"}, Provides: []string{"normalized"}}, func() (contracts.Normalizer[effectiveArtifact], error) {
return effectiveNormalizer{key: pipeline.DefaultNormalizeModule}, nil
}); err != nil {
t.Fatal(err)
}
if err := catalog.Outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: pipeline.DefaultOutputModule, Stage: pipeline.StageOutput, Requires: []string{"normalized"}}, func() (contracts.OutputEncoder, error) {
return effectiveOutput{key: pipeline.DefaultOutputModule}, nil
}); err != nil {
t.Fatal(err)
}
for _, validator := range []struct {
key string
class contracts.ExecutionClass
}{
{key: "default-validator", class: contracts.ExecutionClassDeterministic},
{key: "configured-a", class: contracts.ExecutionClassDeterministic},
{key: "configured-b", class: contracts.ExecutionClassDeterministic},
{key: "llm-validator", class: contracts.ExecutionClassLLMBacked},
} {
if err := pipeline.RegisterTypedValidatorBuilder(catalog.Validators, effectiveArtifactKind, pipeline.ValidatorSpec{Key: validator.key, ExecutionClass: validator.class}, func(options map[string]any) error {
return pipeline.RejectUnknownOptions(options, "nested")
}, func(pipeline.BuildRequest) (contracts.TypedValidator[effectiveArtifact], error) {
return effectiveValidator{name: validator.key, class: validator.class}, nil
}); err != nil {
t.Fatal(err)
}
}
if err := pipeline.RegisterTypedValidatorBuilder(catalog.Validators, effectiveArtifactKind, pipeline.ValidatorSpec{Key: "option-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, func(options map[string]any) error {
return pipeline.RejectUnknownOptions(options, "allowed")
}, func(pipeline.BuildRequest) (contracts.TypedValidator[effectiveArtifact], error) {
return effectiveValidator{name: "option-validator", class: contracts.ExecutionClassDeterministic}, nil
}); err != nil {
t.Fatal(err)
}
if err := catalog.ValidatorChains.Register(pipeline.ValidatorChainMapping{Stage: pipeline.StageExtract, Module: "extract", Validators: []pipeline.ModuleBinding{pipeline.Binding("default-validator")}}); err != nil {
t.Fatal(err)
}
return catalog
}

View File

@@ -0,0 +1,239 @@
package config
import (
"errors"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestPrecedenceFileValuesOverrideBuiltInDefaults(t *testing.T) {
cfg := applyFileConfig(t, `version: 3
concurrency:
total_llm: 4
stage_workers:
extract: 2
output:
directory: ./file-output
cache:
chunk_plans:
directory: ./file-plans
mode: refresh
checkpoints:
directory: ./file-checkpoints
debug:
directory: ./file-debug
`)
if cfg.Concurrency.TotalLLM != 4 || cfg.Concurrency.StageWorkers["extract"] != 2 ||
cfg.Output.Directory != "./file-output" || cfg.Cache.ChunkPlans.Directory != "file-plans" ||
cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheRefresh || cfg.Cache.Checkpoints.Directory != "file-checkpoints" ||
cfg.Debug.Directory != "./file-debug" {
t.Fatalf("file values did not override defaults: %#v", cfg)
}
}
func TestPrecedenceOperationalEnvironmentOverridesFileValues(t *testing.T) {
cfg := applyFileConfig(t, `version: 3
concurrency:
total_llm: 2
stage_workers:
extract: 1
output:
directory: ./file-output
cache:
chunk_plans:
directory: ./file-plans
mode: refresh
checkpoints:
directory: ./file-checkpoints
debug:
directory: ./file-debug
`)
env := map[string]string{
"NOTARIUS_TOTAL_LLM_CONCURRENCY": "8",
"NOTARIUS_STAGE_WORKERS_EXTRACT": "6",
"NOTARIUS_OUTPUT_DIR": "/env/output",
"NOTARIUS_CACHE_CHUNK_PLANS_MODE": "bypass",
"NOTARIUS_CACHE_CHUNK_PLANS_DIR": "/env/plans",
"NOTARIUS_CACHE_CHECKPOINTS_DIR": "/env/checkpoints",
"NOTARIUS_DEBUG_DIR": "/env/debug",
}
if err := cfg.ApplyEnvOverridesWithLookup(lookupValues(env)); err != nil {
t.Fatal(err)
}
if cfg.Concurrency.TotalLLM != 8 || cfg.Concurrency.StageWorkers["extract"] != 6 ||
cfg.Output.Directory != "/env/output" || cfg.Cache.ChunkPlans.Directory != "/env/plans" ||
cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheBypass || cfg.Cache.Checkpoints.Directory != "/env/checkpoints" ||
cfg.Debug.Directory != "/env/debug" {
t.Fatalf("environment values did not override file values: %#v", cfg)
}
}
func TestPrecedenceExtractWorkersFollowEffectiveConcurrencyUnlessExplicit(t *testing.T) {
tests := []struct {
name string
file string
env map[string]string
wantTotal int
wantWorker int
}{
{
name: "default follows environment total",
file: "version: 3\n",
env: map[string]string{"NOTARIUS_TOTAL_LLM_CONCURRENCY": "5"},
wantTotal: 5,
wantWorker: 5,
},
{
name: "file worker is retained",
file: "version: 3\nconcurrency:\n total_llm: 3\n stage_workers:\n extract: 2\n",
env: map[string]string{"NOTARIUS_TOTAL_LLM_CONCURRENCY": "6"},
wantTotal: 6,
wantWorker: 2,
},
{
name: "environment worker is retained",
file: "version: 3\nconcurrency:\n total_llm: 2\n",
env: map[string]string{
"NOTARIUS_TOTAL_LLM_CONCURRENCY": "6",
"NOTARIUS_STAGE_WORKERS_EXTRACT": "4",
},
wantTotal: 6,
wantWorker: 4,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := applyFileConfig(t, tt.file)
if err := cfg.ApplyEnvOverridesWithLookup(lookupValues(tt.env)); err != nil {
t.Fatal(err)
}
if cfg.Concurrency.TotalLLM != tt.wantTotal || cfg.Concurrency.StageWorkers["extract"] != tt.wantWorker {
t.Fatalf("concurrency = %#v, want total %d and extract %d", cfg.Concurrency, tt.wantTotal, tt.wantWorker)
}
})
}
}
func TestPrecedenceEmptyFileCacheDirectoriesDeferPerUserResolution(t *testing.T) {
cfg := applyFileConfig(t, `version: 3
cache:
chunk_plans:
directory: ""
checkpoints:
directory: ""
`)
if err := cfg.Validate(); err != nil {
t.Fatalf("empty file cache directories should be valid: %v", err)
}
if cfg.Cache.ChunkPlans.Directory != "" || cfg.Cache.Checkpoints.Directory != "" {
t.Fatalf("empty cache directories were not preserved for deferred resolution: %#v", cfg.Cache)
}
resolver := func() (string, error) { return "/user/cache", nil }
chunkPlans, err := DefaultChunkPlanRoot(resolver)
if err != nil {
t.Fatal(err)
}
checkpoints, err := DefaultCheckpointRoot(resolver)
if err != nil {
t.Fatal(err)
}
if chunkPlans != "/user/cache/notarius/chunk-plans" || checkpoints != "/user/cache/notarius/checkpoints" {
t.Fatalf("deferred cache roots = %q, %q", chunkPlans, checkpoints)
}
}
func TestDefaultCacheRootsRejectInvalidUserCacheResolvers(t *testing.T) {
tests := []struct {
name string
resolver func() (string, error)
want string
}{
{name: "nil resolver", want: "must not be nil"},
{
name: "resolver failure",
resolver: func() (string, error) {
return "", errors.New("cache home unavailable")
},
want: "resolve user cache directory",
},
{name: "empty directory", resolver: func() (string, error) { return " ", nil }, want: "must not be empty"},
}
families := []struct {
name string
root func(func() (string, error)) (string, error)
}{
{name: "chunk plans", root: DefaultChunkPlanRoot},
{name: "checkpoints", root: DefaultCheckpointRoot},
}
for _, family := range families {
for _, tt := range tests {
t.Run(family.name+"/"+tt.name, func(t *testing.T) {
_, err := family.root(tt.resolver)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("error = %v, want substring %q", err, tt.want)
}
})
}
}
}
func TestEnvEmptyDirectoryOverridesAreErrors(t *testing.T) {
tests := []string{
"NOTARIUS_OUTPUT_DIR",
"NOTARIUS_CACHE_CHUNK_PLANS_DIR",
"NOTARIUS_CACHE_CHECKPOINTS_DIR",
"NOTARIUS_DEBUG_DIR",
}
for _, name := range tests {
t.Run(name, func(t *testing.T) {
cfg := Default()
err := cfg.ApplyEnvOverridesWithLookup(lookupValues(map[string]string{name: " \t"}))
if err == nil || !strings.Contains(err.Error(), name) {
t.Fatalf("error = %v, want responsible environment variable", err)
}
})
}
}
func TestEnvInvalidIntegersAndChunkCacheModesReportTheirNames(t *testing.T) {
tests := map[string]string{
"NOTARIUS_TOTAL_LLM_CONCURRENCY": "not-an-integer",
"NOTARIUS_STAGE_WORKERS_EXTRACT": "not-an-integer",
"NOTARIUS_CACHE_CHUNK_PLANS_MODE": "not-a-cache-mode",
}
for name, value := range tests {
t.Run(name, func(t *testing.T) {
cfg := Default()
err := cfg.ApplyEnvOverridesWithLookup(lookupValues(map[string]string{name: value}))
if err == nil || !strings.Contains(err.Error(), name) {
t.Fatalf("error = %v, want responsible environment variable", err)
}
})
}
}
func TestEnvRemovedProviderVariablesAreIgnored(t *testing.T) {
before := Default()
cfg := Default()
removed := map[string]string{
"NOTARIUS_LLM_DEFAULT_ENDPOINT": "ignored-provider-setting",
"NOTARIUS_LLM_DEFAULT_MODEL": "ignored-provider-setting",
}
if err := cfg.ApplyEnvOverridesWithLookup(lookupValues(removed)); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(cfg, before) {
t.Fatalf("removed provider variables changed configuration: %#v", cfg)
}
}
func lookupValues(values map[string]string) func(string) (string, bool) {
return func(name string) (string, bool) {
value, ok := values[name]
return value, ok
}
}

View File

@@ -0,0 +1,364 @@
package config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestDefaultReturnsDocumentedValuesAndIndependentMaps(t *testing.T) {
first := Default()
if first.Concurrency.TotalLLM != 1 || first.Concurrency.StageWorkers["extract"] != 1 {
t.Fatalf("concurrency defaults = %#v", first.Concurrency)
}
if first.Output.Directory != "./notarius-output" || first.Debug.Directory != "./notarius-debug" {
t.Fatalf("output/debug defaults = %#v, %#v", first.Output, first.Debug)
}
if first.Cache.ChunkPlans.Mode != pipeline.ChunkCacheAuto || first.Cache.ChunkPlans.Directory != "" || first.Cache.Checkpoints.Directory != "" {
t.Fatalf("cache defaults = %#v", first.Cache)
}
if len(first.Pipelines) != 0 {
t.Fatalf("pipeline defaults = %#v", first.Pipelines)
}
first.Concurrency.StageWorkers["extract"] = 99
first.Concurrency.StageWorkers["other"] = 100
first.Pipelines["changed"] = pipeline.PipelineProfile{}
second := Default()
if second.Concurrency.StageWorkers["extract"] != 1 || len(second.Concurrency.StageWorkers) != 1 || len(second.Pipelines) != 0 {
t.Fatalf("Default() returned state shared with an earlier result: %#v", second)
}
}
func TestFileConfigMinimalVersion3AppliesOverDefaults(t *testing.T) {
file := parseFileConfig(t, "version: 3\n")
cfg := Default()
if err := cfg.ApplyFileConfig(file); err != nil {
t.Fatal(err)
}
if cfg.Output.Directory != "./notarius-output" || cfg.Debug.Directory != "./notarius-debug" || cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheAuto {
t.Fatalf("minimal file changed unrelated defaults: %#v", cfg)
}
if cfg.Concurrency.TotalLLM != 1 || cfg.Concurrency.StageWorkers["extract"] != 1 || len(cfg.Pipelines) != 0 {
t.Fatalf("minimal file did not retain defaults: %#v", cfg)
}
}
func TestFileConfigMissingVersionIsReportedBeforeFieldDecoding(t *testing.T) {
_, err := ParseFileConfigYAML([]byte("workspace:\n directory: /tmp/old\n"))
if err == nil || !strings.Contains(err.Error(), "config version is required") {
t.Fatalf("missing version error = %v", err)
}
}
func TestFileConfigRejectsUnknownCurrentAndRemovedFields(t *testing.T) {
tests := []struct {
name string
yaml string
want string
}{
{
name: "removed diagnostics",
yaml: "version: 3\ndiagnostics: {}\n",
want: "field diagnostics not found",
},
{
name: "removed llm profiles",
yaml: "version: 3\nllm_profiles: {}\n",
want: "field llm_profiles not found",
},
{
name: "pipeline field",
yaml: "version: 3\npipelines:\n main:\n unknown: true\n",
want: "field unknown not found",
},
{
name: "lane field",
yaml: "version: 3\npipelines:\n main:\n artifacts:\n spells:\n unknown: true\n",
want: "field unknown not found",
},
{
name: "module binding field",
yaml: "version: 3\npipelines:\n main:\n input:\n module: seriatim\n unknown: true\n",
want: "field unknown not found in module binding",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseFileConfigYAML([]byte(tt.yaml))
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("error = %v, want context %q", err, tt.want)
}
})
}
}
func TestFileConfigModuleBindingsPreserveFormsAndValidatorPresence(t *testing.T) {
cfg := applyFileConfig(t, `version: 3
pipelines:
main:
input: seriatim
chunk:
module: generic
llm_profile: chunk-profile
retries: 2
options:
max_units: 25
references:
glossary: ./glossary.md
validators: []
artifacts:
spells:
extract:
module: dnd/spells
options:
nested:
enabled: true
merge: appendorder
normalize: noop
`)
profile := cfg.Pipelines["main"]
if profile.Input.Module != "seriatim" || profile.Input.Validators.Set {
t.Fatalf("shorthand binding = %#v", profile.Input)
}
if profile.Chunk.Module != "generic" || profile.Chunk.LLMProfile != "chunk-profile" || profile.Chunk.Retries != 2 ||
!reflect.DeepEqual(profile.Chunk.Options, map[string]any{"max_units": 25}) ||
!reflect.DeepEqual(profile.Chunk.References, map[string]string{"glossary": "./glossary.md"}) {
t.Fatalf("object binding = %#v", profile.Chunk)
}
if !profile.Chunk.Validators.Set || len(profile.Chunk.Validators.Validators) != 0 {
t.Fatalf("explicit empty validators = %#v", profile.Chunk.Validators)
}
if profile.Artifacts["spells"].Extract.Module != "dnd/spells" ||
!reflect.DeepEqual(profile.Artifacts["spells"].Extract.Options, map[string]any{
"nested": map[string]any{"enabled": true},
}) {
t.Fatalf("extract binding = %#v", profile.Artifacts["spells"].Extract)
}
if profile.Artifacts["spells"].Merge.Module != "appendorder" || profile.Artifacts["spells"].Normalize.Module != "noop" {
t.Fatalf("stage shorthand bindings = %#v", profile.Artifacts["spells"])
}
}
func TestFileConfigReferencePrecedenceIsRetained(t *testing.T) {
cfg := applyFileConfig(t, `version: 3
pipelines:
main:
input: seriatim
references:
pipeline-only: ./pipeline.txt
shared: ./pipeline-shared.txt
chunk:
module: generic
references:
chunk-only: ./chunk.txt
artifacts:
spells:
references:
lane-only: ./lane.txt
shared: ./lane-shared.txt
overridden: ./lane.txt
extract:
module: dnd/spells
references:
extract-only: ./extract.txt
overridden: ./extract-overridden.txt
merge:
module: appendorder
references:
merge-only: ./merge.txt
normalize:
module: noop
references:
normalize-only: ./normalize.txt
`)
profile := cfg.Pipelines["main"]
if !reflect.DeepEqual(profile.References, map[string]string{
"pipeline-only": "./pipeline.txt",
"shared": "./pipeline-shared.txt",
}) {
t.Fatalf("pipeline references = %#v", profile.References)
}
if !reflect.DeepEqual(profile.Chunk.References, map[string]string{"chunk-only": "./chunk.txt"}) {
t.Fatalf("chunk references = %#v", profile.Chunk.References)
}
lane := profile.Artifacts["spells"]
if !reflect.DeepEqual(lane.References, map[string]string{
"lane-only": "./lane.txt",
"shared": "./lane-shared.txt",
"overridden": "./lane.txt",
}) {
t.Fatalf("lane compatibility references = %#v", lane.References)
}
if !reflect.DeepEqual(lane.Extract.References, map[string]string{
"lane-only": "./lane.txt",
"shared": "./lane-shared.txt",
"overridden": "./extract-overridden.txt",
"extract-only": "./extract.txt",
}) {
t.Fatalf("extract references = %#v", lane.Extract.References)
}
if !reflect.DeepEqual(lane.Merge.References, map[string]string{"merge-only": "./merge.txt"}) ||
!reflect.DeepEqual(lane.Normalize.References, map[string]string{"normalize-only": "./normalize.txt"}) {
t.Fatalf("merge/normalize references = %#v, %#v", lane.Merge.References, lane.Normalize.References)
}
}
func TestFileConfigStageLocalValidatorsPreserveOrderAndFields(t *testing.T) {
cfg := applyFileConfig(t, `version: 3
pipelines:
main:
input: seriatim
chunk:
module: generic
validators:
- generic/always_accept
- module: generic/valid_json
llm_profile: validator-profile
options:
schema: compact
artifacts:
spells:
extract:
module: dnd/spells
validators:
- module: extract/dnd/spells/shape
options:
strict: true
merge:
module: appendorder
validators:
- generic/always_accept
normalize:
module: noop
validators:
- module: generic/valid_json
options:
mode: normalized
`)
profile := cfg.Pipelines["main"]
chunkValidators := profile.Chunk.Validators.Validators
if !profile.Chunk.Validators.Set || len(chunkValidators) != 2 || chunkValidators[0].Module != "generic/always_accept" ||
chunkValidators[1].Module != "generic/valid_json" || chunkValidators[1].LLMProfile != "validator-profile" ||
!reflect.DeepEqual(chunkValidators[1].Options, map[string]any{"schema": "compact"}) {
t.Fatalf("chunk validators = %#v", profile.Chunk.Validators)
}
lane := profile.Artifacts["spells"]
if len(lane.Extract.Validators.Validators) != 1 || lane.Extract.Validators.Validators[0].Module != "extract/dnd/spells/shape" ||
!reflect.DeepEqual(lane.Extract.Validators.Validators[0].Options, map[string]any{"strict": true}) {
t.Fatalf("extract validators = %#v", lane.Extract.Validators)
}
if len(lane.Merge.Validators.Validators) != 1 || lane.Merge.Validators.Validators[0].Module != "generic/always_accept" {
t.Fatalf("merge validators = %#v", lane.Merge.Validators)
}
if len(lane.Normalize.Validators.Validators) != 1 || lane.Normalize.Validators.Validators[0].Module != "generic/valid_json" ||
!reflect.DeepEqual(lane.Normalize.Validators.Validators[0].Options, map[string]any{"mode": "normalized"}) {
t.Fatalf("normalize validators = %#v", lane.Normalize.Validators)
}
}
func TestFileConfigStateSectionsApplyIndependently(t *testing.T) {
cfg := applyFileConfig(t, `version: 3
scriptorium:
profile_dir: ./profiles
concurrency:
total_llm: 7
output:
directory: ./output
cache:
chunk_plans:
directory: ./plans
mode: bypass
checkpoints:
directory: ./checkpoints
debug:
directory: ./debug
`)
if cfg.Scriptorium.ProfileDir != "./profiles" || cfg.Scriptorium.ProfileFile != "" {
t.Fatalf("scriptorium = %#v", cfg.Scriptorium)
}
if cfg.Concurrency.TotalLLM != 7 || cfg.Concurrency.StageWorkers["extract"] != 7 {
t.Fatalf("concurrency = %#v", cfg.Concurrency)
}
if cfg.Output.Directory != "./output" || cfg.Cache.ChunkPlans.Directory != "plans" || cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheBypass ||
cfg.Cache.Checkpoints.Directory != "checkpoints" || cfg.Debug.Directory != "./debug" {
t.Fatalf("state sections = %#v, %#v, %#v, %#v", cfg.Output, cfg.Cache, cfg.Debug, cfg.Scriptorium)
}
if cfg.Output.Directory == cfg.Cache.ChunkPlans.Directory || cfg.Cache.ChunkPlans.Directory == cfg.Cache.Checkpoints.Directory || cfg.Cache.Checkpoints.Directory == cfg.Debug.Directory {
t.Fatal("state roots were coupled")
}
}
func TestFileConfigRejectsTrimmedKeyCollisions(t *testing.T) {
tests := []struct {
name string
yaml string
want string
}{
{
name: "pipeline ids",
yaml: "version: 3\npipelines:\n main: {}\n ' main ': {}\n",
want: "pipeline id \"main\" is duplicated after trimming",
},
{
name: "lane ids",
yaml: "version: 3\npipelines:\n main:\n artifacts:\n spells: {}\n ' spells ': {}\n",
want: "artifact lane id \"spells\" is duplicated after trimming",
},
{
name: "reference slots",
yaml: "version: 3\npipelines:\n main:\n references:\n slot: ./one.txt\n ' slot ': ./two.txt\n",
want: "reference slot \"slot\" is duplicated after trimming",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
file := parseFileConfig(t, tt.yaml)
cfg := Default()
err := cfg.ApplyFileConfig(file)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("ApplyFileConfig() error = %v, want context %q", err, tt.want)
}
})
}
}
func TestLoadFileConfigReportsPathAndOperationContext(t *testing.T) {
dir := t.TempDir()
missing := filepath.Join(dir, "missing.yml")
_, err := LoadFileConfig(missing)
if err == nil || !strings.Contains(err.Error(), "read config file") || !strings.Contains(err.Error(), missing) {
t.Fatalf("missing-file error = %v", err)
}
malformed := filepath.Join(dir, "malformed.yml")
if err := os.WriteFile(malformed, []byte("version: [\n"), 0o600); err != nil {
t.Fatal(err)
}
_, err = LoadFileConfig(malformed)
if err == nil || !strings.Contains(err.Error(), "parse config file") || !strings.Contains(err.Error(), malformed) {
t.Fatalf("malformed-file error = %v", err)
}
}
func parseFileConfig(t *testing.T, source string) FileConfig {
t.Helper()
file, err := ParseFileConfigYAML([]byte(source))
if err != nil {
t.Fatalf("ParseFileConfigYAML() error = %v", err)
}
return file
}
func applyFileConfig(t *testing.T, source string) Config {
t.Helper()
cfg := Default()
if err := cfg.ApplyFileConfig(parseFileConfig(t, source)); err != nil {
t.Fatalf("ApplyFileConfig() error = %v", err)
}
return cfg
}

View File

@@ -117,6 +117,63 @@ func TestRedactedEffectiveConfigPayloadDoesNotAliasSource(t *testing.T) {
assertRedactionTestBindingUnchanged(t, effective.ResolvedPipeline.Input, "effective") assertRedactionTestBindingUnchanged(t, effective.ResolvedPipeline.Input, "effective")
} }
func TestRedactedSummaryPayloadsCoverEveryEffectiveConfigBinding(t *testing.T) {
bindings := map[string]pipeline.ModuleBinding{}
for _, name := range []string{"input", "chunk", "output", "extract", "merge", "normalize", "lane-validator"} {
bindings[name] = redactionTestBinding("summary-" + name)
}
effective := EffectiveConfig{
Config: Config{Pipelines: map[string]pipeline.PipelineProfile{
"redaction-test": {
Input: bindings["input"],
Chunk: bindings["chunk"],
Output: bindings["output"],
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"safe-lane": {
Extract: bindings["extract"],
Merge: bindings["merge"],
Normalize: bindings["normalize"],
Validators: []pipeline.ModuleBinding{bindings["lane-validator"]},
},
},
},
}},
ResolvedPipeline: pipeline.ResolvedPipeline{
Input: bindings["input"],
Chunk: bindings["chunk"],
Output: bindings["output"],
ArtifactLanes: []pipeline.ResolvedArtifactLane{{
ID: "safe-lane",
Extract: bindings["extract"],
Merge: bindings["merge"],
Normalize: bindings["normalize"],
Validators: []pipeline.ModuleBinding{bindings["lane-validator"]},
}},
},
}
payload, ok := effective.RedactedSummaryPayload().(EffectiveConfig)
if !ok {
t.Fatal("RedactedSummaryPayload() returned an unexpected type")
}
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
text := string(encoded)
for name := range bindings {
if strings.Contains(text, "summary-"+name+"-secret") || strings.Contains(text, "summary-"+name+"-nested-secret") {
t.Fatalf("summary payload contains sensitive option for %q: %s", name, text)
}
if !strings.Contains(text, "summary-"+name+"-safe") {
t.Fatalf("summary payload omitted safe option for %q: %s", name, text)
}
}
if !strings.Contains(text, "[REDACTED]") {
t.Fatalf("summary payload contains no redaction marker: %s", text)
}
}
func TestRedactedResolvedPipelinePayloadHandlesTypedOptionContainers(t *testing.T) { func TestRedactedResolvedPipelinePayloadHandlesTypedOptionContainers(t *testing.T) {
type optionMap map[string]string type optionMap map[string]string
type optionList []optionMap type optionList []optionMap

View File

@@ -106,11 +106,16 @@ func validatePipelineProfiles(profiles map[string]pipeline.PipelineProfile) erro
if err := validateReferenceMap(id, "", profile.References); err != nil { if err := validateReferenceMap(id, "", profile.References); err != nil {
return err return err
} }
seenLanes := make(map[string]struct{}, len(profile.Artifacts))
for rawLaneID, lane := range profile.Artifacts { for rawLaneID, lane := range profile.Artifacts {
laneID := strings.TrimSpace(rawLaneID) laneID := strings.TrimSpace(rawLaneID)
if laneID == "" { if laneID == "" {
return fmt.Errorf("pipeline %q artifact lane id must not be empty", id) return fmt.Errorf("pipeline %q artifact lane id must not be empty", id)
} }
if _, ok := seenLanes[laneID]; ok {
return fmt.Errorf("pipeline %q artifact lane id %q is duplicated after trimming", id, laneID)
}
seenLanes[laneID] = struct{}{}
if err := validateReferenceMap(id, laneID, lane.References); err != nil { if err := validateReferenceMap(id, laneID, lane.References); err != nil {
return err return err
} }

View File

@@ -0,0 +1,446 @@
package config
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
)
func TestValidateConcurrencyRules(t *testing.T) {
tests := []struct {
name string
setup func(*Config)
want string
}{
{
name: "non-positive total",
setup: func(cfg *Config) {
cfg.Concurrency.TotalLLM = 0
},
want: "total LLM concurrency must be greater than zero",
},
{
name: "worker below one",
setup: func(cfg *Config) {
cfg.Concurrency.TotalLLM = 3
cfg.Concurrency.StageWorkers = map[string]int{"extract": 0}
cfg.Concurrency.extractWorkersConfigured = true
},
want: "stage_workers.extract must be between 1",
},
{
name: "worker above total",
setup: func(cfg *Config) {
cfg.Concurrency.TotalLLM = 3
cfg.Concurrency.StageWorkers = map[string]int{"extract": 4}
cfg.Concurrency.extractWorkersConfigured = true
},
want: "stage_workers.extract must be between 1",
},
{
name: "worker lower boundary",
setup: func(cfg *Config) {
cfg.Concurrency.TotalLLM = 3
cfg.Concurrency.StageWorkers = map[string]int{"extract": 1}
cfg.Concurrency.extractWorkersConfigured = true
},
},
{
name: "worker upper boundary",
setup: func(cfg *Config) {
cfg.Concurrency.TotalLLM = 3
cfg.Concurrency.StageWorkers = map[string]int{"extract": 3}
cfg.Concurrency.extractWorkersConfigured = true
},
},
{
name: "unknown worker key",
setup: func(cfg *Config) {
cfg.Concurrency.StageWorkers = map[string]int{"worker": 1}
},
want: "stage_workers key \"worker\" is not supported",
},
{
name: "blank worker key",
setup: func(cfg *Config) {
cfg.Concurrency.StageWorkers = map[string]int{" ": 1}
},
want: "stage_workers key must not be empty",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Default()
tt.setup(&cfg)
err := cfg.Validate()
if tt.want == "" {
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("Validate() error = %v, want context %q", err, tt.want)
}
})
}
}
func TestValidateScriptoriumSourcesAreMutuallyExclusive(t *testing.T) {
cfg := Default()
cfg.Scriptorium = ScriptoriumConfig{ProfileDir: "./profiles", ProfileFile: "./profile.yml"}
assertValidationContains(t, cfg, "scriptorium profile_dir and profile_file are mutually exclusive")
}
func TestValidateStateSurfaceRules(t *testing.T) {
tests := []struct {
name string
setup func(*Config)
want string
}{
{
name: "blank output root",
setup: func(cfg *Config) {
cfg.Output.Directory = " "
},
want: "output.directory must not be empty",
},
{
name: "blank debug root",
setup: func(cfg *Config) {
cfg.Debug.Directory = " "
},
want: "debug.directory must not be empty",
},
{
name: "NUL in output root",
setup: func(cfg *Config) {
cfg.Output.Directory = "./out\x00put"
},
want: "output.directory must not contain NUL",
},
{
name: "NUL in chunk plan root",
setup: func(cfg *Config) {
cfg.Cache.ChunkPlans.Directory = "./plans\x00"
},
want: "cache.chunk_plans.directory must not contain NUL",
},
{
name: "NUL in checkpoint root",
setup: func(cfg *Config) {
cfg.Cache.Checkpoints.Directory = "./checkpoints\x00"
},
want: "cache.checkpoints.directory must not contain NUL",
},
{
name: "NUL in debug root",
setup: func(cfg *Config) {
cfg.Debug.Directory = "./debug\x00"
},
want: "debug.directory must not contain NUL",
},
{
name: "invalid chunk plan mode",
setup: func(cfg *Config) {
cfg.Cache.ChunkPlans.Mode = pipeline.ChunkCacheMode("invalid")
},
want: "cache.chunk_plans.mode:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Default()
tt.setup(&cfg)
assertValidationContains(t, cfg, tt.want)
})
}
}
func TestValidateIdentifiersAfterTrimming(t *testing.T) {
tests := []struct {
name string
setup func(*Config)
want string
}{
{
name: "empty pipeline id",
setup: func(cfg *Config) {
cfg.Pipelines = map[string]pipeline.PipelineProfile{" ": {}}
},
want: "pipeline id must not be empty",
},
{
name: "duplicate pipeline ids",
setup: func(cfg *Config) {
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": {}, " main ": {}}
},
want: "pipeline id \"main\" is duplicated after trimming",
},
{
name: "empty lane id",
setup: func(cfg *Config) {
profile := validationProfile()
profile.Artifacts = map[string]pipeline.ArtifactLaneProfile{" ": {}}
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
},
want: "artifact lane id must not be empty",
},
{
name: "duplicate lane ids",
setup: func(cfg *Config) {
profile := validationProfile()
profile.Artifacts = map[string]pipeline.ArtifactLaneProfile{"spells": {}, " spells ": {}}
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
},
want: "artifact lane id \"spells\" is duplicated after trimming",
},
{
name: "empty reference slot",
setup: func(cfg *Config) {
profile := validationProfile()
profile.References = map[string]string{" ": "source.txt"}
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
},
want: "reference slot name must not be empty",
},
{
name: "duplicate reference slots",
setup: func(cfg *Config) {
profile := validationProfile()
profile.References = map[string]string{"slot": "one.txt", " slot ": "two.txt"}
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
},
want: "reference slot \"slot\" is duplicated after trimming",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Default()
tt.setup(&cfg)
assertValidationContains(t, cfg, tt.want)
})
}
}
func TestValidateBindingRetriesAndProfiles(t *testing.T) {
tests := []struct {
name string
setup func(*Config)
want string
}{
{
name: "negative retries",
setup: func(cfg *Config) {
profile := validationProfile()
profile.Input.Retries = -1
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
},
want: "input retries must be greater than or equal to zero",
},
{
name: "whitespace-only input profile",
setup: func(cfg *Config) {
profile := validationProfile()
profile.Input.LLMProfile = " "
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
},
want: "input llm_profile must not be empty when set",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Default()
tt.setup(&cfg)
assertValidationContains(t, cfg, tt.want)
})
}
}
func TestValidateReferencesAreUnsupportedOnInputAndOutput(t *testing.T) {
tests := []struct {
name string
set func(*pipeline.PipelineProfile)
want string
}{
{
name: "input references",
set: func(profile *pipeline.PipelineProfile) {
profile.Input.References = map[string]string{"slot": "source.txt"}
},
want: "input references are not supported",
},
{
name: "output references",
set: func(profile *pipeline.PipelineProfile) {
profile.Output.References = map[string]string{"slot": "source.txt"}
},
want: "output references are not supported",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
profile := validationProfile()
tt.set(&profile)
cfg := Default()
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
assertValidationContains(t, cfg, tt.want)
})
}
}
func TestValidateValidatorBindingRules(t *testing.T) {
tests := []struct {
name string
setup func(*pipeline.PipelineProfile)
want string
}{
{
name: "empty validator module",
setup: func(profile *pipeline.PipelineProfile) {
profile.Chunk.Validators = pipeline.ValidatorOverride{
Set: true,
Validators: []pipeline.ModuleBinding{{}},
}
},
want: "chunk validators[0] module must not be empty",
},
{
name: "validator retries",
setup: func(profile *pipeline.PipelineProfile) {
profile.Chunk.Validators = pipeline.ValidatorOverride{
Set: true,
Validators: []pipeline.ModuleBinding{{
Module: "validator",
Retries: 1,
}},
}
},
want: "chunk validators[0] retries are not supported",
},
{
name: "validator references",
setup: func(profile *pipeline.PipelineProfile) {
profile.Chunk.Validators = pipeline.ValidatorOverride{
Set: true,
Validators: []pipeline.ModuleBinding{{
Module: "validator",
References: map[string]string{"slot": "source.txt"},
}},
}
},
want: "chunk validators[0] references are not supported",
},
{
name: "nested validators",
setup: func(profile *pipeline.PipelineProfile) {
profile.Chunk.Validators = pipeline.ValidatorOverride{
Set: true,
Validators: []pipeline.ModuleBinding{{
Module: "validator",
Validators: pipeline.ValidatorOverride{Set: true},
}},
}
},
want: "chunk validators[0] nested validators are not supported",
},
{
name: "input validator chain",
setup: func(profile *pipeline.PipelineProfile) {
profile.Input.Validators = pipeline.ValidatorOverride{Set: true}
},
want: "input validators are not supported",
},
{
name: "output validator chain",
setup: func(profile *pipeline.PipelineProfile) {
profile.Output.Validators = pipeline.ValidatorOverride{Set: true}
},
want: "output validators are not supported",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
profile := validationProfile()
tt.setup(&profile)
cfg := Default()
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
assertValidationContains(t, cfg, tt.want)
})
}
}
func TestValidateLaneValidatorCompatibility(t *testing.T) {
tests := []struct {
name string
lane func(*pipeline.ArtifactLaneProfile)
want string
}{
{
name: "deprecated non-empty lane validators",
lane: func(lane *pipeline.ArtifactLaneProfile) {
lane.Validators = []pipeline.ModuleBinding{{Module: "old-validator"}}
},
want: "validators are not supported at artifact lane level",
},
{
name: "stage validators omitted",
lane: func(lane *pipeline.ArtifactLaneProfile) {
},
},
{
name: "stage validators explicitly empty",
lane: func(lane *pipeline.ArtifactLaneProfile) {
lane.Extract.Validators = pipeline.ValidatorOverride{Set: true}
},
},
{
name: "stage validators configured",
lane: func(lane *pipeline.ArtifactLaneProfile) {
lane.Extract.Validators = pipeline.ValidatorOverride{
Set: true,
Validators: []pipeline.ModuleBinding{{Module: "validator"}},
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
profile := validationProfile()
lane := profile.Artifacts["lane"]
tt.lane(&lane)
profile.Artifacts["lane"] = lane
cfg := Default()
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
err := cfg.Validate()
if tt.want == "" {
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("Validate() error = %v, want context %q", err, tt.want)
}
})
}
}
func validationProfile() pipeline.PipelineProfile {
return pipeline.PipelineProfile{
ID: "main",
Input: pipeline.Binding("input"),
Artifacts: map[string]pipeline.ArtifactLaneProfile{
"lane": {},
},
}
}
func assertValidationContains(t *testing.T, cfg Config, want string) {
t.Helper()
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("Validate() error = %v, want context %q", err, want)
}
}