diff --git a/README.md b/README.md index e146b68..f8f4393 100644 --- a/README.md +++ b/README.md @@ -115,9 +115,14 @@ Deployment gate script: Gate order: 1. docs contract checks -2. naming + LOC hygiene checks -3. pedantic lint -4. per-file coverage gate (95% minimum) +2. split test-module contract (`cmd/` + `internal/` cannot grow new in-tree `_test.go` files) +3. naming + LOC hygiene checks +4. pedantic lint +5. per-file coverage gate (95% minimum) + +Current migration rule: +- keep new tests in the top-level `testing/` module +- legacy in-tree `_test.go` files are temporarily grandfathered through `testing/hygiene/in_tree_test_allowlist.txt` until they are migrated safely Installer behavior: - `scripts/install.sh` runs the quality gate by default diff --git a/scripts/quality_gate.sh b/scripts/quality_gate.sh index fae6e0e..ed49448 100755 --- a/scripts/quality_gate.sh +++ b/scripts/quality_gate.sh @@ -145,6 +145,9 @@ go test ./hygiene -run TestHygieneContracts/naming_contract -count=1 echo "[quality] hygiene: LOC limits" go test ./hygiene -run TestHygieneContracts/loc_limit -count=1 + +echo "[quality] hygiene: split test-module contract" +go test ./hygiene -run TestHygieneContracts/split_module_contract -count=1 cd "${REPO_DIR}" echo "[quality] lint" diff --git a/testing/hygiene/hygiene_test.go b/testing/hygiene/hygiene_test.go index b81bdb5..86f57af 100644 --- a/testing/hygiene/hygiene_test.go +++ b/testing/hygiene/hygiene_test.go @@ -1,6 +1,7 @@ package hygiene import ( + "bufio" "go/ast" "go/parser" "go/token" @@ -71,6 +72,9 @@ func TestHygieneContracts(t *testing.T) { t.Run("loc_limit", func(t *testing.T) { checkFileLOCLimits(t, files) }) + t.Run("split_module_contract", func(t *testing.T) { + checkSplitModuleContract(t, root, files) + }) } // checkDocContracts runs one orchestration or CLI step. @@ -137,3 +141,108 @@ func checkFileLOCLimits(t *testing.T, files []string) { } } } + +// checkSplitModuleContract runs one orchestration or CLI step. +// Signature: checkSplitModuleContract(t *testing.T, root string, files []string). +// Why: The long-term test layout requirement is top-level `testing/`; this +// guard freezes the remaining in-tree baseline so new root-module tests cannot +// slip in while the migration continues incrementally and safely. +func checkSplitModuleContract(t *testing.T, root string, files []string) { + t.Helper() + + allowlistPath := filepath.Join(root, "testing", "hygiene", "in_tree_test_allowlist.txt") + allowed := loadInTreeTestAllowlist(t, allowlistPath) + actual := make([]string, 0, len(files)) + for _, file := range files { + if !strings.HasSuffix(file, "_test.go") { + continue + } + actual = append(actual, mustRepoRelativePath(t, root, file)) + } + sort.Strings(actual) + + allowedSet := make(map[string]struct{}, len(allowed)) + for _, file := range allowed { + allowedSet[file] = struct{}{} + } + actualSet := make(map[string]struct{}, len(actual)) + for _, file := range actual { + actualSet[file] = struct{}{} + } + + unexpected := make([]string, 0) + for _, file := range actual { + if _, ok := allowedSet[file]; !ok { + unexpected = append(unexpected, file) + } + } + missing := make([]string, 0) + for _, file := range allowed { + if _, ok := actualSet[file]; !ok { + missing = append(missing, file) + } + } + + if len(unexpected) == 0 && len(missing) == 0 { + return + } + + var report strings.Builder + report.WriteString("split-module contract mismatch\n") + if len(unexpected) > 0 { + report.WriteString("unexpected in-tree tests (move them under testing/ or explicitly rebaseline if this is an intentional migration checkpoint):\n") + for _, file := range unexpected { + report.WriteString("- " + file + "\n") + } + } + if len(missing) > 0 { + report.WriteString("stale allowlist entries (remove them from testing/hygiene/in_tree_test_allowlist.txt after a migration):\n") + for _, file := range missing { + report.WriteString("- " + file + "\n") + } + } + t.Fatal(report.String()) +} + +// loadInTreeTestAllowlist runs one orchestration or CLI step. +// Signature: loadInTreeTestAllowlist(t *testing.T, path string) []string. +// Why: The split-module baseline needs one canonical source of truth so the +// gate can distinguish legacy in-tree tests from accidental new ones. +func loadInTreeTestAllowlist(t *testing.T, path string) []string { + t.Helper() + + f, err := os.Open(path) + if err != nil { + t.Fatalf("open in-tree test allowlist %s: %v", path, err) + } + defer f.Close() + + entries := make([]string, 0, 64) + s := bufio.NewScanner(f) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + entries = append(entries, filepath.ToSlash(line)) + } + if err := s.Err(); err != nil { + t.Fatalf("scan in-tree test allowlist %s: %v", path, err) + } + sort.Strings(entries) + return entries +} + +// mustRepoRelativePath runs one orchestration or CLI step. +// Signature: mustRepoRelativePath(t *testing.T, root string, path string) string. +// Why: Relative slash-normalized paths keep split-module hygiene output stable +// across machines and make allowlist diffs easy to review. +func mustRepoRelativePath(t *testing.T, root string, path string) string { + t.Helper() + + rel, err := filepath.Rel(root, path) + if err != nil { + t.Fatalf("make %s relative to %s: %v", path, root, err) + } + return filepath.ToSlash(rel) +} diff --git a/testing/hygiene/in_tree_test_allowlist.txt b/testing/hygiene/in_tree_test_allowlist.txt new file mode 100644 index 0000000..4e240b8 --- /dev/null +++ b/testing/hygiene/in_tree_test_allowlist.txt @@ -0,0 +1,38 @@ +# Remaining root-module test baseline. +# New tests belong under the top-level testing/ module. +cmd/ananke/bootstrap_handoff_additional_test.go +cmd/ananke/bootstrap_handoff_test.go +cmd/ananke/builder_test.go +cmd/ananke/command_additional_test.go +cmd/ananke/command_handlers_injection_test.go +cmd/ananke/command_handlers_status_error_test.go +cmd/ananke/command_handlers_test.go +cmd/ananke/main_dispatch_additional_test.go +cmd/ananke/main_dispatch_test.go +cmd/ananke/power_safety_test.go +cmd/ananke/test_helpers_test.go +internal/cluster/orchestrator_inventory_test.go +internal/cluster/orchestrator_report_test.go +internal/cluster/orchestrator_test.go +internal/cluster/orchestrator_unit_additional_test.go +internal/cluster/orchestrator_vault_test.go +internal/config/config_test.go +internal/config/load_additional_test.go +internal/config/validate_matrix_test.go +internal/execx/runner_additional_test.go +internal/execx/runner_test.go +internal/metrics/exporter_additional_test.go +internal/metrics/exporter_test.go +internal/service/daemon_additional_test.go +internal/service/daemon_coverage_closeout_test.go +internal/service/daemon_quality_branches_test.go +internal/service/daemon_test.go +internal/sshutil/repair_test.go +internal/sshutil/sshutil_test.go +internal/state/heal_test.go +internal/state/intent_additional_test.go +internal/state/intent_test.go +internal/state/store_additional_test.go +internal/state/store_test.go +internal/ups/nut_additional_test.go +internal/ups/nut_test.go