diff --git a/docs/superpowers/plans/2026-06-21-crucible-build-parity.md b/docs/superpowers/plans/2026-06-21-crucible-build-parity.md index 922e310..1838d65 100644 --- a/docs/superpowers/plans/2026-06-21-crucible-build-parity.md +++ b/docs/superpowers/plans/2026-06-21-crucible-build-parity.md @@ -1329,6 +1329,9 @@ git commit -m "test(topdata): parity guard — cdn_channel yields accessory rows - [ ] **Step 1:** Merge Phase 1; tag/release Crucible per the repo's existing release workflow. Note the resulting version tag for Phase 3. +> ✅ Release tag: `v0.3.0` +> + --- ## Phase 3 — Toolchain convergence (flakes) @@ -1392,6 +1395,8 @@ Edit `sow-topdata/flake.nix`. Add the input and thread it through: > Note: `forAllSystems` now passes both `system` and `pkgs`. If the repo's existing helper signature differs, match the convention used by `sow-assets-manifest/flake.nix` (which already pins crucible per-system) rather than introducing a new pattern. +> Operator concern: The `scripts/` still exist? We should be removing wrappers for tasks Crucible controls. + - [ ] **Step 2: Lock the input** Run: `cd sow-topdata && nix flake update sow-tools` (or `nix flake lock --update-input sow-tools`) diff --git a/internal/project/project.go b/internal/project/project.go index 40ba9c7..d9656c5 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -788,6 +788,13 @@ func (p *Project) ValidateLayout() error { } info, err := os.Stat(path) if err != nil { + // An output dir that does not exist yet is fine: the builder creates + // it (MkdirAll) before writing. A bare clone must validate/build from + // a clean tree with no pre-step (R2/parity) — only a path that exists + // but is NOT a directory is a real error. + if errors.Is(err, fs.ErrNotExist) { + continue + } failures = append(failures, fmt.Errorf("%s: %w", label, err)) continue } diff --git a/internal/project/project_test.go b/internal/project/project_test.go index 9c10e9c..cf5b1ac 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -1113,6 +1113,52 @@ func TestValidateLayoutAllowsMissingAssetsDir(t *testing.T) { } } +// paths.build is an OUTPUT dir the builder creates (MkdirAll) before writing, so +// a bare clone with no build dir yet must still validate/build with no pre-step +// (R2/parity). Only a build path that exists but is not a directory is an error. +func TestValidateLayoutAllowsMissingBuildDir(t *testing.T) { + root := t.TempDir() + mkdirAll(t, filepath.Join(root, "src")) + // deliberately do NOT create build/ + + proj := &Project{ + Root: root, + Config: Config{ + Module: ModuleConfig{Name: "Test", ResRef: "test"}, + Paths: PathConfig{Source: "src", Build: "build"}, + }, + } + + if err := proj.ValidateLayout(); err != nil { + t.Fatalf("ValidateLayout returned error for missing build dir: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "build")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected missing build dir to remain absent, got err=%v", err) + } +} + +// A build path that exists but is a regular file is still a hard error. +func TestValidateLayoutRejectsBuildDirThatIsAFile(t *testing.T) { + root := t.TempDir() + mkdirAll(t, filepath.Join(root, "src")) + if err := os.WriteFile(filepath.Join(root, "build"), []byte("i am a file, not a dir"), 0o644); err != nil { + t.Fatal(err) + } + + proj := &Project{ + Root: root, + Config: Config{ + Module: ModuleConfig{Name: "Test", ResRef: "test"}, + Paths: PathConfig{Source: "src", Build: "build"}, + }, + } + + err := proj.ValidateLayout() + if err == nil || !strings.Contains(err.Error(), "paths.build must be a directory") { + t.Fatalf("expected paths.build-must-be-a-directory error, got %v", err) + } +} + func TestValidateLayoutRejectsInvalidTopDataPackageOutputNames(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "src"))