From c5bec10bb5d5643374f6c03232400c626878a35c Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 15:06:54 +0100 Subject: [PATCH] CAT-BG5: mesh-build-go regenerates a contracts module's Go files and names each out of step The unit level fails on a file that differs, is missing or was not generated; a build ask answers the same files in its out-of-step, a failed build's too, so a trunk build's end raises generated-types-out-of-step (lead's ruling on A2k finding 1). The compare is buildstep's, each build module its own root, with the seat generator as a requirement. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- modules/mesh-build-go/README.md | 6 + modules/mesh-build-go/buildstep/askrun.go | 26 ++++ modules/mesh-build-go/buildstep/generated.go | 132 +++++++++++++++++ .../mesh-build-go/buildstep/generated_test.go | 134 ++++++++++++++++++ modules/mesh-build-go/buildstep/go.mod | 1 + modules/mesh-build-go/buildstep/step.go | 6 + .../mesh-build-go/cmd/mesh-build-go/asks.go | 2 + .../mesh-build-go/cmd/mesh-build-go/levels.go | 3 + .../mesh-build-go/cmd/mesh-build-go/test.go | 34 +++++ .../cmd/mesh-build-go/test_test.go | 34 +++++ 10 files changed, 378 insertions(+) create mode 100644 modules/mesh-build-go/buildstep/generated.go create mode 100644 modules/mesh-build-go/buildstep/generated_test.go diff --git a/modules/mesh-build-go/README.md b/modules/mesh-build-go/README.md index f3729444..7ffcfc4e 100644 --- a/modules/mesh-build-go/README.md +++ b/modules/mesh-build-go/README.md @@ -142,6 +142,12 @@ trunk build's, in chunks, only what the holder lacks; the holder asks the builde seat's events. The step answers the seat's form `oci-artifact-registry//@` (ADR 0605 §9); the builder writes the older form beside it while a counted node-engine reads only that (ADR 0624); a publish the holder refuses, or a seat nothing holds, fails the step `not stored: : `. The packed bytes never stay on the node. +A contracts module (a package artifact naming `generated-from`) has its generated Go files regenerated with the seat +generator this module is built with and compared (`buildstep/generated.go`): the unit level of `build_go_test` fails +naming each file that differs, is missing or was not written by the generator (its `out-of-step`), and a build ask +answers the same files in its answer's `out-of-step`, a failed build's too, so a trunk build's end can raise +`generated-types-out-of-step` where no test step runs. Its TypeScript files are mesh-build-node's to compare. + The module writes into no store itself and requires no `artifact-store`. A Go package (kind `package`, language `go`; `gopackage.go`) is the directory its one path names packed as Go's module zip, every file under `@/`, its digest the zip's (no `h1:` sum is answered: nothing carries it on); a trunk build the builder gave a version publishes it through diff --git a/modules/mesh-build-go/buildstep/askrun.go b/modules/mesh-build-go/buildstep/askrun.go index e63ff9be..bd5cd599 100644 --- a/modules/mesh-build-go/buildstep/askrun.go +++ b/modules/mesh-build-go/buildstep/askrun.go @@ -29,6 +29,10 @@ type AskSteps struct { // {"imports": [...]}, sorted, from the workspace the build ran in. A build that built an artifact asks it once, at // its end; nil reports none. Imports StepFunc + // Compare regenerates a contracts module's generated files (a package artifact naming generated-from) and answers + // {"out-of-step": [...]} (CompareStep): a build ask runs it once on the fetched workspace and answers the paths in + // its out-of-step, a failed build's too, so a trunk build's end names them where no test step runs. Nil compares none. + Compare StepFunc // Toolchain is the toolchain the module builds with now; a build ask naming another is declined (Declining). Nil checks nothing // (an image or an archive names none). Toolchain func(ctx context.Context) (delivery.ToolchainUsed, error) @@ -156,6 +160,24 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) } } f = got + if s.Compare != nil { + Phase(ctx, "compare the generated files") + out, err := s.Compare(ctx, map[string]any{"workspace": f.Workspace, "module": job.Path}) + if err != nil { + return nil, fmt.Errorf("the generated files of %s: %w", job.Module, err) + } + r, err := raw(out) + if err != nil { + return nil, err + } + var read struct { + OutOfStep []string `json:"out-of-step"` + } + if err := json.Unmarshal(r, &read); err != nil { + return nil, fmt.Errorf("the compare of %s answered what is not a list: %s", job.Module, r) + } + answer.OutOfStep = read.OutOfStep + } } var ( out any @@ -186,6 +208,10 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) out, err = s.Build(ctx, args) } if err != nil { + // The compare's out-of-step is answered with the failure, so the build's end still names it. + if len(answer.OutOfStep) > 0 { + return answer, fmt.Errorf("%s: %w", a.Artifact, err) + } return nil, fmt.Errorf("%s: %w", a.Artifact, err) } r, err := raw(out) diff --git a/modules/mesh-build-go/buildstep/generated.go b/modules/mesh-build-go/buildstep/generated.go new file mode 100644 index 00000000..4e5e265b --- /dev/null +++ b/modules/mesh-build-go/buildstep/generated.go @@ -0,0 +1,132 @@ +package buildstep + +// **Regenerate and compare** (CAT-BG5, CAT-BN2; novox/hq ADR 0608 §5, design 26 *Contracts modules*). A module whose +// package artifact names `generated-from` holds contracts only: every file under its generated roots is written by the +// seat generator (seatgen) from the definitions in that directory. A build module regenerates them with the generator +// it was built with (its version is this package's requirement, so a new generator is a new build module and a new +// fingerprint of what it builds) and names each file out of step: one that differs, one missing, one under a generated +// root the generator did not write. The test step fails on any; a trunk build's ask answers them in its out-of-step +// (delivery.BuildAnswer.OutOfStep), so the builder's build end raises generated-types-out-of-step even where no test +// step runs. Each build module compares its own roots: mesh-build-go `go`, mesh-build-node `ts`. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/seatgen/seatgen" +) + +// GeneratedRootGo and GeneratedRootTS are the generated roots of a contracts module, one per build module. +const ( + GeneratedRootGo = "go" + GeneratedRootTS = "ts" +) + +var goModuleLine = regexp.MustCompile(`(?m)^module\s+(\S+)`) + +// OutOfStep is every file under root, a path from the module's directory, that the generator writes otherwise than the +// module holds it, for each `generated-from` its package artifacts name, sorted and each once; none for a module naming +// none. The generated Go tree's module path is read from /go/go.mod and the TypeScript package's name from +// /ts/package.json, as the contracts module states them. +func OutOfStep(moduleDir, root string) ([]string, error) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if errors.Is(err, os.ErrNotExist) { + return nil, nil // no manifest here: nothing names generated-from + } + if err != nil { + return nil, fmt.Errorf("the module's manifest cannot be read for its generated files: %w", err) + } + var m catalogue.Manifest + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("the module's manifest does not parse: %w", err) + } + froms := map[string]bool{} + if m.Build != nil { + for _, a := range m.Build.Artifacts { + if a.GeneratedFrom != "" { + froms[filepath.Clean(a.GeneratedFrom)] = true + } + } + } + seen := map[string]bool{} + for from := range froms { + if filepath.IsAbs(from) || from == ".." || strings.HasPrefix(from, "../") { + return nil, fmt.Errorf("generated-from %q is not inside the module", from) + } + dir := filepath.Join(moduleDir, from) + goModule, tsPackage, err := generatedNames(dir) + if err != nil { + return nil, err + } + g, err := seatgen.Load(dir, goModule, tsPackage) + if err != nil { + return nil, fmt.Errorf("the seat definitions in %s do not load: %w", from, err) + } + files, err := seatgen.Generate(g) + if err != nil { + return nil, fmt.Errorf("the seat definitions in %s do not generate: %w", from, err) + } + out, err := seatgen.Compare(dir, files) + if err != nil { + return nil, err + } + for _, line := range out { + p, _, _ := strings.Cut(line, ": ") + if p == root || strings.HasPrefix(p, root+"/") { + seen[filepath.ToSlash(filepath.Join(from, p))] = true + } + } + } + paths := make([]string, 0, len(seen)) + for p := range seen { + paths = append(paths, p) + } + sort.Strings(paths) + return paths, nil +} + +func generatedNames(dir string) (goModule, tsPackage string, err error) { + if raw, err := os.ReadFile(filepath.Join(dir, GeneratedRootGo, "go.mod")); err == nil { + if m := goModuleLine.FindSubmatch(raw); m != nil { + goModule = string(m[1]) + } + } + if raw, err := os.ReadFile(filepath.Join(dir, GeneratedRootTS, "package.json")); err == nil { + var p struct { + Name string `json:"name"` + } + if json.Unmarshal(raw, &p) == nil { + tsPackage = p.Name + } + } + if goModule == "" || tsPackage == "" { + return "", "", errors.New("a contracts module names its generated Go module in go/go.mod and its TypeScript package in ts/package.json; " + + filepath.Base(dir) + " names " + map[bool]string{true: "not the TypeScript package", false: "not the Go module"}[goModule != ""]) + } + return goModule, tsPackage, nil +} + +// CompareStep is AskSteps.Compare for a build module comparing root: it reads {"workspace", "module"}, the workspace's +// tree where tree says it is (the build module's own layout), and answers {"out-of-step": [...]}. +func CompareStep(root string, tree func(workspace string) string) StepFunc { + return func(_ context.Context, args map[string]any) (any, error) { + ws, _ := args["workspace"].(string) + mod, _ := args["module"].(string) + if ws == "" { + return nil, Refuse("the compare names no workspace") + } + paths, err := OutOfStep(filepath.Join(tree(ws), filepath.FromSlash(mod)), root) + if err != nil { + return nil, err + } + return map[string]any{"out-of-step": paths}, nil + } +} diff --git a/modules/mesh-build-go/buildstep/generated_test.go b/modules/mesh-build-go/buildstep/generated_test.go new file mode 100644 index 00000000..54681643 --- /dev/null +++ b/modules/mesh-build-go/buildstep/generated_test.go @@ -0,0 +1,134 @@ +package buildstep + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/seatgen/seatgen" +) + +// contracts writes a contracts module of one seat, its generated files as the generator writes them, and answers its +// directory and the generated paths. +func contracts(t *testing.T, manifest string) (string, []string) { + t.Helper() + dir := t.TempDir() + for p, text := range map[string]string{ + "module.json": manifest, + "seats/index.json": `{"format":1,"seats":["one-store"]}`, + "seats/one-store/seat.json": `{"format":1,"seat":"one-store","scope":"mesh","grain":"seat-list","decision":"ADR 1","verbs":[{"name":"query","input":{"type":"object"}}]}`, + } { + writeContract(t, filepath.Join(dir, p), text) + } + m, err := seatgen.Load(dir, "example.org/c/go", "@example/c") + if err != nil { + t.Fatal(err) + } + files, err := seatgen.Generate(m) + if err != nil { + t.Fatal(err) + } + var paths []string + for p, b := range files { + writeContract(t, filepath.Join(dir, p), string(b)) + paths = append(paths, p) + } + // The names the compare reads, where the contracts module states them; a generated go.mod or package.json is kept. + if _, ok := files["go/go.mod"]; !ok { + writeContract(t, filepath.Join(dir, "go/go.mod"), "module example.org/c/go\n") + } + if _, ok := files["ts/package.json"]; !ok { + writeContract(t, filepath.Join(dir, "ts/package.json"), `{"name":"@example/c"}`) + } + return dir, paths +} + +func writeContract(t *testing.T, path, text string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(text), 0o644); err != nil { + t.Fatal(err) + } +} + +// **CAT-BG5, CAT-BN2**: a contracts module's generated files are regenerated and compared, each build module its own +// root; a file that differs, one missing and one the generator did not write are each named; a clean tree and a module +// naming no generated-from name none; a build ask answers them in its out-of-step. +func TestAContractsModulesGeneratedFilesAreComparedWithTheGenerator(t *testing.T) { + marked := `{"module":"c","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"],"generated-from":"."}]}}` + dir, paths := contracts(t, marked) + var goFile, tsFile string + for _, p := range paths { + if strings.HasPrefix(p, "go/") && strings.HasSuffix(p, ".go") && goFile == "" { + goFile = p + } + if strings.HasPrefix(p, "ts/") && strings.HasSuffix(p, ".ts") && tsFile == "" { + tsFile = p + } + } + if goFile == "" || tsFile == "" { + t.Fatalf("the generator wrote no Go or no TypeScript file: %v", paths) + } + for _, root := range []string{GeneratedRootGo, GeneratedRootTS} { + if got, err := OutOfStep(dir, root); err != nil || len(got) != 0 { + t.Fatalf("a clean tree, root %s: %v %v", root, got, err) + } + } + writeContract(t, filepath.Join(dir, goFile), "package changed\n") + writeContract(t, filepath.Join(dir, "go/extra.go"), "package extra\n") + writeContract(t, filepath.Join(dir, tsFile), "// changed\n") + got, err := OutOfStep(dir, GeneratedRootGo) + if want := sorted(goFile, "go/extra.go"); err != nil || !reflect.DeepEqual(got, want) { + t.Errorf("Go root: %v %v, want %v", got, err, want) + } + if got, _ := OutOfStep(dir, GeneratedRootTS); !reflect.DeepEqual(got, []string{tsFile}) { + t.Errorf("TypeScript root: %v, want %s alone", got, tsFile) + } + _ = os.Remove(filepath.Join(dir, goFile)) + if got, _ := OutOfStep(dir, GeneratedRootGo); !reflect.DeepEqual(got, sorted(goFile, "go/extra.go")) { + t.Errorf("a missing file is not named: %v", got) + } + plain, _ := contracts(t, `{"module":"p","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"]}]}}`) + writeContract(t, filepath.Join(plain, "go/extra.go"), "package extra\n") + if got, err := OutOfStep(plain, GeneratedRootGo); err != nil || len(got) != 0 { + t.Errorf("a module naming no generated-from compared: %v %v", got, err) + } + + // The build ask answers them beside what it built, and with a failed build too. + built := 0 + steps := AskSteps{Compare: CompareStep(GeneratedRootGo, func(ws string) string { return ws }), + Fetch: func(context.Context, map[string]any) (any, error) { + return map[string]any{"workspace": filepath.Dir(dir)}, nil + }, + Build: func(context.Context, map[string]any) (any, error) { + if built++; built > 1 { + return nil, errors.New("the second artifact does not build") + } + return map[string]any{"digest": "sha256:aa"}, nil + }} + job := delivery.BuildJob{Module: "c", Path: filepath.Base(dir), Fetch: map[string]any{"repository": "novox/c"}, + Artifacts: []delivery.AskArtifact{{Artifact: "go"}}} + out, err := RunAsk(context.Background(), steps, ask(job)) + if a, ok := out.(delivery.BuildAnswer); err != nil || !ok || !reflect.DeepEqual(a.OutOfStep, sorted(goFile, "go/extra.go")) || len(a.Artifacts) != 1 { + t.Errorf("the build ask's out-of-step: %+v %v", out, err) + } + job.Artifacts = append(job.Artifacts, delivery.AskArtifact{Artifact: "go-other"}) + built = 0 + out, err = RunAsk(context.Background(), steps, ask(job)) + if a, ok := out.(delivery.BuildAnswer); err == nil || !ok || len(a.OutOfStep) != 2 { + t.Errorf("a failed build answered no out-of-step: %+v %v", out, err) + } +} + +func sorted(paths ...string) []string { + sort.Strings(paths) + return paths +} diff --git a/modules/mesh-build-go/buildstep/go.mod b/modules/mesh-build-go/buildstep/go.mod index d61305e9..d414771d 100644 --- a/modules/mesh-build-go/buildstep/go.mod +++ b/modules/mesh-build-go/buildstep/go.mod @@ -4,6 +4,7 @@ go 1.26.0 require ( git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 + git.novox.be/novox/seatgen v0.0.0 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-build-go/buildstep/step.go b/modules/mesh-build-go/buildstep/step.go index a265184e..ede19129 100644 --- a/modules/mesh-build-go/buildstep/step.go +++ b/modules/mesh-build-go/buildstep/step.go @@ -83,6 +83,12 @@ func finish(put Put, key string, step delivery.Step, result any, err error, logf step.State, step.Reason = delivery.StepRefused, err.Error() default: step.State, step.Reason = delivery.StepFailed, err.Error() + // A failed step may answer what it found on the way (a build's out-of-step files): kept on its end. + if result != nil { + if raw, merr := json.Marshal(result); merr == nil { + step.Result = raw + } + } } werr := writeEnd(put, key, step) if werr == nil { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/asks.go b/modules/mesh-build-go/cmd/mesh-build-go/asks.go index 5579cc6c..25dcb8eb 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/asks.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/asks.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "path/filepath" "time" "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" @@ -38,6 +39,7 @@ func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps { return buildstep.AskSteps{ Fetch: byName["build_go_fetch"], Build: byName["build_go_build"], Digest: byName["build_go_digest"], Test: byName["build_go_test"], Imports: byName[ImportsTool], + Compare: buildstep.CompareStep(buildstep.GeneratedRootGo, func(ws string) string { return filepath.Join(m.Work, ws, "tree") }), Toolchain: func(ctx context.Context) (delivery.ToolchainUsed, error) { installed, err := m.Probe(ctx) if err != nil { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/levels.go b/modules/mesh-build-go/cmd/mesh-build-go/levels.go index beb540fe..a6266f88 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/levels.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/levels.go @@ -72,6 +72,9 @@ type TestResult struct { // JudgedBy is the program that ran the level (ADR 0535 §5): "installed", or "candidate " under the // runner workspace. JudgedBy string `json:"judged_by,omitempty"` + // OutOfStep are the generated files of a contracts module in the workspace out of step with what the generator writes + // (CAT-BG5, buildstep.OutOfStep): the unit level fails on any, Why naming each. Absent when none is. + OutOfStep []string `json:"out-of-step,omitempty"` // By is the build module and the SDK it was built with (sdkstep.By): the checker names it when a failure hides // its cause. By string `json:"by,omitempty"` diff --git a/modules/mesh-build-go/cmd/mesh-build-go/test.go b/modules/mesh-build-go/cmd/mesh-build-go/test.go index f02dcc07..5d1823f6 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/test.go @@ -15,6 +15,7 @@ import ( "sync" "time" + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" sdkstep "git.novox.be/novox/mesh-sdk/go/buildstep" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/forgekey" @@ -100,9 +101,42 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) { return nil, err } res.JudgedBy = RunnerInstalled + if level == LevelUnit { + if err := compareGenerated(tree, res); err != nil { + return nil, err + } + } return res, nil } +// compareGenerated regenerates the generated Go files of each contracts module in the tree (the tree's own manifest and +// each modules//module.json; CAT-BG5) and fails the level on any out of step, Why naming each path. +func compareGenerated(tree string, res *TestResult) error { + manifests, _ := filepath.Glob(filepath.Join(tree, "modules", "*", "module.json")) + if _, err := os.Stat(filepath.Join(tree, "module.json")); err == nil { + manifests = append([]string{filepath.Join(tree, "module.json")}, manifests...) + } + for _, mf := range manifests { + dir := filepath.Dir(mf) + paths, err := buildstep.OutOfStep(dir, buildstep.GeneratedRootGo) + if err != nil { + return err + } + rel, _ := filepath.Rel(tree, dir) + for _, p := range paths { + res.OutOfStep = append(res.OutOfStep, filepath.ToSlash(filepath.Join(rel, p))) + } + } + if len(res.OutOfStep) > 0 { + why := "generated files out of step with the seat generator (regenerate them): " + strings.Join(res.OutOfStep, ", ") + if res.Why != "" { + why = res.Why + "; " + why + } + res.Passed, res.Why = false, why + } + return nil +} + // testAsCandidate runs a level as the candidate's program (ADR 0535 §1): the integration level is refused first as the // installed program would refuse it (no postgres), then the whole level runs in the candidate's one shut-off process. func (m *Module) testAsCandidate(ctx context.Context, workspace, level string, packages []string, pattern string, names []string) (*TestResult, error) { diff --git a/modules/mesh-build-go/cmd/mesh-build-go/test_test.go b/modules/mesh-build-go/cmd/mesh-build-go/test_test.go index aa6d1d1a..b0206d38 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/test_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/test_test.go @@ -178,6 +178,40 @@ func TestUnitLevelOfAFixtureWithNoTestsSaysSo(t *testing.T) { } } +// CAT-BG5: a contracts module (a package artifact naming generated-from) whose generated Go files are out of step with +// the seat generator fails the unit level, each path named in its out-of-step and its why; its TypeScript is not this +// module's to compare. +func TestUnitLevelOfAContractsModuleOutOfStepFailsNamingEachPath(t *testing.T) { + m, states, ws, _ := workspaceFor(t, "empty") + tree := filepath.Join(m.Work, ws, "tree") + for p, text := range map[string]string{ + "module.json": `{"module":"c","build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"],"generated-from":"."}]}}`, + "seats/index.json": `{"format":1,"seats":["one-store"]}`, + "seats/one-store/seat.json": `{"format":1,"seat":"one-store","scope":"mesh","grain":"seat-list","decision":"ADR 1","verbs":[{"name":"query","input":{"type":"object"}}]}`, + "go/go.mod": "module example.org/c/go\n", + "go/stray.go": "package stray\n", + "ts/package.json": `{"name":"@example/c"}`, + "ts/stray.ts": "// not this module's\n", + } { + full := filepath.Join(tree, filepath.FromSlash(p)) + _ = os.MkdirAll(filepath.Dir(full), 0o755) + if err := os.WriteFile(full, []byte(text), 0o644); err != nil { + t.Fatal(err) + } + } + res, st := runUnit(t, m, states, ws) + stray := false + for _, p := range res.OutOfStep { + stray = stray || p == "go/stray.go" + if !strings.HasPrefix(p, "go/") { + t.Errorf("%s is not under the Go root", p) + } + } + if st.State != delivery.StepPassed || res.Passed || !stray || !strings.Contains(res.Why, "go/stray.go") { + t.Errorf("result = %+v (%s %s)", res, st.State, st.Reason) + } +} + // A package that does not build fails the level with the compiler's words. func TestUnitLevelOfAPackageThatDoesNotBuildFailsWithTheCompilersLine(t *testing.T) { m, states, ws, _ := workspaceFor(t, "broken")