From 383ed634438fea39276e1ec06ef7065e3ba94dfc Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Tue, 16 Jun 2026 01:47:43 -0500 Subject: [PATCH 01/12] docs: design for multi-source verification targets --- docs/design/multi-source-targets.md | 158 ++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/design/multi-source-targets.md diff --git a/docs/design/multi-source-targets.md b/docs/design/multi-source-targets.md new file mode 100644 index 0000000000..049a8a7f0d --- /dev/null +++ b/docs/design/multi-source-targets.md @@ -0,0 +1,158 @@ +# Design — multi-source verification targets + +**Status:** approved / ready to implement. Let a target's `source` span more than +one directory so a Go-service target whose scenario tests live across several +packages (daemon, shared `internal/` packages, a sibling CLI) can be verified as +one unit. + +## Thesis + +Today `Target.Source` is a single string, so `specify verify ` scans +exactly one directory for scenario bindings. That's wrong for a target whose +bound tests are spread across packages. Trove's `go-service` target spans +`cmd/troved`, `internal`, and `cmd/trove-transcode`; narrowing to `cmd/troved` +misses bindings in `internal/organize`, `internal/medianame`, `internal/watch`, +and pointing at `.` is too broad (it would pull unrelated app/web bindings into a +Go-service verification). + +The fix is a backward-compatible widening: `source` accepts **either** a string +(unchanged) **or** an array of directories. Verification scans every listed root +and joins the bindings. + +```jsonc +// legacy — still valid, behaves exactly as today +"source": "apps/trove/app" + +// new — multiple roots, joined into one target +"source": ["cmd/troved", "internal", "cmd/trove-transcode"] +``` + +## Where source is modeled, and the boundary + +Two structs hold a `source`, in two layers that do **not** import each other: + +- `config.Target.Source` — parsed from `.speckit/specs.json`, so it needs the + flexible string-or-array decode. +- `engine.VerifyConfig.Source` — built **in-process** by the CLI + (`cmd/specify`'s `verifyConfigFor`), never JSON-decoded. Confirmed by grep: + the only constructions are literals in the CLI. + +So the flexible JSON behavior belongs in `config` alone. The engine takes a plain +`[]string`. The CLI is the glue that maps `config.SourcePaths` → `[]string`. This +keeps the existing engine↔config independence (neither imports the other). + +## Component 1 — `config.SourcePaths` + +A small compatibility type rather than a breaking schema change: + +```go +type SourcePaths []string +``` + +- **`UnmarshalJSON`** accepts a JSON string (`"x"` → `["x"]`) or a JSON array of + strings (`["a","b"]` → as-is). Each entry is trimmed of surrounding + whitespace as it's read. Any other JSON shape (number, object, array of + non-strings) is a clear unmarshal error. +- **`MarshalJSON`** is *ergonomic*: a single path serializes back as a bare + string, multiple paths as an array. Existing single-source configs round-trip + byte-for-byte the same shape when SpecKit rewrites the file (`target add`, + `deploy add`, `secrets sync`), so this change causes **zero churn** on configs + in the wild. +- **`Validate(target string) []error`** — an empty list is "missing source dir" + (same message class as today); any blank/whitespace-only entry is its own + explicit error. +- **`First() string`** — the first path (or `""`). Used only by the deploy/secrets + app-dir heuristic, which is single-app by nature. + +`Target.Source` becomes `SourcePaths`. `Target.Validate` delegates its source +checks to `SourcePaths.Validate`. + +## Component 2 — engine scans every root + +- `VerifyConfig.Source` becomes `[]string`. +- Add two thin aggregators next to the existing single-dir scanners: + - `ScanBindingsMany(root string, paths []string) ([]Binding, error)` + - `ScanDeviationsMany(root string, paths []string) (map[SpecID]string, error)` + + Each loops the configured paths, calls the existing `ScanBindings(dir)` / + `ScanDeviations(dir)` per `filepath.Join(root, path)`, and concatenates + (deviations merge into one map). The single-dir functions stay as-is — they're + the natural unit and keep their existing tests. +- `joinTarget` (verify) and `Parity` switch to the `*Many` variants. + +Semantics that are unchanged because they operate on the *joined* binding set, +which doesn't care how many roots produced it: + +- Binding identity, file, and line metadata — files may now come from any root. +- `bindings: "scoped"` still drops untagged tests; `strict` still flags them. +- A **dangling** binding (to an undeclared scenario) from *any* root still fails. +- Locking is unchanged: a spec locks only when all its in-scope scenarios passed + and source integrity is clean. + +`parity` is included even though the handoff doc only named verify: it reads +`cfg.Source` through the identical path, so `specify parity ` +would otherwise break. + +## Component 3 — CLI wiring (`cmd/specify`) + +- `verifyConfigFor`: `Source: []string(t.Source)`. +- `target add` (scaffold path): scaffolds emit one source string → wrap as + `config.SourcePaths{rt.Source}`. +- `targetRegisterCmd` / `registerTarget`: + - `--source` becomes **repeatable** (`StringArrayVar`). Zero `--source` flags → + fall back to the manifest's single derived source; one or more → use them + verbatim. This lets a user author Trove's multi-source target without + hand-editing JSON, though hand-editing remains fully supported. + - The completeness check becomes "at least one source path". +- `deploy.go` / `secrets.go`: `filepath.Dir(t.Source.First())`. + +The scaffold layer (`internal/scaffold`) is untouched: a generated target is +always single-source, and `RenderedTarget.Source` stays a string. No projected +assets change, so no golden-manifest regeneration is needed. + +## Component 4 — docs + +`docs/config.md` and the README target examples gain the array form alongside the +string form. + +## Tests (TDD) + +Config (`internal/config`): + +1. Loads a legacy string `source` (→ one path, behaves as today). +2. Loads an array `source` (→ N paths). +3. Validation rejects missing source, empty array, and blank/whitespace entries. +4. `MarshalJSON` round-trip: one path → string, multiple → array. + +Engine (`internal/engine`): + +5. Verify joins bindings from two separate source roots into one target. Fixture: + two spec scenarios in the spec library; one bound Go test in `cmd/example`; + one bound Go test in `internal/example`; a gotest JSON report containing both + passing identities → both scenarios pass and the target locks the spec. +6. A dangling binding from *either* root still fails verification. +7. `bindings: "scoped"` still ignores untagged tests while preserving the + multi-source joined bindings. + +CLI (`cmd/specify`): + +8. `target register` with repeated `--source` writes an array-form target. + +## Touch points (full enumeration) + +- `internal/config/config.go` — `SourcePaths` type + `Target.Source` + validation. +- `internal/engine/verify_run.go` — `VerifyConfig.Source []string`; `joinTarget` + uses `ScanBindingsMany`. +- `internal/engine/verify.go` — add `ScanBindingsMany`. +- `internal/engine/parity.go` — add `ScanDeviationsMany`; `Parity` uses it. +- `cmd/specify/main.go` — `verifyConfigFor`, `target add` wrap, `register` flag + + completeness check. +- `cmd/specify/deploy.go`, `cmd/specify/secrets.go` — `t.Source.First()`. +- `docs/config.md`, `README` — array-form examples. + +## Out of scope / non-goals + +- Modeling products/contracts as first-class config (tracked separately). +- Per-source binding modes (one `bindings` mode still governs the whole target). +- Changing scaffold output to emit multi-source targets (scaffolds are + single-source by construction). From 1c493808d60856d3475e382af41030f12b48f12c Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Tue, 16 Jun 2026 02:01:26 -0500 Subject: [PATCH 02/12] config: add SourcePaths (string-or-array source) type --- internal/config/config.go | 60 +++++++++++++++++++++++ internal/config/sourcepaths_test.go | 75 +++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 internal/config/sourcepaths_test.go diff --git a/internal/config/config.go b/internal/config/config.go index f5a185e37d..36e7bcf3d4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,66 @@ type Paths struct { Features string `json:"features"` } +// SourcePaths is one or more source directories scanned for scenario bindings. +// It decodes from either a JSON string (one dir) or a JSON array of dirs, so a +// target whose bound tests span several packages can list them all while a +// single-dir target stays a bare string. See docs/design/multi-source-targets.md. +type SourcePaths []string + +// UnmarshalJSON accepts a JSON string ("x") or a JSON array of strings +// (["a","b"]). Each entry is trimmed of surrounding whitespace; blank entries +// are kept so Validate can report them. +func (sp *SourcePaths) UnmarshalJSON(b []byte) error { + var one string + if err := json.Unmarshal(b, &one); err == nil { + *sp = SourcePaths{strings.TrimSpace(one)} + return nil + } + var many []string + if err := json.Unmarshal(b, &many); err != nil { + return fmt.Errorf("source must be a string or an array of strings: %w", err) + } + out := make(SourcePaths, len(many)) + for i, s := range many { + out[i] = strings.TrimSpace(s) + } + *sp = out + return nil +} + +// MarshalJSON writes one path as a bare string and multiple as an array, so an +// existing single-source config round-trips to the same on-disk shape. +func (sp SourcePaths) MarshalJSON() ([]byte, error) { + if len(sp) == 1 { + return json.Marshal(sp[0]) + } + return json.Marshal([]string(sp)) +} + +// First returns the first source path, or "" when there are none. Used by the +// deploy/secrets app-dir heuristic, which is single-app by nature. +func (sp SourcePaths) First() string { + if len(sp) == 0 { + return "" + } + return sp[0] +} + +// Validate reports source problems for the named target: no paths at all, or any +// blank/whitespace-only entry. +func (sp SourcePaths) Validate(target string) []error { + if len(sp) == 0 { + return []error{fmt.Errorf("target %q: missing source dir", target)} + } + var errs []error + for _, s := range sp { + if strings.TrimSpace(s) == "" { + errs = append(errs, fmt.Errorf("target %q: source contains a blank entry", target)) + } + } + return errs +} + // Target is one implementation of a product: the test command to run (a shell // string, à la a Mise task's `run`; empty when the report already exists), the // report format/path the engine joins, the source dir scanned for bindings, and diff --git a/internal/config/sourcepaths_test.go b/internal/config/sourcepaths_test.go new file mode 100644 index 0000000000..5a93915815 --- /dev/null +++ b/internal/config/sourcepaths_test.go @@ -0,0 +1,75 @@ +package config + +import ( + "encoding/json" + "testing" +) + +func TestSourcePathsUnmarshalString(t *testing.T) { + var sp SourcePaths + if err := json.Unmarshal([]byte(`"apps/web/app"`), &sp); err != nil { + t.Fatal(err) + } + if len(sp) != 1 || sp[0] != "apps/web/app" { + t.Fatalf("string source should decode to one path, got %v", sp) + } +} + +func TestSourcePathsUnmarshalArrayTrims(t *testing.T) { + var sp SourcePaths + if err := json.Unmarshal([]byte(`["cmd/troved","internal"," cmd/trove-transcode "]`), &sp); err != nil { + t.Fatal(err) + } + want := SourcePaths{"cmd/troved", "internal", "cmd/trove-transcode"} + if len(sp) != len(want) || sp[0] != want[0] || sp[1] != want[1] || sp[2] != want[2] { + t.Fatalf("array source should decode trimmed, got %v", sp) + } +} + +func TestSourcePathsUnmarshalRejectsNonString(t *testing.T) { + var sp SourcePaths + if err := json.Unmarshal([]byte(`123`), &sp); err == nil { + t.Error("a number must be rejected") + } + if err := json.Unmarshal([]byte(`[1,2]`), &sp); err == nil { + t.Error("an array of numbers must be rejected") + } +} + +func TestSourcePathsMarshalErgonomic(t *testing.T) { + one, err := json.Marshal(SourcePaths{"a"}) + if err != nil { + t.Fatal(err) + } + if string(one) != `"a"` { + t.Fatalf("one path should marshal as a bare string, got %s", one) + } + many, err := json.Marshal(SourcePaths{"a", "b"}) + if err != nil { + t.Fatal(err) + } + if string(many) != `["a","b"]` { + t.Fatalf("multiple paths should marshal as an array, got %s", many) + } +} + +func TestSourcePathsValidate(t *testing.T) { + if errs := (SourcePaths{}).Validate("t"); len(errs) == 0 { + t.Error("an empty source list must be invalid") + } + if errs := (SourcePaths{"a"}).Validate("t"); len(errs) != 0 { + t.Errorf("a single path is valid, got %v", errs) + } + if errs := (SourcePaths{"a", ""}).Validate("t"); len(errs) == 0 { + t.Error("a blank entry must be invalid") + } +} + +func TestSourcePathsFirst(t *testing.T) { + if (SourcePaths{"a", "b"}).First() != "a" { + t.Error("First must return the first path") + } + if (SourcePaths{}).First() != "" { + t.Error("First of empty must be empty") + } +} From f90cc544523efe69d1918d2c7cc3ad2a013b3037 Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Tue, 16 Jun 2026 02:05:17 -0500 Subject: [PATCH 03/12] config: document SourcePaths empty-marshal + add round-trip test --- internal/config/config.go | 4 +++- internal/config/sourcepaths_test.go | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index 36e7bcf3d4..bdc3940a0c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -63,7 +63,9 @@ func (sp *SourcePaths) UnmarshalJSON(b []byte) error { } // MarshalJSON writes one path as a bare string and multiple as an array, so an -// existing single-source config round-trips to the same on-disk shape. +// existing single-source config round-trips to the same on-disk shape. An empty +// SourcePaths marshals as JSON null; Validate rejects empty before any Save, so +// that shape never reaches disk. func (sp SourcePaths) MarshalJSON() ([]byte, error) { if len(sp) == 1 { return json.Marshal(sp[0]) diff --git a/internal/config/sourcepaths_test.go b/internal/config/sourcepaths_test.go index 5a93915815..6d8ba9116b 100644 --- a/internal/config/sourcepaths_test.go +++ b/internal/config/sourcepaths_test.go @@ -2,6 +2,7 @@ package config import ( "encoding/json" + "reflect" "testing" ) @@ -73,3 +74,20 @@ func TestSourcePathsFirst(t *testing.T) { t.Error("First of empty must be empty") } } + +func TestSourcePathsRoundTrip(t *testing.T) { + cases := []SourcePaths{{"a"}, {"a", "b", "c"}} + for _, want := range cases { + b, err := json.Marshal(want) + if err != nil { + t.Fatalf("marshal %v: %v", want, err) + } + var got SourcePaths + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal %s: %v", b, err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("round-trip mismatch: %v -> %s -> %v", want, b, got) + } + } +} From ecbefe2064a4e486390038cb384beee7ef9edd4a Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Tue, 16 Jun 2026 02:07:02 -0500 Subject: [PATCH 04/12] engine: add ScanBindingsMany + ScanDeviationsMany --- internal/engine/parity.go | 17 +++++++++++++++ internal/engine/scan_many_test.go | 35 +++++++++++++++++++++++++++++++ internal/engine/verify.go | 16 ++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 internal/engine/scan_many_test.go diff --git a/internal/engine/parity.go b/internal/engine/parity.go index a561a0e985..293a6ce34e 100644 --- a/internal/engine/parity.go +++ b/internal/engine/parity.go @@ -54,6 +54,23 @@ func (r ParityReport) Gated() bool { return false } +// ScanDeviationsMany merges deviation markers across every source root under +// root — the multi-source form of ScanDeviations. A scenario seen in more than +// one root keeps the last reason scanned. +func ScanDeviationsMany(root string, paths []string) (map[specmodel.SpecID]string, error) { + merged := map[specmodel.SpecID]string{} + for _, p := range paths { + devs, err := ScanDeviations(filepath.Join(root, p)) + if err != nil { + return nil, err + } + for k, v := range devs { + merged[k] = v + } + } + return merged, nil +} + // Parity crosses the verify outcome with deviation markers into the five-state // matrix (D11). Deviation-presence and test-outcome are computed on independent // axes, so a marker over a failing test is suspect, never declared-deviation. diff --git a/internal/engine/scan_many_test.go b/internal/engine/scan_many_test.go new file mode 100644 index 0000000000..b411dc9b2e --- /dev/null +++ b/internal/engine/scan_many_test.go @@ -0,0 +1,35 @@ +package engine + +import "testing" + +func TestScanBindingsMany(t *testing.T) { + root := t.TempDir() + writeSpecFile(t, root, "cmd/a/a_test.go", "// [scenario.x.one]\nfunc TestOne(t *testing.T) {}\n") + writeSpecFile(t, root, "internal/b/b_test.go", "// [scenario.x.two]\nfunc TestTwo(t *testing.T) {}\n") + + bs, err := ScanBindingsMany(root, []string{"cmd/a", "internal/b"}) + if err != nil { + t.Fatal(err) + } + got := map[string]bool{} + for _, b := range bs { + got[string(b.Scenario)] = true + } + if !got["scenario.x.one"] || !got["scenario.x.two"] { + t.Fatalf("expected bindings from both roots, got %+v", bs) + } +} + +func TestScanDeviationsMany(t *testing.T) { + root := t.TempDir() + writeSpecFile(t, root, "cmd/a/a.go", "// SPEC: scenario.x.one (deviates: wip)\n") + writeSpecFile(t, root, "internal/b/b.go", "// SPEC: scenario.x.two (deviates: later)\n") + + devs, err := ScanDeviationsMany(root, []string{"cmd/a", "internal/b"}) + if err != nil { + t.Fatal(err) + } + if devs["scenario.x.one"] != "wip" || devs["scenario.x.two"] != "later" { + t.Fatalf("expected merged deviations from both roots, got %+v", devs) + } +} diff --git a/internal/engine/verify.go b/internal/engine/verify.go index e9aaf157b8..cc650eeb0c 100644 --- a/internal/engine/verify.go +++ b/internal/engine/verify.go @@ -218,3 +218,19 @@ func ScanBindings(dir string) ([]Binding, error) { }) return bindings, err } + +// ScanBindingsMany scans every source root under root for scenario↔test +// bindings and concatenates them — the multi-source form of ScanBindings. Each +// path is joined to root; a target may list one dir or several (a daemon, shared +// packages, a sibling CLI). See docs/design/multi-source-targets.md. +func ScanBindingsMany(root string, paths []string) ([]Binding, error) { + var bindings []Binding + for _, p := range paths { + bs, err := ScanBindings(filepath.Join(root, p)) + if err != nil { + return nil, err + } + bindings = append(bindings, bs...) + } + return bindings, nil +} From fe9d1bb735898fd5b025504d49bf2d6dea7cf49f Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Tue, 16 Jun 2026 02:12:43 -0500 Subject: [PATCH 05/12] engine: test ScanDeviationsMany last-wins + tolerance of a missing source root --- internal/engine/scan_many_test.go | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/internal/engine/scan_many_test.go b/internal/engine/scan_many_test.go index b411dc9b2e..a8bb60b936 100644 --- a/internal/engine/scan_many_test.go +++ b/internal/engine/scan_many_test.go @@ -33,3 +33,39 @@ func TestScanDeviationsMany(t *testing.T) { t.Fatalf("expected merged deviations from both roots, got %+v", devs) } } + +func TestScanDeviationsManyLastWins(t *testing.T) { + root := t.TempDir() + writeSpecFile(t, root, "cmd/a/a.go", "// SPEC: scenario.x.one (deviates: first)\n") + writeSpecFile(t, root, "internal/b/b.go", "// SPEC: scenario.x.one (deviates: second)\n") + + devs, err := ScanDeviationsMany(root, []string{"cmd/a", "internal/b"}) + if err != nil { + t.Fatal(err) + } + if devs["scenario.x.one"] != "second" { + t.Fatalf("later root's reason should win, got %q", devs["scenario.x.one"]) + } +} + +func TestScanBindingsManyToleratesMissingRoot(t *testing.T) { + root := t.TempDir() + writeSpecFile(t, root, "cmd/a/a_test.go", "// [scenario.x.one]\nfunc TestOne(t *testing.T) {}\n") + // "internal/b" is never created — a missing source root is tolerated (no + // error), matching walkSourceFiles' contract for vendored/absent trees. The + // present root's bindings still come back; a genuinely wrong path instead + // surfaces later as unjoinable scenarios, not a silent pass. + bs, err := ScanBindingsMany(root, []string{"cmd/a", "internal/b"}) + if err != nil { + t.Fatalf("a missing source root must be tolerated, got %v", err) + } + found := false + for _, b := range bs { + if b.Scenario == "scenario.x.one" { + found = true + } + } + if !found { + t.Fatal("expected the present root's binding despite the missing sibling") + } +} From de9dd558e25a38f68a18f2abfd91be93f0f4a72e Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Tue, 16 Jun 2026 02:15:37 -0500 Subject: [PATCH 06/12] engine: verify + parity scan across multiple source roots --- cmd/specify/main.go | 2 +- internal/engine/parity.go | 2 +- internal/engine/parity_test.go | 2 +- internal/engine/verify_multisource_test.go | 101 +++++++++++++++++++++ internal/engine/verify_run.go | 4 +- internal/engine/verify_run_test.go | 8 +- 6 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 internal/engine/verify_multisource_test.go diff --git a/cmd/specify/main.go b/cmd/specify/main.go index b012f3e598..d6c605bafc 100644 --- a/cmd/specify/main.go +++ b/cmd/specify/main.go @@ -1028,7 +1028,7 @@ func verifyConfigFor(root, target string) (engine.VerifyConfig, error) { if !ok { return engine.VerifyConfig{}, fmt.Errorf("target %q not in %s (have: %s)", target, config.File, strings.Join(targetNames(cfg), ", ")) } - return engine.VerifyConfig{Command: t.Command, Format: t.Format, Report: t.Report, Source: t.Source, Bindings: t.Bindings}, nil + return engine.VerifyConfig{Command: t.Command, Format: t.Format, Report: t.Report, Source: []string{t.Source}, Bindings: t.Bindings}, nil } // targetNames lists the configured target names, sorted. diff --git a/internal/engine/parity.go b/internal/engine/parity.go index 293a6ce34e..c8cdd34232 100644 --- a/internal/engine/parity.go +++ b/internal/engine/parity.go @@ -81,7 +81,7 @@ func Parity(root, target string, cfg VerifyConfig) (ParityReport, error) { if err != nil { return ParityReport{}, err } - deviations, err := ScanDeviations(filepath.Join(root, cfg.Source)) + deviations, err := ScanDeviationsMany(root, cfg.Source) if err != nil { return ParityReport{}, err } diff --git a/internal/engine/parity_test.go b/internal/engine/parity_test.go index 7d8444ff4b..97026022be 100644 --- a/internal/engine/parity_test.go +++ b/internal/engine/parity_test.go @@ -74,7 +74,7 @@ func TestParityFiveStates(t *testing.T) { writeSpecFile(t, root, "web/src/x.ts", parityImpl) writeSpecFile(t, root, "web/report.junit.xml", parityReport) - report, err := Parity(root, "web", VerifyConfig{Format: "junit", Report: "web/report.junit.xml", Source: "web"}) + report, err := Parity(root, "web", VerifyConfig{Format: "junit", Report: "web/report.junit.xml", Source: []string{"web"}}) if err != nil { t.Fatal(err) } diff --git a/internal/engine/verify_multisource_test.go b/internal/engine/verify_multisource_test.go new file mode 100644 index 0000000000..917f36f33b --- /dev/null +++ b/internal/engine/verify_multisource_test.go @@ -0,0 +1,101 @@ +package engine + +import "testing" + +const multiSpec = `--- +id: story.demo.multi +kind: story +--- + +# Story: demo multi + +## Acceptance Criteria + +### Scenario 1: alpha + + + +- Given alpha + +### Scenario 2: beta + + + +- Given beta +` + +// One scenario is bound by a test under cmd/example, the other under +// internal/example — the two-source shape this feature exists for. +const multiAlphaSrc = "// [scenario.demo.multi.alpha]\nfunc TestAlpha(t *testing.T) {}\n" +const multiBetaSrc = "// [scenario.demo.multi.beta]\nfunc TestBeta(t *testing.T) {}\n" +const multiReport = "{\"Action\":\"pass\",\"Package\":\"cmd/example\",\"Test\":\"TestAlpha\"}\n" + + "{\"Action\":\"pass\",\"Package\":\"internal/example\",\"Test\":\"TestBeta\"}\n" + +func setupMultiSourceProject(t *testing.T) string { + t.Helper() + root := t.TempDir() + writeSpecFile(t, root, "features/0001-multi/stories/demo.multi.md", multiSpec) + writeSpecFile(t, root, "cmd/example/example_test.go", multiAlphaSrc) + writeSpecFile(t, root, "internal/example/example_test.go", multiBetaSrc) + writeSpecFile(t, root, "report.gotest.json", multiReport) + return root +} + +// Verify joins bindings from two separate source roots into one target: both +// scenarios pass and the spec locks. +func TestVerifyJoinsMultipleSourceRoots(t *testing.T) { + root := setupMultiSourceProject(t) + cfg := VerifyConfig{Format: "gotest", Report: "report.gotest.json", Source: []string{"cmd/example", "internal/example"}} + + v, locked, err := Verify(root, "go-service", cfg) + if err != nil { + t.Fatal(err) + } + if !v.Green() { + t.Fatalf("expected green across both roots: %+v", v) + } + if len(locked) != 1 || locked[0] != "story.demo.multi" { + t.Fatalf("expected story.demo.multi locked, got %v", locked) + } +} + +// A dangling binding (to an undeclared scenario) from EITHER root still fails. +func TestVerifyMultiSourceDanglingFromAnyRoot(t *testing.T) { + root := setupMultiSourceProject(t) + // a second test in internal/example binds a scenario the spec never declares + writeSpecFile(t, root, "internal/example/extra_test.go", "// [scenario.demo.multi.ghost]\nfunc TestGhost(t *testing.T) {}\n") + + cfg := VerifyConfig{Format: "gotest", Report: "report.gotest.json", Source: []string{"cmd/example", "internal/example"}} + v, locked, err := Verify(root, "go-service", cfg) + if err != nil { + t.Fatal(err) + } + if len(v.Dangling) == 0 { + t.Error("a dangling binding from a second root must be detected") + } + if v.Green() || len(locked) != 0 { + t.Errorf("a dangling binding must block green + lock: green=%v locked=%v", v.Green(), locked) + } +} + +// scoped bindings drop an untagged test in one root while still joining the +// bound tests across both roots. +func TestVerifyMultiSourceScopedDropsUntagged(t *testing.T) { + root := setupMultiSourceProject(t) + // an untagged unit test alongside the bound one in cmd/example + writeSpecFile(t, root, "cmd/example/unit_test.go", "func TestHelper(t *testing.T) {}\n") + report := multiReport + "{\"Action\":\"pass\",\"Package\":\"cmd/example\",\"Test\":\"TestHelper\"}\n" + writeSpecFile(t, root, "report.gotest.json", report) + + cfg := VerifyConfig{Format: "gotest", Report: "report.gotest.json", Source: []string{"cmd/example", "internal/example"}, Bindings: "scoped"} + v, locked, err := Verify(root, "go-service", cfg) + if err != nil { + t.Fatal(err) + } + if len(v.Unbound) != 0 { + t.Errorf("scoped must drop the untagged TestHelper, got %+v", v.Unbound) + } + if !v.Green() || len(locked) != 1 { + t.Fatalf("expected green + lock under scoped multi-source: green=%v locked=%v", v.Green(), locked) + } +} diff --git a/internal/engine/verify_run.go b/internal/engine/verify_run.go index 7fe6818bae..c64a843a42 100644 --- a/internal/engine/verify_run.go +++ b/internal/engine/verify_run.go @@ -19,7 +19,7 @@ type VerifyConfig struct { Command string `json:"command,omitempty"` Format string `json:"format"` // "junit" | "swift" | "gotest" Report string `json:"report"` // report path, relative to root - Source string `json:"source"` // test source dir, relative to root + Source []string `json:"source"` // test source dirs, relative to root (one or more) // Bindings selects how an untagged test (one that binds no scenario) is // treated: "strict" (default) makes it an unbound D12 violation — every test // must prove a scenario; "scoped" treats untagged tests as out of scope, so a @@ -67,7 +67,7 @@ func joinTarget(root string, cfg VerifyConfig) (VerifyResult, map[specmodel.Spec return VerifyResult{}, nil, nil, err } - bindings, err := ScanBindings(filepath.Join(root, cfg.Source)) + bindings, err := ScanBindingsMany(root, cfg.Source) if err != nil { return VerifyResult{}, nil, nil, err } diff --git a/internal/engine/verify_run_test.go b/internal/engine/verify_run_test.go index f4285bfc9b..32e1c2d1d3 100644 --- a/internal/engine/verify_run_test.go +++ b/internal/engine/verify_run_test.go @@ -54,7 +54,7 @@ func setupVerifyProject(t *testing.T, report string) string { // SPEC: story.engine.lock (scenario.engine.lock.writes-on-green) func TestVerifyGreenWritesLock(t *testing.T) { root := setupVerifyProject(t, junitReport(true, true)) - cfg := VerifyConfig{Format: "junit", Report: "web/report.junit.xml", Source: "web"} + cfg := VerifyConfig{Format: "junit", Report: "web/report.junit.xml", Source: []string{"web"}} v, locked, err := Verify(root, "web", cfg) if err != nil { @@ -117,7 +117,7 @@ func setupGoVerifyProject(t *testing.T) string { // SPEC: story.engine.verify (scenario.engine.verify.source-bound-join) func TestVerifyGoScopedBindings(t *testing.T) { root := setupGoVerifyProject(t) - cfg := VerifyConfig{Format: "gotest", Report: "cmd/x/report.gotest.json", Source: "cmd/x", Bindings: "scoped"} + cfg := VerifyConfig{Format: "gotest", Report: "cmd/x/report.gotest.json", Source: []string{"cmd/x"}, Bindings: "scoped"} v, locked, err := Verify(root, "go", cfg) if err != nil { t.Fatal(err) @@ -137,7 +137,7 @@ func TestVerifyGoScopedBindings(t *testing.T) { // proving scoped is an opt-in relaxation, not a change to the default contract. func TestVerifyStrictFlagsUntaggedTest(t *testing.T) { root := setupGoVerifyProject(t) - cfg := VerifyConfig{Format: "gotest", Report: "cmd/x/report.gotest.json", Source: "cmd/x"} // Bindings "" = strict + cfg := VerifyConfig{Format: "gotest", Report: "cmd/x/report.gotest.json", Source: []string{"cmd/x"}} // Bindings "" = strict v, locked, err := Verify(root, "go", cfg) if err != nil { t.Fatal(err) @@ -153,7 +153,7 @@ func TestVerifyStrictFlagsUntaggedTest(t *testing.T) { // SPEC: story.engine.lock (scenario.engine.lock.no-write-on-red) func TestVerifyRedWritesNoLock(t *testing.T) { root := setupVerifyProject(t, junitReport(true, false)) // scenario b fails - cfg := VerifyConfig{Format: "junit", Report: "web/report.junit.xml", Source: "web"} + cfg := VerifyConfig{Format: "junit", Report: "web/report.junit.xml", Source: []string{"web"}} v, locked, err := Verify(root, "web", cfg) if err != nil { From 27fcd00283cb7507b360292e8fb5d0ad05317020 Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Tue, 16 Jun 2026 02:20:14 -0500 Subject: [PATCH 07/12] engine: fix stale VerifyConfig doc + clarify dangling test --- internal/engine/verify_multisource_test.go | 3 ++- internal/engine/verify_run.go | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/engine/verify_multisource_test.go b/internal/engine/verify_multisource_test.go index 917f36f33b..f10190f49b 100644 --- a/internal/engine/verify_multisource_test.go +++ b/internal/engine/verify_multisource_test.go @@ -62,7 +62,8 @@ func TestVerifyJoinsMultipleSourceRoots(t *testing.T) { // A dangling binding (to an undeclared scenario) from EITHER root still fails. func TestVerifyMultiSourceDanglingFromAnyRoot(t *testing.T) { root := setupMultiSourceProject(t) - // a second test in internal/example binds a scenario the spec never declares + // TestGhost appears in source only — a dangling binding fires from the + // source scan, not the report, so no matching report entry is needed. writeSpecFile(t, root, "internal/example/extra_test.go", "// [scenario.demo.multi.ghost]\nfunc TestGhost(t *testing.T) {}\n") cfg := VerifyConfig{Format: "gotest", Report: "report.gotest.json", Source: []string{"cmd/example", "internal/example"}} diff --git a/internal/engine/verify_run.go b/internal/engine/verify_run.go index c64a843a42..01f1146dd4 100644 --- a/internal/engine/verify_run.go +++ b/internal/engine/verify_run.go @@ -13,12 +13,12 @@ import ( // VerifyConfig describes how to verify a target: the test command to run as a // shell string (optional — empty if the report already exists, à la a Mise -// task's `run`), the report format and path, and the test source directory. +// task's `run`), the report format and path, and one or more test source directories. // Normally supplied by the target pack's verify adapter. type VerifyConfig struct { - Command string `json:"command,omitempty"` - Format string `json:"format"` // "junit" | "swift" | "gotest" - Report string `json:"report"` // report path, relative to root + Command string `json:"command,omitempty"` + Format string `json:"format"` // "junit" | "swift" | "gotest" + Report string `json:"report"` // report path, relative to root Source []string `json:"source"` // test source dirs, relative to root (one or more) // Bindings selects how an untagged test (one that binds no scenario) is // treated: "strict" (default) makes it an unbound D12 violation — every test From 8cad750de94b30b46dc01e31b6e385f9d800e05b Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Tue, 16 Jun 2026 02:24:28 -0500 Subject: [PATCH 08/12] config+cli: multi-source targets end-to-end (Target.Source, register --source) Flips config.Target.Source from string to SourcePaths, wires validation through SourcePaths.Validate, makes `target register --source` repeatable (StringArrayVar), and removes the Task 3 transitional shim. --- cmd/specify/deploy.go | 2 +- cmd/specify/main.go | 28 ++++++++++---- cmd/specify/register_test.go | 27 +++++++++++-- cmd/specify/secrets.go | 2 +- internal/config/config.go | 18 ++++----- internal/config/config_test.go | 4 +- internal/config/load_source_test.go | 60 +++++++++++++++++++++++++++++ 7 files changed, 117 insertions(+), 24 deletions(-) create mode 100644 internal/config/load_source_test.go diff --git a/cmd/specify/deploy.go b/cmd/specify/deploy.go index 2eb4cab3c7..78f3de5a2f 100644 --- a/cmd/specify/deploy.go +++ b/cmd/specify/deploy.go @@ -66,7 +66,7 @@ func deployAddCmd() *cobra.Command { appDir := dir if appDir == "" { - appDir = filepath.Dir(t.Source) // e.g. apps/web/app -> apps/web + appDir = filepath.Dir(t.Source.First()) // e.g. apps/web/app -> apps/web } if !safeRenderValue(appDir) { return fmt.Errorf("deploy dir %q contains characters unsafe for a workflow file", appDir) diff --git a/cmd/specify/main.go b/cmd/specify/main.go index d6c605bafc..620180221d 100644 --- a/cmd/specify/main.go +++ b/cmd/specify/main.go @@ -333,7 +333,7 @@ func targetAddCmd() *cobra.Command { } if err := config.AddTarget(".", name, config.Target{ Stack: stack, Product: product, - Command: rt.Command, Format: rt.Format, Report: rt.Report, Source: rt.Source, Bindings: rt.Bindings, + Command: rt.Command, Format: rt.Format, Report: rt.Report, Source: config.SourcePaths{rt.Source}, Bindings: rt.Bindings, }); err != nil { return err } @@ -396,7 +396,8 @@ func targetAddCmd() *cobra.Command { // — or to match a member wired differently than the scaffold — pass the fields as // flags. Unlike `target add`, it writes no files and runs no scripts. func targetRegisterCmd() *cobra.Command { - var stack, dir, product, format, command, report, source, bindings string + var stack, dir, product, format, command, report, bindings string + var source []string c := &cobra.Command{ Use: "register [flags]", Short: "Register an existing member as a target (no scaffolding)", @@ -414,14 +415,15 @@ func targetRegisterCmd() *cobra.Command { c.Flags().StringVar(&format, "format", "", "report format (junit|swift|gotest) — overrides the stack default") c.Flags().StringVar(&command, "command", "", "test command that produces the report — overrides the stack default") c.Flags().StringVar(&report, "report", "", "report path the engine joins (root-relative) — overrides the stack default") - c.Flags().StringVar(&source, "source", "", "source dir scanned for bindings — overrides the stack default") + c.Flags().StringArrayVar(&source, "source", nil, "source dir scanned for bindings (repeatable for multi-source targets) — overrides the stack default") c.Flags().StringVar(&bindings, "bindings", "", "binding mode (strict|scoped) — overrides the stack default") return c } // regOpts are the inputs to registerTarget (the flags of `target register`). type regOpts struct { - name, stack, dir, product, format, command, report, source, bindings string + name, stack, dir, product, format, command, report, bindings string + source []string } // registerTarget records an existing member as a target under root's @@ -465,10 +467,10 @@ func registerTarget(root string, o regOpts) error { Command: firstNonEmpty(o.command, rt.Command), Format: firstNonEmpty(o.format, rt.Format), Report: firstNonEmpty(o.report, rt.Report), - Source: firstNonEmpty(o.source, rt.Source), + Source: resolveSources(o.source, rt.Source), Bindings: firstNonEmpty(o.bindings, rt.Bindings), } - if t.Format == "" || t.Report == "" || t.Source == "" { + if t.Format == "" || t.Report == "" || len(t.Source) == 0 { return fmt.Errorf("target register: incomplete wiring — provide --format, --report, and --source (stack %q has no scaffold to derive them from)", o.stack) } if t.Format != "junit" && t.Format != "swift" && t.Format != "gotest" { @@ -503,6 +505,18 @@ func firstNonEmpty(a, b string) string { return b } +// resolveSources picks the explicit --source paths when any were given, else the +// stack manifest's single derived source (dropped when empty). +func resolveSources(flags []string, fallback string) config.SourcePaths { + if len(flags) > 0 { + return config.SourcePaths(flags) + } + if fallback == "" { + return nil + } + return config.SourcePaths{fallback} +} + // resolveVariant picks an axis option (runtime/data): the flag value, else the // manifest default. Errors if the stack offers options but the chosen kind is // unknown, or if a kind is given for a stack with no such axis. @@ -1028,7 +1042,7 @@ func verifyConfigFor(root, target string) (engine.VerifyConfig, error) { if !ok { return engine.VerifyConfig{}, fmt.Errorf("target %q not in %s (have: %s)", target, config.File, strings.Join(targetNames(cfg), ", ")) } - return engine.VerifyConfig{Command: t.Command, Format: t.Format, Report: t.Report, Source: []string{t.Source}, Bindings: t.Bindings}, nil + return engine.VerifyConfig{Command: t.Command, Format: t.Format, Report: t.Report, Source: []string(t.Source), Bindings: t.Bindings}, nil } // targetNames lists the configured target names, sorted. diff --git a/cmd/specify/register_test.go b/cmd/specify/register_test.go index 4e56f4bae9..3c37c81574 100644 --- a/cmd/specify/register_test.go +++ b/cmd/specify/register_test.go @@ -24,7 +24,7 @@ func TestRegisterTargetFromManifest(t *testing.T) { t.Fatalf("Load: found=%v err=%v", found, err) } got := cfg.Targets["troved"] - if got.Stack != "go-service" || got.Format != "gotest" || got.Bindings != "scoped" || got.Source != "cmd/troved" { + if got.Stack != "go-service" || got.Format != "gotest" || got.Bindings != "scoped" || got.Source.First() != "cmd/troved" { t.Errorf("target = %+v, want go-service/gotest/scoped/source=cmd/troved", got) } if got.Report == "" || got.Command == "" { @@ -46,14 +46,14 @@ func TestRegisterTargetExplicitFlags(t *testing.T) { err := registerTarget(root, regOpts{ name: "services", stack: "ts-lib", dir: "packages/services", format: "junit", command: "cd packages/services && mise run test", - report: "packages/services/junit.xml", source: "packages/services/src", bindings: "scoped", + report: "packages/services/junit.xml", source: []string{"packages/services/src"}, bindings: "scoped", }) if err != nil { t.Fatal(err) } cfg, _, _ := config.Load(root) got := cfg.Targets["services"] - if got.Stack != "ts-lib" || got.Format != "junit" || got.Source != "packages/services/src" || got.Bindings != "scoped" { + if got.Stack != "ts-lib" || got.Format != "junit" || got.Source.First() != "packages/services/src" || got.Bindings != "scoped" { t.Errorf("target = %+v", got) } } @@ -92,3 +92,24 @@ func TestRegisterTargetErrors(t *testing.T) { t.Error("expected error for an unsafe target name") } } + +// register a multi-source target via repeated --source: the array round-trips +// through specs.json (no scaffold, no files written). +func TestRegisterTargetMultiSource(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "cmd/troved"), 0o755); err != nil { + t.Fatal(err) + } + err := registerTarget(root, regOpts{ + name: "go-service", stack: "go-service", dir: "cmd/troved", + source: []string{"cmd/troved", "internal", "cmd/trove-transcode"}, + }) + if err != nil { + t.Fatal(err) + } + cfg, _, _ := config.Load(root) + got := cfg.Targets["go-service"].Source + if len(got) != 3 || got[0] != "cmd/troved" || got[2] != "cmd/trove-transcode" { + t.Fatalf("expected a 3-source target, got %v", got) + } +} diff --git a/cmd/specify/secrets.go b/cmd/specify/secrets.go index 13d3563af1..357ee94c63 100644 --- a/cmd/specify/secrets.go +++ b/cmd/specify/secrets.go @@ -119,7 +119,7 @@ func secretsSyncCmd() *cobra.Command { appDir := dir if appDir == "" { - appDir = filepath.Dir(t.Source) + appDir = filepath.Dir(t.Source.First()) } for _, op := range plan { if err := pushSecret(op, t.Deploy.Kind, repo, appDir); err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index bdc3940a0c..1d3b127258 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -103,13 +103,13 @@ func (sp SourcePaths) Validate(target string) []error { // an optional product label. A target shared by several products lists them all // via Products. type Target struct { - Product string `json:"product,omitempty"` - Products []string `json:"products,omitempty"` - Stack string `json:"stack,omitempty"` // selects the pack/scaffold: web|website|apple|android|go-cli|go-service|node-cli|swift-package|swift-cli|ts-lib|vscode-extension - Command string `json:"command,omitempty"` - Format string `json:"format"` // junit | swift | gotest - Report string `json:"report"` - Source string `json:"source"` + Product string `json:"product,omitempty"` + Products []string `json:"products,omitempty"` + Stack string `json:"stack,omitempty"` // selects the pack/scaffold: web|website|apple|android|go-cli|go-service|node-cli|swift-package|swift-cli|ts-lib|vscode-extension + Command string `json:"command,omitempty"` + Format string `json:"format"` // junit | swift | gotest + Report string `json:"report"` + Source SourcePaths `json:"source"` // Bindings is how untagged tests are treated: "strict" (default — every test // must bind a scenario) or "scoped" (untagged tests are out of scope, so a // suite mixing scenario tests with plain unit tests still verifies what it @@ -262,9 +262,7 @@ func (c Config) Validate() []error { if t.Report == "" { errs = append(errs, fmt.Errorf("target %q: missing report path", name)) } - if t.Source == "" { - errs = append(errs, fmt.Errorf("target %q: missing source dir", name)) - } + errs = append(errs, t.Source.Validate(name)...) if t.Deploy != nil { errs = append(errs, t.Deploy.Validate(name)...) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a36537e6bb..7180680f23 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -121,7 +121,7 @@ func TestSharedTargetListsBothProducts(t *testing.T) { func TestAddTargetRoundTrips(t *testing.T) { root := t.TempDir() // no specs.json yet → AddTarget creates it - if err := AddTarget(root, "web", Target{Stack: "web", Command: "mise run test", Format: "junit", Report: "j.xml", Source: "app"}); err != nil { + if err := AddTarget(root, "web", Target{Stack: "web", Command: "mise run test", Format: "junit", Report: "j.xml", Source: SourcePaths{"app"}}); err != nil { t.Fatal(err) } cfg, found, err := Load(root) @@ -135,7 +135,7 @@ func TestAddTargetRoundTrips(t *testing.T) { t.Errorf("generated config should validate: %v", errs) } // a second target preserves the first - if err := AddTarget(root, "ios", Target{Stack: "apple", Format: "swift", Report: "r", Source: "s"}); err != nil { + if err := AddTarget(root, "ios", Target{Stack: "apple", Format: "swift", Report: "r", Source: SourcePaths{"s"}}); err != nil { t.Fatal(err) } cfg, _, _ = Load(root) diff --git a/internal/config/load_source_test.go b/internal/config/load_source_test.go new file mode 100644 index 0000000000..81afac4c91 --- /dev/null +++ b/internal/config/load_source_test.go @@ -0,0 +1,60 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func writeRawConfig(t *testing.T, root, js string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(root, ".speckit"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, File), []byte(js), 0o644); err != nil { + t.Fatal(err) + } +} + +// A legacy string source and a new array source both load, and validation passes. +func TestLoadStringAndArraySource(t *testing.T) { + root := t.TempDir() + writeRawConfig(t, root, `{ + "version": 1, + "paths": {"specs": "specs", "features": "features"}, + "targets": { + "legacy": {"format": "junit", "report": "r", "source": "apps/web/app"}, + "multi": {"format": "gotest", "report": "r2", "source": ["cmd/troved", "internal", "cmd/trove-transcode"]} + } + }`) + + cfg, found, err := Load(root) + if err != nil || !found { + t.Fatalf("Load: found=%v err=%v", found, err) + } + if got := cfg.Targets["legacy"].Source; len(got) != 1 || got[0] != "apps/web/app" { + t.Fatalf("legacy string source = %v, want [apps/web/app]", got) + } + if got := cfg.Targets["multi"].Source; len(got) != 3 || got[2] != "cmd/trove-transcode" { + t.Fatalf("array source = %v, want 3 paths", got) + } + if errs := cfg.Validate(); len(errs) != 0 { + t.Fatalf("expected valid config, got %v", errs) + } +} + +// Validation rejects an empty array source. +func TestValidateRejectsEmptyArraySource(t *testing.T) { + root := t.TempDir() + writeRawConfig(t, root, `{ + "version": 1, + "targets": {"bad": {"format": "junit", "report": "r", "source": []}} + }`) + cfg, _, err := Load(root) + if err != nil { + t.Fatal(err) + } + if errs := cfg.Validate(); len(errs) == 0 { + t.Error("an empty array source must be a validation error") + } +} From 3edd172d7741a3c5d101bb81a7ada709886e42db Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Tue, 16 Jun 2026 02:30:14 -0500 Subject: [PATCH 09/12] cli: tighten multi-source register test (assert manifest seeding) + fix comment --- cmd/specify/register_test.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/cmd/specify/register_test.go b/cmd/specify/register_test.go index 3c37c81574..6e203fefc0 100644 --- a/cmd/specify/register_test.go +++ b/cmd/specify/register_test.go @@ -93,8 +93,9 @@ func TestRegisterTargetErrors(t *testing.T) { } } -// register a multi-source target via repeated --source: the array round-trips -// through specs.json (no scaffold, no files written). +// register a multi-source target via repeated --source flags: the 3-path array +// round-trips through specs.json. No member files are written (register never +// scaffolds), though the go-service manifest still seeds the test wiring. func TestRegisterTargetMultiSource(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, "cmd/troved"), 0o755); err != nil { @@ -108,8 +109,12 @@ func TestRegisterTargetMultiSource(t *testing.T) { t.Fatal(err) } cfg, _, _ := config.Load(root) - got := cfg.Targets["go-service"].Source - if len(got) != 3 || got[0] != "cmd/troved" || got[2] != "cmd/trove-transcode" { - t.Fatalf("expected a 3-source target, got %v", got) + tgt := cfg.Targets["go-service"] + if len(tgt.Source) != 3 || tgt.Source[0] != "cmd/troved" || tgt.Source[2] != "cmd/trove-transcode" { + t.Fatalf("expected a 3-source target, got %v", tgt.Source) + } + // the --source override is orthogonal to the manifest-seeded wiring + if tgt.Format != "gotest" { + t.Errorf("expected format seeded from the go-service manifest, got %q", tgt.Format) } } From ca9076ea67ffd409f20081a989117ad152015c9e Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Tue, 16 Jun 2026 02:31:25 -0500 Subject: [PATCH 10/12] docs: document array (multi-source) target source --- docs/config.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/config.md b/docs/config.md index 813b696219..a2ede1f11c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -33,7 +33,7 @@ trailing commas). "command": "go test -json ./cmd/... ./internal/... > .speckit/daemon.gotest.json", "format": "gotest", "report": ".speckit/daemon.gotest.json", - "source": "cmd", + "source": ["cmd/troved", "internal", "cmd/trove-transcode"], "bindings": "scoped" } } @@ -52,6 +52,10 @@ optional `bindings` mode. - **`format`** — how the test report is parsed: `junit` (Vitest, Gradle), `swift` (Swift Testing's event stream), or `gotest` (the NDJSON `go test -json` writes; the join identity is the `func Test…` name). +- **`source`** — the directory (or directories) scanned for scenario bindings. + A single string scans one dir; a JSON array scans every listed dir and joins + the bindings into one target (e.g. a Go service whose tests span `cmd/` and + `internal/`). One or more non-empty paths are required. - **`bindings`** — how an untagged test (one that binds no scenario) is treated: `strict` (the default — every test must prove a scenario, an untagged one is an unbound D12 violation) or `scoped` (untagged tests are out of scope, so a suite From 9dd61cdea31b0f09ef77f899fc92ad0a554edd6e Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Tue, 16 Jun 2026 02:33:26 -0500 Subject: [PATCH 11/12] docs: source field summary notes one-or-more dirs --- docs/config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config.md b/docs/config.md index a2ede1f11c..cc927aabf3 100644 --- a/docs/config.md +++ b/docs/config.md @@ -46,7 +46,7 @@ projected for — `init --integration ` records it here so `target add` / shown) locates the spec library; each **target** carries a `stack` (selects its platform pack — see below), the verify wiring (`command` to run, `report` `format` ∈ `junit`/`swift`/`gotest`, `report` -path, `source` dir scanned for bindings), an optional `product` label, and an +path, `source` dir(s) scanned for bindings), an optional `product` label, and an optional `bindings` mode. - **`format`** — how the test report is parsed: `junit` (Vitest, Gradle), `swift` From 0c60855e3bf07b7b63c87e662aff6e4df79ad403 Mon Sep 17 00:00:00 2001 From: Mark Malstrom Date: Tue, 16 Jun 2026 02:36:45 -0500 Subject: [PATCH 12/12] config: precise MarshalJSON empty/nil doc note --- internal/config/config.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 1d3b127258..4406ff5cce 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -64,8 +64,8 @@ func (sp *SourcePaths) UnmarshalJSON(b []byte) error { // MarshalJSON writes one path as a bare string and multiple as an array, so an // existing single-source config round-trips to the same on-disk shape. An empty -// SourcePaths marshals as JSON null; Validate rejects empty before any Save, so -// that shape never reaches disk. +// SourcePaths marshals as []/null (nil → null, empty slice → []); Validate +// rejects empty before any Save, so that shape never reaches disk. func (sp SourcePaths) MarshalJSON() ([]byte, error) { if len(sp) == 1 { return json.Marshal(sp[0])