diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44762475..467398b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -430,3 +430,45 @@ jobs: # make CI green. - name: Audit (fail on high/critical) run: pnpm audit --prod --audit-level high + + workflow-lint: + name: Workflow files are valid + runs-on: ubuntu-latest + timeout-minutes: 5 + # A workflow file with an invalid expression does not degrade gracefully: + # GitHub cannot parse it, so it never resolves `name:` or any job, and it + # records a bare failed run against whatever event fired. `perf-k6.yml` + # sat in exactly that state for weeks — `env.PERF_K6_FULL_RUN` in a + # job-level `if:`, where the `env` context is not available — failing on + # every single push. Nothing was watching, because nothing lints the + # workflows themselves. This job is that watcher. + steps: + - name: Checkout code + uses: actions/checkout@v7 + + # Pinned by version AND checksum rather than pulling a third-party + # action: one fewer `uses:` SHA to keep current, and the binary is + # verified before it runs. + - name: Install actionlint + env: + ACTIONLINT_VERSION: 1.7.10 + ACTIONLINT_SHA256: f4c76b71db5755a713e6055cbb0857ed07e103e028bda117817660ebadb4386f + run: | + set -euo pipefail + curl -fsSL -o actionlint.tar.gz \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + echo "${ACTIONLINT_SHA256} actionlint.tar.gz" | sha256sum --check --strict + tar xzf actionlint.tar.gz actionlint + ./actionlint --version + + # actionlint runs shellcheck over `run:` blocks only when the binary is + # on PATH, and it *skips silently* when it is not — which is how a local + # run can report clean while this job finds five issues. ubuntu-latest + # ships shellcheck, so assert it rather than trust it: if a future runner + # image drops it, the gate would quietly stop checking the `run:` blocks + # that drive Postgres, Redis and the deploy steps. + - name: Assert shellcheck is available + run: shellcheck --version + + - name: Lint workflow files + run: ./actionlint diff --git a/.github/workflows/deploy-cms.yml b/.github/workflows/deploy-cms.yml index 2ae61915..350f8fe9 100644 --- a/.github/workflows/deploy-cms.yml +++ b/.github/workflows/deploy-cms.yml @@ -104,7 +104,7 @@ jobs: run: pnpm --filter @lumibase/cms test - name: Build CMS - run: pnpm --filter @lumibase/cms run build:${TARGET_ENV} + run: pnpm --filter @lumibase/cms run "build:${TARGET_ENV}" - name: Check Cloudflare deploy credentials id: cloudflare-credentials @@ -120,7 +120,7 @@ jobs: - name: Deploy CMS if: steps.cloudflare-credentials.outputs.can_deploy == 'true' - run: pnpm --filter @lumibase/cms run deploy:${TARGET_ENV} + run: pnpm --filter @lumibase/cms run "deploy:${TARGET_ENV}" - name: Verify deployment health if: steps.cloudflare-credentials.outputs.can_deploy == 'true' diff --git a/.github/workflows/perf-k6.yml b/.github/workflows/perf-k6.yml index b998f19f..b26d55c7 100644 --- a/.github/workflows/perf-k6.yml +++ b/.github/workflows/perf-k6.yml @@ -17,8 +17,6 @@ concurrency: env: NODE_VERSION: 24 - # Set to "true" in repo/org variables to run the full compose + k6 job nightly. - PERF_K6_FULL_RUN: ${{ vars.PERF_K6_FULL_RUN || 'false' }} jobs: validate-scripts: @@ -57,10 +55,17 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 45 needs: validate-scripts + # `vars`, not `env`: the `env` context is not available in a job-level `if` + # (only `github`, `inputs`, `needs`, `vars` are). Referencing it there does + # not evaluate to empty — it makes the whole workflow file invalid, which is + # why this file failed instantly on every event for weeks. Set the repo/org + # variable PERF_K6_FULL_RUN to "true" to let the nightly schedule run the + # full compose + k6 job; unset means the scheduled run stops after + # validate-scripts. if: >- github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.label.name == 'perf-k6') || - (github.event_name == 'schedule' && env.PERF_K6_FULL_RUN == 'true') + (github.event_name == 'schedule' && vars.PERF_K6_FULL_RUN == 'true') steps: - uses: actions/checkout@v7 @@ -77,7 +82,7 @@ jobs: - name: Wait for Postgres run: | - for i in $(seq 1 30); do + for _ in $(seq 1 30); do docker compose -f docker/docker-compose.yml exec -T postgres pg_isready -U lumibase && exit 0 sleep 2 done @@ -109,7 +114,7 @@ jobs: PORT: '1989' run: | pnpm -F @lumibase/cms exec tsx src/serve.ts & - for i in $(seq 1 60); do + for _ in $(seq 1 60); do curl -sf http://127.0.0.1:1989/health && exit 0 sleep 2 done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4f0fc200..10520d94 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,9 +101,11 @@ jobs: range="$tag" echo "Changes included in ${tag}:" > release-notes.md fi - echo >> release-notes.md - git log --pretty=format:'- %s (%h)' "$range" >> release-notes.md - echo >> release-notes.md + { + echo + git log --pretty=format:'- %s (%h)' "$range" + echo + } >> release-notes.md fi - name: Create GitHub Release diff --git a/.kiro/steering/out-of-scope-backlog.md b/.kiro/steering/out-of-scope-backlog.md index 7b77cfaa..b37487f1 100644 --- a/.kiro/steering/out-of-scope-backlog.md +++ b/.kiro/steering/out-of-scope-backlog.md @@ -52,5 +52,6 @@ | B29 | 2026-08-24 · landing/docs GA consent | task (gap · blocked) | `apps/marketplace` (submodule private `lumibase-ai/marketplace`) | Yêu cầu ban đầu là bật GA4-sau-consent cho **cả ba** site public: landing, docs, marketplace. Hai site đầu đã xong trong PR này; marketplace **không làm được từ repo này** — submodule chưa checkout (`git submodule status` cho `-5958c50e…`, dir chỉ còn `.next`/`out`/`node_modules` cũ, không có `.git` lẫn `src`) và remote không đọc được bằng cả hai account đang login (`git ls-remote` qua SSH và HTTPS đều trả `Repository not found`; `gh repo view lumibase-ai/marketplace` → `Could not resolve to a Repository`). Hệ quả: `marketplace.lumibase.dev` (hoặc domain tương ứng) vẫn **không có** banner consent lẫn control opt-out, trong khi privacy policy ở landing giờ đã mô tả cơ chế consent như đặc tính chung của các site public → policy nói rộng hơn thực tế đúng một site | medium | `open` | Sửa trong repo `lumibase-ai/marketplace`: nó là Next app (build ra `apps/marketplace/out`) nên copy nguyên mẫu của `apps/landing` — `` + `` dùng `@lumibase/analytics-consent`. **Lưu ý ràng buộc:** marketplace bị loại khỏi pnpm workspace (`pnpm-workspace.yaml: "!apps/marketplace"`) và install bằng `--ignore-workspace`, nên KHÔNG dùng được `workspace:*` — hoặc publish package này lên registry, hoặc copy logic kèm chú thích trỏ về `packages/analytics-consent` làm nguồn. Cần thêm `NEXT_PUBLIC_GA_ID` vào step build marketplace ở `release.yml` + `pages-deploy.yml` (hiện chỉ truyền cho step build của landing/docs) | | B30 | 2026-08-30 · rescue blog SSRF khỏi worktree cũ | bug (flake) | `apps/docs/src/components/__tests__/analytics-consent.test.tsx` (`beforeEach` dòng 34) | Cả 9 test của file đỏ với `TypeError: Cannot read properties of undefined (reading 'clear')` tại `localStorage.clear()` — kèm warning `localStorage is not available because --localstorage-file was not provided`, tức global `localStorage` bị resolve về builtin experimental của **Node 24** (undefined) thay vì `localStorage` của jsdom. Đo được **1/2 lần** chạy `pnpm test` (turbo, 12 package song song) trên `main` sạch tại `b124d953`; ngay sau đó: file chạy riêng **pass** (9/9), full suite `apps/docs` chạy riêng **pass** (24 file · 157 test), và `pnpm test` toàn repo chạy lại **pass** (12/12 task). Không tái lập theo yêu cầu ⇒ flake nhạy tải, không phải regression. Cùng class với B13 nhưng nguyên nhân khác: đây là **global bị thiếu**, không phải timeout của `findBy*`. File do PR #426 (GA consent) thêm, nên chưa từng chạy qua nhiều chu kỳ CI | medium | `open` | Hệ quả: pre-commit hook (`husky` → `check:all` + `pnpm test`) và CI job `test` đỏ ngẫu nhiên, chặn commit ở máy local dù diff không liên quan (gặp khi commit 3 file `blog/`). Fix: không dựa vào global `localStorage` của môi trường — stub tường minh trong `beforeEach` (hoặc `vi.stubGlobal('localStorage', …)`), hoặc set `environmentOptions`/`pool` cho `apps/docs` sao cho jsdom luôn thắng builtin của Node 24. Nếu đã chuẩn hoá Node 24 toàn team thì rà cả các test khác đọc `localStorage`/`sessionStorage` trực tiếp — cùng một class | | B33 | 2026-08-30 · rà batch dependabot (#411–#421) | bug (guard chết) | `scripts/version-check.mjs` | Script **không được gọi từ đâu cả** — không có trong `scripts` của `package.json` (`pnpm version:check` chạy `sync-version.mjs --check`, một file khác), không trong workflow nào, không trong `.husky/pre-commit` — và nếu chạy thì nó **fail**: nó assert `engines.node === '>=20'` trong khi root khai `>=22.13.0` (nay là `^22.22.2 \|\| ^24.15.0 \|\| >=26.0.0`). Nó cũng assert `packageManager` và `lockfileVersion`. Hệ quả: hàng rào cơ giới duy nhất canh `engines.node` không hề chạy — phát hiện đúng lúc jsdom 30 nâng sàn Node và không gate nào nói gì | medium | `open` | Chọn một trong hai, đừng để lửng: (a) nối vào `check:all` + đổi expectation `engines.node` thành hằng đọc từ chính `package.json` hay một sàn khai tường minh, hoặc (b) xoá file và thừa nhận `sync-version.mjs` là guard duy nhất. Nếu chọn (a) thì đây là chỗ đúng để cơ giới hoá DoD §2e: so `engines.node` của repo với `engines.node` của mọi toolchain đã cài, thay vì rà tay | -| B31 | 2026-08-30 · rà batch dependabot (#411–#421) | bug (CI) | `.github/workflows/perf-k6.yml` | Workflow đỏ ở **mọi** commit push lên `main` — kiểm 30 run gần nhất (2026-08-29 → 08-30), không có một `success` nào, kể cả trên `b124d953` (trước batch này). Không phải do batch. Vì nó không phải required check nên không chặn merge, nhưng một workflow đỏ vĩnh viễn làm mờ tín hiệu: người ta học cách bỏ qua màu đỏ, đúng lúc một cái đỏ thật xuất hiện. Liên quan `v1-release-criteria.md` §7 (k6 baseline "nên có, không chặn tag") và các task k6 đang hoãn của `high-load-cache-readiness` | low | `open` | Đọc log để biết nó thiếu môi trường tải hay thật sự vỡ. Rồi hoặc sửa, hoặc chuyển sang `workflow_dispatch`/schedule để nó không đỏ trên mọi push — đừng để nguyên trạng | +| B31 | 2026-08-30 · rà batch dependabot (#411–#421) | bug (CI) | `.github/workflows/perf-k6.yml` | Workflow đỏ ở **mọi** commit push lên `main` — kiểm 30 run gần nhất (2026-08-29 → 08-30), không có một `success` nào, kể cả trên `b124d953` (trước batch này). Không phải do batch. Vì nó không phải required check nên không chặn merge, nhưng một workflow đỏ vĩnh viễn làm mờ tín hiệu: người ta học cách bỏ qua màu đỏ, đúng lúc một cái đỏ thật xuất hiện. Liên quan `v1-release-criteria.md` §7 (k6 baseline "nên có, không chặn tag") và các task k6 đang hoãn của `high-load-cache-readiness` | low | `fixed` | **Nguyên nhân không phải thiếu môi trường tải mà là file workflow không parse được.** `on:` của nó vốn KHÔNG khai `push` (chỉ `workflow_dispatch` / `schedule` / `pull_request: [labeled]`), nhưng job `perf-gate` dùng `env.PERF_K6_FULL_RUN` trong `if:` **cấp job**, nơi context `env` không tồn tại (chỉ có `github`, `inputs`, `needs`, `vars`). Biểu thức sai không "đánh giá thành rỗng" — nó làm **cả file invalid**: GitHub không parse được nên không resolve nổi `name:` (API trả `name` = đúng đường dẫn file, dấu hiệu nhận biết), không tạo job nào, và ghi một run failed cho **bất kỳ** event nào, kể cả event mà `on:` không khai. Fix = `vars.PERF_K6_FULL_RUN`; sau đó workflow tôn trọng `on:` và không còn chạy trên push. Verify bằng `actionlint` (0 finding trên toàn bộ `.github/workflows`, gồm cả shellcheck). Chống tái diễn cả class bằng job CI mới `workflow-lint` chạy `actionlint` (pin version + sha256) — vì lý do thật sự khiến nó sống được nhiều tuần là **không có gì lint chính các workflow** | | B32 | 2026-08-30 · rà batch dependabot (#411–#421) | bug (flake · class) | `.husky/pre-commit` ↔ `pnpm test` (turbo concurrency mặc định) | Nối tiếp B13: nâng `testTimeout` lên 15s **không** đóng được class, chỉ nâng trần. Đo tại commit này: `pnpm test` ở concurrency mặc định của turbo cho 6–8 fail rải khắp `apps/studio` (`field-inspector`, `fields-tab`, `materialize-page`, `marketplace-publish`, `security-audit-tab`, `setup-state-gate`, `mission-control`, `backup-code-page`) và `apps/cms` (`flow-service`, `backup-codes-persister`, `recovery/service`, `audit/routes`) — **tất cả** đều là `Test timed out in 15000ms`, tức là chạm đúng cái trần B13 vừa nâng. Cùng bộ đó với `TURBO_CONCURRENCY=1` thì **12/12 task xanh, 0 fail**; chạy từng package riêng cũng xanh. Nguyên nhân là tranh tài nguyên, không phải test sai. Nguy ở chỗ `.husky/pre-commit` gọi đúng `pnpm test`: commit hợp lệ bị chặn ngẫu nhiên, và cách chữa tự nhiên nhất mà người ta sẽ chọn là `--no-verify` — tắt luôn hàng rào. Đã gặp thật trong lần commit này | medium | `open` | Đừng nâng `testTimeout` lần nữa (đó là cách B13 thất bại). Giới hạn song song thay vì giới hạn thời gian: đặt `concurrency` cho task `test` trong `turbo.json` hoặc `TURBO_CONCURRENCY` trong `.husky/pre-commit`, và/hoặc hạ `poolOptions.maxThreads` cho hai suite jsdom nặng nhất. Cân nhắc cho pre-commit chạy tập bị ảnh hưởng thay vì toàn bộ suite, để hàng rào không đắt tới mức bị vòng qua | +| B34 | 2026-08-30 · rebase #441 lên main | bug (class) | `.kiro/steering/out-of-scope-backlog.md` ↔ `scripts/check-registry-numbering.mjs` | ID của **chính bảng này** không được kiểm trùng. #434 và #436 cùng cấp `B30` cho hai finding không liên quan (flake `localStorage` của `analytics-consent.test.tsx` vs guard chết `version-check.mjs`); PR merge sau phải renumber tay thành `B33`, và va chạm chỉ lộ ra dưới dạng conflict lúc rebase — không gate nào nói gì. Đúng cùng class mà `registry:check` đã cơ giới hoá cho cột `#` của Setup Impact Registry (từng có hai dòng #20/#31/#32), chỉ khác là bảng này bị bỏ sót. Nguy hơn vẻ ngoài vì ID backlog được **trích dẫn theo số** ở dòng khác ("Nối tiếp B13", "cùng class với B10", "Xem B24") và trong CHANGELOG, nên một lần renumber im lặng làm gãy tham chiếu mà không test nào phủ | medium | `fixed` | Mở rộng `scripts/check-registry-numbering.mjs` để kiểm **cả hai** bảng (`#` của setup-impact và `B` của file này), báo lỗi kèm số kế tiếp an toàn và nhắc giữ lại occurrence đang được dòng khác trích dẫn. Đã verify hai chiều: chèn `B30` trùng → exit 1 đúng dòng; bỏ ra → exit 0. Chạy sẵn trong `pnpm check:all` (và do đó cả pre-commit + CI) nên không cần thêm entry mới | diff --git a/CHANGELOG.md b/CHANGELOG.md index d70c70c8..a0f4f6d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,12 @@ Source: [github.com/khuepm/lumibase](https://github.com/khuepm/lumibase) · Webs while `apps/docs` declared `shiki@^1.22.0` — so `MarkdownRenderer` was handing a 1.x `Highlighter` to a 4.x rehype plugin. The docs test suite mocks shiki, so nothing caught it; the pair is now on one major. +- **`@types/react-dom` override raised `19.2.4` → `19.2.5`.** The pin is exact, + so it wins over whatever the manifests declare — which is why the pending + minor-and-patch group bump (manifests to `^19.2.5`) failed `drift:check`: the + two ranges no longer intersected. Raising the override first clears that + without the group PR having to touch it, and `19.2.5` still satisfies today's + `^19.2.4` declarations, so this is a no-op for anyone not on the group bump. - **`engines.node` raised to `^22.22.2 || ^24.15.0 || >=26.0.0`** (was `>=22.13.0`), the floor jsdom 30 requires. The previous range admitted Node 22.13–22.22.1, 23.x and 24.0–24.14, all of which jsdom 30 rejects; nanoid 6 @@ -109,6 +115,37 @@ Source: [github.com/khuepm/lumibase](https://github.com/khuepm/lumibase) · Webs ### Fixed +- **`perf-k6.yml` was an invalid workflow file, failing on every push for + weeks.** Its `on:` never declared `push` at all — the failures were not the + load-test job running and breaking. The `perf-gate` job's `if:` referenced + `env.PERF_K6_FULL_RUN`, and the `env` context does not exist in a job-level + `if:` (only `github`, `inputs`, `needs`, `vars`). That does not evaluate to + empty; it makes the whole file unparseable, so GitHub never resolved `name:` + or any job and recorded a bare failed run against every event, `on:` filters + included. The giveaway was the API reporting the run's `name` as the file path + with an empty job list. Switched to `vars.PERF_K6_FULL_RUN`; the workflow now + honours `on:` and no longer runs on push. Set the repo variable + `PERF_K6_FULL_RUN=true` to let the nightly schedule run the full compose + k6 + job. Closes backlog `B31`. +- **`registry:check` now also guards the out-of-scope backlog's `ID` column,** + not just the Setup Impact Registry's `#` column. Two PRs claimed `B30` for + unrelated findings and the collision only surfaced as a rebase conflict, which + is the same failure the `#` guard already existed to prevent — the backlog + table had simply been left out. It matters more than it looks: backlog ids are + cited by id from other rows ("Nối tiếp B13") and from CHANGELOG entries, so a + silent renumber breaks references nothing tests. Closes `B34`. +- **CI now lints the workflow files themselves** (`workflow-lint` job running + `actionlint`, pinned by version and SHA-256 rather than adding another + third-party action to keep current). The reason a broken workflow could stay + broken for weeks is that nothing checked this class at all: an unparseable + workflow looks, from the outside, exactly like a workflow that ran and failed. + `actionlint` reports zero findings across `.github/workflows`, shellcheck + included over the `run:` blocks that drive Postgres, Redis and the deploy + steps — which required fixing five pre-existing findings it surfaced on first + run: two unquoted `${TARGET_ENV}` expansions in `deploy-cms.yml` (SC2086), two + unused loop variables in `perf-k6.yml` (SC2034), and a run of individual + redirects in `release.yml` (SC2129). The job also asserts shellcheck is on + PATH, because actionlint skips it *silently* when it is absent. - **The TOTP endpoints are actually reachable.** All six of them answered `404 NOT_FOUND` against a running server. `index.ts` attached the sub-routers *after* mounting their parents (`api.route('/auth', authRouter)` then diff --git a/package.json b/package.json index f16784b1..3efb6792 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "pnpm": { "overrides": { "@types/react": "19.2.18", - "@types/react-dom": "19.2.4", + "@types/react-dom": "19.2.5", "brace-expansion@1": "^1.1.16", "brace-expansion@5": "^5.0.8", "dompurify": "^3.4.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e17842d..8062bbee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: overrides: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4 + '@types/react-dom': 19.2.5 brace-expansion@1: ^1.1.16 brace-expansion@5: ^5.0.8 dompurify: ^3.4.13 @@ -169,8 +169,8 @@ importers: specifier: 19.2.18 version: 19.2.18 '@types/react-dom': - specifier: 19.2.4 - version: 19.2.4(@types/react@19.2.18) + specifier: 19.2.5 + version: 19.2.5(@types/react@19.2.18) eslint: specifier: ^10 version: 10.8.1(jiti@2.7.0) @@ -246,13 +246,13 @@ importers: version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))) '@testing-library/react': specifier: ^16.0.1 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@types/react': specifier: 19.2.18 version: 19.2.18 '@types/react-dom': - specifier: 19.2.4 - version: 19.2.4(@types/react@19.2.18) + specifier: 19.2.5 + version: 19.2.5(@types/react@19.2.18) '@vitejs/plugin-react': specifier: ^6.1.0 version: 6.1.0(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) @@ -318,8 +318,8 @@ importers: specifier: 19.2.18 version: 19.2.18 '@types/react-dom': - specifier: 19.2.4 - version: 19.2.4(@types/react@19.2.18) + specifier: 19.2.5 + version: 19.2.5(@types/react@19.2.18) autoprefixer: specifier: ^10.5.4 version: 10.5.4(postcss@8.5.26) @@ -404,7 +404,7 @@ importers: version: 2.11.1 '@xyflow/react': specifier: ^12.11.3 - version: 12.11.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 12.11.3(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) class-variance-authority: specifier: ^0.7.0 version: 0.7.1 @@ -413,7 +413,7 @@ importers: version: 2.1.1 cmdk: specifier: ^1.0.0 - version: 1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.1.1(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) dompurify: specifier: ^3.4.13 version: 3.4.13 @@ -459,13 +459,13 @@ importers: version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12))) '@testing-library/react': specifier: ^16.0.1 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@types/react': specifier: 19.2.18 version: 19.2.18 '@types/react-dom': - specifier: 19.2.4 - version: 19.2.4(@types/react@19.2.18) + specifier: 19.2.5 + version: 19.2.5(@types/react@19.2.18) '@types/zxcvbn': specifier: ^4.4.5 version: 4.4.5 @@ -1747,7 +1747,7 @@ packages: resolution: {integrity: sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==} peerDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4 + '@types/react-dom': 19.2.5 react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: @@ -1760,7 +1760,7 @@ packages: resolution: {integrity: sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==} peerDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4 + '@types/react-dom': 19.2.5 react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: @@ -1782,7 +1782,7 @@ packages: resolution: {integrity: sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==} peerDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4 + '@types/react-dom': 19.2.5 react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: @@ -1804,7 +1804,7 @@ packages: resolution: {integrity: sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==} peerDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4 + '@types/react-dom': 19.2.5 react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: @@ -1817,7 +1817,7 @@ packages: resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==} peerDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4 + '@types/react-dom': 19.2.5 react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: @@ -1830,7 +1830,7 @@ packages: resolution: {integrity: sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==} peerDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4 + '@types/react-dom': 19.2.5 react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: @@ -2453,7 +2453,7 @@ packages: peerDependencies: '@testing-library/dom': ^10.0.0 '@types/react': 19.2.18 - '@types/react-dom': 19.2.4 + '@types/react-dom': 19.2.5 react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 peerDependenciesMeta: @@ -2564,8 +2564,8 @@ packages: '@types/prompts@2.4.9': resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==} - '@types/react-dom@19.2.4': - resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + '@types/react-dom@19.2.5': + resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==} peerDependencies: '@types/react': 19.2.18 @@ -2832,7 +2832,7 @@ packages: resolution: {integrity: sha512-G3jogHz2GWUtIOkhavUGno2YzY9u6fILIJBttfsBendb0/HWB90JG+sOTAvlIMEwyvq9zgy9V9ZQSwyQjR5QzQ==} peerDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4 + '@types/react-dom': 19.2.5 react: '>=17' react-dom: '>=17' peerDependenciesMeta: @@ -6839,18 +6839,18 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - '@radix-ui/react-dialog@1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-dialog@1.1.17(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.4 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-context': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-id': 1.1.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-slot': 1.3.0(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.18)(react@19.2.8) aria-hidden: 1.2.6 @@ -6859,20 +6859,20 @@ snapshots: react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-dismissable-layer@1.1.13(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-dismissable-layer@1.1.13(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.4 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.18)(react@19.2.8) '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react-dom': 19.2.5(@types/react@19.2.18) '@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: @@ -6880,16 +6880,16 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - '@radix-ui/react-focus-scope@1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-focus-scope@1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react-dom': 19.2.5(@types/react@19.2.18) '@radix-ui/react-id@1.1.2(@types/react@19.2.18)(react@19.2.8)': dependencies: @@ -6898,33 +6898,33 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - '@radix-ui/react-portal@1.1.12(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-portal@1.1.12(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-primitive@2.1.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-primitive@2.1.6(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/react-slot': 1.3.0(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react-dom': 19.2.5(@types/react@19.2.18) '@radix-ui/react-slot@1.3.0(@types/react@19.2.18)(react@19.2.8)': dependencies: @@ -7414,7 +7414,7 @@ snapshots: optionalDependencies: vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)) - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@babel/runtime': 7.29.2 '@testing-library/dom': 10.4.1 @@ -7422,7 +7422,7 @@ snapshots: react-dom: 19.2.8(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react-dom': 19.2.5(@types/react@19.2.18) '@turbo/darwin-64@2.10.11': optional: true @@ -7522,7 +7522,7 @@ snapshots: '@types/node': 26.2.0 kleur: 3.0.3 - '@types/react-dom@19.2.4(@types/react@19.2.18)': + '@types/react-dom@19.2.5(@types/react@19.2.18)': dependencies: '@types/react': 19.2.18 @@ -7785,7 +7785,7 @@ snapshots: '@whatwg-node/promise-helpers': 1.3.2 tslib: 2.8.1 - '@xyflow/react@12.11.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@xyflow/react@12.11.3(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@xyflow/system': 0.0.80 classcat: 5.0.5 @@ -7794,7 +7794,7 @@ snapshots: zustand: 4.5.7(@types/react@19.2.18)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@types/react-dom': 19.2.5(@types/react@19.2.18) transitivePeerDependencies: - immer @@ -8070,12 +8070,12 @@ snapshots: cluster-key-slot@1.1.1: {} - cmdk@1.1.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + cmdk@1.1.1(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-dialog': 1.1.17(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dialog': 1.1.17(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-id': 1.1.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 826936fa..2d14b599 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,7 +24,7 @@ packages: overrides: "@types/react": "19.2.18" - "@types/react-dom": "19.2.4" + "@types/react-dom": "19.2.5" "brace-expansion@1": "^1.1.16" "brace-expansion@5": "^5.0.8" "dompurify": "^3.4.13" diff --git a/scripts/check-registry-numbering.mjs b/scripts/check-registry-numbering.mjs index 69ba72ff..1b91194f 100644 --- a/scripts/check-registry-numbering.mjs +++ b/scripts/check-registry-numbering.mjs @@ -2,77 +2,125 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; /** - * Registry-numbering tripwire. + * Registry-id tripwire. * - * The `#` column of the Setup Impact Registry - * (`.kiro/specs/admin-setup-wizard/setup-impact.md`) is a unique row id. - * Parallel feature branches kept picking "the next number" independently and - * collided — at one point the table carried two #20/#31/#32 rows (and many - * more). Until now the only fence was a manual `grep` in the Definition of - * Done (§2), i.e. a checklist a reviewer had to remember to run. + * Two tables in this repo use a hand-assigned unique row id, and both have been + * bitten by the same thing: parallel branches independently pick "the next + * number", and whichever merges second silently duplicates it. * - * Per DoD §6 ("cơ giới hóa" — mechanize the fence, don't rely on human - * recall), this script fails CI when the `#` column contains a duplicate, so - * the collision is caught at PR time instead of during a later cleanup. + * 1. Setup Impact Registry (`.kiro/specs/admin-setup-wizard/setup-impact.md`) + * — `#` column. At one point the table carried two #20/#31/#32 rows (and + * many more). That is what this script was originally written for. * - * It parses ONLY the table under the `## Registry` heading, so the numbered - * prose list above it and the notes below it never register as rows. + * 2. Out-of-scope findings backlog (`.kiro/steering/out-of-scope-backlog.md`) + * — `ID` column (`B`). Added after PR #434 and PR #436 both claimed + * `B30` for unrelated findings; the collision only surfaced as a rebase + * conflict, and resolving it by hand is exactly the manual step DoD §6 + * says to mechanize. Backlog ids are cited by other rows ("Nối tiếp B13", + * "cùng class với B10") and by CHANGELOG entries, so a silent renumber + * breaks references that no test covers. + * + * Per DoD §6 ("cơ giới hóa" — mechanize the fence, don't rely on human recall), + * this script fails CI when either id column contains a duplicate, so the + * collision is caught at PR time instead of during a later cleanup. + * + * It parses ONLY the table under each file's `## Registry` heading, so numbered + * prose above it and notes below it never register as rows. */ const repoRoot = process.cwd(); -const registryPath = path.join( - repoRoot, - '.kiro/specs/admin-setup-wizard/setup-impact.md', -); -const source = await readFile(registryPath, 'utf8'); -const lines = source.split('\n'); +/** @type {{label: string, file: string, row: RegExp, render: (t: string) => string, next: (tokens: string[]) => string}[]} */ +const registries = [ + { + label: 'Setup Impact Registry', + file: '.kiro/specs/admin-setup-wizard/setup-impact.md', + // A data row looks like `| 42 | ... |` or `| 28b | ... |`. The header + // (`| # | ... |`) and separator (`|---|`) rows never match. + row: /^\|\s*(\d+[a-z]?)\s*\|/, + render: (token) => `#${token}`, + next: (tokens) => `#${maxNumeric(tokens) + 1}`, + }, + { + label: 'Out-of-scope backlog', + file: '.kiro/steering/out-of-scope-backlog.md', + // A data row looks like `| B30 | ... |`. The header (`| ID | ... |`) and + // separator rows never match. + row: /^\|\s*B(\d+)\s*\|/, + render: (token) => `B${token}`, + next: (tokens) => `B${maxNumeric(tokens) + 1}`, + }, +]; -// Bound the scan to the "## Registry" section (up to the next H2 heading). -const start = lines.findIndex((l) => /^##\s+Registry\s*$/.test(l)); -if (start === -1) { - console.error(`Registry numbering check failed: -- could not find the "## Registry" heading in ${path.relative(repoRoot, registryPath)}`); - process.exit(1); +/** Highest integer among the collected id tokens. */ +function maxNumeric(tokens) { + const numbers = tokens.map((t) => parseInt(t, 10)).filter((n) => !Number.isNaN(n)); + return numbers.length > 0 ? Math.max(...numbers) : 0; } -let end = lines.findIndex((l, i) => i > start && /^##\s+/.test(l)); -if (end === -1) end = lines.length; -// A registry data row looks like `| 42 | ... |` or `| 28b | ... |`. -// The header (`| # | ... |`) and separator (`|---|`) rows never match. -const ROW = /^\|\s*(\d+[a-z]?)\s*\|/; +const failures = []; +const summaries = []; -const seen = new Map(); // token -> [lineNumbers] -for (let i = start + 1; i < end; i++) { - const m = lines[i].match(ROW); - if (!m) continue; - const token = m[1]; - if (!seen.has(token)) seen.set(token, []); - seen.get(token).push(i + 1); -} +for (const registry of registries) { + const file = path.join(repoRoot, registry.file); + const rel = path.relative(repoRoot, file); -if (seen.size === 0) { - console.error(`Registry numbering check failed: -- parsed 0 rows under "## Registry" — the table shape may have changed`); - process.exit(1); -} + let source; + try { + source = await readFile(file, 'utf8'); + } catch (error) { + failures.push(`- could not read ${rel}: ${error.message}`); + continue; + } + + const lines = source.split('\n'); -const duplicates = [...seen.entries()].filter(([, ls]) => ls.length > 1); + // Bound the scan to the "## Registry" section (up to the next H2 heading). + const start = lines.findIndex((l) => /^##\s+Registry\s*$/.test(l)); + if (start === -1) { + failures.push(`- could not find the "## Registry" heading in ${rel}`); + continue; + } + let end = lines.findIndex((l, i) => i > start && /^##\s+/.test(l)); + if (end === -1) end = lines.length; + + const seen = new Map(); // token -> [lineNumbers] + for (let i = start + 1; i < end; i++) { + const m = lines[i].match(registry.row); + if (!m) continue; + const token = m[1]; + if (!seen.has(token)) seen.set(token, []); + seen.get(token).push(i + 1); + } + + // A table that suddenly parses as empty means the shape changed and this + // guard stopped guarding — fail rather than report a cheerful zero. + if (seen.size === 0) { + failures.push(`- parsed 0 rows under "## Registry" in ${rel} — the table shape may have changed`); + continue; + } -if (duplicates.length > 0) { - console.error('Registry numbering check failed — duplicate row numbers:'); - for (const [token, ls] of duplicates) { - console.error(`- #${token} used on lines ${ls.join(', ')}`); + const duplicates = [...seen.entries()].filter(([, ls]) => ls.length > 1); + if (duplicates.length > 0) { + failures.push(`- ${registry.label} (${rel}) has duplicate row ids:`); + for (const [token, ls] of duplicates) { + failures.push(` ${registry.render(token)} used on lines ${ls.join(', ')}`); + } + failures.push( + ` Give each colliding row a new id starting at ${registry.next([...seen.keys()])}; ` + + `keep the occurrence that other rows cite by id so cross-references stay valid.`, + ); + continue; } - const max = Math.max( - ...[...seen.keys()].map((t) => parseInt(t, 10)).filter((n) => !Number.isNaN(n)), - ); - console.error( - `\nThe # column is a unique row id. Give each colliding row a new number ` + - `greater than the current max (${max}); keep the occurrence that other ` + - `rows cite by number so cross-references stay valid. See DoD §2 and §6.`, - ); + + summaries.push(`${registry.label}: ${seen.size} rows, all ids unique`); +} + +if (failures.length > 0) { + console.error('Registry id check failed:'); + for (const failure of failures) console.error(failure); + console.error('\nSee DoD §2 and §6.'); process.exit(1); } -console.log(`Registry numbering OK: ${seen.size} rows, all # values unique.`); +console.log(`Registry ids OK — ${summaries.join(' · ')}.`);