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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/specify/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 21 additions & 7 deletions cmd/specify/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -399,7 +399,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 <name> [flags]",
Short: "Register an existing member as a target (no scaffolding)",
Expand All @@ -417,14 +418,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
Expand Down Expand Up @@ -468,10 +470,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" {
Expand Down Expand Up @@ -509,6 +511,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.
Expand Down Expand Up @@ -1034,7 +1048,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.
Expand Down
10 changes: 5 additions & 5 deletions cmd/specify/monorepo_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func TestWireMonorepoInlineThenPromote(t *testing.T) {
writeWebMember(t, root, "apps/web")
if err := config.AddTarget(root, "web", config.Target{
Stack: "web", Command: "mise //apps/web:test", Format: "junit",
Report: "apps/web/junit.xml", Source: "apps/web/app",
Report: "apps/web/junit.xml", Source: config.SourcePaths{"apps/web/app"},
}); err != nil {
t.Fatal(err)
}
Expand All @@ -47,7 +47,7 @@ func TestWireMonorepoInlineThenPromote(t *testing.T) {
writeWebMember(t, root, "apps/web2")
if err := config.AddTarget(root, "web2", config.Target{
Stack: "web", Command: "mise //apps/web2:test", Format: "junit",
Report: "apps/web2/junit.xml", Source: "apps/web2/app",
Report: "apps/web2/junit.xml", Source: config.SourcePaths{"apps/web2/app"},
}); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -133,7 +133,7 @@ func TestWireMonorepoSwiftCrossStackPromotion(t *testing.T) {
renderMember(t, root, "apple", "Photos", "apps/Photos")
if err := config.AddTarget(root, "Photos", config.Target{
Stack: "apple", Command: "mise //apps/Photos:test", Format: "swift",
Report: "apps/Photos/test.swift-events.ndjson", Source: "apps/Photos/Core",
Report: "apps/Photos/test.swift-events.ndjson", Source: config.SourcePaths{"apps/Photos/Core"},
}); err != nil {
t.Fatal(err)
}
Expand All @@ -151,7 +151,7 @@ func TestWireMonorepoSwiftCrossStackPromotion(t *testing.T) {
renderMember(t, root, "swift-package", "Widgets", "packages/Widgets")
if err := config.AddTarget(root, "Widgets", config.Target{
Stack: "swift-package", Command: "mise //packages/Widgets:test", Format: "swift",
Report: "packages/Widgets/test.swift-events.ndjson", Source: "packages/Widgets/Sources",
Report: "packages/Widgets/test.swift-events.ndjson", Source: config.SourcePaths{"packages/Widgets/Sources"},
}); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -190,7 +190,7 @@ func TestWireMonorepoGoPromotion(t *testing.T) {
renderMember(t, root, "go-service", m.name, m.dir)
if err := config.AddTarget(root, m.name, config.Target{
Stack: "go-service", Command: "mise //" + m.dir + ":test", Format: "gotest",
Report: m.dir + "/test.gotest.json", Source: m.dir, Bindings: "scoped",
Report: m.dir + "/test.gotest.json", Source: config.SourcePaths{m.dir}, Bindings: "scoped",
}); err != nil {
t.Fatal(err)
}
Expand Down
32 changes: 29 additions & 3 deletions cmd/specify/register_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -92,3 +92,29 @@ func TestRegisterTargetErrors(t *testing.T) {
t.Error("expected error for an unsafe target name")
}
}

// 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 {
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)
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)
}
}
2 changes: 1 addition & 1 deletion cmd/specify/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Expand All @@ -46,12 +46,16 @@ projected for — `init --integration <agent>` 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`
(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
Expand Down
158 changes: 158 additions & 0 deletions docs/design/multi-source-targets.md
Original file line number Diff line number Diff line change
@@ -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 <target>` 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 <multi-source-target>`
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).
Loading
Loading