From f10e6ad0d30dc97500416dd6d34d5bc45e16589f Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 12 Aug 2026 14:02:54 +0100 Subject: [PATCH 1/4] test: guard kernel architecture boundaries --- .github/tests/test_repository_contract.py | 88 ++++++++++++++++++- .github/workflows/ci.yml | 18 ++++ .../2026-08-12-kernel-architecture-guards.md | 3 + 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 release-notes/2026-08-12-kernel-architecture-guards.md diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index da90725..46a96c9 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -18,6 +18,29 @@ CONFIG = REPO / "project.example.json" +GO_IMPORT_DECLARATION = re.compile( + r"(?ms)^[ \t]*import[ \t]+(?:\((?P.*?)^[ \t]*\)|(?P[^\n]+))" +) +GO_IMPORT_SPEC = re.compile( + r"^[ \t]*(?:(?P[._A-Za-z][A-Za-z0-9_]*)[ \t]+)?" + r"(?P\"(?:\\.|[^\"\\])*\"|`[^`]*`)" +) + + +def go_imports(path: Path) -> list[tuple[str | None, str]]: + imports: list[tuple[str | None, str]] = [] + for declaration in GO_IMPORT_DECLARATION.finditer(path.read_text()): + body = declaration.group("block") or declaration.group("single") or "" + for line in body.splitlines(): + match = GO_IMPORT_SPEC.match(line) + if match is None: + continue + literal = match.group("path") + import_path = literal[1:-1] if literal.startswith("`") else json.loads(literal) + imports.append((match.group("alias"), import_path)) + return imports + + class RepositoryContract(unittest.TestCase): @classmethod def setUpClass(cls) -> None: @@ -387,13 +410,43 @@ def test_public_tree_excludes_private_context_and_v1_operating_guidance(self) -> def test_general_kernel_is_domain_neutral_and_owns_shared_control_laws(self) -> None: kernel = REPO / "boatstack" / "kernel" - source = "\n".join(path.read_text() for path in sorted(kernel.glob("*.go"))) + kernel_files = sorted(kernel.glob("*.go")) + source = "\n".join(path.read_text() for path in kernel_files) for token in ( "git", "repository", "worktree", "branch", "pull request", "coding agent", "publication", ): self.assertNotIn(token, source.lower(), token) + boatstack_packages = "github.com/operatorstack/boatstack/boatstack/" + kernel_package = boatstack_packages + "kernel" + for path in kernel_files: + invalid = [ + import_path + for _, import_path in go_imports(path) + if import_path.startswith(boatstack_packages) + and import_path != kernel_package + ] + self.assertEqual([], invalid, f"kernel dependency direction: {path}") + + kernel_tests = sorted(kernel.glob("*_test.go")) + test_source = "\n".join(path.read_text() for path in kernel_tests) + self.assertNotRegex(test_source, r'\bexec\.Command\(\s*"git"') + self.assertNotRegex( + test_source, + r'\bexec\.CommandContext\([^,\n]+,\s*"git"', + ) + self.assertNotIn("testRepository", test_source) + self.assertNotIn("softwaredelivery", test_source.lower()) + for path in kernel_tests: + self.assertFalse( + any( + "/softwaredelivery" in import_path + for _, import_path in go_imports(path) + ), + f"kernel test fixture imports software delivery: {path}", + ) + runtime = (kernel / "runtime.go").read_text() software_relation = ( REPO / "boatstack" / "internal" / "softwaredelivery" / @@ -431,6 +484,39 @@ def test_general_kernel_is_domain_neutral_and_owns_shared_control_laws(self) -> "./internal/effects", "./internal/surfaces", ): self.assertNotIn(retired, component_ci) + self.assertIn("run: go test -race ./...", component_ci) + self.assertIn( + "run: go test -race ./kernel ./internal/softwaredelivery/effects", + component_ci, + ) + + @unittest.expectedFailure + def test_kernel_runtime_has_no_production_consumer_yet(self) -> None: + # Migration task T5 must replace this marker with a permanent positive + # production-reachability assertion when the generic runtime is adopted. + kernel_package = "github.com/operatorstack/boatstack/boatstack/kernel" + runtime_symbols = ( + "NewRuntime", "Runtime", "Store", "Domain", "Operator", "Receipt", + ) + consumers: list[str] = [] + for path in sorted((REPO / "boatstack").rglob("*.go")): + if path.name.endswith("_test.go") or path.parent == REPO / "boatstack" / "kernel": + continue + source = path.read_text() + for alias, import_path in go_imports(path): + if import_path != kernel_package or alias == "_": + continue + package_name = "kernel" if alias in (None, ".") else alias + if alias == ".": + pattern = rf"\b(?:{'|'.join(runtime_symbols)})\b" + else: + pattern = rf"\b{re.escape(package_name)}\.(?:{'|'.join(runtime_symbols)})\b" + if re.search(pattern, source): + consumers.append(str(path.relative_to(REPO))) + self.assertTrue( + consumers, + "migration T5 has not connected the generic kernel runtime to production code", + ) def test_documented_cli_verbs_are_registered_v2_surfaces(self) -> None: documents = [ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8397274..61353bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,12 @@ jobs: - name: Show toolchain run: go version working-directory: boatstack + - name: Test runtime with race detector + if: matrix.os == 'ubuntu-latest' + run: go test -race ./... + working-directory: boatstack - name: Test runtime + if: matrix.os == 'macos-latest' run: go test ./... working-directory: boatstack - name: Build helper @@ -70,6 +75,19 @@ jobs: shell: bash run: bash -n install.sh + race-critical: + name: race-critical + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: boatstack/go.mod + cache-dependency-path: boatstack/go.sum + - name: Test concurrency-bearing packages + working-directory: boatstack + run: go test -race ./kernel ./internal/softwaredelivery/effects + # Windows `go test` is dominated by per-process spawn latency (each test spawns # several `git` processes; the suite is ~330 tests run serially), so the full # suite takes ~15 min on Windows vs ~1 min on Unix. In-process t.Parallel() is diff --git a/release-notes/2026-08-12-kernel-architecture-guards.md b/release-notes/2026-08-12-kernel-architecture-guards.md new file mode 100644 index 0000000..4323db7 --- /dev/null +++ b/release-notes/2026-08-12-kernel-architecture-guards.md @@ -0,0 +1,3 @@ +### Guard the kernel architecture boundary + +Repository checks now prevent software-delivery dependencies and fixtures from entering the general kernel, exercise race detection on Linux, and keep the pending production-runtime migration visible. From 5b52fd3a6149d3cce791d2388ee518f33d9ad2bd Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 12 Aug 2026 14:08:18 +0100 Subject: [PATCH 2/4] test: parse comments in Go import guards --- .github/tests/test_repository_contract.py | 71 ++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index 46a96c9..d326ee9 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -27,9 +27,61 @@ ) +def strip_go_comments(source: str) -> str: + result: list[str] = [] + index = 0 + state = "code" + while index < len(source): + current = source[index] + following = source[index + 1] if index + 1 < len(source) else "" + if state == "code": + if current == "/" and following == "/": + result.extend(" ") + index += 2 + state = "line-comment" + continue + if current == "/" and following == "*": + result.extend(" ") + index += 2 + state = "block-comment" + continue + if current in ('"', "'", "`"): + state = {"\"": "string", "'": "rune", "`": "raw"}[current] + result.append(current) + index += 1 + continue + if state == "line-comment": + result.append("\n" if current == "\n" else " ") + index += 1 + if current == "\n": + state = "code" + continue + if state == "block-comment": + if current == "*" and following == "/": + result.extend(" ") + index += 2 + state = "code" + continue + result.append("\n" if current == "\n" else " ") + index += 1 + continue + result.append(current) + index += 1 + if state in ("string", "rune") and current == "\\" and index < len(source): + result.append(source[index]) + index += 1 + continue + if (state == "string" and current == '"') or ( + state == "rune" and current == "'" + ) or (state == "raw" and current == "`"): + state = "code" + return "".join(result) + + def go_imports(path: Path) -> list[tuple[str | None, str]]: imports: list[tuple[str | None, str]] = [] - for declaration in GO_IMPORT_DECLARATION.finditer(path.read_text()): + source = strip_go_comments(path.read_text()) + for declaration in GO_IMPORT_DECLARATION.finditer(source): body = declaration.group("block") or declaration.group("single") or "" for line in body.splitlines(): match = GO_IMPORT_SPEC.match(line) @@ -490,6 +542,23 @@ def test_general_kernel_is_domain_neutral_and_owns_shared_control_laws(self) -> component_ci, ) + def test_go_import_parser_accepts_commented_imports(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) / "consumer.go" + fixture.write_text( + 'package consumer\n\nimport /* migration T5 */ ' + '"github.com/operatorstack/boatstack/boatstack/kernel"\n' + ) + self.assertEqual( + [ + ( + None, + "github.com/operatorstack/boatstack/boatstack/kernel", + ) + ], + go_imports(fixture), + ) + @unittest.expectedFailure def test_kernel_runtime_has_no_production_consumer_yet(self) -> None: # Migration task T5 must replace this marker with a permanent positive From 40af45f9f843ce960dfcef53a103ae23227f910f Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 12 Aug 2026 14:19:44 +0100 Subject: [PATCH 3/4] test: parse kernel imports with Go AST --- .github/tests/go_source_metadata.go | 85 ++++++++++++ .github/tests/test_repository_contract.py | 155 +++++++--------------- .github/workflows/ci.yml | 2 +- 3 files changed, 137 insertions(+), 105 deletions(-) create mode 100644 .github/tests/go_source_metadata.go diff --git a/.github/tests/go_source_metadata.go b/.github/tests/go_source_metadata.go new file mode 100644 index 0000000..2bd0ab1 --- /dev/null +++ b/.github/tests/go_source_metadata.go @@ -0,0 +1,85 @@ +package main + +import ( + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path" + "sort" + "strconv" +) + +const kernelPackage = "github.com/operatorstack/boatstack/boatstack/kernel" + +var runtimeSymbols = map[string]bool{ + "NewRuntime": true, + "Runtime": true, + "Store": true, + "Domain": true, + "Operator": true, + "Receipt": true, +} + +type metadata struct { + Path string `json:"path"` + Imports []string `json:"imports"` + RuntimeConsumers []string `json:"runtime_consumers"` +} + +func main() { + results := make([]metadata, 0, len(os.Args)-1) + for _, filename := range os.Args[1:] { + if filename == "--" { + continue + } + parsed, err := parser.ParseFile(token.NewFileSet(), filename, nil, parser.AllErrors) + if err != nil { + fatalf("parse %s: %v", filename, err) + } + item := metadata{Path: filename, Imports: []string{}, RuntimeConsumers: []string{}} + kernelAliases := map[string]bool{} + for _, spec := range parsed.Imports { + importPath, err := strconv.Unquote(spec.Path.Value) + if err != nil { + fatalf("decode import in %s: %v", filename, err) + } + item.Imports = append(item.Imports, importPath) + if importPath != kernelPackage || (spec.Name != nil && spec.Name.Name == "_") { + continue + } + alias := path.Base(importPath) + if spec.Name != nil { + alias = spec.Name.Name + } + kernelAliases[alias] = true + } + ast.Inspect(parsed, func(node ast.Node) bool { + switch value := node.(type) { + case *ast.SelectorExpr: + alias, ok := value.X.(*ast.Ident) + if ok && kernelAliases[alias.Name] && runtimeSymbols[value.Sel.Name] { + item.RuntimeConsumers = append(item.RuntimeConsumers, value.Sel.Name) + } + case *ast.Ident: + if kernelAliases["."] && runtimeSymbols[value.Name] { + item.RuntimeConsumers = append(item.RuntimeConsumers, value.Name) + } + } + return true + }) + sort.Strings(item.Imports) + sort.Strings(item.RuntimeConsumers) + results = append(results, item) + } + if err := json.NewEncoder(os.Stdout).Encode(results); err != nil { + fatalf("encode metadata: %v", err) + } +} + +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index d326ee9..1da7be4 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -18,79 +18,22 @@ CONFIG = REPO / "project.example.json" -GO_IMPORT_DECLARATION = re.compile( - r"(?ms)^[ \t]*import[ \t]+(?:\((?P.*?)^[ \t]*\)|(?P[^\n]+))" -) -GO_IMPORT_SPEC = re.compile( - r"^[ \t]*(?:(?P[._A-Za-z][A-Za-z0-9_]*)[ \t]+)?" - r"(?P\"(?:\\.|[^\"\\])*\"|`[^`]*`)" -) - - -def strip_go_comments(source: str) -> str: - result: list[str] = [] - index = 0 - state = "code" - while index < len(source): - current = source[index] - following = source[index + 1] if index + 1 < len(source) else "" - if state == "code": - if current == "/" and following == "/": - result.extend(" ") - index += 2 - state = "line-comment" - continue - if current == "/" and following == "*": - result.extend(" ") - index += 2 - state = "block-comment" - continue - if current in ('"', "'", "`"): - state = {"\"": "string", "'": "rune", "`": "raw"}[current] - result.append(current) - index += 1 - continue - if state == "line-comment": - result.append("\n" if current == "\n" else " ") - index += 1 - if current == "\n": - state = "code" - continue - if state == "block-comment": - if current == "*" and following == "/": - result.extend(" ") - index += 2 - state = "code" - continue - result.append("\n" if current == "\n" else " ") - index += 1 - continue - result.append(current) - index += 1 - if state in ("string", "rune") and current == "\\" and index < len(source): - result.append(source[index]) - index += 1 - continue - if (state == "string" and current == '"') or ( - state == "rune" and current == "'" - ) or (state == "raw" and current == "`"): - state = "code" - return "".join(result) - - -def go_imports(path: Path) -> list[tuple[str | None, str]]: - imports: list[tuple[str | None, str]] = [] - source = strip_go_comments(path.read_text()) - for declaration in GO_IMPORT_DECLARATION.finditer(source): - body = declaration.group("block") or declaration.group("single") or "" - for line in body.splitlines(): - match = GO_IMPORT_SPEC.match(line) - if match is None: - continue - literal = match.group("path") - import_path = literal[1:-1] if literal.startswith("`") else json.loads(literal) - imports.append((match.group("alias"), import_path)) - return imports +def go_source_metadata(paths: list[Path]) -> list[dict[str, object]]: + result = subprocess.run( + [ + "go", + "run", + str(REPO / ".github" / "tests" / "go_source_metadata.go"), + "--", + *map(str, paths), + ], + cwd=REPO, + text=True, + capture_output=True, + ) + if result.returncode != 0: + raise RuntimeError(result.stdout + result.stderr) + return json.loads(result.stdout) class RepositoryContract(unittest.TestCase): @@ -472,10 +415,11 @@ def test_general_kernel_is_domain_neutral_and_owns_shared_control_laws(self) -> boatstack_packages = "github.com/operatorstack/boatstack/boatstack/" kernel_package = boatstack_packages + "kernel" - for path in kernel_files: + kernel_metadata = go_source_metadata(kernel_files) + for path, metadata in zip(kernel_files, kernel_metadata, strict=True): invalid = [ import_path - for _, import_path in go_imports(path) + for import_path in metadata["imports"] if import_path.startswith(boatstack_packages) and import_path != kernel_package ] @@ -490,11 +434,12 @@ def test_general_kernel_is_domain_neutral_and_owns_shared_control_laws(self) -> ) self.assertNotIn("testRepository", test_source) self.assertNotIn("softwaredelivery", test_source.lower()) - for path in kernel_tests: + test_metadata = go_source_metadata(kernel_tests) + for path, metadata in zip(kernel_tests, test_metadata, strict=True): self.assertFalse( any( "/softwaredelivery" in import_path - for _, import_path in go_imports(path) + for import_path in metadata["imports"] ), f"kernel test fixture imports software delivery: {path}", ) @@ -542,46 +487,48 @@ def test_general_kernel_is_domain_neutral_and_owns_shared_control_laws(self) -> component_ci, ) - def test_go_import_parser_accepts_commented_imports(self) -> None: + def test_go_import_parser_accepts_legal_import_forms(self) -> None: with tempfile.TemporaryDirectory() as directory: - fixture = Path(directory) / "consumer.go" - fixture.write_text( + commented = Path(directory) / "commented.go" + commented.write_text( 'package consumer\n\nimport /* migration T5 */ ' '"github.com/operatorstack/boatstack/boatstack/kernel"\n' ) + line_broken = Path(directory) / "line_broken.go" + line_broken.write_text( + 'package consumer\n\nimport\n b "\\u0067ithub.com/operatorstack/' + 'boatstack/boatstack/internal/buildinfo"\n' + ) self.assertEqual( [ - ( - None, - "github.com/operatorstack/boatstack/boatstack/kernel", - ) + "github.com/operatorstack/boatstack/boatstack/kernel", + "github.com/operatorstack/boatstack/boatstack/internal/buildinfo", + ], + [ + metadata["imports"][0] + for metadata in go_source_metadata([commented, line_broken]) ], - go_imports(fixture), ) @unittest.expectedFailure def test_kernel_runtime_has_no_production_consumer_yet(self) -> None: # Migration task T5 must replace this marker with a permanent positive # production-reachability assertion when the generic runtime is adopted. - kernel_package = "github.com/operatorstack/boatstack/boatstack/kernel" - runtime_symbols = ( - "NewRuntime", "Runtime", "Store", "Domain", "Operator", "Receipt", - ) - consumers: list[str] = [] - for path in sorted((REPO / "boatstack").rglob("*.go")): - if path.name.endswith("_test.go") or path.parent == REPO / "boatstack" / "kernel": - continue - source = path.read_text() - for alias, import_path in go_imports(path): - if import_path != kernel_package or alias == "_": - continue - package_name = "kernel" if alias in (None, ".") else alias - if alias == ".": - pattern = rf"\b(?:{'|'.join(runtime_symbols)})\b" - else: - pattern = rf"\b{re.escape(package_name)}\.(?:{'|'.join(runtime_symbols)})\b" - if re.search(pattern, source): - consumers.append(str(path.relative_to(REPO))) + production_files = [ + path + for path in sorted((REPO / "boatstack").rglob("*.go")) + if not path.name.endswith("_test.go") + and path.parent != REPO / "boatstack" / "kernel" + ] + consumers = [ + str(path.relative_to(REPO)) + for path, metadata in zip( + production_files, + go_source_metadata(production_files), + strict=True, + ) + if metadata["runtime_consumers"] + ] self.assertTrue( consumers, "migration T5 has not connected the generic kernel runtime to production code", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61353bd..cd136c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,5 @@ # Boatstack-owned control plane. -name: Verify Boatstack distribution +name: CI on: pull_request: From 3638b7bdd12d6e237b715b97c312fd8d5a77b2f0 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Wed, 12 Aug 2026 14:23:28 +0100 Subject: [PATCH 4/4] ci: keep release trigger aligned with CI --- .github/tests/test_repository_contract.py | 6 +++++- .github/workflows/auto-release.yml | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index 1da7be4..f479add 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -192,6 +192,7 @@ def test_codex_review_is_secret_scoped_read_only_and_structured(self) -> None: self.assertFalse((REPO / "UPSTREAM.json").exists()) def test_release_builds_six_checksum_bound_v2_runtimes(self) -> None: + ci = (REPO / ".github" / "workflows" / "ci.yml").read_text() release = (REPO / ".github" / "workflows" / "release.yml").read_text() automatic = (REPO / ".github" / "workflows" / "auto-release.yml").read_text() for asset in ( @@ -207,7 +208,10 @@ def test_release_builds_six_checksum_bound_v2_runtimes(self) -> None: self.assertIn(symbol, release) self.assertIn('source_commit="$(git rev-parse HEAD)"', release) self.assertIn("sha256sum", release) - self.assertIn('workflows: ["Verify Boatstack distribution"]', automatic) + ci_name = re.search(r"(?m)^name:\s*(.+?)\s*$", ci) + self.assertIsNotNone(ci_name) + self.assertEqual(ci_name.group(1), "CI") + self.assertIn(f'workflows: ["{ci_name.group(1)}"]', automatic) def test_manual_release_is_prerelease_only_and_exact_source_bound(self) -> None: # control-law: branch-prerelease-publishes-only-an-exact-new-rc-source diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 5326de4..5d79420 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -3,7 +3,7 @@ name: Publish verified Boatstack release on: workflow_run: - workflows: ["Verify Boatstack distribution"] + workflows: ["CI"] types: [completed] permissions: