From 389649679fc878caf3f29a456d0c09e255d48010 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:06:28 +0000 Subject: [PATCH 1/4] docs(specs): 028 re-measure on master and raise OD-11 (T002, T003) Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- specs/028-split-big-packages/OPEN-DECISIONS.md | 6 ++++-- specs/028-split-big-packages/research.md | 11 +++++++++++ specs/028-split-big-packages/tasks.md | 4 ++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/specs/028-split-big-packages/OPEN-DECISIONS.md b/specs/028-split-big-packages/OPEN-DECISIONS.md index ef6dee6a9..c00c1040a 100644 --- a/specs/028-split-big-packages/OPEN-DECISIONS.md +++ b/specs/028-split-big-packages/OPEN-DECISIONS.md @@ -1,6 +1,6 @@ # Open Decisions: 028-split-big-packages -All decisions for this spec are settled; none is open. A new question gets its own OD entry here and is not treated as decided until a maintainer rules on it. +One decision is open (OD-11). A new question gets its own OD entry here and is not treated as decided until a maintainer rules on it. ## Settled elsewhere (binding, not reopened here) @@ -19,4 +19,6 @@ All decisions for this spec are settled; none is open. A new question gets its o - **OD-9 (FR-005): relative CRD paths in a copied envtest setup.** Allowed: a copied `suite_envtest_test.go` may add exactly one `".."` per extra folder level to its CRD directory paths (today `operator/internal/controller/suite_envtest_test.go:45,48`, `api/internal/handlers/suite_envtest_test.go:55`); the test-edit check allows only that and the PR lists it. Chosen on a card (2026-10-21). - **OD-10 (constitution I): new e2e test.** None. Nothing user-facing changes; every move PR runs the full existing e2e suite with `ci: full-run` plus the test-name parity check. Recorded as a constitution I exemption for a behaviour-preserving refactor. Chosen on a card (2026-10-21). -No open decisions remain. +## Open + +- **OD-11 (FR-018, contracts/layout-check.md rule 1): dev scripts under `web/scripts/`.** `web/scripts/i18n-migrate.ts` (1,195 lines, spec 019 codemod run with `npm run i18n:migrate`) is a tracked `.ts` file over 500 lines that no FR names and no exemption covers. Options: (a) exempt `web/scripts/` from the ceiling as dev tooling that never ships (recommended: it is not dashboard code and splitting a single-run codemod buys nothing); (b) split it like any other file. Asked 2026-10-22. diff --git a/specs/028-split-big-packages/research.md b/specs/028-split-big-packages/research.md index 2df782d74..a90dacb53 100644 --- a/specs/028-split-big-packages/research.md +++ b/specs/028-split-big-packages/research.md @@ -3,6 +3,17 @@ Measured on master `8a67f2e` (2026-10-20) unless stated. Decisions reference [OPEN-DECISIONS.md](./OPEN-DECISIONS.md). +## Re-measure (T003, master `54d5819`, 2026-10-22) + +- Production files over 500 lines (FR-018 exemptions applied): 30 Go, 18 web. The 30 Go + files are exactly the ones FR-018, R3, R4 and R7 name (the earlier "34" over-counted). + The 18 web files are the 17 in R6 plus `web/scripts/i18n-migrate.ts` (1,195), a spec 019 + dev codemod outside `web/src/` that no FR names; see OD-11. +- `api/internal/handlers`: 146 `.go` files, 48 production. `operator/internal/controller`: + 150 `.go` files, 41 production. +- Spec 027's `docs/layout.md` is not on master yet (027 implementation not started), so + every brief cites `specs/027-contributor-docs-layout-map/OPEN-DECISIONS.md` (T002). + ## R1. What a "move" may change (FR-002, FR-005, OD-6) - **Decision**: a PR may contain only these change kinds. Production files: `git mv`; diff --git a/specs/028-split-big-packages/tasks.md b/specs/028-split-big-packages/tasks.md index e0ab684fe..d08db166b 100644 --- a/specs/028-split-big-packages/tasks.md +++ b/specs/028-split-big-packages/tasks.md @@ -15,8 +15,8 @@ description: "Task list for 028 split big packages and files" ## Phase 1: Setup - [X] T001 Settle OD-1..OD-10 in `OPEN-DECISIONS.md` and fold them into spec, plan and these tasks (valgul, 2026-10-20/21) -- [ ] T002 Confirm spec 027's `docs/layout.md` has merged to master (027 implementation PR); if not, follow `specs/027-contributor-docs-layout-map/OPEN-DECISIONS.md` and cite it in every brief -- [ ] T003 Re-measure on current master: production files over 500 lines (Go, web), files per package for `api/internal/handlers` and `operator/internal/controller`, and record the SHA and numbers at the top of `research.md` +- [X] T002 Confirm spec 027's `docs/layout.md` has merged to master (027 implementation PR); if not, follow `specs/027-contributor-docs-layout-map/OPEN-DECISIONS.md` and cite it in every brief *Done note: not merged on `54d5819` (2026-10-22); briefs cite 027 OPEN-DECISIONS.md.* +- [X] T003 Re-measure on current master: production files over 500 lines (Go, web), files per package for `api/internal/handlers` and `operator/internal/controller`, and record the SHA and numbers at the top of `research.md` *Done note: research.md "Re-measure"; raised OD-11.* ## Phase 2: Foundational (blocks every move PR) From 876b8cd4b009884ca8c65ff46ba4b5f5b73f97df Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:10:35 +0000 Subject: [PATCH 2/4] docs(specs): settle 028 OD-11, split web/scripts/i18n-migrate.ts (T061) Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- specs/028-split-big-packages/OPEN-DECISIONS.md | 8 ++++---- specs/028-split-big-packages/research.md | 2 +- specs/028-split-big-packages/spec.md | 2 +- specs/028-split-big-packages/tasks.md | 1 + 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/specs/028-split-big-packages/OPEN-DECISIONS.md b/specs/028-split-big-packages/OPEN-DECISIONS.md index c00c1040a..3ca50dd5e 100644 --- a/specs/028-split-big-packages/OPEN-DECISIONS.md +++ b/specs/028-split-big-packages/OPEN-DECISIONS.md @@ -1,12 +1,12 @@ # Open Decisions: 028-split-big-packages -One decision is open (OD-11). A new question gets its own OD entry here and is not treated as decided until a maintainer rules on it. +All decisions for this spec are settled; none is open. A new question gets its own OD entry here and is not treated as decided until a maintainer rules on it. ## Settled elsewhere (binding, not reopened here) - Spec 027 OD-3 (Go layout), OD-4 (web `pages/` layout, 500-line screens), OD-5 (this spec adds the folder-shape check to `make lint`), OD-2 (`specs.md` stays per module). valgul, 2026-10-20. -## Settled (valgul, 2026-10-20 and 2026-10-21) +## Settled (valgul, 2026-10-20 to 2026-10-22) - **OD-1 (FR-013): split order.** Web first, then api, then operator; then the small services; tunnel and sentinel wait for their audit fixes (#721–#723, #729) to merge. Chosen on a card. - **OD-2 (US2, US3, FR-002): sub-package boundaries.** By resource: `api/internal/handlers/` and `operator/internal/controller/` each become sub-packages per resource group, with helpers shared by several groups in one internal helper package per module, one group per PR. (Premise corrected before asking: a by-layer split does not exist in today's code and would need logic rewrites, which this spec forbids.) Chosen on a card. @@ -19,6 +19,6 @@ One decision is open (OD-11). A new question gets its own OD entry here and is n - **OD-9 (FR-005): relative CRD paths in a copied envtest setup.** Allowed: a copied `suite_envtest_test.go` may add exactly one `".."` per extra folder level to its CRD directory paths (today `operator/internal/controller/suite_envtest_test.go:45,48`, `api/internal/handlers/suite_envtest_test.go:55`); the test-edit check allows only that and the PR lists it. Chosen on a card (2026-10-21). - **OD-10 (constitution I): new e2e test.** None. Nothing user-facing changes; every move PR runs the full existing e2e suite with `ci: full-run` plus the test-name parity check. Recorded as a constitution I exemption for a behaviour-preserving refactor. Chosen on a card (2026-10-21). -## Open +- **OD-11 (FR-018, contracts/layout-check.md rule 1): dev scripts under `web/scripts/`.** No exemption: `web/scripts/i18n-migrate.ts` (1,195 lines, spec 019 codemod) is split like any other file, in place, into files of 500 lines or fewer (T061). Chosen on a card (2026-10-22). -- **OD-11 (FR-018, contracts/layout-check.md rule 1): dev scripts under `web/scripts/`.** `web/scripts/i18n-migrate.ts` (1,195 lines, spec 019 codemod run with `npm run i18n:migrate`) is a tracked `.ts` file over 500 lines that no FR names and no exemption covers. Options: (a) exempt `web/scripts/` from the ceiling as dev tooling that never ships (recommended: it is not dashboard code and splitting a single-run codemod buys nothing); (b) split it like any other file. Asked 2026-10-22. +No open decisions remain. diff --git a/specs/028-split-big-packages/research.md b/specs/028-split-big-packages/research.md index a90dacb53..b7faa3853 100644 --- a/specs/028-split-big-packages/research.md +++ b/specs/028-split-big-packages/research.md @@ -8,7 +8,7 @@ Measured on master `8a67f2e` (2026-10-20) unless stated. Decisions reference - Production files over 500 lines (FR-018 exemptions applied): 30 Go, 18 web. The 30 Go files are exactly the ones FR-018, R3, R4 and R7 name (the earlier "34" over-counted). The 18 web files are the 17 in R6 plus `web/scripts/i18n-migrate.ts` (1,195), a spec 019 - dev codemod outside `web/src/` that no FR names; see OD-11. + dev codemod outside `web/src/` that no FR names; OD-11 (2026-10-22): split it too (T061). - `api/internal/handlers`: 146 `.go` files, 48 production. `operator/internal/controller`: 150 `.go` files, 41 production. - Spec 027's `docs/layout.md` is not on master yet (027 implementation not started), so diff --git a/specs/028-split-big-packages/spec.md b/specs/028-split-big-packages/spec.md index 357dd06c5..b54faa3a1 100644 --- a/specs/028-split-big-packages/spec.md +++ b/specs/028-split-big-packages/spec.md @@ -201,7 +201,7 @@ with one violation (run by the check's own unit test in CI, not by a throwaway P **Ceiling and enforcement** -- **FR-018**: Every production source file (Go and web) MUST end at 500 lines or fewer (OD-3). Exempt: generated files (`zz_generated.*`, `web/src/api/types.ts`, `operator/config/`), CRD `operator/api/v1alpha1/*_types.go`, and the e2e harness under `test/e2e/` (out of scope, owned by spec 029). This covers files outside the packages named above too: today `api/internal/audit/audit.go` 1,050, `capture-sidecar/internal/httpserver/handlers.go` 975, `agent/internal/mods/mods.go` 932, `api/cmd/main.go` 914, `i18n/cmd/i18n-check/main.go` 781, `api/internal/auth/oidc.go` 711, `agent/internal/rcon/battleye.go` 703, `agent/internal/files/rooted_linux.go` 603, `operator/cmd/main.go` 519, `agent/internal/players/players.go` 509, and web `lib/endpoints.ts` 1,046, `components/modules/BuildModuleDialog.tsx` 973, `components/CaptureWidget.tsx` 826, `components/server/ServerActionsCard.tsx` 658, `components/modules/SourceDialog.tsx` 573. +- **FR-018**: Every production source file (Go and web) MUST end at 500 lines or fewer (OD-3). Exempt: generated files (`zz_generated.*`, `web/src/api/types.ts`, `operator/config/`), CRD `operator/api/v1alpha1/*_types.go`, and the e2e harness under `test/e2e/` (out of scope, owned by spec 029). This covers files outside the packages named above too: today `api/internal/audit/audit.go` 1,050, `capture-sidecar/internal/httpserver/handlers.go` 975, `agent/internal/mods/mods.go` 932, `api/cmd/main.go` 914, `i18n/cmd/i18n-check/main.go` 781, `api/internal/auth/oidc.go` 711, `agent/internal/rcon/battleye.go` 703, `agent/internal/files/rooted_linux.go` 603, `operator/cmd/main.go` 519, `agent/internal/players/players.go` 509, and web `lib/endpoints.ts` 1,046, `components/modules/BuildModuleDialog.tsx` 973, `components/CaptureWidget.tsx` 826, `components/server/ServerActionsCard.tsx` 658, `components/modules/SourceDialog.tsx` 573, and the dev codemod `web/scripts/i18n-migrate.ts` 1,195 (OD-11). - **FR-019**: After the last move, `make lint` MUST run a layout check that enforces the folder shape (027 OD-5) and the 500-line ceiling for production files; test files are exempt (OD-4). **Web types** diff --git a/specs/028-split-big-packages/tasks.md b/specs/028-split-big-packages/tasks.md index d08db166b..4dcef7a48 100644 --- a/specs/028-split-big-packages/tasks.md +++ b/specs/028-split-big-packages/tasks.md @@ -41,6 +41,7 @@ description: "Task list for 028 split big packages and files" - [ ] T017 [US4] PR: `components/modules/BuildModuleDialog.tsx` (973) and `components/modules/SourceDialog.tsx` (573) split in place (same folder) - [ ] T018 [US4] PR: `components/CaptureWidget.tsx` (826) split in place - [ ] T019 [US4] PR: `components/server/ServerActionsCard.tsx` (658) split in place +- [ ] T061 [US4] PR: `web/scripts/i18n-migrate.ts` (1,195) split in place into files of ≤ 500 lines under `web/scripts/` (OD-11); `npm run i18n:migrate` entry point unchanged - [ ] T020 [US4] For each web PR: sonnet browser smoke test of the moved screens, visual-diff and screenshot jobs green, `web/specs.md` paths updated **Checkpoint**: no screen code in `web/src/routes/`; no web production file over 500 lines. From ac2ff3aee30c63c06e0c7aebbfcf932b8751a6c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:18:11 +0000 Subject: [PATCH 3/4] ci: add move parity checks (spec 028 T004-T007) hack/move_parity.py proves a move PR keeps every test name (names) and changes test files only in the OD-6/OD-9 mechanical ways (test-edits). The move-parity job runs it on any PR that adds a brief under specs/028-split-big-packages/briefs/; the unit tests run in make test-ci-scope. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- .github/workflows/ci.yaml | 24 + Makefile | 1 + hack/move_parity.py | 423 ++++++++++++++++++ hack/test_move_parity.py | 276 ++++++++++++ specs/028-split-big-packages/briefs/README.md | 14 + specs/028-split-big-packages/tasks.md | 8 +- 6 files changed, 742 insertions(+), 4 deletions(-) create mode 100644 hack/move_parity.py create mode 100644 hack/test_move_parity.py create mode 100644 specs/028-split-big-packages/briefs/README.md diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 26d9ee778..d0a503e8c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -284,6 +284,8 @@ jobs: - 'hack/test_ci_scope.py' - 'hack/check_changed_coverage.py' - 'hack/test_check_changed_coverage.py' + - 'hack/move_parity.py' + - 'hack/test_move_parity.py' - 'hack/check-ci-report-coverage.sh' - 'hack/test-check-ci-report-coverage.sh' - 'hack/testdata/check-ci-report-coverage/**' @@ -713,6 +715,28 @@ jobs: - name: submodule pointers are on upstream main run: ./hack/check-submodule-freshness.sh + # Spec 028: a PR that adds a move brief under specs/028-split-big-packages/briefs/ must + # keep every test name and change test files only in the OD-6/OD-9 mechanical ways. + # PR-only and needs no scope: it skips itself when the change carries no brief. + move-parity: + name: move parity (spec 028) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + submodules: false + persist-credentials: false + - name: test names and test edits + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.sha }} + run: python3 hack/move_parity.py ci --base "$BASE_SHA" --head "$HEAD_SHA" + # Spec 013 G4: block a PR that adds a dependency with a known high or # critical advisory. PR-only; on push it is skipped, which the report # already counts as neutral. No failure-proof fixture by ruling (OD-4). diff --git a/Makefile b/Makefile index 364339f79..476249ada 100644 --- a/Makefile +++ b/Makefile @@ -494,3 +494,4 @@ clean: ## Remove build artifacts test-ci-scope: ## Verify dependency-aware CI and image selection (CI only) python3 -m unittest discover -s hack -p 'test_ci_scope.py' -v python3 -m unittest discover -s hack -p 'test_check_changed_coverage.py' -v + python3 -m unittest discover -s hack -p 'test_move_parity.py' -v diff --git a/hack/move_parity.py b/hack/move_parity.py new file mode 100644 index 000000000..06286dec3 --- /dev/null +++ b/hack/move_parity.py @@ -0,0 +1,423 @@ +"""Move parity gate for spec 028 split-big-packages PRs (tasks T004 and T006). + +`names` proves that every Go Test/Fuzz/Example/Benchmark function and every Vitest or +Playwright title chain survives a move. `test-edits` proves that each changed test file +differs from its base only by the mechanical edits OD-6 and OD-9 allow (see +specs/028-split-big-packages/OPEN-DECISIONS.md), as listed in the PR's move brief. +`ci` runs both for every move brief that the change adds or edits. Python stdlib only. +""" + +import argparse +import difflib +import posixpath +import re +import subprocess +import sys +from collections import Counter +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +BRIEF_DIR = "specs/028-split-big-packages/briefs" +KINDS = ["package", "imports", "qualified-names", "mock-paths", "envtest-paths", "new-testmain"] +GO_FUNC = re.compile(r"^func ((?:Test|Fuzz|Example|Benchmark)\w*)\(", re.MULTILINE) +WEB_CALL = re.compile( + r"""^(\s*)(?:test\.)?(describe|it|test)(?:\.\w+)*(?:\([^()\n]*\))?\s*\(\s*(['"`])((?:\\.|(?!\3).)*)\3""") +GO_PACKAGE = re.compile(r"^package\s+(\w+)$") +GO_IMPORT_BLOCK = re.compile(r"^import\s*\($") +GO_IMPORT_LINE = re.compile(r'^import\s+(?:[\w.]+\s+)?"[^"]*"$') +TS_IMPORT_FROM = re.compile(r"""from\s+(['"]).*\1\s*;?\s*$""") +TS_IMPORT_BARE = re.compile(r"""import\s+(['"]).*\1\s*;?\s*$""") +MOCK_CALL = re.compile( + r"""((?:\bvi\.(?:mock|doMock|importActual)|\bimport)\(\s*)(['"`])((?:\\.|(?!\2).)*)\2""") +JOIN_DOTDOT = re.compile(r'"\.\.",\s*') + + +def git(root, *args): + """Run git in root and return its stdout.""" + return subprocess.run(["git", *args], cwd=root, check=True, capture_output=True, + text=True, encoding="utf-8", errors="replace").stdout + + +def read_blobs(root, rev, paths): + """Return {path: text} for files at rev, read with one git cat-file --batch call.""" + if not paths: + return {} + request = "".join(f"{rev}:{path}\n" for path in paths).encode("utf-8") + data = subprocess.run(["git", "cat-file", "--batch"], cwd=root, check=True, + input=request, capture_output=True).stdout + texts, pos = {}, 0 + for path in paths: + end = data.index(b"\n", pos) + fields = data[pos:end].decode("utf-8", errors="replace").split() + pos = end + 1 + if len(fields) < 3 or fields[1] == "missing": + raise ValueError(f"{rev}:{path} is not a file at that revision") + size = int(fields[2]) + texts[path] = data[pos:pos + size].decode("utf-8", errors="replace") + pos += size + 1 + return texts + + +def is_test(path): + """Return True for Go test files and the Vitest and Playwright specs this gate covers.""" + return (path.endswith("_test.go") + or (path.startswith("web/") and path.endswith((".test.ts", ".test.tsx"))) + or (path.startswith("web/e2e/") and path.endswith(".spec.ts"))) + + +def web_keys(text): + """Return the "web:" title chains of the describe/it/test calls in one spec file.""" + keys, stack = [], [] + for line in text.splitlines(): + match = WEB_CALL.match(line) + if not match: + continue + indent, kind, title = len(match[1]), match[2], match[4] + while stack and stack[-1][0] >= indent: + stack.pop() + keys.append("web:" + " > ".join([name for _, name in stack] + [title])) + if kind == "describe": + stack.append((indent, title)) + return keys + + +def test_keys(path, text): + """Return the comparable keys for one test file; the package path is dropped on purpose.""" + if path.endswith(".go"): + module = "test/e2e" if path.startswith("test/e2e/") else path.split("/", 1)[0] + return [f"{module}:{name}" for name in GO_FUNC.findall(text) if name != "TestMain"] + return web_keys(text) + + +def collect_keys(root, rev): + """Return a Counter of comparable test keys at one revision.""" + listing = git(root, "ls-tree", "-r", "-z", "--name-only", rev).split("\0") + paths = [path for path in listing if path and is_test(path)] + texts = read_blobs(root, rev, paths) + keys = Counter() + for path in paths: + keys.update(test_keys(path, texts[path])) + return keys + + +def cmd_names(root, base, head): + """Compare test keys between two revisions and return the exit code.""" + before, after = collect_keys(root, base), collect_keys(root, head) + missing, extra = before - after, after - before + if not missing and not extra: + print(f"equal, {sum(before.values())} tests") + return 0 + for key, count in sorted(missing.items()): + print(f"missing: {key} (x{count})") + for key, count in sorted(extra.items()): + print(f"extra: {key} (x{count})") + return 1 + + +def brief_tables(text): + """Return {section number: data rows} for the markdown tables of a move brief.""" + tables, section, in_table = {}, None, False + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("## "): + match = re.match(r"##\s+(\d+)\b", stripped) + section, in_table = (int(match[1]) if match else None), False + elif not stripped.startswith("|"): + in_table = False + elif not in_table: + in_table = True + else: + cells = [cell.strip().replace("`", "").strip() + for cell in stripped.strip("|").split("|")] + if section is not None and not all(re.fullmatch(r":?-+:?", c) for c in cells): + tables.setdefault(section, []).append(cells) + return tables + + +def parse_brief(text): + """Return (moves, exports, copies) from sections 3, 4 and 5 of a move brief.""" + tables = brief_tables(text) + moves = {row[0]: row[1] for row in tables.get(3, []) if len(row) >= 2} + exports = [(row[0], row[1], row[2]) for row in tables.get(4, []) if len(row) >= 3] + copies = {} + for row in tables.get(5, []): + match = re.search(r"copy of\s+(\S+)", row[1]) if len(row) >= 2 else None + if match: + copies[row[0]] = match[1] + return moves, exports, copies + + +def pair_files(root, base, head, moves, copies): + """Return (pairs, failures) for the test files changed between base and head. + + pairs holds (base path, head path, is_copy); failures maps a path to its reason. + """ + diff = git(root, "diff", "--name-status", "-M50%", base, head) + pairs, failures, added, deleted = [], {}, [], [] + for line in diff.splitlines(): + fields = line.split("\t") + status = fields[0][:1] + if status == "R": + old, new = fields[1], fields[2] + if is_test(old) and is_test(new): + pairs.append((old, new, False)) + elif is_test(old): + failures[old] = "test file deleted" + elif is_test(new): + failures[new] = "test file added" + elif status == "M" and is_test(fields[1]): + pairs.append((fields[1], fields[1], False)) + elif status == "A" and is_test(fields[1]): + added.append(fields[1]) + elif status == "D" and is_test(fields[1]): + deleted.append(fields[1]) + for path in deleted: + destination = moves.get(path) + if destination in added: + added.remove(destination) + pairs.append((path, destination, False)) + else: + failures[path] = "test file deleted" + for path in added: + if path in copies: + pairs.append((copies[path], path, True)) + else: + failures[path] = "test file added" + return pairs, failures + + +def go_parts(text): + """Split a Go file into (package name, import lines, remaining lines).""" + package, imports, body, block = "", [], [], False + for line in text.splitlines(): + stripped = line.strip() + if block: + imports.append(stripped) + block = stripped != ")" + elif GO_IMPORT_BLOCK.match(stripped): + imports.append(stripped) + block = True + elif GO_IMPORT_LINE.match(stripped): + imports.append(stripped) + elif not package and GO_PACKAGE.match(stripped): + package = GO_PACKAGE.match(stripped)[1] + else: + body.append(line) + return package, imports, body + + +def ts_parts(text): + """Split a TypeScript file into (import statement lines, remaining lines).""" + imports, body, open_import = [], [], False + for line in text.splitlines(): + stripped = line.strip() + if open_import: + imports.append(stripped) + open_import = not TS_IMPORT_FROM.search(stripped) + elif stripped.startswith("import "): + imports.append(stripped) + open_import = not (TS_IMPORT_FROM.search(stripped) or TS_IMPORT_BARE.match(stripped)) + else: + body.append(line) + return imports, body + + +def mock_args(text): + """Return the path literals passed to vi.mock, vi.doMock, vi.importActual and import().""" + return [match[3] for match in MOCK_CALL.finditer(text)] + + +def mock_normal(text): + """Replace every mock path literal with "".""" + return MOCK_CALL.sub(lambda match: match[1] + '""', text) + + +def map_identifiers(text, exports): + """Rewrite head-side names that moved packages back to their base spelling.""" + for old, new, package in exports: + text = re.sub(rf"\b{re.escape(package)}\.{re.escape(new)}\b", new, text) + text = re.sub(rf"\b{re.escape(new)}\b", lambda _, old=old: old, text) + news = {new for _, new, _ in exports} + for package in {package for _, _, package in exports}: + text = re.sub(rf"\b{re.escape(package)}\.(\w+)\b", + lambda match: match[1] if match[1] in news else match[0], text) + return text + + +def envtest_paths(base_lines, head_lines, extra): + """Apply the OD-9 allowance to filepath.Join lines; return (base, head, changed, reason).""" + if len(base_lines) != len(head_lines): + return base_lines, head_lines, False, "" + base_out, head_out, changed = [], [], False + for base_line, head_line in zip(base_lines, head_lines): + if "filepath.Join(" in base_line or "filepath.Join(" in head_line: + base_dots, head_dots = base_line.count('".."'), head_line.count('".."') + if base_dots or head_dots: + if head_dots - base_dots != extra: + reason = (f'filepath.Join: head adds {head_dots - base_dots} ".." for ' + f'{extra} extra folder level(s): {head_line.strip()}') + return [], [], False, reason + changed = changed or head_dots != base_dots + base_line = JOIN_DOTDOT.sub("", base_line) + head_line = JOIN_DOTDOT.sub("", head_line) + base_out.append(base_line) + head_out.append(head_line) + return base_out, head_out, changed, "" + + +def normal(lines): + """Strip each line, collapse whitespace runs and drop empty lines.""" + result = [re.sub(r"\s+", " ", line).strip() for line in lines] + return [line for line in result if line] + + +def compare(base_lines, head_lines): + """Return (reason, first five differing lines) after the shared normalisation.""" + base_norm, head_norm = normal(base_lines), normal(head_lines) + if base_norm == head_norm: + return "", [] + diff = [line for line in difflib.unified_diff(base_norm, head_norm, n=0, lineterm="") + if line.startswith(("+", "-")) and not line.startswith(("+++", "---"))] + return "content differs", diff[:5] + + +def check_go(base_text, head_text, exports, extra): + """Compare one Go test file pair; return (kinds, reason, diff lines).""" + base_pkg, base_imports, base_body = go_parts(base_text) + head_pkg, head_imports, head_body = go_parts(head_text) + kinds = set() + if base_pkg != head_pkg: + kinds.add("package") + if base_imports != head_imports: + kinds.add("imports") + head_joined = "\n".join(head_body) + mapped = map_identifiers(head_joined, exports) + if mapped != head_joined: + kinds.add("qualified-names") + base_lines, head_lines, changed, reason = envtest_paths(base_body, mapped.split("\n"), extra) + if reason: + return kinds, reason, [] + if changed: + kinds.add("envtest-paths") + reason, diff = compare(base_lines, head_lines) + return kinds, reason, diff + + +def check_ts(base_text, head_text, exports): + """Compare one TypeScript test file pair; return (kinds, reason, diff lines).""" + base_imports, base_body = ts_parts(base_text) + head_imports, head_body = ts_parts(head_text) + kinds = set() + if base_imports != head_imports: + kinds.add("imports") + if mock_args(base_text) != mock_args(head_text): + kinds.add("mock-paths") + head_joined = "\n".join(head_body) + mapped = map_identifiers(head_joined, exports) + if mapped != head_joined: + kinds.add("qualified-names") + reason, diff = compare(mock_normal("\n".join(base_body)).split("\n"), + mock_normal(mapped).split("\n")) + return kinds, reason, diff + + +def check_pair(root, base, head, old, new, is_copy, exports): + """Return (kinds, reason, diff lines) for one changed test file.""" + base_text = read_blobs(root, base, [old])[old] + head_text = read_blobs(root, head, [new])[new] + if new.endswith(".go"): + extra = posixpath.dirname(new).count("/") - posixpath.dirname(old).count("/") + kinds, reason, diff = check_go(base_text, head_text, exports, extra) + else: + kinds, reason, diff = check_ts(base_text, head_text, exports) + if is_copy: + kinds.add("new-testmain") + return kinds, reason, diff + + +def ordered(kinds): + """Return the kinds in their canonical order.""" + return [kind for kind in KINDS if kind in kinds] + + +def cmd_test_edits(root, base, head, brief): + """Prove each changed test file differs only by OD-6 and OD-9 edits; return the exit code.""" + moves, exports, copies = parse_brief(Path(brief).read_text(encoding="utf-8")) + pairs, failures = pair_files(root, base, head, moves, copies) + results = {} + for old, new, is_copy in pairs: + results[new] = check_pair(root, base, head, old, new, is_copy, exports) + for path, reason in failures.items(): + results[path] = (set(), reason, []) + failed, seen = 0, set() + for path in sorted(results): + kinds, reason, diff = results[path] + if reason: + failed += 1 + print(f"{path}: FAIL: {reason}") + for line in diff: + print(f" {line}") + else: + seen |= kinds + print(f"{path}: pass ({', '.join(ordered(kinds)) or 'no changes'})") + if failed: + print(f"fail, {failed} of {len(results)} test files") + return 1 + print(f"pass, {len(results)} test files touched, kinds: " + f"{', '.join(ordered(seen)) or 'none'}") + return 0 + + +def changed_briefs(root, base, head): + """Return the move briefs added or modified between base and head.""" + names = git(root, "diff", "--name-only", "--diff-filter=AM", base, head).splitlines() + return [name for name in names + if posixpath.dirname(name) == BRIEF_DIR and name.endswith(".md") + and posixpath.basename(name) != "README.md"] + + +def cmd_ci(root, base, head): + """Run names and test-edits for every move brief in the change; return the exit code.""" + briefs = changed_briefs(root, base, head) + if not briefs: + print("no spec 028 move brief in this change; skipped") + return 0 + code = cmd_names(root, base, head) + for brief in briefs: + print(f"== {brief}") + if cmd_test_edits(root, base, head, root / brief): + code = 1 + return code + + +def main(argv=None): + """Parse arguments, run the requested check and return the exit code.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=str(ROOT)) + commands = parser.add_subparsers(dest="command", required=True) + names = commands.add_parser("names", help="compare test names between two revisions") + names.add_argument("base") + names.add_argument("head") + edits = commands.add_parser("test-edits", help="check test file edits against a brief") + edits.add_argument("base") + edits.add_argument("head") + edits.add_argument("--brief", required=True) + ci = commands.add_parser("ci", help="run both checks for every move brief in the change") + ci.add_argument("--base", required=True) + ci.add_argument("--head", required=True) + args = parser.parse_args(argv) + root = Path(args.root) + try: + if args.command == "names": + return cmd_names(root, args.base, args.head) + if args.command == "test-edits": + return cmd_test_edits(root, args.base, args.head, args.brief) + return cmd_ci(root, args.base, args.head) + except (OSError, ValueError, KeyError, IndexError, subprocess.CalledProcessError) as exc: + print(f"::error::move parity: {exc}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hack/test_move_parity.py b/hack/test_move_parity.py new file mode 100644 index 000000000..097454386 --- /dev/null +++ b/hack/test_move_parity.py @@ -0,0 +1,276 @@ +"""Behavioral coverage for the spec 028 move parity gate (run in CI, tasks T005 and T006).""" + +import contextlib +import io +import subprocess +import tempfile +import unittest +from pathlib import Path + +from move_parity import main, web_keys + + +GIT = ["git", "-c", "user.name=test", "-c", "user.email=test@example.com", + "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", + # background gc could still be writing .git when the temp dir is removed + "-c", "gc.auto=0", "-c", "maintenance.auto=false"] +GO_TEST = """package x + +import "testing" + +func TestA(t *testing.T) { +\twant := 1 +\tif got := 1; got != want { +\t\tt.Fatal("bad") +\t} +} +""" +WEB_SPEC = """import { render } from "@testing-library/react"; +import { X } from "../routes/X"; + +vi.mock("../routes/X", () => ({ X: () => null })); + +describe("suite", () => { + it("one", () => { + expect(1).toBe(1); + }); + it("two", () => { + expect(2).toBe(2); + }); +}); +""" +SUITE = """package controller + +import ( +\t"path/filepath" +\t"testing" +) + +func TestMain(m *testing.M) { +\tcrd := filepath.Join("..", "..", "config", "crd", "bases") +\t_ = crd +} +""" + + +def brief_text(moves=(), exports=(), copies=()): + """Return a move brief with the Moves, Exports and New files tables.""" + def table(rows, headers): + """Return markdown lines for one table.""" + lines = ["| " + " | ".join(headers) + " |", "|" + "---|" * len(headers)] + return lines + ["| " + " | ".join(row) + " |" for row in rows] + return "\n".join([ + "# Brief", "", "## 3. Moves", "", + *table(moves, ["source path", "destination path", "whole file or declarations"]), "", + "## 4. Exports", "", + *table(exports, ["old identifier", "new identifier", "package"]), "", + "## 5. New files", "", + *table(copies, ["path", "copy of"]), "", + ]) + + +class MoveParityTests(unittest.TestCase): + """Run the move parity commands against temporary git repositories.""" + + def setUp(self): + """Create an empty git repository and a directory for briefs.""" + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.briefs = tempfile.TemporaryDirectory() + self.addCleanup(self.briefs.cleanup) + self.root = Path(self.temp.name) + self.git("init", "-q") + self.base = self.head = "" + + def git(self, *args): + """Run git in the fixture repository and return its trimmed output.""" + return subprocess.run([*GIT, *args], cwd=self.root, check=True, capture_output=True, + text=True).stdout.strip() + + def write(self, name, content): + """Write a fixture file in the repository.""" + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + def commit(self): + """Commit every fixture change and return the commit id.""" + self.git("add", "-A") + self.git("commit", "-q", "--allow-empty", "-m", "change") + return self.git("rev-parse", "HEAD") + + def brief(self, text): + """Write a brief outside the repository and return its path.""" + path = Path(self.briefs.name) / "brief.md" + path.write_text(text, encoding="utf-8") + return str(path) + + def run_main(self, *args): + """Run the gate and return (exit code, stdout).""" + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = main(["--root", str(self.root), *args]) + return code, out.getvalue() + + def names(self): + """Run names between the recorded base and head.""" + return self.run_main("names", self.base, self.head) + + def edits(self, brief): + """Run test-edits between the recorded base and head with a brief text.""" + return self.run_main("test-edits", self.base, self.head, "--brief", self.brief(brief)) + + def test_names_equal_lists_pass(self): + """Unchanged test names pass even when test bodies change.""" + self.write("api/x/a_test.go", GO_TEST) + self.write("web/src/s.test.tsx", WEB_SPEC) + self.base = self.commit() + self.write("api/x/a_test.go", GO_TEST.replace("want := 1", "want := 1 // note")) + self.head = self.commit() + code, out = self.names() + self.assertEqual(code, 0, out) + self.assertIn("equal, 4 tests", out) + + def test_removed_go_test_fails(self): + """A removed Go test function is reported as missing.""" + self.write("api/x/a_test.go", GO_TEST + "\nfunc TestB(t *testing.T) {}\n") + self.base = self.commit() + self.write("api/x/a_test.go", GO_TEST) + self.head = self.commit() + code, out = self.names() + self.assertEqual(code, 1) + self.assertIn("missing: api:TestB (x1)", out) + + def test_removed_it_fails(self): + """A removed it() inside a describe block is reported with its title chain.""" + self.write("web/src/s.test.tsx", WEB_SPEC) + self.base = self.commit() + self.write("web/src/s.test.tsx", WEB_SPEC.replace( + ' it("two", () => {\n expect(2).toBe(2);\n });\n', "")) + self.head = self.commit() + code, out = self.names() + self.assertEqual(code, 1) + self.assertIn("missing: web:suite > two (x1)", out) + + def test_go_test_moved_to_new_package_passes(self): + """A test that moves to another package directory of the same module keeps its key.""" + self.write("api/x/a_test.go", GO_TEST) + self.base = self.commit() + self.write("api/x/sub/a_test.go", GO_TEST.replace("package x", "package sub")) + (self.root / "api/x/a_test.go").unlink() + self.head = self.commit() + code, out = self.names() + self.assertEqual(code, 0, out) + self.assertIn("equal, 1 tests", out) + + def test_nested_describe_chain(self): + """Nested describe blocks produce the full title chain.""" + text = ('describe("A", () => {\n describe("b", () => {\n it("c", () => {});\n' + ' });\n});\n') + self.assertEqual(web_keys(text), ["web:A", "web:A > b", "web:A > b > c"]) + + def test_renamed_go_file_with_package_change_passes(self): + """A renamed Go test file whose only change is the package clause passes.""" + self.write("api/x/a_test.go", GO_TEST) + self.base = self.commit() + self.git("mv", "api/x/a_test.go", "api/x/b_test.go") + self.write("api/x/b_test.go", GO_TEST.replace("package x", "package y")) + self.head = self.commit() + code, out = self.edits(brief_text()) + self.assertEqual(code, 0, out) + self.assertIn("api/x/b_test.go: pass (package)", out) + + def test_assertion_change_fails(self): + """A changed assertion in a moved test file fails the edit check.""" + self.write("api/x/a_test.go", GO_TEST) + self.base = self.commit() + self.git("mv", "api/x/a_test.go", "api/x/b_test.go") + self.write("api/x/b_test.go", GO_TEST.replace("want := 1", "want := 2")) + self.head = self.commit() + code, out = self.edits(brief_text()) + self.assertEqual(code, 1) + self.assertIn("FAIL", out) + self.assertIn("fail, 1 of 1 test files", out) + + def test_copied_envtest_suite_one_level_deeper(self): + """A listed copy with exactly one extra ".." per filepath.Join line passes.""" + self.write("operator/internal/controller/suite_envtest_test.go", SUITE) + self.base = self.commit() + self.write("operator/internal/controller/sub/suite_envtest_test.go", SUITE.replace( + "package controller", "package sub").replace( + '"..", "..", "config"', '"..", "..", "..", "config"')) + self.head = self.commit() + brief = brief_text(copies=[("`operator/internal/controller/sub/suite_envtest_test.go`", + "copy of `operator/internal/controller/suite_envtest_test.go`")]) + code, out = self.edits(brief) + self.assertEqual(code, 0, out) + self.assertIn("new-testmain", out) + self.assertIn("envtest-paths", out) + + def test_copied_envtest_suite_two_extra_levels_fail(self): + """Two extra ".." tokens on one filepath.Join line fail the OD-9 allowance.""" + self.write("operator/internal/controller/suite_envtest_test.go", SUITE) + self.base = self.commit() + self.write("operator/internal/controller/sub/suite_envtest_test.go", SUITE.replace( + "package controller", "package sub").replace( + '"..", "..", "config"', '"..", "..", "..", "..", "config"')) + self.head = self.commit() + brief = brief_text(copies=[("`operator/internal/controller/sub/suite_envtest_test.go`", + "copy of `operator/internal/controller/suite_envtest_test.go`")]) + code, out = self.edits(brief) + self.assertEqual(code, 1) + self.assertIn("FAIL", out) + + def test_mock_path_and_import_changes_pass(self): + """A changed vi.mock path and import line pass with their kinds reported.""" + self.write("web/src/routes/s.test.tsx", WEB_SPEC) + self.base = self.commit() + self.write("web/src/routes/s.test.tsx", WEB_SPEC.replace( + 'vi.mock("../routes/X"', 'vi.mock("../pages/X"').replace( + 'import { X } from "../routes/X";', 'import { X } from "../pages/X";')) + self.head = self.commit() + code, out = self.edits(brief_text()) + self.assertEqual(code, 0, out) + self.assertIn("mock-paths", out) + self.assertIn("imports", out) + + def test_qualified_name_passes_only_with_exports_row(self): + """foo(x) to httpx.Foo(x) passes with the Exports row and fails without it.""" + base_go = ('package x\n\nimport "testing"\n\nfunc TestUse(t *testing.T) {\n' + '\tif foo(1) != 1 {\n\t\tt.Fatal("bad")\n\t}\n}\n') + head_go = ('package x\n\nimport (\n\t"testing"\n\n\t"example.com/api/httpx"\n)\n\n' + 'func TestUse(t *testing.T) {\n\tif httpx.Foo(1) != 1 {\n' + '\t\tt.Fatal("bad")\n\t}\n}\n') + self.write("api/x/a_test.go", base_go) + self.base = self.commit() + self.write("api/x/a_test.go", head_go) + self.head = self.commit() + code, out = self.edits(brief_text(exports=[("`foo`", "`Foo`", "`httpx`")])) + self.assertEqual(code, 0, out) + self.assertIn("qualified-names", out) + code, out = self.edits(brief_text()) + self.assertEqual(code, 1) + + def test_added_test_file_not_in_brief_fails(self): + """A new test file without a New files row fails.""" + self.write("api/x/a_test.go", GO_TEST) + self.base = self.commit() + self.write("api/x/extra_test.go", GO_TEST.replace("TestA", "TestExtra")) + self.head = self.commit() + code, out = self.edits(brief_text()) + self.assertEqual(code, 1) + self.assertIn("test file added", out) + + def test_ci_without_brief_is_skipped(self): + """ci exits 0 and prints the skip line when the change has no move brief.""" + self.write("api/x/a.go", "package x\n") + self.base = self.commit() + self.write("api/x/a.go", "package x\n// note\n") + self.head = self.commit() + code, out = self.run_main("ci", "--base", self.base, "--head", self.head) + self.assertEqual(code, 0) + self.assertIn("no spec 028 move brief in this change; skipped", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/specs/028-split-big-packages/briefs/README.md b/specs/028-split-big-packages/briefs/README.md new file mode 100644 index 000000000..3211cd2ba --- /dev/null +++ b/specs/028-split-big-packages/briefs/README.md @@ -0,0 +1,14 @@ +# Move briefs (spec 028) + +One brief per move PR, named `-.md`. Each follows the section list in +[`../contracts/move-brief.md`](../contracts/move-brief.md). + +The `hack/move_parity.py` script parses three of the sections, so keep these table shapes: + +- **3. Moves**: `| source path | destination path | whole file or declarations |` +- **4. Exports**: `| old identifier | new identifier | package |`, one row per name that crosses a new package line +- **5. New files**: `| path | copy of `source` |`, for helper files and `suite_envtest_test.go` copies + +Backticks are stripped before parsing. Header and `---` rows are skipped. + +The `move parity (spec 028)` CI job runs `python3 hack/move_parity.py ci` on any PR that adds or edits a brief here. It checks that every test name survives and that each changed test file differs only by the OD-6 and OD-9 edits. It skips itself when the PR carries no brief. diff --git a/specs/028-split-big-packages/tasks.md b/specs/028-split-big-packages/tasks.md index 4dcef7a48..abd7400bf 100644 --- a/specs/028-split-big-packages/tasks.md +++ b/specs/028-split-big-packages/tasks.md @@ -20,10 +20,10 @@ description: "Task list for 028 split big packages and files" ## Phase 2: Foundational (blocks every move PR) -- [ ] T004 Write `hack/move_parity.py` (stdlib) with `names ` (static scan of Go `Test*/Fuzz*/Example*` funcs and Vitest/Playwright title chains) and `test-edits --brief ` (R2 point 3: allowed kinds from OD-6 and OD-9 only) -- [ ] T005 Write `hack/test_move_parity.py` unit tests: equal and unequal name lists; a renamed test file with only a package-line change passes; an assertion change fails; an extra `".."` in a copied envtest CRD path passes only when OD-9 allows it; a changed `vi.mock` path passes -- [ ] T006 Wire `hack/test_move_parity.py` into the target that `make test-ci-scope` runs in CI (`.github/workflows/ci.yaml:1521`), and add a CI step that runs `move_parity.py names` and `move_parity.py test-edits` on every PR carrying a brief under `specs/028-split-big-packages/briefs/`, passing the PR base SHA, head SHA and that brief; coordinating with open spec 023 PRs that edit the same job; PR "ci: add move parity checks (spec 028)", labels `type: ci`, `area: shared` -- [ ] T007 Create `specs/028-split-big-packages/briefs/README.md` pointing to the brief contract +- [X] T004 Write `hack/move_parity.py` (stdlib) with `names ` (static scan of Go `Test*/Fuzz*/Example*` funcs and Vitest/Playwright title chains) and `test-edits --brief ` (R2 point 3: allowed kinds from OD-6 and OD-9 only) +- [X] T005 Write `hack/test_move_parity.py` unit tests: equal and unequal name lists; a renamed test file with only a package-line change passes; an assertion change fails; an extra `".."` in a copied envtest CRD path passes only when OD-9 allows it; a changed `vi.mock` path passes +- [X] T006 Wire `hack/test_move_parity.py` into the target that `make test-ci-scope` runs in CI (`.github/workflows/ci.yaml:1521`), and add a CI step that runs `move_parity.py names` and `move_parity.py test-edits` on every PR carrying a brief under `specs/028-split-big-packages/briefs/`, passing the PR base SHA, head SHA and that brief; coordinating with open spec 023 PRs that edit the same job; PR "ci: add move parity checks (spec 028)", labels `type: ci`, `area: shared` +- [X] T007 Create `specs/028-split-big-packages/briefs/README.md` pointing to the brief contract **Checkpoint**: parity tooling merged; move PRs can start. From f2e025c39668d629e4d85ecb221247f206340401 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:47:53 +0000 Subject: [PATCH 4/4] docs(specs): 028 briefs cite docs/layout.md now that 027 merged (T002) Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Claude --- specs/028-split-big-packages/research.md | 3 +-- specs/028-split-big-packages/tasks.md | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/specs/028-split-big-packages/research.md b/specs/028-split-big-packages/research.md index b7faa3853..b93aa6f27 100644 --- a/specs/028-split-big-packages/research.md +++ b/specs/028-split-big-packages/research.md @@ -11,8 +11,7 @@ Measured on master `8a67f2e` (2026-10-20) unless stated. Decisions reference dev codemod outside `web/src/` that no FR names; OD-11 (2026-10-22): split it too (T061). - `api/internal/handlers`: 146 `.go` files, 48 production. `operator/internal/controller`: 150 `.go` files, 41 production. -- Spec 027's `docs/layout.md` is not on master yet (027 implementation not started), so - every brief cites `specs/027-contributor-docs-layout-map/OPEN-DECISIONS.md` (T002). +- Spec 027's `docs/layout.md` merged via PR #795 (2026-10-22); every brief cites it (T002). ## R1. What a "move" may change (FR-002, FR-005, OD-6) diff --git a/specs/028-split-big-packages/tasks.md b/specs/028-split-big-packages/tasks.md index abd7400bf..afa72b1c8 100644 --- a/specs/028-split-big-packages/tasks.md +++ b/specs/028-split-big-packages/tasks.md @@ -15,7 +15,7 @@ description: "Task list for 028 split big packages and files" ## Phase 1: Setup - [X] T001 Settle OD-1..OD-10 in `OPEN-DECISIONS.md` and fold them into spec, plan and these tasks (valgul, 2026-10-20/21) -- [X] T002 Confirm spec 027's `docs/layout.md` has merged to master (027 implementation PR); if not, follow `specs/027-contributor-docs-layout-map/OPEN-DECISIONS.md` and cite it in every brief *Done note: not merged on `54d5819` (2026-10-22); briefs cite 027 OPEN-DECISIONS.md.* +- [X] T002 Confirm spec 027's `docs/layout.md` has merged to master (027 implementation PR); if not, follow `specs/027-contributor-docs-layout-map/OPEN-DECISIONS.md` and cite it in every brief *Done note: merged via PR #795 (2026-10-22); briefs cite `docs/layout.md`.* - [X] T003 Re-measure on current master: production files over 500 lines (Go, web), files per package for `api/internal/handlers` and `operator/internal/controller`, and record the SHA and numbers at the top of `research.md` *Done note: research.md "Re-measure"; raised OD-11.* ## Phase 2: Foundational (blocks every move PR)