From c29d2bc5ea9034253e2cada2d04f0de6360642f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 20:08:08 +0000 Subject: [PATCH 1/6] fix(spec): shard the liveness state counts one file per governed type The generated liveness counts were one file with a row per type and a shared total row. Every PR that moved a verdict rewrote that total, and GitHub's server-side merge runs no custom driver, so any two in-flight liveness PRs conflicted on it and each landing left the others dirty. gen:liveness-counts now writes packages/spec/liveness/state-counts/.md, one shard per governed type carrying only its own row, rewrites only the shards whose bytes moved, prunes strays and deletes the retired single file. No total is committed: check:liveness sums the shards at read time (success line and --json countsTotal). check:liveness reconciles each shard, a stray shard and the retired file; check:generated, the regen table and .gitattributes route the directory. WIP: tests follow in the next commit. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- .gitattributes | 13 +- packages/spec/liveness/state-counts.md | 70 ---- packages/spec/liveness/state-counts/action.md | 15 + packages/spec/liveness/state-counts/agent.md | 15 + .../liveness/state-counts/analytics_cube.md | 15 + packages/spec/liveness/state-counts/api.md | 15 + packages/spec/liveness/state-counts/app.md | 15 + .../liveness/state-counts/batch_endpoints.md | 15 + packages/spec/liveness/state-counts/book.md | 15 + .../spec/liveness/state-counts/capability.md | 15 + .../spec/liveness/state-counts/connector.md | 15 + .../liveness/state-counts/crud_endpoints.md | 15 + .../spec/liveness/state-counts/dashboard.md | 15 + .../spec/liveness/state-counts/dataset.md | 15 + .../spec/liveness/state-counts/datasource.md | 15 + packages/spec/liveness/state-counts/doc.md | 15 + .../liveness/state-counts/email_template.md | 15 + packages/spec/liveness/state-counts/field.md | 15 + packages/spec/liveness/state-counts/flow.md | 15 + packages/spec/liveness/state-counts/hook.md | 15 + packages/spec/liveness/state-counts/job.md | 15 + .../spec/liveness/state-counts/manifest.md | 15 + .../spec/liveness/state-counts/mapping.md | 15 + .../state-counts/metadata_endpoints.md | 15 + packages/spec/liveness/state-counts/object.md | 15 + packages/spec/liveness/state-counts/page.md | 15 + .../spec/liveness/state-counts/permission.md | 15 + .../spec/liveness/state-counts/position.md | 15 + packages/spec/liveness/state-counts/qa.md | 15 + packages/spec/liveness/state-counts/query.md | 15 + .../state-counts/realtime_subscription.md | 15 + packages/spec/liveness/state-counts/report.md | 15 + .../spec/liveness/state-counts/rest_api.md | 15 + .../liveness/state-counts/route_generation.md | 15 + packages/spec/liveness/state-counts/seed.md | 15 + .../liveness/state-counts/sharing_rule.md | 15 + packages/spec/liveness/state-counts/skill.md | 15 + packages/spec/liveness/state-counts/tool.md | 15 + .../spec/liveness/state-counts/translation.md | 15 + .../spec/liveness/state-counts/validation.md | 15 + packages/spec/liveness/state-counts/view.md | 15 + .../spec/liveness/state-counts/webhook.md | 15 + packages/spec/scripts/check-generated.ts | 7 +- .../scripts/liveness/build-state-counts.mts | 48 ++- .../spec/scripts/liveness/check-liveness.mts | 48 +-- .../spec/scripts/liveness/readme-table.mts | 303 +++++++++++++----- scripts/regen-artifacts.mjs | 11 +- 47 files changed, 910 insertions(+), 190 deletions(-) delete mode 100644 packages/spec/liveness/state-counts.md create mode 100644 packages/spec/liveness/state-counts/action.md create mode 100644 packages/spec/liveness/state-counts/agent.md create mode 100644 packages/spec/liveness/state-counts/analytics_cube.md create mode 100644 packages/spec/liveness/state-counts/api.md create mode 100644 packages/spec/liveness/state-counts/app.md create mode 100644 packages/spec/liveness/state-counts/batch_endpoints.md create mode 100644 packages/spec/liveness/state-counts/book.md create mode 100644 packages/spec/liveness/state-counts/capability.md create mode 100644 packages/spec/liveness/state-counts/connector.md create mode 100644 packages/spec/liveness/state-counts/crud_endpoints.md create mode 100644 packages/spec/liveness/state-counts/dashboard.md create mode 100644 packages/spec/liveness/state-counts/dataset.md create mode 100644 packages/spec/liveness/state-counts/datasource.md create mode 100644 packages/spec/liveness/state-counts/doc.md create mode 100644 packages/spec/liveness/state-counts/email_template.md create mode 100644 packages/spec/liveness/state-counts/field.md create mode 100644 packages/spec/liveness/state-counts/flow.md create mode 100644 packages/spec/liveness/state-counts/hook.md create mode 100644 packages/spec/liveness/state-counts/job.md create mode 100644 packages/spec/liveness/state-counts/manifest.md create mode 100644 packages/spec/liveness/state-counts/mapping.md create mode 100644 packages/spec/liveness/state-counts/metadata_endpoints.md create mode 100644 packages/spec/liveness/state-counts/object.md create mode 100644 packages/spec/liveness/state-counts/page.md create mode 100644 packages/spec/liveness/state-counts/permission.md create mode 100644 packages/spec/liveness/state-counts/position.md create mode 100644 packages/spec/liveness/state-counts/qa.md create mode 100644 packages/spec/liveness/state-counts/query.md create mode 100644 packages/spec/liveness/state-counts/realtime_subscription.md create mode 100644 packages/spec/liveness/state-counts/report.md create mode 100644 packages/spec/liveness/state-counts/rest_api.md create mode 100644 packages/spec/liveness/state-counts/route_generation.md create mode 100644 packages/spec/liveness/state-counts/seed.md create mode 100644 packages/spec/liveness/state-counts/sharing_rule.md create mode 100644 packages/spec/liveness/state-counts/skill.md create mode 100644 packages/spec/liveness/state-counts/tool.md create mode 100644 packages/spec/liveness/state-counts/translation.md create mode 100644 packages/spec/liveness/state-counts/validation.md create mode 100644 packages/spec/liveness/state-counts/view.md create mode 100644 packages/spec/liveness/state-counts/webhook.md diff --git a/.gitattributes b/.gitattributes index 678a8be9fc3..ab3d279573e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -70,12 +70,19 @@ # # The liveness state table's counts joined at #7377 for the same reason, one file # over — 9 of its 30 rows had drifted from the gate before anyone re-ran the -# documented snippet. Same split and the same caveat: `liveness/state-counts.md` -# is the numbers and is driver-managed; `liveness/README.md` is the Notes prose — +# documented snippet. Same split and the same caveat: `liveness/state-counts/` is +# the numbers and is driver-managed; `liveness/README.md` is the Notes prose — # hand-written measurement of how each type got where it is — and is NOT. # Regenerating a Note would fabricate a verdict, which that README calls worse # than a missing row. # +# #20361 then SHARDED those counts, one file per governed type, and stopped +# committing a total: the single file's shared total row was rewritten by every +# liveness PR, so in the driver-less server-side merge any two of them conflicted +# and each landing left every other one `dirty`, with no CI run until a +# merge-and-regenerate round. The cure is the one the header above records for +# the three hottest artifacts; the gate sums the shards when it reads them. +# # The elevation census page joined at #13646 — a generated `file:line` anchor # table whose correct merged values are on NEITHER side of a conflict (measured on # #13625: five conflicted anchors resolved to 4408/5771/6019/6382/6575 against @@ -138,7 +145,7 @@ # neither side. packages/spec/spec-changes.json merge=os-regen -packages/spec/liveness/state-counts.md merge=os-regen +packages/spec/liveness/state-counts/** merge=os-regen packages/spec/authorable-surface/** merge=os-regen packages/spec/authorable-surface.base.json merge=os-regen packages/spec/authorable-defaults/** merge=os-regen diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md deleted file mode 100644 index 33e4ce3dd0b..00000000000 --- a/packages/spec/liveness/state-counts.md +++ /dev/null @@ -1,70 +0,0 @@ - - - -# Liveness state table — the counts (generated) - -Every number the [liveness ledger README](./README.md)'s "Current state" table -used to publish, computed by the gate that enforces them — -`scripts/liveness/check-liveness.mts --json`, `types..byStatus`, the -counting method fixed in #4488. The Notes prose, which is hand-written -measurement of how each type got where it is, stays in the README and is never -regenerated. - -Split out at #7377 on #5107's precedent. Nine of the thirty rows had drifted -from the gate by the time anyone re-ran the documented snippet, and -hand-maintained counts merge in the one way that hides: two PRs each move a -different row by their own correct delta, the rows do not overlap, git merges -them without complaint, and the result is a table nobody wrote down. The -correct resolution was always "recompute from the merged tree", so this path -carries `merge=os-regen` (#4675) and the recomputation is mandatory rather than -remembered. **Never hand-patch a number here** — fix the ledger or the schema -and regenerate. - -Counts are at the gate's one-level walk granularity and include the ADR-0010 -protection envelope, which the gate auto-classifies `live` on every type that -spreads `MetadataProtectionFields`. See the README's counting-method section -for both corollaries. - -| Type | live | exp | elsewhere | dead | planned | classified | -|---|---|---|---|---|---|---| -| `object` | 50 | 0 | 0 | 0 | 1 | 51 | -| `field` | 91 | 0 | 0 | 1 | 1 | 93 | -| `flow` | 34 | 0 | 0 | 6 | 0 | 40 | -| `action` | 45 | 0 | 0 | 4 | 0 | 49 | -| `hook` | 19 | 0 | 0 | 3 | 0 | 22 | -| `permission` | 36 | 0 | 0 | 6 | 0 | 42 | -| `position` | 12 | 0 | 0 | 0 | 0 | 12 | -| `agent` | 20 | 4 | 0 | 2 | 0 | 26 | -| `tool` | 13 | 1 | 0 | 0 | 0 | 14 | -| `skill` | 16 | 0 | 0 | 1 | 0 | 17 | -| `dataset` | 27 | 0 | 0 | 0 | 0 | 27 | -| `page` | 22 | 0 | 0 | 1 | 1 | 24 | -| `view` | 78 | 0 | 0 | 11 | 0 | 89 | -| `report` | 21 | 0 | 0 | 0 | 0 | 21 | -| `dashboard` | 42 | 0 | 0 | 13 | 0 | 55 | -| `webhook` | 19 | 0 | 0 | 0 | 0 | 19 | -| `query` | 16 | 0 | 0 | 5 | 0 | 21 | -| `datasource` | 30 | 0 | 0 | 0 | 0 | 30 | -| `app` | 49 | 0 | 0 | 9 | 1 | 59 | -| `book` | 20 | 0 | 0 | 1 | 0 | 21 | -| `doc` | 15 | 0 | 0 | 0 | 0 | 15 | -| `email_template` | 21 | 0 | 0 | 0 | 0 | 21 | -| `job` | 15 | 0 | 0 | 1 | 0 | 16 | -| `mapping` | 14 | 0 | 0 | 0 | 0 | 14 | -| `seed` | 13 | 0 | 0 | 0 | 0 | 13 | -| `translation` | 23 | 0 | 0 | 0 | 1 | 24 | -| `validation` | 18 | 0 | 0 | 0 | 0 | 18 | -| `api` | 25 | 0 | 0 | 1 | 2 | 28 | -| `capability` | 12 | 0 | 0 | 0 | 0 | 12 | -| `qa` | 8 | 0 | 0 | 1 | 0 | 9 | -| `manifest` | 23 | 0 | 1 | 15 | 0 | 39 | -| `crud_endpoints` | 6 | 0 | 0 | 2 | 0 | 8 | -| `metadata_endpoints` | 7 | 0 | 0 | 2 | 0 | 9 | -| `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 | -| `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 | -| `rest_api` | 12 | 0 | 0 | 12 | 0 | 24 | -| `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | -| `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | -| `connector` | 29 | 0 | 0 | 30 | 1 | 60 | -| `analytics_cube` | 18 | 0 | 0 | 9 | 0 | 27 | -| **total** | **940** | **5** | **1** | **148** | **9** | **1103** | diff --git a/packages/spec/liveness/state-counts/action.md b/packages/spec/liveness/state-counts/action.md new file mode 100644 index 00000000000..e66bfbb7495 --- /dev/null +++ b/packages/spec/liveness/state-counts/action.md @@ -0,0 +1,15 @@ + + + +# `action` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `action` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `action` | 45 | 0 | 0 | 4 | 0 | 49 | diff --git a/packages/spec/liveness/state-counts/agent.md b/packages/spec/liveness/state-counts/agent.md new file mode 100644 index 00000000000..ef8818fa15a --- /dev/null +++ b/packages/spec/liveness/state-counts/agent.md @@ -0,0 +1,15 @@ + + + +# `agent` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `agent` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `agent` | 20 | 4 | 0 | 2 | 0 | 26 | diff --git a/packages/spec/liveness/state-counts/analytics_cube.md b/packages/spec/liveness/state-counts/analytics_cube.md new file mode 100644 index 00000000000..7825385730f --- /dev/null +++ b/packages/spec/liveness/state-counts/analytics_cube.md @@ -0,0 +1,15 @@ + + + +# `analytics_cube` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `analytics_cube` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `analytics_cube` | 18 | 0 | 0 | 9 | 0 | 27 | diff --git a/packages/spec/liveness/state-counts/api.md b/packages/spec/liveness/state-counts/api.md new file mode 100644 index 00000000000..8eaa1433bd9 --- /dev/null +++ b/packages/spec/liveness/state-counts/api.md @@ -0,0 +1,15 @@ + + + +# `api` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `api` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `api` | 25 | 0 | 0 | 1 | 2 | 28 | diff --git a/packages/spec/liveness/state-counts/app.md b/packages/spec/liveness/state-counts/app.md new file mode 100644 index 00000000000..deb6ea3ab26 --- /dev/null +++ b/packages/spec/liveness/state-counts/app.md @@ -0,0 +1,15 @@ + + + +# `app` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `app` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `app` | 49 | 0 | 0 | 9 | 1 | 59 | diff --git a/packages/spec/liveness/state-counts/batch_endpoints.md b/packages/spec/liveness/state-counts/batch_endpoints.md new file mode 100644 index 00000000000..d2f09515d1b --- /dev/null +++ b/packages/spec/liveness/state-counts/batch_endpoints.md @@ -0,0 +1,15 @@ + + + +# `batch_endpoints` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `batch_endpoints` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 | diff --git a/packages/spec/liveness/state-counts/book.md b/packages/spec/liveness/state-counts/book.md new file mode 100644 index 00000000000..ec8aac4381d --- /dev/null +++ b/packages/spec/liveness/state-counts/book.md @@ -0,0 +1,15 @@ + + + +# `book` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `book` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `book` | 20 | 0 | 0 | 1 | 0 | 21 | diff --git a/packages/spec/liveness/state-counts/capability.md b/packages/spec/liveness/state-counts/capability.md new file mode 100644 index 00000000000..c1ca5ec9bcd --- /dev/null +++ b/packages/spec/liveness/state-counts/capability.md @@ -0,0 +1,15 @@ + + + +# `capability` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `capability` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `capability` | 12 | 0 | 0 | 0 | 0 | 12 | diff --git a/packages/spec/liveness/state-counts/connector.md b/packages/spec/liveness/state-counts/connector.md new file mode 100644 index 00000000000..7f422072040 --- /dev/null +++ b/packages/spec/liveness/state-counts/connector.md @@ -0,0 +1,15 @@ + + + +# `connector` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `connector` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `connector` | 29 | 0 | 0 | 30 | 1 | 60 | diff --git a/packages/spec/liveness/state-counts/crud_endpoints.md b/packages/spec/liveness/state-counts/crud_endpoints.md new file mode 100644 index 00000000000..906315f706c --- /dev/null +++ b/packages/spec/liveness/state-counts/crud_endpoints.md @@ -0,0 +1,15 @@ + + + +# `crud_endpoints` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `crud_endpoints` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `crud_endpoints` | 6 | 0 | 0 | 2 | 0 | 8 | diff --git a/packages/spec/liveness/state-counts/dashboard.md b/packages/spec/liveness/state-counts/dashboard.md new file mode 100644 index 00000000000..1be1bf5c734 --- /dev/null +++ b/packages/spec/liveness/state-counts/dashboard.md @@ -0,0 +1,15 @@ + + + +# `dashboard` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `dashboard` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `dashboard` | 42 | 0 | 0 | 13 | 0 | 55 | diff --git a/packages/spec/liveness/state-counts/dataset.md b/packages/spec/liveness/state-counts/dataset.md new file mode 100644 index 00000000000..1649dd3c2d5 --- /dev/null +++ b/packages/spec/liveness/state-counts/dataset.md @@ -0,0 +1,15 @@ + + + +# `dataset` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `dataset` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `dataset` | 27 | 0 | 0 | 0 | 0 | 27 | diff --git a/packages/spec/liveness/state-counts/datasource.md b/packages/spec/liveness/state-counts/datasource.md new file mode 100644 index 00000000000..2c7559a73ef --- /dev/null +++ b/packages/spec/liveness/state-counts/datasource.md @@ -0,0 +1,15 @@ + + + +# `datasource` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `datasource` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `datasource` | 30 | 0 | 0 | 0 | 0 | 30 | diff --git a/packages/spec/liveness/state-counts/doc.md b/packages/spec/liveness/state-counts/doc.md new file mode 100644 index 00000000000..314a315a461 --- /dev/null +++ b/packages/spec/liveness/state-counts/doc.md @@ -0,0 +1,15 @@ + + + +# `doc` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `doc` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `doc` | 15 | 0 | 0 | 0 | 0 | 15 | diff --git a/packages/spec/liveness/state-counts/email_template.md b/packages/spec/liveness/state-counts/email_template.md new file mode 100644 index 00000000000..49daed28b3e --- /dev/null +++ b/packages/spec/liveness/state-counts/email_template.md @@ -0,0 +1,15 @@ + + + +# `email_template` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `email_template` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `email_template` | 21 | 0 | 0 | 0 | 0 | 21 | diff --git a/packages/spec/liveness/state-counts/field.md b/packages/spec/liveness/state-counts/field.md new file mode 100644 index 00000000000..1261ef56763 --- /dev/null +++ b/packages/spec/liveness/state-counts/field.md @@ -0,0 +1,15 @@ + + + +# `field` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `field` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `field` | 91 | 0 | 0 | 1 | 1 | 93 | diff --git a/packages/spec/liveness/state-counts/flow.md b/packages/spec/liveness/state-counts/flow.md new file mode 100644 index 00000000000..7f9a304cac9 --- /dev/null +++ b/packages/spec/liveness/state-counts/flow.md @@ -0,0 +1,15 @@ + + + +# `flow` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `flow` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `flow` | 34 | 0 | 0 | 6 | 0 | 40 | diff --git a/packages/spec/liveness/state-counts/hook.md b/packages/spec/liveness/state-counts/hook.md new file mode 100644 index 00000000000..a5baed4958b --- /dev/null +++ b/packages/spec/liveness/state-counts/hook.md @@ -0,0 +1,15 @@ + + + +# `hook` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `hook` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `hook` | 19 | 0 | 0 | 3 | 0 | 22 | diff --git a/packages/spec/liveness/state-counts/job.md b/packages/spec/liveness/state-counts/job.md new file mode 100644 index 00000000000..de384704b8c --- /dev/null +++ b/packages/spec/liveness/state-counts/job.md @@ -0,0 +1,15 @@ + + + +# `job` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `job` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `job` | 15 | 0 | 0 | 1 | 0 | 16 | diff --git a/packages/spec/liveness/state-counts/manifest.md b/packages/spec/liveness/state-counts/manifest.md new file mode 100644 index 00000000000..c646c3f5cdb --- /dev/null +++ b/packages/spec/liveness/state-counts/manifest.md @@ -0,0 +1,15 @@ + + + +# `manifest` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `manifest` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `manifest` | 23 | 0 | 1 | 15 | 0 | 39 | diff --git a/packages/spec/liveness/state-counts/mapping.md b/packages/spec/liveness/state-counts/mapping.md new file mode 100644 index 00000000000..f668996e0e8 --- /dev/null +++ b/packages/spec/liveness/state-counts/mapping.md @@ -0,0 +1,15 @@ + + + +# `mapping` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `mapping` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `mapping` | 14 | 0 | 0 | 0 | 0 | 14 | diff --git a/packages/spec/liveness/state-counts/metadata_endpoints.md b/packages/spec/liveness/state-counts/metadata_endpoints.md new file mode 100644 index 00000000000..6d1345bba99 --- /dev/null +++ b/packages/spec/liveness/state-counts/metadata_endpoints.md @@ -0,0 +1,15 @@ + + + +# `metadata_endpoints` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `metadata_endpoints` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `metadata_endpoints` | 7 | 0 | 0 | 2 | 0 | 9 | diff --git a/packages/spec/liveness/state-counts/object.md b/packages/spec/liveness/state-counts/object.md new file mode 100644 index 00000000000..8ab2c1ecfbf --- /dev/null +++ b/packages/spec/liveness/state-counts/object.md @@ -0,0 +1,15 @@ + + + +# `object` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `object` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `object` | 50 | 0 | 0 | 0 | 1 | 51 | diff --git a/packages/spec/liveness/state-counts/page.md b/packages/spec/liveness/state-counts/page.md new file mode 100644 index 00000000000..25177d8eb2b --- /dev/null +++ b/packages/spec/liveness/state-counts/page.md @@ -0,0 +1,15 @@ + + + +# `page` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `page` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `page` | 22 | 0 | 0 | 1 | 1 | 24 | diff --git a/packages/spec/liveness/state-counts/permission.md b/packages/spec/liveness/state-counts/permission.md new file mode 100644 index 00000000000..98618ba725a --- /dev/null +++ b/packages/spec/liveness/state-counts/permission.md @@ -0,0 +1,15 @@ + + + +# `permission` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `permission` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `permission` | 36 | 0 | 0 | 6 | 0 | 42 | diff --git a/packages/spec/liveness/state-counts/position.md b/packages/spec/liveness/state-counts/position.md new file mode 100644 index 00000000000..1d47113ce27 --- /dev/null +++ b/packages/spec/liveness/state-counts/position.md @@ -0,0 +1,15 @@ + + + +# `position` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `position` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `position` | 12 | 0 | 0 | 0 | 0 | 12 | diff --git a/packages/spec/liveness/state-counts/qa.md b/packages/spec/liveness/state-counts/qa.md new file mode 100644 index 00000000000..e8a11badf74 --- /dev/null +++ b/packages/spec/liveness/state-counts/qa.md @@ -0,0 +1,15 @@ + + + +# `qa` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `qa` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `qa` | 8 | 0 | 0 | 1 | 0 | 9 | diff --git a/packages/spec/liveness/state-counts/query.md b/packages/spec/liveness/state-counts/query.md new file mode 100644 index 00000000000..a44ba0ed50a --- /dev/null +++ b/packages/spec/liveness/state-counts/query.md @@ -0,0 +1,15 @@ + + + +# `query` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `query` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `query` | 16 | 0 | 0 | 5 | 0 | 21 | diff --git a/packages/spec/liveness/state-counts/realtime_subscription.md b/packages/spec/liveness/state-counts/realtime_subscription.md new file mode 100644 index 00000000000..13b3301825c --- /dev/null +++ b/packages/spec/liveness/state-counts/realtime_subscription.md @@ -0,0 +1,15 @@ + + + +# `realtime_subscription` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `realtime_subscription` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | diff --git a/packages/spec/liveness/state-counts/report.md b/packages/spec/liveness/state-counts/report.md new file mode 100644 index 00000000000..ab1a34f8c02 --- /dev/null +++ b/packages/spec/liveness/state-counts/report.md @@ -0,0 +1,15 @@ + + + +# `report` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `report` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `report` | 21 | 0 | 0 | 0 | 0 | 21 | diff --git a/packages/spec/liveness/state-counts/rest_api.md b/packages/spec/liveness/state-counts/rest_api.md new file mode 100644 index 00000000000..3cff11186b9 --- /dev/null +++ b/packages/spec/liveness/state-counts/rest_api.md @@ -0,0 +1,15 @@ + + + +# `rest_api` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `rest_api` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `rest_api` | 12 | 0 | 0 | 12 | 0 | 24 | diff --git a/packages/spec/liveness/state-counts/route_generation.md b/packages/spec/liveness/state-counts/route_generation.md new file mode 100644 index 00000000000..62984774a64 --- /dev/null +++ b/packages/spec/liveness/state-counts/route_generation.md @@ -0,0 +1,15 @@ + + + +# `route_generation` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `route_generation` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 | diff --git a/packages/spec/liveness/state-counts/seed.md b/packages/spec/liveness/state-counts/seed.md new file mode 100644 index 00000000000..abd5a20e44e --- /dev/null +++ b/packages/spec/liveness/state-counts/seed.md @@ -0,0 +1,15 @@ + + + +# `seed` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `seed` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `seed` | 13 | 0 | 0 | 0 | 0 | 13 | diff --git a/packages/spec/liveness/state-counts/sharing_rule.md b/packages/spec/liveness/state-counts/sharing_rule.md new file mode 100644 index 00000000000..2db89089414 --- /dev/null +++ b/packages/spec/liveness/state-counts/sharing_rule.md @@ -0,0 +1,15 @@ + + + +# `sharing_rule` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `sharing_rule` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | diff --git a/packages/spec/liveness/state-counts/skill.md b/packages/spec/liveness/state-counts/skill.md new file mode 100644 index 00000000000..3fbf014c6bf --- /dev/null +++ b/packages/spec/liveness/state-counts/skill.md @@ -0,0 +1,15 @@ + + + +# `skill` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `skill` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `skill` | 16 | 0 | 0 | 1 | 0 | 17 | diff --git a/packages/spec/liveness/state-counts/tool.md b/packages/spec/liveness/state-counts/tool.md new file mode 100644 index 00000000000..dcaca43b912 --- /dev/null +++ b/packages/spec/liveness/state-counts/tool.md @@ -0,0 +1,15 @@ + + + +# `tool` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `tool` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `tool` | 13 | 1 | 0 | 0 | 0 | 14 | diff --git a/packages/spec/liveness/state-counts/translation.md b/packages/spec/liveness/state-counts/translation.md new file mode 100644 index 00000000000..41392111e07 --- /dev/null +++ b/packages/spec/liveness/state-counts/translation.md @@ -0,0 +1,15 @@ + + + +# `translation` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `translation` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `translation` | 23 | 0 | 0 | 0 | 1 | 24 | diff --git a/packages/spec/liveness/state-counts/validation.md b/packages/spec/liveness/state-counts/validation.md new file mode 100644 index 00000000000..0379b2a20a2 --- /dev/null +++ b/packages/spec/liveness/state-counts/validation.md @@ -0,0 +1,15 @@ + + + +# `validation` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `validation` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `validation` | 18 | 0 | 0 | 0 | 0 | 18 | diff --git a/packages/spec/liveness/state-counts/view.md b/packages/spec/liveness/state-counts/view.md new file mode 100644 index 00000000000..2b76c6cdc40 --- /dev/null +++ b/packages/spec/liveness/state-counts/view.md @@ -0,0 +1,15 @@ + + + +# `view` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `view` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `view` | 78 | 0 | 0 | 11 | 0 | 89 | diff --git a/packages/spec/liveness/state-counts/webhook.md b/packages/spec/liveness/state-counts/webhook.md new file mode 100644 index 00000000000..d052b4768eb --- /dev/null +++ b/packages/spec/liveness/state-counts/webhook.md @@ -0,0 +1,15 @@ + + + +# `webhook` — liveness counts (generated) + +This type's row of the liveness state table, computed by the gate that enforces +it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its +Notes prose is the `webhook` row of [the ledger README](../README.md), which +also states the counting method. One file per governed type, and no total is +committed anywhere: `check:liveness` sums the shards when it reads them. +**Never hand-patch a number here** — fix the ledger or the schema and regenerate. + +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `webhook` | 19 | 0 | 0 | 0 | 0 | 19 | diff --git a/packages/spec/scripts/check-generated.ts b/packages/spec/scripts/check-generated.ts index e78a5000950..179f8b78ed4 100644 --- a/packages/spec/scripts/check-generated.ts +++ b/packages/spec/scripts/check-generated.ts @@ -156,10 +156,15 @@ const GATED: ReadonlyArray<{ // // Last among the non-`ratchet` entries on the cheapest-first rule: it eagerly // loads every Zod schema and walks all 30 governed types. + // + // A DIRECTORY since #20361: one shard per governed type and no committed + // total, so two PRs moving different types never touch the same file — the + // single file's shared total row made every in-flight liveness PR conflict + // with the next one to land, in the server-side merge no driver reaches. { check: 'check:liveness', gen: 'gen:liveness-counts', - artifact: 'liveness/state-counts.md', + artifact: 'liveness/state-counts/', }, // GATED by the definition above — it compares a checked-in artifact // (test-typecheck-debt.json) against what `tsc -p tsconfig.test.json` measures diff --git a/packages/spec/scripts/liveness/build-state-counts.mts b/packages/spec/scripts/liveness/build-state-counts.mts index 3e29e3fab6d..5215b4e90e2 100644 --- a/packages/spec/scripts/liveness/build-state-counts.mts +++ b/packages/spec/scripts/liveness/build-state-counts.mts @@ -2,8 +2,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Writes `packages/spec/liveness/state-counts.md` — every number the liveness - * ledger's "Current state" table used to publish by hand (#7377). + * Writes `packages/spec/liveness/state-counts/.md` — every number the + * liveness ledger's "Current state" table used to publish by hand (#7377), one + * shard per governed type (#20361). * * The table's Notes prose merged cleanly for a year; its NUMBERS drifted from the * gate on 9 of 30 rows and nothing could see it, because the count columns were @@ -45,6 +46,18 @@ * Regeneration is WHOLESALE — this script never patches a number in place, and * neither should you. * + * ## One shard per type, and no total (#20361) + * + * The artifact was one file with a row per type and a shared total row, and the + * `merge=os-regen` driver that defers it only runs in a LOCAL merge. GitHub's + * server-side merge runs none, so any two in-flight PRs that moved verdicts + * conflicted on the total row, and each landing turned every other one `dirty` — + * no CI run until a merge-and-regenerate round. So each type's row is its own + * file, the total is summed by whoever reads it (the gate prints it; so does this + * script) and committed nowhere, and a shard whose bytes did not change is not + * rewritten: a regeneration touches exactly the types whose counts moved. The + * retired single file is deleted if a merge carried it back. + * * Usage: * tsx build-state-counts.mts # rewrite the artifact * @@ -53,19 +66,23 @@ */ import { spawnSync } from 'node:child_process'; -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, rmSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { - STATE_COUNTS_FILE, + LEGACY_STATE_COUNTS_FILE, + STATE_COUNTS_DIR, STATE_COUNTS_PATH, STATE_COUNTS_TOTALS_GUIDANCE, foldStateCounts, + formatStateCountsTotal, parseStateTable, reconcileStateCountTotals, - renderStateCounts, + renderStateCountShards, + sumStateCounts, + writeStateCountShards, } from './readme-table.mts'; const here = dirname(fileURLToPath(import.meta.url)); @@ -94,7 +111,7 @@ let report: { try { report = JSON.parse(run.stdout || ''); } catch { - console.error(`✗ ${gate} --json produced no parseable report — refusing to write ${STATE_COUNTS_FILE}.`); + console.error(`✗ ${gate} --json produced no parseable report — refusing to write ${STATE_COUNTS_PATH}.`); console.error(' Nothing is written from a half-measurement; a stale artifact is the safer state.\n'); if (run.error) console.error(` ${run.error.message}`); if (run.stderr) console.error(run.stderr); @@ -124,19 +141,26 @@ const totalErrors = reconcileStateCountTotals({ classified: Object.fromEntries(Object.entries(types).map(([t, v]) => [t, v.classified])), }); if (totalErrors.length) { - console.error(`✗ refusing to write ${STATE_COUNTS_FILE} — the fold does not preserve the walk's total:\n`); + console.error(`✗ refusing to write ${STATE_COUNTS_PATH} — the fold does not preserve the walk's total:\n`); totalErrors.forEach((s) => console.error(` ${s}`)); console.error(''); STATE_COUNTS_TOTALS_GUIDANCE.forEach((line) => console.error(line ? ` ${line}` : '')); process.exit(1); } -const rendered = renderStateCounts(rows); -writeFileSync(join(ledgerRoot, STATE_COUNTS_FILE), rendered); +const { written, removed } = writeStateCountShards(join(ledgerRoot, STATE_COUNTS_DIR), renderStateCountShards(rows)); +const legacy = join(ledgerRoot, LEGACY_STATE_COUNTS_FILE); +const legacyRemoved = existsSync(legacy); +if (legacyRemoved) rmSync(legacy); -const total = rows.reduce((a, r) => a + r.live + r.experimental + r['live-elsewhere'] + r.dead + r.planned, 0); -console.log(`✓ wrote ${STATE_COUNTS_PATH}`); -console.log(` ${rows.length} governed type(s), ${total} classified propert(ies).`); +console.log(`✓ wrote ${STATE_COUNTS_PATH} — ${rows.length} governed type(s), one shard each.`); +console.log( + ` ${written.length} shard(s) rewritten${written.length ? ` (${written.join(', ')})` : ''}, ` + + `${removed.length} pruned${removed.length ? ` (${removed.join(', ')})` : ''}` + + (legacyRemoved ? `, and the retired ${LEGACY_STATE_COUNTS_FILE} deleted` : '') + + '.', +); +console.log(` total, summed here and committed nowhere: ${formatStateCountsTotal(sumStateCounts(rows))}.`); // ── the #7257 skeleton, preserved ── // Prefer the gate's own reconciliation when the report carries it; fall back to a diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 6bd1d578df8..9f6c11062c8 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -234,19 +234,24 @@ import { type ContainerCoverage, } from './drill.mts'; import { + LEGACY_STATE_COUNTS_FILE, README_ORPHAN_ROW_GUIDANCE, README_TABLE_GUIDANCE, - STATE_COUNTS_FILE, + STATE_COUNTS_DIR, STATE_COUNTS_GUIDANCE, STATE_COUNTS_PATH, STATE_COUNTS_TOTALS_GUIDANCE, STATUS_COLUMNS, foldStateCounts, + formatStateCountsTotal, parseStateTable, + readStateCountShards, reconcileReadmeTable, reconcileStateCountTotals, reconcileStateCounts, - renderStateCounts, + renderStateCountShards, + sumStateCounts, + type StateCountsTotal, } from './readme-table.mts'; const here = dirname(fileURLToPath(import.meta.url)); @@ -720,8 +725,9 @@ const report: any = { readmeHeadingErrors: [] as string[], // "N governed types" disagrees with the rows / with GOVERNED readmeMalformedRows: [] as string[], // a table line the row parser could not read — never silently skipped readmeRowCount: 0, // rows the parser found, printed every run so the number is visible rather than believed - countsArtifactErrors: [] as string[], // state-counts.md is missing, or its bytes are not what the gate measures (#7377) - countsRowSetErrors: [] as string[], // the README's row set and the artifact's disagree + countsArtifactErrors: [] as string[], // a state-counts/ shard is missing, stale or stray, or the retired single file is back (#7377, #20361) + countsRowSetErrors: [] as string[], // the README's row set and the shards' disagree + countsTotal: null as StateCountsTotal | null, // the table's total, summed at read time — no file commits it (#20361) countsHandEdited: [] as string[], // a count column is back in the README — a hand-maintained number in the merge path countsTotalErrors: [] as string[], // the four columns and the walk's own `classified` disagree — the fold dropped a status (#13083) unknownStatus: [] as string[], // a ledger `status` outside STATUS_COLUMNS — counted by the walk, dropped by the fold (#13083) @@ -1215,6 +1221,14 @@ report.deferredChildKeys = coverage.deferredChildKeys; // reason: a gate that fails is worth exactly as much as the proof that it fails, // and this table is complete on a green tree. const readmeFile = join(ledgerRoot, 'README.md'); +// The rows every shard is rendered from, and the table's total, which is summed +// HERE — at read time — because no file commits it any more (#20361): a +// committed total was the one line every liveness PR rewrote. +const countRows = foldStateCounts( + GOVERNED, + Object.fromEntries(Object.entries(report.types).map(([t, v]) => [t, v.byStatus])), +); +report.countsTotal = sumStateCounts(countRows); if (!existsSync(readmeFile)) { report.readmeHeadingErrors.push(`${readmeFile} does not exist — the ledger index is gone`); } else { @@ -1228,21 +1242,16 @@ if (!existsSync(readmeFile)) { report.readmeMalformedRows = readme.malformed; report.readmeRowCount = stateTable.rows.length; - // ── the count columns, now a generated artifact (#7377) ── + // ── the count columns, now a generated artifact (#7377), one shard per type (#20361) ── // Read from `ledgerRoot` for the same reason the table above is: it is what // lets the self-test point the REAL gate at a copy with one number skewed and // read the exit code. An artifact the gate could only ever find in its own // green state is an artifact whose check is unproven. - const countsFile = join(ledgerRoot, STATE_COUNTS_FILE); const counts = reconcileStateCounts({ table: stateTable, - rendered: renderStateCounts( - foldStateCounts( - GOVERNED, - Object.fromEntries(Object.entries(report.types).map(([t, v]) => [t, v.byStatus])), - ), - ), - onDisk: existsSync(countsFile) ? readFileSync(countsFile, 'utf8') : null, + rendered: renderStateCountShards(countRows), + onDisk: readStateCountShards(join(ledgerRoot, STATE_COUNTS_DIR)), + legacyOnDisk: existsSync(join(ledgerRoot, LEGACY_STATE_COUNTS_FILE)), }); report.countsArtifactErrors = counts.artifactErrors; report.countsRowSetErrors = counts.rowSetErrors; @@ -1648,7 +1657,7 @@ if (asJson) { console.log( '\n This is the shape UNCLASSIFIED above cannot catch, and it is worse than\n' + ' UNCLASSIFIED because it looks DONE: the row has a verdict, the forward pass is\n' + - ` satisfied, the walk counts it — and then ${STATE_COUNTS_FILE} drops it, because\n` + + ` satisfied, the walk counts it — and then ${STATE_COUNTS_DIR}/ drops it, because\n` + ` the fold reads ${STATUS_COLUMNS.join(' / ')} and nothing else. The published\n` + ' total comes out short by exactly these rows, and every other check in this gate\n' + ' compares that total against itself and agrees (#13083).\n\n' + @@ -1769,7 +1778,7 @@ if (asJson) { } if (report.countsRowSetErrors.length) { console.log( - `\n✗ ${report.countsRowSetErrors.length} row(s) where README.md and ${STATE_COUNTS_FILE} disagree:`, + `\n✗ ${report.countsRowSetErrors.length} row(s) where README.md and ${STATE_COUNTS_DIR}/ disagree:`, ); report.countsRowSetErrors.forEach((s: string) => console.log(` ${s}`)); console.log( @@ -1796,7 +1805,7 @@ if (asJson) { } if (report.countsTotalErrors.length) { console.log( - `\n✗ ${report.countsTotalErrors.length} governed type(s) where ${STATE_COUNTS_FILE}'s columns ` + + `\n✗ ${report.countsTotalErrors.length} governed type(s) where the ${STATE_COUNTS_DIR}/ shard's columns ` + "do not add up to the walk's own count:", ); report.countsTotalErrors.forEach((s: string) => console.log(` ${s}`)); @@ -1940,8 +1949,11 @@ if (asJson) { `for each of the ${report.readmeRowCount} governed type(s) it claims to index.`, ); console.log( - `✓ ${STATE_COUNTS_PATH} is current — the same ${report.readmeRowCount} row(s), ` + - 'no count column left in the README.', + `✓ ${STATE_COUNTS_PATH} is current — one shard per governed type, the same ` + + `${report.readmeRowCount} row(s) as the README, no count column left in the README.`, + ); + console.log( + ` total across the shards, summed at read time and committed nowhere: ${formatStateCountsTotal(report.countsTotal)}.`, ); if (report.undrilledChildKeys) { console.log( diff --git a/packages/spec/scripts/liveness/readme-table.mts b/packages/spec/scripts/liveness/readme-table.mts index 141214603d5..e6705338cfd 100644 --- a/packages/spec/scripts/liveness/readme-table.mts +++ b/packages/spec/scripts/liveness/readme-table.mts @@ -46,16 +46,35 @@ // prose — hand-written measurement, "how this type got where it is", the one part // of the table a script cannot author. // +// WHY THE ARTIFACT IS A DIRECTORY (#20361). #7377 made the numbers one generated +// file with a row per type AND a shared total row. `merge=os-regen` defers that +// file only in a LOCAL merge; GitHub's server-side merge — the one that decides a +// PR's `mergeable` state and builds the ref CI runs on — runs no custom driver. So +// every PR that moved a verdict rewrote the one total row, any two of them in +// flight conflicted on it, and the moment one landed every other went `dirty` and +// got no CI run at all. When the two deltas happened to be EQUAL the text merge +// was worse than a conflict: both sides wrote the same total, git took it once, +// and the merged table published a total short by one side's move. So the counts +// are sharded one file per governed type (`state-counts/.md`, the +// `.gitattributes` cure its header already names), each shard carries only its +// own row, and NO total is committed — the gate sums the shards when it reads +// them. Two PRs that move different types now touch disjoint files; a same-type +// pair still conflicts on that type's one row, which is the residue sharding +// cannot remove and the local driver still owns. +// // This module therefore serves two reconciliations over one parse: // // - `reconcileReadmeTable` — the row set against `GOVERNED` (#7257, unchanged); -// - `reconcileStateCounts` — the artifact against the gate's own report, the -// README's row set against the artifact's, and the README against a count -// column coming back (#7377). +// - `reconcileStateCounts` — every shard against the gate's own report, the +// README's row set against the shards', and the README against a count +// column coming back (#7377, sharded at #20361). // // STILL NOT CHECKED, and it must stay that way: the Notes cell's CONTENT. A // manufactured Note is worse than a missing row. +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + /** One parsed row of the "Current state" table. */ export interface StateTableRow { /** The type named in the row's first cell. */ @@ -253,18 +272,35 @@ export const README_ORPHAN_ROW_GUIDANCE = [ ]; /* ══════════════════════════════════════════════════════════════════════════ - * The count columns, as a generated artifact (#7377) + * The count columns, as a generated artifact (#7377), sharded (#20361) * ══════════════════════════════════════════════════════════════════════════ */ -/** Where the generated counts live, relative to the ledger root. */ -export const STATE_COUNTS_FILE = 'state-counts.md'; +/** + * Where the generated counts live, relative to the ledger root: a DIRECTORY + * holding one `.md` shard per governed type, and nothing else (#20361). + */ +export const STATE_COUNTS_DIR = 'state-counts'; /** Its repo-relative path, for failure messages a reader can open. */ -export const STATE_COUNTS_PATH = `packages/spec/liveness/${STATE_COUNTS_FILE}`; +export const STATE_COUNTS_PATH = `packages/spec/liveness/${STATE_COUNTS_DIR}/`; + +/** + * The single file the shards replaced. Named for exactly one live reason: a + * branch cut before #20361 still carries it, and a merge of `main` into that + * branch meets it as a modify/delete. Kept, it would publish a stale table and a + * stale total beside the shards, and nothing would re-render it — so its presence + * is an artifact error, and the generator deletes it. + */ +export const LEGACY_STATE_COUNTS_FILE = 'state-counts.md'; /** The one command that rewrites it. Named in every failure below. */ export const STATE_COUNTS_GEN_COMMAND = 'pnpm --filter @objectstack/spec gen:liveness-counts'; +/** The shard file that carries one governed type's row. */ +export function stateCountShardName(type: string): string { + return `${type}.md`; +} + /** * The status columns the table publishes, in the order it publishes them. * `live-elsewhere` is the deliberate fifth (#13483): dead here by measurement, @@ -272,7 +308,7 @@ export const STATE_COUNTS_GEN_COMMAND = 'pnpm --filter @objectstack/spec gen:liv * deletable and must not satisfy `live`'s local-evidence rules (its own * executable criteria live in elsewhere.mts). Widening this list is an * artifact-shape decision (#7377): `StateCountsRow`, `foldStateCounts` and - * `renderStateCounts` name every column by hand — move all of them together + * `renderStateCountShard` name every column by hand — move all of them together * with this line, then regenerate. */ export const STATUS_COLUMNS = ['live', 'experimental', 'live-elsewhere', 'dead', 'planned'] as const; @@ -321,7 +357,7 @@ export function foldStateCounts( * `foldStateCounts` above reads the published names and nothing else, so a `byStatus` * bucket it cannot name — a ledger row written `"status": "planed"` — is dropped * on the floor. Every check downstream then agrees with every other, because - * they are all reading the same understated fold: `renderStateCounts` computes + * they are all reading the same understated fold: `renderStateCountShard` computes * the `classified` column as the SUM OF THE FOUR COLUMNS BESIDE IT, the * freshness leg compares those bytes against a re-render of the same fold, and * the README agrees with that. The published total is smaller than the ledger by @@ -336,7 +372,7 @@ export function foldStateCounts( * Deliberately NOT an "other" column. That would change what the artifact * PUBLISHES — a fifth column, new bytes, a re-render of every row — and the * defect here is that the gate cannot SEE a dropped status, not that the table - * should carry one. This leg leaves `renderStateCounts` byte-identical and adds + * should carry one. This leg leaves `renderStateCountShard` byte-identical and adds * a reading; the file's idiom for "a population the artifact must not hide" is a * `reconcile*` returning named errors (see `reconcileStateCounts`, and the * heading rule its interface states), not a wider table. @@ -375,7 +411,8 @@ export function reconcileStateCountTotals({ const unnamed = Object.entries(byStatus[row.type] ?? {}).filter(([s]) => !named.has(s)); errors.push( - `${row.type} — ${STATE_COUNTS_FILE} publishes ${columnSum} classified, the walk counted ${walked}` + + `${row.type} — ${STATE_COUNTS_DIR}/${stateCountShardName(row.type)} publishes ${columnSum} classified, ` + + `the walk counted ${walked}` + (unnamed.length ? `; ${unnamed.map(([s, n]) => `${n} in \`${s}\``).join(', ')} — not one of ${STATUS_COLUMNS.join(' / ')}` : '; no unnamed status accounts for the gap — the fold and the walk have come apart for another reason'), @@ -399,7 +436,7 @@ export const STATE_COUNTS_TOTALS_GUIDANCE = [ ' the offending row. Never add the misspelling to STATUS_COLUMNS to get green.', '', ' • a status DELIBERATELY added to STATUS_COLUMNS — then the vocabulary grew and', - ' the fold did not. `StateCountsRow`, `foldStateCounts` and `renderStateCounts`', + ' the fold did not. `StateCountsRow`, `foldStateCounts` and `renderStateCountShard`', ' all name every column by hand, and a new one publishes as a COLUMN, which', ' changes what the artifact contains. That is an artifact-shape decision (#7377):', ' make it deliberately, move all the named sites together, and regenerate —', @@ -410,74 +447,138 @@ export const STATE_COUNTS_TOTALS_GUIDANCE = [ ]; /** - * Render the whole artifact. The generator writes this; the gate renders it again - * and compares BYTES. + * Render ONE governed type's shard. The generator writes these; the gate renders + * them again and compares BYTES, shard by shard. * * Byte comparison, deliberately not a second parser — #5107's rule, and the * reason it is a rule: two implementations of the same truth eventually disagree, * and the one that wins is whichever the gate happens to call, which is how a * green check ends up standing over a wrong file. Regeneration is WHOLESALE; this * function never patches a number in place and neither should anyone. + * + * Everything in a shard is about its own type and nothing else (#20361). That is + * the whole locality claim: a line naming a sibling type, the governed-type count + * or a total would be a line two PRs moving different types both rewrite, which + * is exactly the conflict the shard exists to remove. So the prose names only + * this type, links the README without the heading anchor (that anchor carries + * the governed-type count), and the total is left to the reader that sums. */ -export function renderStateCounts(rows: readonly StateCountsRow[]): string { - const total = rows.reduce( - (a, r) => ({ - type: 'total', - live: a.live + r.live, - experimental: a.experimental + r.experimental, - 'live-elsewhere': a['live-elsewhere'] + r['live-elsewhere'], - dead: a.dead + r.dead, - planned: a.planned + r.planned, - }), - { type: 'total', live: 0, experimental: 0, 'live-elsewhere': 0, dead: 0, planned: 0 }, - ); - - const classifiedOf = (r: StateCountsRow) => STATUS_COLUMNS.reduce((a, c) => a + r[c], 0); - const body = rows.map( - (r) => `| \`${r.type}\` | ${r.live} | ${r.experimental} | ${r['live-elsewhere']} | ${r.dead} | ${r.planned} | ${classifiedOf(r)} |`, - ); - +export function renderStateCountShard(row: StateCountsRow): string { return [ '', ``, '', - '# Liveness state table — the counts (generated)', - '', - 'Every number the [liveness ledger README](./README.md)\'s "Current state" table', - 'used to publish, computed by the gate that enforces them —', - '`scripts/liveness/check-liveness.mts --json`, `types..byStatus`, the', - 'counting method fixed in #4488. The Notes prose, which is hand-written', - 'measurement of how each type got where it is, stays in the README and is never', - 'regenerated.', - '', - 'Split out at #7377 on #5107\'s precedent. Nine of the thirty rows had drifted', - 'from the gate by the time anyone re-ran the documented snippet, and', - 'hand-maintained counts merge in the one way that hides: two PRs each move a', - 'different row by their own correct delta, the rows do not overlap, git merges', - 'them without complaint, and the result is a table nobody wrote down. The', - 'correct resolution was always "recompute from the merged tree", so this path', - 'carries `merge=os-regen` (#4675) and the recomputation is mandatory rather than', - 'remembered. **Never hand-patch a number here** — fix the ledger or the schema', - 'and regenerate.', + `# \`${row.type}\` — liveness counts (generated)`, '', - 'Counts are at the gate\'s one-level walk granularity and include the ADR-0010', - 'protection envelope, which the gate auto-classifies `live` on every type that', - 'spreads `MetadataProtectionFields`. See the README\'s counting-method section', - 'for both corollaries.', + `This type's row of the liveness state table, computed by the gate that enforces`, + 'it (`scripts/liveness/check-liveness.mts --json`, `types..byStatus`). Its', + `Notes prose is the \`${row.type}\` row of [the ledger README](../README.md), which`, + 'also states the counting method. One file per governed type, and no total is', + 'committed anywhere: `check:liveness` sums the shards when it reads them.', + '**Never hand-patch a number here** — fix the ledger or the schema and regenerate.', '', '| Type | live | exp | elsewhere | dead | planned | classified |', '|---|---|---|---|---|---|---|', - ...body, - `| **total** | **${total.live}** | **${total.experimental}** | **${total['live-elsewhere']}** | **${total.dead}** | **${total.planned}** | **${classifiedOf(total)}** |`, + `| \`${row.type}\` | ${row.live} | ${row.experimental} | ${row['live-elsewhere']} | ${row.dead} | ` + + `${row.planned} | ${classifiedOf(row)} |`, '', ].join('\n'); } +/** Every shard, keyed by its file name, in `GOVERNED` order. */ +export function renderStateCountShards(rows: readonly StateCountsRow[]): Map { + const out = new Map(); + for (const row of rows) { + const name = stateCountShardName(row.type); + if (out.has(name)) throw new Error(`two governed rows render the same shard ${name} — GOVERNED lists a type twice`); + out.set(name, renderStateCountShard(row)); + } + return out; +} + +/** The `classified` column: the published status columns, summed. */ +function classifiedOf(row: Omit): number { + return STATUS_COLUMNS.reduce((a, c) => a + row[c], 0); +} + +/** The table's total, which no file carries any more — summed where it is read. */ +export interface StateCountsTotal { + live: number; + experimental: number; + 'live-elsewhere': number; + dead: number; + planned: number; + classified: number; +} + +/** + * Sum the rows at READ time (#20361). This is the number the single-file artifact + * used to commit as its `**total**` row — the one line every liveness PR rewrote, + * so the one line any two of them conflicted on. It is computed by whoever needs + * it, from the same fold the shards are rendered from, and written nowhere. + */ +export function sumStateCounts(rows: readonly StateCountsRow[]): StateCountsTotal { + const total: StateCountsTotal = { live: 0, experimental: 0, 'live-elsewhere': 0, dead: 0, planned: 0, classified: 0 }; + for (const row of rows) { + for (const c of STATUS_COLUMNS) total[c] += row[c]; + total.classified += classifiedOf(row); + } + return total; +} + +/** `940 live · 5 experimental · … = 1103 classified` — one line, column order. */ +export function formatStateCountsTotal(total: StateCountsTotal): string { + return `${STATUS_COLUMNS.map((c) => `${total[c]} ${c}`).join(' · ')} = ${total.classified} classified`; +} + +/** + * Read the shard directory as the gate compares it: every entry's bytes, keyed by + * name, or `null` when the directory does not exist. A subdirectory is keyed with + * a trailing `/` and no bytes, so it can only ever surface as a stray — nothing in + * a generator-owned directory is skipped silently. + */ +export function readStateCountShards(dir: string): Map | null { + if (!existsSync(dir)) return null; + const out = new Map(); + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isFile()) out.set(entry.name, readFileSync(join(dir, entry.name), 'utf8')); + else out.set(`${entry.name}/`, ''); + } + return out; +} + +/** + * Write every shard whose bytes changed, and prune everything else in the + * directory. An unchanged shard is not rewritten, so a regeneration touches + * exactly the types whose counts moved — the locality this layout is for — and + * the returned lists say which, so the generator can print them. + */ +export function writeStateCountShards( + dir: string, + shards: ReadonlyMap, +): { written: string[]; removed: string[] } { + mkdirSync(dir, { recursive: true }); + const written: string[] = []; + for (const [name, text] of shards) { + const file = join(dir, name); + if (existsSync(file) && readFileSync(file, 'utf8') === text) continue; + writeFileSync(file, text); + written.push(name); + } + const removed: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (shards.has(entry.name)) continue; + rmSync(join(dir, entry.name), { recursive: true, force: true }); + removed.push(entry.isFile() ? entry.name : `${entry.name}/`); + } + return { written, removed }; +} + /** What `reconcileStateCounts` found. Separate from `ReadmeReconciliation` on purpose — one population per failure heading. */ export interface StateCountsReconciliation { - /** The artifact is absent, or its bytes are not what the gate renders right now. */ + /** A shard is absent, stale or stray, the directory is gone, or the retired single file came back. */ artifactErrors: string[]; - /** The README's row set and the artifact's disagree, in either direction. */ + /** The README's row set and the shards' disagree, in either direction. */ rowSetErrors: string[]; /** A count column has come back into the README — a hand-maintained number in the merge path again. */ handCountErrors: string[]; @@ -487,17 +588,21 @@ export interface StateCountsReconciliation { const COUNT_CELL_RE = /^(\d+|[–—-])$/; /** - * Reconcile the generated artifact against the gate, and the README against the - * artifact. + * Reconcile the generated shards against the gate, and the README against the + * shards. * * Three legs, and each fails for a reason the other two cannot see: * - * A. FRESHNESS — the artifact equals what the gate measures right now. This is - * the leg the hand-edit used to buy for free: touching a schema forced you - * back through the table to confirm the Note beside the number still held. - * It still does, and the failure says so — `gen:` then READ the diff. - * B. ROW SET — every artifact row has a README row and back. The README's rows - * are reconciled against `GOVERNED` separately (#7257) and the artifact is + * A. FRESHNESS — every shard equals what the gate measures right now, no shard + * is missing, nothing else sits in the directory, and the retired single + * file is gone. This is the leg the hand-edit used to buy for free: touching + * a schema forced you back through the table to confirm the Note beside the + * number still held. It still does, and the failure says so — `gen:` then + * READ the diff. A STRAY shard fails too: a type that left `GOVERNED` would + * otherwise keep publishing its last counts, beside rows that no longer + * include it, and nobody would re-render them. + * B. ROW SET — every rendered shard has a README row and back. The README's rows + * are reconciled against `GOVERNED` separately (#7257) and the shards are * generated FROM `GOVERNED`, so in a green tree this is implied; it is * checked anyway because "implied by two other checks" is how the heading's * completeness claim survived unfalsifiable for a year. @@ -511,13 +616,16 @@ export function reconcileStateCounts({ table, rendered, onDisk, + legacyOnDisk, }: { /** The parsed README section — rows and their cells. */ table: ParsedStateTable; - /** What `renderStateCounts` produces from the gate's report right now. */ - rendered: string; - /** The artifact's bytes, or `null` when the file does not exist. */ - onDisk: string | null; + /** What `renderStateCountShards` produces from the gate's report right now. */ + rendered: ReadonlyMap; + /** The shard directory as `readStateCountShards` reads it, or `null` when it does not exist. */ + onDisk: ReadonlyMap | null; + /** Whether the retired single-file artifact is still on disk beside the shards. */ + legacyOnDisk: boolean; }): StateCountsReconciliation { const artifactErrors: string[] = []; const rowSetErrors: string[] = []; @@ -525,25 +633,49 @@ export function reconcileStateCounts({ if (onDisk === null) { artifactErrors.push(`${STATE_COUNTS_PATH} is MISSING — the table's numbers are published by nothing.`); - } else if (onDisk !== rendered) { + } else { + for (const [name, text] of rendered) { + const current = onDisk.get(name); + if (current === undefined) { + artifactErrors.push(`${STATE_COUNTS_PATH}${name} is MISSING — a governed type whose counts nothing publishes.`); + } else if (current !== text) { + artifactErrors.push( + `${STATE_COUNTS_PATH}${name} is STALE — it does not match what the gate measures right now.\n` + + ` ${firstStateCountsDifference(current, text)}`, + ); + } + } + for (const name of [...onDisk.keys()].sort()) { + if (rendered.has(name)) continue; + artifactErrors.push( + `${STATE_COUNTS_PATH}${name} is STRAY — no governed type renders it. The directory is ` + + 'generator-owned: a type that left GOVERNED, or a file written by hand, and either way ' + + 'numbers nothing re-renders.', + ); + } + } + if (legacyOnDisk) { artifactErrors.push( - `${STATE_COUNTS_PATH} is STALE — it does not match what the gate measures right now.\n` + - ` ${firstStateCountsDifference(onDisk, rendered)}`, + `packages/spec/liveness/${LEGACY_STATE_COUNTS_FILE} is RETIRED — the counts are one shard per ` + + `governed type under ${STATE_COUNTS_PATH}, and this file's committed total row is the line every ` + + 'liveness PR rewrote. A branch cut before the split keeps it through a merge; delete it.', ); } - // Leg B reads the artifact the gate just RENDERED, not the copy on disk: on a - // stale artifact leg A has already fired, and reconciling against a file we + // Leg B reads the shards the gate just RENDERED, not the copies on disk: on a + // stale shard leg A has already fired, and reconciling against a file we // know to be wrong would report the same defect twice under two headings. - const artifactTypes = parseRenderedCountRows(rendered); + const artifactTypes = [...rendered.values()].flatMap(parseRenderedCountRows); const readmeTypes = table.rows.map((r) => r.type); const readmeSet = new Set(readmeTypes); const artifactSet = new Set(artifactTypes); for (const t of artifactTypes) { - if (!readmeSet.has(t)) rowSetErrors.push(`${t} — counted in ${STATE_COUNTS_FILE}, no row in the README table`); + if (!readmeSet.has(t)) { + rowSetErrors.push(`${t} — counted in ${STATE_COUNTS_DIR}/${stateCountShardName(t)}, no row in the README table`); + } } for (const t of readmeTypes) { - if (!artifactSet.has(t)) rowSetErrors.push(`${t} — a README row with no counts in ${STATE_COUNTS_FILE}`); + if (!artifactSet.has(t)) rowSetErrors.push(`${t} — a README row with no counts in ${STATE_COUNTS_DIR}/`); } for (const row of table.rows) { @@ -558,7 +690,7 @@ export function reconcileStateCounts({ return { artifactErrors, rowSetErrors, handCountErrors }; } -/** The type names the rendered artifact publishes, in its own order. */ +/** The type names a rendered shard publishes, in its own order. */ function parseRenderedCountRows(rendered: string): string[] { const out: string[] = []; for (const line of rendered.split('\n')) { @@ -579,9 +711,11 @@ function firstStateCountsDifference(actual: string, expected: string): string { return 'the files differ but no line does — a trailing-newline difference.'; } -/** The prescription printed under a stale or missing artifact. */ +/** The prescription printed under a stale, missing or stray shard. */ export const STATE_COUNTS_GUIDANCE = [ - `The count columns are GENERATED (#7377). Regenerate them, wholesale:`, + `The count columns are GENERATED (#7377), one shard per governed type. Regenerate`, + 'them, wholesale — the generator rewrites only the shards whose counts moved and', + 'prunes anything else in the directory:', '', ` ${STATE_COUNTS_GEN_COMMAND}`, '', @@ -592,7 +726,8 @@ export const STATE_COUNTS_GUIDANCE = [ 'keeping: #7377 found `translation` publishing `dead 2` next to a sentence', 'naming one key, and that key had already been removed.', '', - '⛔ Never hand-patch a number in the artifact, and never put a count column back', - 'into the README table. Both put the numbers back in the merge path, where they', - 'merge clean and wrong (#5107).', + '⛔ Never hand-patch a number in a shard, never commit a total, and never put a', + 'count column back into the README table. All three put the numbers back in the', + 'merge path, where they merge clean and wrong (#5107) or conflict for every PR', + 'in flight at once.', ]; diff --git a/scripts/regen-artifacts.mjs b/scripts/regen-artifacts.mjs index 8ca6cb41674..20be940b194 100644 --- a/scripts/regen-artifacts.mjs +++ b/scripts/regen-artifacts.mjs @@ -216,8 +216,15 @@ export const REGEN_ARTIFACTS = Object.freeze([ // produces the numbers rather than by a parser reading them back. No // `readsDist`: the gate walks `src/` Zod schemas through tsx, so a merge that // moved sources is all it needs to be re-run against. - { - path: 'packages/spec/liveness/state-counts.md', + // + // #20361 SHARDED it — one `.md` per governed type, no committed total — + // for the reason the three directory rows above were sharded (#5837): this + // driver runs only in a local merge, and the single file's shared total row + // made any two in-flight liveness PRs conflict in GitHub's server-side one. + // Different types now touch disjoint files; a same-type pair still meets on + // that type's one row, and that residue is what this row still routes. + { + path: 'packages/spec/liveness/state-counts/**', gen: 'gen:liveness-counts', check: 'check:liveness', }, From 48f23c540222396986e80cd4aad6747208272801 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 20:13:11 +0000 Subject: [PATCH 2/6] test(spec): pin the sharded liveness counts against a driver-free merge state-counts-merge.test.ts builds a throwaway repository carrying the real attribute line and no merge driver (the server-side shape), commits the renderer's shards, and asks git merge-tree: two moves of different types (different deltas, equal deltas, adjacent rows) merge clean and equal the regeneration of both; a same-type pair still conflicts on that type's shard. readme-table and check-liveness tests follow the shard layout, cover a stray shard and the retired single file, and pin parity: the total the gate prints equals the sum of the shards on disk. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- .../scripts/liveness/check-liveness.test.ts | 117 +++++++--- .../scripts/liveness/readme-table.test.ts | 216 ++++++++++++++---- .../liveness/state-counts-merge.test.ts | 207 +++++++++++++++++ packages/spec/vitest.repo-tests.json | 1 + 4 files changed, 462 insertions(+), 79 deletions(-) create mode 100644 packages/spec/scripts/liveness/state-counts-merge.test.ts diff --git a/packages/spec/scripts/liveness/check-liveness.test.ts b/packages/spec/scripts/liveness/check-liveness.test.ts index c2f850cb1bf..696094c2eb6 100644 --- a/packages/spec/scripts/liveness/check-liveness.test.ts +++ b/packages/spec/scripts/liveness/check-liveness.test.ts @@ -21,7 +21,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { spawnSync } from 'node:child_process'; import { createRequire } from 'node:module'; -import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -646,13 +646,15 @@ describe('check:liveness — the README state table (#7257)', () => { }); }); -// The generated count artifact (#7377). Same argument as the block above and the -// same mechanism: on a green tree the artifact is current and the README carries -// no numbers, so `pnpm check:liveness` passing says nothing about whether these -// legs can fire. `--ledger-root` points the REAL gate at a copy — which `cpSync` -// carries `state-counts.md` into alongside README.md — so a case can delete the -// artifact, skew one number, or put a column back and read the real exit code. -describe('check:liveness — the generated count artifact (#7377)', () => { +// The generated count artifact (#7377), one shard per governed type (#20361). +// Same argument as the block above and the same mechanism: on a green tree the +// shards are current and the README carries no numbers, so `pnpm check:liveness` +// passing says nothing about whether these legs can fire. `--ledger-root` points +// the REAL gate at a copy — which `cpSync` carries the `state-counts/` directory +// into alongside README.md — so a case can delete the shards, skew one number, +// bring the retired single file back or put a column back, and read the real +// exit code. +describe('check:liveness — the generated count artifact (#7377, sharded #20361)', () => { let tmp: string; beforeAll(() => { @@ -669,7 +671,7 @@ describe('check:liveness — the generated count artifact (#7377)', () => { } it('FAILS when the artifact is gone — the numbers are published by nothing', () => { - const root = withCopy('missing', (r) => rmSync(path.join(r, 'state-counts.md'))); + const root = withCopy('missing', (r) => rmSync(path.join(r, 'state-counts'), { recursive: true })); const { status, output } = runGate(root); expect(status, output).toBe(1); @@ -681,9 +683,9 @@ describe('check:liveness — the generated count artifact (#7377)', () => { // The leg that replaces what the hand-edit used to buy. It must name the line // that moved: "the file is stale" sends the next reader to diff 30 rows, and // the point of the failure is the ONE row whose Note may no longer hold. - it('FAILS on a single skewed count, and names the line', () => { + it('FAILS on a single skewed count, and names the shard and the line', () => { const root = withCopy('skewed', (r) => { - const f = path.join(r, 'state-counts.md'); + const f = path.join(r, 'state-counts', 'view.md'); const md = readFileSync(f, 'utf8'); const before = md.match(/^\| `view` \| (\d+) \|/m); expect(before, 'the view row moved — repoint this case').not.toBeNull(); @@ -692,13 +694,26 @@ describe('check:liveness — the generated count artifact (#7377)', () => { const { status, output } = runGate(root); expect(status, output).toBe(1); - expect(output).toContain('is STALE'); + expect(output).toContain('state-counts/view.md is STALE'); expect(output).toContain('first difference at line'); expect(output).toContain('`view`'); // The half of the hand-edit worth keeping — regenerate AND re-read the Note. expect(output).toContain('READ the diff'); }); + // The transition hazard (#20361). A branch cut before the split meets the + // deletion as a modify/delete on its next base merge; a resolution that keeps + // the file would publish a stale table and a stale TOTAL beside the shards, + // re-rendered by nothing. It must be red, and the repair must be named. + it('FAILS when the retired single-file artifact comes back beside the shards', () => { + const root = withCopy('legacy', (r) => writeFileSync(path.join(r, 'state-counts.md'), '| **total** | **1** |\n')); + + const { status, output } = runGate(root); + expect(status, output).toBe(1); + expect(output).toContain('state-counts.md is RETIRED'); + expect(output).toContain('gen:liveness-counts'); + }); + // The leg neither of the others can see: a re-added column leaves the artifact // fresh and the row sets equal, so the table would publish two sets of numbers // with only one of them enforced. @@ -727,18 +742,45 @@ describe('check:liveness — the generated count artifact (#7377)', () => { const { status, output } = runGate(root); expect(status, output).toBe(1); - expect(output).toContain('where README.md and state-counts.md disagree'); - expect(output).toContain('qa — counted in state-counts.md, no row in the README table'); + expect(output).toContain('where README.md and state-counts/ disagree'); + expect(output).toContain('qa — counted in state-counts/qa.md, no row in the README table'); }); - // The control for all four: the same copy, unedited, is green and says so. + // The control for all five: the same copy, unedited, is green and says so. // Without it every "exit 1" above is also satisfied by the copy being unusable. - it('is green against a verbatim copy, and says the artifact is current', () => { + // + // It is also the PARITY pin the split owes (#20361). The total is no longer + // committed anywhere, so the one place it is published is this line — and it + // must be the sum of the shards actually on disk, read back here row by row, + // not a second copy of the gate's own arithmetic. + it('is green against a verbatim copy, says the shards are current, and prints their sum', () => { const root = path.join(tmp, 'verbatim'); cpSync(LEDGERS, root, { recursive: true }); const { status, output } = runGate(root); expect(status, output).toBe(0); - expect(output).toMatch(/state-counts\.md is current — the same \d+ row\(s\), no count column left/); + expect(output).toMatch(/state-counts\/ is current — one shard per governed type, the same \d+ row\(s\) as the README/); + + const printed = output.match(/summed at read time and committed nowhere: (.+) = (\d+) classified\./); + expect(printed, output).not.toBeNull(); + const byColumn = Object.fromEntries( + printed![1].split(' · ').map((part) => { + const [n, c] = part.split(' '); + return [c, Number(n)]; + }), + ); + + const onDisk = readdirSync(path.join(root, 'state-counts')); + expect(onDisk.length).toBeGreaterThan(0); + const summed = new Array(STATUS_COLUMNS.length + 1).fill(0); + for (const name of onDisk) { + const rows = readFileSync(path.join(root, 'state-counts', name), 'utf8') + .split('\n') + .filter((l) => /^\| `[a-z_]+` \|/.test(l)); + expect(rows, name).toHaveLength(1); + rows[0].split('|').slice(2, -1).forEach((c, i) => (summed[i] += Number(c.trim()))); + } + expect(STATUS_COLUMNS.map((c) => byColumn[c])).toEqual(summed.slice(0, STATUS_COLUMNS.length)); + expect(Number(printed![2])).toBe(summed[STATUS_COLUMNS.length]); }); }); @@ -834,7 +876,7 @@ describe('check:liveness — the manifest is inside the governed universe (#1072 // A ledger `status` was free text: any truthy string was classified and counted, // then dropped by `foldStateCounts`, which reads four names and nothing else. The -// gate stayed GREEN over an understated total, because `state-counts.md` computes +// gate stayed GREEN over an understated total, because the count artifact computes // its `classified` column as the sum of those four columns and the freshness leg // compares it against a re-render of the same fold — every reconciliation in the // gate comparing that number against itself. @@ -1297,30 +1339,31 @@ interface Carrier { eligible: number; } -/** Move one unit between two status columns of a copied `state-counts.md`. */ +/** + * Move one unit between two status columns of a copied `state-counts/.md` + * shard. Its own row only: no file commits a total any more (#20361), so there + * is no second line to keep in step. + */ function moveCount(root: string, type: string, from: string, to: string): void { const fromCol = STATUS_COLUMNS.indexOf(from as (typeof STATUS_COLUMNS)[number]); const toCol = STATUS_COLUMNS.indexOf(to as (typeof STATUS_COLUMNS)[number]); expect(fromCol, `unknown status "${from}"`).toBeGreaterThanOrEqual(0); expect(toCol, `unknown status "${to}"`).toBeGreaterThanOrEqual(0); - const countsFile = path.join(root, 'state-counts.md'); + const countsFile = path.join(root, 'state-counts', `${type}.md`); let text = readFileSync(countsFile, 'utf8'); // The generated count artifact is checked on every run, so a sample that // moves a verdict and leaves the counts behind goes red for the WRONG reason // and masks the verdict this block is reading. - const shift = (rowRe: RegExp, wrap: (n: number) => string): void => { - const m = rowRe.exec(text); - expect(m, `no state-counts row matching ${rowRe}`).not.toBeNull(); - const nums = m![1].split('|').map((c) => Number(c.trim().replaceAll('*', ''))); - expect(nums).toHaveLength(STATUS_COLUMNS.length + 1); - nums[fromCol] -= 1; - nums[toCol] += 1; - const rebuilt = `${m![0].slice(0, m![0].indexOf('|', 1) + 1)} ${nums.map(wrap).join(' | ')} |`; - text = text.slice(0, m!.index) + rebuilt + text.slice(m!.index + m![0].length); - }; - shift(new RegExp(`^\\| \`${type}\` \\| (.+) \\|$`, 'm'), (n) => String(n)); - shift(/^\| \*\*total\*\* \| (.+) \|$/m, (n) => `**${n}**`); + const rowRe = new RegExp(`^\\| \`${type}\` \\| (.+) \\|$`, 'm'); + const m = rowRe.exec(text); + expect(m, `no state-counts row matching ${rowRe}`).not.toBeNull(); + const nums = m![1].split('|').map((c) => Number(c.trim())); + expect(nums).toHaveLength(STATUS_COLUMNS.length + 1); + nums[fromCol] -= 1; + nums[toCol] += 1; + const rebuilt = `${m![0].slice(0, m![0].indexOf('|', 1) + 1)} ${nums.join(' | ')} |`; + text = text.slice(0, m!.index) + rebuilt + text.slice(m!.index + m![0].length); writeFileSync(countsFile, text); } @@ -1439,10 +1482,12 @@ describe('check:liveness — a tombstoned key may not be graded `live` (#19062)' const red = sampleWith('apart-live', 'live'); const green = sampleWith('apart-dead', 'dead'); - const differing = readdirSync(green).filter( - (f) => readFileSync(path.join(green, f), 'utf8') !== readFileSync(path.join(red, f), 'utf8'), - ); - expect(differing.sort()).toEqual([`${carrier.type}.json`, 'state-counts.md'].sort()); + // Recursive: the counts are a directory of shards now (#20361), and a + // top-level listing would read that directory as a file. + const differing = (readdirSync(green, { recursive: true }) as string[]) + .filter((f) => statSync(path.join(green, f)).isFile()) + .filter((f) => readFileSync(path.join(green, f), 'utf8') !== readFileSync(path.join(red, f), 'utf8')); + expect(differing.sort()).toEqual([`${carrier.type}.json`, path.join('state-counts', `${carrier.type}.md`)].sort()); const redLedger = JSON.parse(readFileSync(path.join(red, `${carrier.type}.json`), 'utf8')); const greenLedger = JSON.parse(readFileSync(path.join(green, `${carrier.type}.json`), 'utf8')); diff --git a/packages/spec/scripts/liveness/readme-table.test.ts b/packages/spec/scripts/liveness/readme-table.test.ts index e90e8c6061e..2680044b8f4 100644 --- a/packages/spec/scripts/liveness/readme-table.test.ts +++ b/packages/spec/scripts/liveness/readme-table.test.ts @@ -10,8 +10,12 @@ // and from check-liveness.test.ts (the real gate, against a mutated copy of the // real README, reaching a real `process.exit(1)`). -import { describe, it, expect } from 'vitest'; +import { afterEach, beforeEach, describe, it, expect } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { + LEGACY_STATE_COUNTS_FILE, README_ORPHAN_ROW_GUIDANCE, README_TABLE_GUIDANCE, STATE_COUNTS_GEN_COMMAND, @@ -20,11 +24,16 @@ import { STATE_COUNTS_TOTALS_GUIDANCE, STATUS_COLUMNS, foldStateCounts, + formatStateCountsTotal, parseStateTable, + readStateCountShards, reconcileReadmeTable, reconcileStateCountTotals, reconcileStateCounts, - renderStateCounts, + renderStateCountShard, + renderStateCountShards, + sumStateCounts, + writeStateCountShards, } from './readme-table.mts'; /** A miniature README with the same section shape as the real one. */ @@ -228,6 +237,13 @@ const COUNTS = [ { type: 'api', live: 25, experimental: 0, 'live-elsewhere': 0, dead: 0, planned: 2 }, ]; +/** A shard's one table row, read back as numbers — test-side only; the gate compares bytes. */ +function parseShardRow(shard: string): number[] { + const rows = shard.split('\n').filter((l) => /^\| `[a-z_]+` \|/.test(l)); + expect(rows).toHaveLength(1); + return rows[0].split('|').slice(2, -1).map((c) => Number(c.trim())); +} + /** The 2-column README the split produced — prose only, no numbers. */ function proseReadme(rows = ['| object | notes |', '| field | notes |', '| api | notes |']) { return readme({ rows }); @@ -262,84 +278,162 @@ describe('foldStateCounts', () => { }); }); -describe('renderStateCounts', () => { - it('publishes a row per type, a classified column, and a total', () => { - const out = renderStateCounts(COUNTS); +describe('renderStateCountShard', () => { + it('publishes the type\'s own row and a classified column', () => { + const out = renderStateCountShard(COUNTS[0]); expect(out).toContain('| Type | live | exp | elsewhere | dead | planned | classified |'); expect(out).toContain('| `object` | 49 | 0 | 0 | 0 | 1 | 50 |'); - expect(out).toContain('| **total** | **140** | **0** | **0** | **0** | **3** | **143** |'); }); // The fifth column counts into `classified` like the other four (#13483) — // an elsewhere-verdict is a CLASSIFIED property, precisely not a gap. - it('counts live-elsewhere into the row and total classified sums', () => { - const out = renderStateCounts([ - { type: 'manifest', live: 22, experimental: 0, 'live-elsewhere': 1, dead: 15, planned: 0 }, - ]); + it('counts live-elsewhere into the row\'s classified sum', () => { + const out = renderStateCountShard({ + type: 'manifest', live: 22, experimental: 0, 'live-elsewhere': 1, dead: 15, planned: 0, + }); expect(out).toContain('| `manifest` | 22 | 0 | 1 | 15 | 0 | 38 |'); - expect(out).toContain('| **total** | **22** | **0** | **1** | **15** | **0** | **38** |'); }); it('says it is generated and names the one command that rewrites it', () => { - const out = renderStateCounts(COUNTS); + const out = renderStateCountShard(COUNTS[0]); expect(out).toContain('GENERATED — DO NOT EDIT BY HAND'); expect(out).toContain(STATE_COUNTS_GEN_COMMAND); }); + // THE LOCALITY CLAIM (#20361), asserted on the bytes rather than described. + // A shard that carried a total, a sibling's row or the governed-type count + // would carry a line that two PRs moving DIFFERENT types both rewrite — the + // exact conflict the split removes. So: exactly one table row, it is this + // type's, no total, and no line that differs between two shards except the + // lines naming the type itself or its numbers. + it('carries only its own row — no total, no sibling, nothing another type moves', () => { + const out = renderStateCountShard(COUNTS[1]); + const rows = out.split('\n').filter((l) => /^\| `[a-z_]+` \|/.test(l)); + expect(rows).toEqual(['| `field` | 66 | 0 | 0 | 0 | 0 | 66 |']); + expect(out).not.toMatch(/total\*\*/); + expect(out).not.toContain('`object`'); + expect(out).not.toContain('`api`'); + + const other = renderStateCountShard(COUNTS[2]).split('\n'); + const differing = out.split('\n').filter((l, i) => l !== other[i]); + for (const line of differing) expect(line, line).toMatch(/`field`/); + }); + // The whole scheme rests on the generator and the gate rendering the same // bytes from the same model. A renderer that varied by call would make the // freshness check fail on a file it had itself just written. it('is deterministic — the same model renders the same bytes', () => { - expect(renderStateCounts(COUNTS)).toBe(renderStateCounts(COUNTS)); + expect(renderStateCountShards(COUNTS)).toEqual(renderStateCountShards(COUNTS)); + }); + + it('keys one shard per type, in GOVERNED order, and refuses a type listed twice', () => { + expect([...renderStateCountShards(COUNTS).keys()]).toEqual(['object.md', 'field.md', 'api.md']); + expect(() => renderStateCountShards([COUNTS[0], COUNTS[0]])).toThrow(/twice/); + }); +}); + +// The total the single file used to COMMIT (#20361). It is still a published +// number — `check:liveness` prints it — so it is still pinned; what changed is +// that it is summed where it is read, never written where two PRs both rewrite it. +describe('sumStateCounts — the total, at read time', () => { + it('sums every column and the classified column', () => { + expect(sumStateCounts(COUNTS)).toEqual({ + live: 140, experimental: 0, 'live-elsewhere': 0, dead: 0, planned: 3, classified: 143, + }); + expect(formatStateCountsTotal(sumStateCounts(COUNTS))).toBe( + '140 live · 0 experimental · 0 live-elsewhere · 0 dead · 3 planned = 143 classified', + ); + }); + + // PARITY, the pin the split owes: the number the shards add up to is the + // number the single file's `**total**` row published for the same model. + // Checked against the shards as RENDERED, parsed back by this test (the gate + // itself never parses a shard — it compares bytes). + it('equals the sum of the rendered shards\' own rows', () => { + const parsed = [...renderStateCountShards(COUNTS).values()].map(parseShardRow); + const summed = parsed.reduce((a, r) => a.map((n, i) => n + r[i])); + const t = sumStateCounts(COUNTS); + expect(summed).toEqual([t.live, t.experimental, t['live-elsewhere'], t.dead, t.planned, t.classified]); }); }); describe('reconcileStateCounts — what it must catch', () => { - const rendered = renderStateCounts(COUNTS); + const rendered = renderStateCountShards(COUNTS); + const table = () => parseStateTable(proseReadme()); + const quiet = { artifactErrors: [], rowSetErrors: [], handCountErrors: [] }; - it('is quiet when the artifact is current and the README carries prose only', () => { - const r = reconcileStateCounts({ table: parseStateTable(proseReadme()), rendered, onDisk: rendered }); - expect(r).toEqual({ artifactErrors: [], rowSetErrors: [], handCountErrors: [] }); + it('is quiet when every shard is current and the README carries prose only', () => { + const r = reconcileStateCounts({ table: table(), rendered, onDisk: new Map(rendered), legacyOnDisk: false }); + expect(r).toEqual(quiet); }); - it('catches a MISSING artifact', () => { - const r = reconcileStateCounts({ table: parseStateTable(proseReadme()), rendered, onDisk: null }); + it('catches a MISSING directory', () => { + const r = reconcileStateCounts({ table: table(), rendered, onDisk: null, legacyOnDisk: false }); expect(r.artifactErrors).toHaveLength(1); expect(r.artifactErrors[0]).toContain('MISSING'); expect(r.artifactErrors[0]).toContain(STATE_COUNTS_PATH); }); + it('catches ONE missing shard, and names it', () => { + const onDisk = new Map(rendered); + onDisk.delete('api.md'); + const r = reconcileStateCounts({ table: table(), rendered, onDisk, legacyOnDisk: false }); + expect(r.artifactErrors).toEqual([expect.stringContaining(`${STATE_COUNTS_PATH}api.md is MISSING`)]); + }); + // The leg that replaces what the hand-edit used to buy: touching a schema - // forced you back through the table. A stale artifact must be loud, and it must - // point at the line that moved rather than at the file. - it('catches a SKEWED count and names the first differing line', () => { - const onDisk = rendered.replace('| `field` | 66 |', '| `field` | 67 |'); - const r = reconcileStateCounts({ table: parseStateTable(proseReadme()), rendered, onDisk }); + // forced you back through the table. A stale shard must be loud, and it must + // point at the line that moved rather than at the directory. + it('catches a SKEWED count, names the shard and the first differing line', () => { + const onDisk = new Map(rendered); + onDisk.set('field.md', rendered.get('field.md')!.replace('| `field` | 66 |', '| `field` | 67 |')); + const r = reconcileStateCounts({ table: table(), rendered, onDisk, legacyOnDisk: false }); expect(r.artifactErrors).toHaveLength(1); - expect(r.artifactErrors[0]).toContain('STALE'); + expect(r.artifactErrors[0]).toContain(`${STATE_COUNTS_PATH}field.md is STALE`); expect(r.artifactErrors[0]).toContain('- | `field` | 67 |'); expect(r.artifactErrors[0]).toContain('+ | `field` | 66 |'); }); + // A type that left GOVERNED would otherwise keep publishing its last counts + // beside rows that no longer include it, and nothing would re-render them. + it('catches a STRAY shard, and a stray subdirectory', () => { + const onDisk = new Map(rendered); + onDisk.set('ghost.md', renderStateCountShard({ ...COUNTS[0], type: 'ghost' })); + onDisk.set('nested/', ''); + const r = reconcileStateCounts({ table: table(), rendered, onDisk, legacyOnDisk: false }); + expect(r.artifactErrors).toEqual([ + expect.stringContaining(`${STATE_COUNTS_PATH}ghost.md is STRAY`), + expect.stringContaining(`${STATE_COUNTS_PATH}nested/ is STRAY`), + ]); + }); + + // A branch cut before the split meets the deletion as a modify/delete, and a + // resolution that keeps the file would publish a stale table and a stale total + // beside the shards, re-rendered by nothing. + it('catches the RETIRED single file coming back', () => { + const r = reconcileStateCounts({ table: table(), rendered, onDisk: new Map(rendered), legacyOnDisk: true }); + expect(r.artifactErrors).toEqual([expect.stringContaining(`${LEGACY_STATE_COUNTS_FILE} is RETIRED`)]); + }); + it('catches a type with counts and no README row', () => { - const table = parseStateTable(proseReadme(['| object | notes |', '| field | notes |'])); - const r = reconcileStateCounts({ table, rendered, onDisk: rendered }); - expect(r.rowSetErrors).toEqual([expect.stringContaining('api')]); + const t = parseStateTable(proseReadme(['| object | notes |', '| field | notes |'])); + const r = reconcileStateCounts({ table: t, rendered, onDisk: new Map(rendered), legacyOnDisk: false }); + expect(r.rowSetErrors).toEqual([expect.stringContaining('api — counted in state-counts/api.md')]); }); it('catches the mirror — a README row with no counts', () => { - const table = parseStateTable(proseReadme([...['| object | n |', '| field | n |', '| api | n |'], '| ghost | n |'])); - const r = reconcileStateCounts({ table, rendered, onDisk: rendered }); + const t = parseStateTable(proseReadme([...['| object | n |', '| field | n |', '| api | n |'], '| ghost | n |'])); + const r = reconcileStateCounts({ table: t, rendered, onDisk: new Map(rendered), legacyOnDisk: false }); expect(r.rowSetErrors).toEqual([expect.stringContaining('ghost')]); }); // The leg neither of the other two can see. A re-added column leaves the - // artifact fresh and the row sets equal, so the table would publish two sets of + // shards fresh and the row sets equal, so the table would publish two sets of // numbers with only one of them enforced — strictly worse than the drift #7377 // started from. it('catches a count column coming back into the README', () => { - const table = parseStateTable(proseReadme(['| object | 49 | – | 0 | 1 | notes |', '| field | n |', '| api | n |'])); - const r = reconcileStateCounts({ table, rendered, onDisk: rendered }); + const t = parseStateTable(proseReadme(['| object | 49 | – | 0 | 1 | notes |', '| field | n |', '| api | n |'])); + const r = reconcileStateCounts({ table: t, rendered, onDisk: new Map(rendered), legacyOnDisk: false }); expect(r.handCountErrors).toHaveLength(1); expect(r.handCountErrors[0]).toContain('object'); // `–` counts: it is the spelling the old table used for "none of these", so @@ -353,27 +447,63 @@ describe('reconcileStateCounts — what it must catch', () => { // ("Dead 9 = the seven #4142 tombstones"). Only a cell that is NOTHING BUT a // number is a column; anything looser would make the pin unsatisfiable. it('stays quiet on a Notes cell that merely mentions numbers', () => { - const table = parseStateTable(proseReadme(['| object | Dead 9 = the seven #4142 tombstones + 2 | ', '| field | n |', '| api | n |'])); - const r = reconcileStateCounts({ table, rendered, onDisk: rendered }); + const t = parseStateTable(proseReadme(['| object | Dead 9 = the seven #4142 tombstones + 2 | ', '| field | n |', '| api | n |'])); + const r = reconcileStateCounts({ table: t, rendered, onDisk: new Map(rendered), legacyOnDisk: false }); expect(r.handCountErrors).toEqual([]); }); - it('reconciles the row set against the RENDERED artifact, not the stale copy on disk', () => { - // Otherwise a stale artifact missing a row would report the same defect twice, - // under two headings, and the second one would be a lie about the row set. - const onDisk = rendered.split('\n').filter((l) => !l.startsWith('| `api` |')).join('\n'); - const r = reconcileStateCounts({ table: parseStateTable(proseReadme()), rendered, onDisk }); + it('reconciles the row set against the RENDERED shards, not the stale copies on disk', () => { + // Otherwise a missing shard would report the same defect twice, under two + // headings, and the second one would be a lie about the row set. + const onDisk = new Map(rendered); + onDisk.delete('api.md'); + const r = reconcileStateCounts({ table: table(), rendered, onDisk, legacyOnDisk: false }); expect(r.artifactErrors).toHaveLength(1); expect(r.rowSetErrors).toEqual([]); }); }); +// The shard directory on a real disk: the writer the generator calls and the +// reader the gate calls, round-tripped, so the two cannot disagree about what +// "the directory" contains. +describe('writeStateCountShards / readStateCountShards', () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), 'os-state-count-shards-')); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it('reads a missing directory as null, never as an empty set', () => { + expect(readStateCountShards(path.join(dir, 'absent'))).toBeNull(); + }); + + it('writes every shard once, then rewrites ONLY the shard whose bytes moved', () => { + const first = writeStateCountShards(dir, renderStateCountShards(COUNTS)); + expect(first.written.sort()).toEqual(['api.md', 'field.md', 'object.md']); + expect(readStateCountShards(dir)).toEqual(renderStateCountShards(COUNTS)); + + const moved = COUNTS.map((r) => (r.type === 'field' ? { ...r, live: r.live - 1, dead: r.dead + 1 } : r)); + const second = writeStateCountShards(dir, renderStateCountShards(moved)); + expect(second).toEqual({ written: ['field.md'], removed: [] }); + }); + + it('prunes a shard no type renders, and anything else in the directory', () => { + writeStateCountShards(dir, renderStateCountShards(COUNTS)); + writeFileSync(path.join(dir, 'ghost.md'), 'stray'); + mkdirSync(path.join(dir, 'nested')); + const r = writeStateCountShards(dir, renderStateCountShards(COUNTS.slice(0, 2))); + expect(r.removed.sort()).toEqual(['api.md', 'ghost.md', 'nested/']); + expect([...readStateCountShards(dir)!.keys()].sort()).toEqual(['field.md', 'object.md']); + }); +}); + describe('the counts prescription', () => { - it('names the generator and forbids both ways of hand-writing a number', () => { + it('names the generator and forbids every way of hand-writing a number', () => { const text = STATE_COUNTS_GUIDANCE.join('\n'); expect(text).toContain(STATE_COUNTS_GEN_COMMAND); expect(text).toContain('Never hand-patch a number'); - expect(text).toContain('never put a count column back'); + expect(text).toContain('never commit a total'); + expect(text).toContain('never put a'); }); // The half of the hand-edit worth keeping: a moved number means a Note beside @@ -390,7 +520,7 @@ describe('the counts prescription', () => { // The fold's own blind spot (#13083) // // Every leg above reads the README or the artifact, and a fold that dropped a -// status satisfies all of them: `renderStateCounts` computes `classified` as the +// status satisfies all of them: `renderStateCountShard` computes `classified` as the // sum of the four columns beside it, the freshness leg re-renders the same fold // and compares bytes, and the README agrees with that. So the population these // cases describe is invisible to every test above this line, and the real gate diff --git a/packages/spec/scripts/liveness/state-counts-merge.test.ts b/packages/spec/scripts/liveness/state-counts-merge.test.ts new file mode 100644 index 00000000000..4cc46f37270 --- /dev/null +++ b/packages/spec/scripts/liveness/state-counts-merge.test.ts @@ -0,0 +1,207 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The sharded liveness counts, asked of git the way GitHub asks it (#20361). +// +// WHY THIS TEST SPAWNS GIT. The defect the shards cure was never in a renderer: +// it was in a MERGE. `state-counts.md` was one generated file with a row per +// type and a shared `**total**` row, so every PR that moved a verdict rewrote +// that total. `merge=os-regen` defers the file in a LOCAL merge only; GitHub's +// server-side merge — the one that decides `mergeable` and builds the ref CI +// runs on — has no custom driver. Measured on the dispatch base `2b24b8b823`, +// in a bare probe clone with no driver registered, each side regenerated with +// the real `gen:liveness-counts`: +// +// - `field.useGrouping` planned→dead against `sharing_rule.type` +// planned→live: `git merge-tree` exit 1, CONFLICT (content) in +// `packages/spec/liveness/state-counts.md` — the two rows are 36 lines +// apart and the only overlap is the total row; +// - the same `field` move against `sharing_rule.type` planned→dead, i.e. an +// EQUAL delta: exit 0 and WRONG — both sides wrote the identical total, git +// took it once, and the merged table said `dead 149` where the two moves +// make 150. +// +// So the question a unit test of the renderer cannot answer — "do two PRs that +// move different types merge, and merge RIGHT, with no driver?" — is asked here +// of `git merge-tree` over the real renderer's output, in a throwaway repository +// that carries the real attribute line and no driver. The same-type pair is the +// lit control: it MUST still conflict (one file's own row changed twice), or a +// clean result above would be equally explained by a harness that cannot see a +// conflict at all. + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { gitFreeEnv } from '../../../../scripts/git-env.mjs'; + +import { + STATE_COUNTS_DIR, + renderStateCountShards, + writeStateCountShards, + type StateCountsRow, + type StatusColumn, +} from './readme-table.mts'; + +/** Every fixture git is LOCAL-ONLY and hermetic: no inherited `GIT_*`, no global or system config. */ +const HERMETIC_ENV: NodeJS.ProcessEnv = (() => { + const env = gitFreeEnv(); + env.GIT_CONFIG_GLOBAL = '/dev/null'; + env.GIT_CONFIG_SYSTEM = '/dev/null'; + env.GIT_CONFIG_NOSYSTEM = '1'; + return env; +})(); + +const GIT_ARGS = ['-c', 'user.name=t', '-c', 'user.email=t@example.invalid', '-c', 'gc.auto=0', '-c', 'maintenance.auto=false']; + +/** The dispatch base's rows for the types the moves below touch, plus an untouched neighbour. */ +const BASE: readonly StateCountsRow[] = [ + { type: 'object', live: 50, experimental: 0, 'live-elsewhere': 0, dead: 0, planned: 1 }, + { type: 'field', live: 91, experimental: 0, 'live-elsewhere': 0, dead: 1, planned: 1 }, + { type: 'sharing_rule', live: 16, experimental: 0, 'live-elsewhere': 0, dead: 0, planned: 1 }, + { type: 'connector', live: 29, experimental: 0, 'live-elsewhere': 0, dead: 30, planned: 1 }, +]; + +interface Move { type: string; from: StatusColumn; to: StatusColumn } + +/** One ledger verdict moved — what a PR that flips a row does to the fold. */ +function apply(rows: readonly StateCountsRow[], ...moves: Move[]): StateCountsRow[] { + return rows.map((row) => { + const next = { ...row }; + for (const m of moves) { + if (m.type !== row.type) continue; + next[m.from] -= 1; + next[m.to] += 1; + } + return next; + }); +} + +let repo: string; + +function git(...args: string[]): { status: number | null; stdout: string; stderr: string } { + const r = spawnSync('git', [...GIT_ARGS, ...args], { cwd: repo, encoding: 'utf8', env: HERMETIC_ENV }); + if (r.error) throw r.error; + return { status: r.status, stdout: r.stdout, stderr: r.stderr }; +} + +function mustGit(...args: string[]): string { + const r = git(...args); + expect(r.status, `git ${args.join(' ')}\n${r.stderr}`).toBe(0); + return r.stdout; +} + +/** Commit the shards for `rows` on a new branch cut from `base`, exactly as the generator writes them. */ +function branch(name: string, rows: readonly StateCountsRow[]): void { + mustGit('checkout', '-q', '-b', name, 'base'); + writeStateCountShards(path.join(repo, STATE_COUNTS_DIR), renderStateCountShards(rows)); + mustGit('add', '-A'); + mustGit('commit', '-q', '-m', name); +} + +/** `git merge-tree --write-tree` of two branches: its exit code, and the paths it names on a conflict. */ +function mergeTree(a: string, b: string): { status: number | null; tree: string; conflicted: string[] } { + const r = git('merge-tree', '--write-tree', '--name-only', '--no-messages', a, b); + const [tree = '', ...rest] = r.stdout.trim().split('\n'); + return { status: r.status, tree, conflicted: rest.filter(Boolean) }; +} + +/** Every file of the merged tree, as `path -> bytes`. */ +function treeFiles(tree: string): Map { + const out = new Map(); + for (const p of mustGit('ls-tree', '-r', '--name-only', tree).trim().split('\n')) { + out.set(p, mustGit('show', `${tree}:${p}`)); + } + return out; +} + +/** What the generator would write for `rows`, as the tree paths it would occupy. */ +function expectedFiles(rows: readonly StateCountsRow[]): Map { + const out = new Map([['.gitattributes', ATTRIBUTES]]); + for (const [name, text] of renderStateCountShards(rows)) out.set(`${STATE_COUNTS_DIR}/${name}`, text); + return out; +} + +// The real routing line, relative to this fixture's root. Carried so the fixture +// is what GitHub sees — the attribute present, its driver absent — rather than a +// repository that never asked for a driver at all. +const ATTRIBUTES = `${STATE_COUNTS_DIR}/** merge=os-regen\n`; + +const FIELD_PLANNED_TO_DEAD: Move = { type: 'field', from: 'planned', to: 'dead' }; +const FIELD_LIVE_TO_DEAD: Move = { type: 'field', from: 'live', to: 'dead' }; +const SHARING_PLANNED_TO_LIVE: Move = { type: 'sharing_rule', from: 'planned', to: 'live' }; +const SHARING_PLANNED_TO_DEAD: Move = { type: 'sharing_rule', from: 'planned', to: 'dead' }; +const OBJECT_PLANNED_TO_DEAD: Move = { type: 'object', from: 'planned', to: 'dead' }; + +describe('state-counts/ shards — two PRs, no merge driver (#20361)', () => { + beforeAll(() => { + repo = mkdtempSync(path.join(tmpdir(), 'os-state-counts-merge-')); + mustGit('init', '-q', '-b', 'base'); + writeFileSync(path.join(repo, '.gitattributes'), ATTRIBUTES); + writeStateCountShards(path.join(repo, STATE_COUNTS_DIR), renderStateCountShards(BASE)); + mustGit('add', '-A'); + mustGit('commit', '-q', '-m', 'base'); + + branch('field-planned-dead', apply(BASE, FIELD_PLANNED_TO_DEAD)); + branch('field-live-dead', apply(BASE, FIELD_LIVE_TO_DEAD)); + branch('sharing-planned-live', apply(BASE, SHARING_PLANNED_TO_LIVE)); + branch('sharing-planned-dead', apply(BASE, SHARING_PLANNED_TO_DEAD)); + branch('object-planned-dead', apply(BASE, OBJECT_PLANNED_TO_DEAD)); + }); + afterAll(() => rmSync(repo, { recursive: true, force: true })); + + // The premise of every case below: this is GitHub's merge, not ours. A + // registered driver would defer the path and make "clean" mean nothing. + it('merges with the attribute present and NO driver registered — the server-side shape', () => { + expect(git('config', '--get', 'merge.os-regen.driver').status).toBe(1); + expect(git('check-attr', 'merge', '--', `${STATE_COUNTS_DIR}/field.md`).stdout.trim()).toBe( + `${STATE_COUNTS_DIR}/field.md: merge: os-regen`, + ); + }); + + it('a regeneration touches only the shard of the type that moved', () => { + expect(mustGit('diff', '--name-only', 'base', 'field-planned-dead').trim()).toBe(`${STATE_COUNTS_DIR}/field.md`); + expect(mustGit('diff', '--name-only', 'base', 'sharing-planned-live').trim()).toBe( + `${STATE_COUNTS_DIR}/sharing_rule.md`, + ); + }); + + // THE CARD'S REPRODUCTION, now clean: the pair that conflicted on the total row. + it('two moves of DIFFERENT types, different deltas: merges clean, and the result is the regeneration of both', () => { + const m = mergeTree('field-planned-dead', 'sharing-planned-live'); + expect(m.status, m.conflicted.join('\n')).toBe(0); + expect(treeFiles(m.tree)).toEqual(expectedFiles(apply(BASE, FIELD_PLANNED_TO_DEAD, SHARING_PLANNED_TO_LIVE))); + }); + + // The pair the single file merged CLEAN AND WRONG. Clean is not enough here: + // the merged tree must equal what regenerating the merged ledgers writes. + it('two moves of different types with an EQUAL delta: merges clean AND right — no shared line to double-count', () => { + const m = mergeTree('field-planned-dead', 'sharing-planned-dead'); + expect(m.status, m.conflicted.join('\n')).toBe(0); + expect(treeFiles(m.tree)).toEqual(expectedFiles(apply(BASE, FIELD_PLANNED_TO_DEAD, SHARING_PLANNED_TO_DEAD))); + }); + + // Adjacent rows conflicted in the single file even with no total (git refuses + // two edits on touching lines). Different files cannot touch. + it('two moves of ADJACENT types merge clean — the rows no longer share a file', () => { + const m = mergeTree('object-planned-dead', 'field-planned-dead'); + expect(m.status, m.conflicted.join('\n')).toBe(0); + expect(treeFiles(m.tree)).toEqual(expectedFiles(apply(BASE, OBJECT_PLANNED_TO_DEAD, FIELD_PLANNED_TO_DEAD))); + }); + + it('no merged tree carries a total — the sum is the reader\'s, not a file\'s', () => { + const m = mergeTree('field-planned-dead', 'sharing-planned-live'); + for (const [p, text] of treeFiles(m.tree)) expect(text, p).not.toMatch(/^\|\s*\**total/im); + }); + + // THE LIT CONTROL. A same-type pair changes one file's one row twice, which is + // a real conflict and must stay one — the residue sharding cannot remove, and + // what the local driver still owns. Without it, every "exit 0" above is also + // explained by a merge harness that never reports a conflict. + it('two moves of the SAME type still conflict, on that type\'s shard and nowhere else', () => { + const m = mergeTree('field-planned-dead', 'field-live-dead'); + expect(m.status).toBe(1); + expect(m.conflicted).toEqual([`${STATE_COUNTS_DIR}/field.md`]); + }); +}); diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 5386ac6a77f..cceb22f1821 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -9,6 +9,7 @@ "scripts/file-description.test.ts", "scripts/liveness/evidence.test.ts", "scripts/liveness/proof-registry.test.ts", + "scripts/liveness/state-counts-merge.test.ts", "scripts/publish-smoke-boot-failure.test.ts", "scripts/publish-smoke-port-collision.test.ts", "scripts/published-projection-choke-point.test.ts", From 2703082e39c06aadd49ded36313baa03a855ddd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 20:26:52 +0000 Subject: [PATCH 3/6] fix(spec): shard the strictness-ledger counts one file per source directory The strictness ledger's generated counts file had the same shape as the liveness counts: per-directory rows plus a global section and a posture total row that every schema-touching PR rewrote. Measured on the base in a driver-free probe clone, one strict site added in ui/ against two in data/ conflicted on the counts file while both sources merged clean. gen:strictness-ledger now writes docs/audits/2026-07-unknown-key-strictness-ledger.counts/.md, one shard per packages/spec/src directory with sites (triaged dirs carry their posture row, per-file sites, open files and buckets; untriaged dirs their site total). The cross-directory totals are summed at read time by check:strictness-ledger and gen:strictness-ledger and committed nowhere. The check reconciles missing, stale and stray shards and the retired single file; the ledger's links point at the shards. The text-shard read/write/reconcile helpers move to scripts/lib/sharded-artifacts.ts and serve both count artifacts, and the driver-free merge pin now covers both (count-shards-merge.test.ts). The liveness README and two ledger notes that named the retired file follow. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- .gitattributes | 7 +- ...07-unknown-key-strictness-ledger.counts.md | 267 --------------- .../ai.md | 22 ++ .../api.md | 22 ++ .../automation.md | 73 ++++ .../data.md | 91 +++++ .../identity.md | 22 ++ .../integration.md | 22 ++ .../kernel.md | 22 ++ .../marketplace.md | 22 ++ .../qa.md | 22 ++ .../security.md | 61 ++++ .../shared.md | 22 ++ .../studio.md | 47 +++ .../system.md | 22 ++ .../ui.md | 74 +++++ .../2026-07-unknown-key-strictness-ledger.md | 16 +- packages/spec/liveness/README.md | 21 +- packages/spec/liveness/book.json | 2 +- packages/spec/liveness/translation.json | 2 +- .../build-strictness-ledger-counts.mts | 32 +- packages/spec/scripts/check-generated.ts | 5 +- .../spec/scripts/check-strictness-ledger.mts | 70 ++-- .../spec/scripts/count-shards-merge.test.ts | 313 ++++++++++++++++++ .../spec/scripts/lib/sharded-artifacts.ts | 83 +++++ .../spec/scripts/lib/strictness-ledger-doc.ts | 270 +++++++++------ .../scripts/liveness/build-state-counts.mts | 4 +- .../spec/scripts/liveness/check-liveness.mts | 4 +- .../spec/scripts/liveness/readme-table.mts | 86 ++--- .../scripts/liveness/readme-table.test.ts | 19 +- .../liveness/state-counts-merge.test.ts | 207 ------------ .../scripts/strictness-ledger-doc.test.ts | 86 ++++- packages/spec/vitest.repo-tests.json | 2 +- scripts/regen-artifacts.mjs | 8 +- 34 files changed, 1324 insertions(+), 724 deletions(-) delete mode 100644 docs/audits/2026-07-unknown-key-strictness-ledger.counts.md create mode 100644 docs/audits/2026-07-unknown-key-strictness-ledger.counts/ai.md create mode 100644 docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md create mode 100644 docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md create mode 100644 docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md create mode 100644 docs/audits/2026-07-unknown-key-strictness-ledger.counts/identity.md create mode 100644 docs/audits/2026-07-unknown-key-strictness-ledger.counts/integration.md create mode 100644 docs/audits/2026-07-unknown-key-strictness-ledger.counts/kernel.md create mode 100644 docs/audits/2026-07-unknown-key-strictness-ledger.counts/marketplace.md create mode 100644 docs/audits/2026-07-unknown-key-strictness-ledger.counts/qa.md create mode 100644 docs/audits/2026-07-unknown-key-strictness-ledger.counts/security.md create mode 100644 docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md create mode 100644 docs/audits/2026-07-unknown-key-strictness-ledger.counts/studio.md create mode 100644 docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md create mode 100644 docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md create mode 100644 packages/spec/scripts/count-shards-merge.test.ts delete mode 100644 packages/spec/scripts/liveness/state-counts-merge.test.ts diff --git a/.gitattributes b/.gitattributes index ab3d279573e..2e5d982303c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -67,6 +67,9 @@ # cleanly because they do not overlap, and the subtotal merges clean and WRONG # (seven cases in one day). Note it is the counts file, not the ledger — the # ledger's prose is hand-written and must never be resolved by regenerating. +# #20361 sharded it into `….counts/`, one file per source directory, and stopped +# committing its cross-directory totals — the same measured conflict as the +# liveness counts below, in the same driver-less server-side merge. # # The liveness state table's counts joined at #7377 for the same reason, one file # over — 9 of its 30 rows had drifted from the gate before anyone re-ran the @@ -81,7 +84,7 @@ # liveness PR, so in the driver-less server-side merge any two of them conflicted # and each landing left every other one `dirty`, with no CI run until a # merge-and-regenerate round. The cure is the one the header above records for -# the three hottest artifacts; the gate sums the shards when it reads them. +# the three hottest artifacts; each gate sums its shards when it reads them. # # The elevation census page joined at #13646 — a generated `file:line` anchor # table whose correct merged values are on NEITHER side of a conflict (measured on @@ -156,7 +159,7 @@ packages/spec/export-origins/** merge=os-regen packages/spec/declaration-map/** merge=os-regen packages/spec/api-surface-signatures.json merge=os-regen docs/protocol-upgrade-guide.md merge=os-regen -docs/audits/2026-07-unknown-key-strictness-ledger.counts.md merge=os-regen +docs/audits/2026-07-unknown-key-strictness-ledger.counts/** merge=os-regen content/docs/references/** merge=os-regen content/docs/permissions/system-context.mdx merge=os-regen skills/*/references/_index.md merge=os-regen diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md deleted file mode 100644 index 0133c3b34d0..00000000000 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ /dev/null @@ -1,267 +0,0 @@ - - - -# Unknown-key strictness ledger — the counts (generated) - -Every number the #4001 strictness ledger publishes, computed from the AST -(`packages/spec/scripts/lib/strictness-ledger.ts`). The verdicts, the evidence and -the exemption rationales live in [the ledger itself](./2026-07-unknown-key-strictness-ledger.md) and -are hand-written; **this file has no prose to preserve** and is regenerated whole. - -Split out at #5107. These numbers were the ledger's entire merge-conflict surface: -two batches each decrement a header by their own delta, git merges the rows cleanly, -and the subtotal — which conflicts with nothing — merges clean and wrong. Seven cases -in one day. The correct resolution was always "recompute from the merged tree", so the -path carries `merge=os-regen` (#4675) and the recomputation is now mandatory rather -than remembered. **Never hand-patch a number here** — fix the code or the verdict and -regenerate. - -## Global - -| Measure | Value | -|---|---| -| Triaged directories | 5 | -| Object sites in them | 461 | -| Still-open (strip) sites | 125 | -| Files carrying at least one | 22 | - -Remaining strip sites by class: - -| Bucket | Sites | -|---|---| -| authorable — the ruling's forced scope | 1 | -| unresolved — needs a per-schema verdict | 0 | -| wire / open — out of forced scope | 120 | -| no door — no carrier, ADR-0049 territory | 3 | -| no gate — carrier live, no parse | 0 | -| covered — no carrier, no parse, guarded at every consumer | 1 | - -## Posture, per triaged directory - -The `strict` column is the one the campaign schedules against; it counts both the -`strictObject(` helper and the older `z.object(…).strict()` spelling, and — since -#5072 — no longer counts a `strictObject(…).passthrough()` chain as closed. - -| Dir | Sites | strict | passthrough | catchall | strip | -|---|---|---|---|---|---| -| `ui/` | 188 | 178 | 3 | 0 | 7 | -| `data/` | 159 | 76 | 1 | 0 | 82 | -| `automation/` | 67 | 43 | 0 | 1 | 23 | -| `security/` | 20 | 7 | 0 | 0 | 13 | -| `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **461** | **331** | **4** | **1** | **125** | - -## File-level triage — site counts - -Object sites per file: every `z.object(` / `strictObject(` / `z.strictObject(` / -`z.looseObject(` CALL, read from the AST. A file with zero sites has nothing to -classify and is not listed (it becomes reportable the day it grows its first site). - -### `ui/` — sites - -| File | Sites | -|---|---| -| `action-params.zod.ts` | 1 | -| `action.zod.ts` | 9 | -| `app.zod.ts` | 19 | -| `bulk-action.zod.ts` | 4 | -| `chart.zod.ts` | 8 | -| `component.zod.ts` | 56 | -| `dashboard.zod.ts` | 11 | -| `dataset.zod.ts` | 4 | -| `i18n.zod.ts` | 1 | -| `page.zod.ts` | 7 | -| `report.zod.ts` | 3 | -| `responsive.zod.ts` | 1 | -| `sharing.zod.ts` | 1 | -| `view.zod.ts` | 62 | -| `widget.zod.ts` | 1 | -| **total** | **188** | - -### `data/` — sites - -| File | Sites | -|---|---| -| `analytics.zod.ts` | 7 | -| `data-engine.zod.ts` | 15 | -| `datasource.zod.ts` | 6 | -| `document.zod.ts` | 8 | -| `driver-nosql.zod.ts` | 10 | -| `driver-sql.zod.ts` | 2 | -| `driver.zod.ts` | 9 | -| `driver/memory.zod.ts` | 6 | -| `driver/mongo.zod.ts` | 1 | -| `driver/mysql.zod.ts` | 1 | -| `driver/postgres.zod.ts` | 1 | -| `driver/sqlite.zod.ts` | 2 | -| `driver/turso.zod.ts` | 2 | -| `external-catalog.zod.ts` | 4 | -| `field-value.zod.ts` | 3 | -| `field.zod.ts` | 13 | -| `filter.zod.ts` | 12 | -| `hook-body.zod.ts` | 2 | -| `hook.zod.ts` | 7 | -| `mapping.zod.ts` | 3 | -| `object.zod.ts` | 21 | -| `query.zod.ts` | 5 | -| `seed-loader.zod.ts` | 12 | -| `seed.zod.ts` | 1 | -| `validation.zod.ts` | 6 | -| **total** | **159** | - -### `automation/` — sites - -| File | Sites | -|---|---| -| `approval.zod.ts` | 4 | -| `bpmn-interop.zod.ts` | 5 | -| `builtin-node-config.zod.ts` | 10 | -| `control-flow.zod.ts` | 6 | -| `execution.zod.ts` | 12 | -| `flow-function.zod.ts` | 1 | -| `flow.zod.ts` | 11 | -| `io-node-config.zod.ts` | 2 | -| `node-executor.zod.ts` | 4 | -| `schemaless-node-config.zod.ts` | 4 | -| `state-machine.zod.ts` | 6 | -| `time-relative-trigger.zod.ts` | 1 | -| `webhook.zod.ts` | 1 | -| **total** | **67** | - -### `security/` — sites - -| File | Sites | -|---|---| -| `explain.zod.ts` | 11 | -| `permission.zod.ts` | 4 | -| `rls.zod.ts` | 3 | -| `sharing.zod.ts` | 2 | -| **total** | **20** | - -### `studio/` — sites - -| File | Sites | -|---|---| -| `flow-builder.zod.ts` | 7 | -| `object-designer.zod.ts` | 12 | -| `plugin.zod.ts` | 8 | -| **total** | **27** | - -## Remaining strip sites — the batch-planning map - -Per file, how many of its sites still silently discard unknown keys. The `Class` -column that decides the bucket split is hand-written in the ledger; the arithmetic -over it is here. - -### `ui/` — open - -**7 strip of 188**, in 4 file(s). - -| File | Strip | Sites | -|---|---|---| -| `action-params.zod.ts` | 1 | 1 | -| `app.zod.ts` | 1 | 19 | -| `view.zod.ts` | 4 | 62 | -| `widget.zod.ts` | 1 | 1 | -| **total** | **7** | **188** | - -| Bucket | Sites | -|---|---| -| authorable — the ruling's forced scope | 1 | -| unresolved — needs a per-schema verdict | 0 | -| wire / open — out of forced scope | 4 | -| no door — no carrier, ADR-0049 territory | 1 | -| no gate — carrier live, no parse | 0 | -| covered — no carrier, no parse, guarded at every consumer | 1 | - -### `data/` — open - -**82 strip of 159**, in 11 file(s). - -| File | Strip | Sites | -|---|---|---| -| `data-engine.zod.ts` | 15 | 15 | -| `document.zod.ts` | 8 | 8 | -| `driver-nosql.zod.ts` | 10 | 10 | -| `driver-sql.zod.ts` | 2 | 2 | -| `driver.zod.ts` | 9 | 9 | -| `external-catalog.zod.ts` | 4 | 4 | -| `field.zod.ts` | 2 | 13 | -| `filter.zod.ts` | 11 | 12 | -| `hook.zod.ts` | 5 | 7 | -| `query.zod.ts` | 4 | 5 | -| `seed-loader.zod.ts` | 12 | 12 | -| **total** | **82** | **159** | - -| Bucket | Sites | -|---|---| -| authorable — the ruling's forced scope | 0 | -| unresolved — needs a per-schema verdict | 0 | -| wire / open — out of forced scope | 80 | -| no door — no carrier, ADR-0049 territory | 2 | -| no gate — carrier live, no parse | 0 | -| covered — no carrier, no parse, guarded at every consumer | 0 | - -### `automation/` — open - -**23 strip of 67**, in 5 file(s). - -| File | Strip | Sites | -|---|---|---| -| `bpmn-interop.zod.ts` | 5 | 5 | -| `control-flow.zod.ts` | 1 | 6 | -| `execution.zod.ts` | 12 | 12 | -| `flow.zod.ts` | 1 | 11 | -| `node-executor.zod.ts` | 4 | 4 | -| **total** | **23** | **67** | - -| Bucket | Sites | -|---|---| -| authorable — the ruling's forced scope | 0 | -| unresolved — needs a per-schema verdict | 0 | -| wire / open — out of forced scope | 23 | -| no door — no carrier, ADR-0049 territory | 0 | -| no gate — carrier live, no parse | 0 | -| covered — no carrier, no parse, guarded at every consumer | 0 | - -### `security/` — open - -**13 strip of 20**, in 2 file(s). - -| File | Strip | Sites | -|---|---|---| -| `explain.zod.ts` | 11 | 11 | -| `rls.zod.ts` | 2 | 3 | -| **total** | **13** | **20** | - -| Bucket | Sites | -|---|---| -| authorable — the ruling's forced scope | 0 | -| unresolved — needs a per-schema verdict | 0 | -| wire / open — out of forced scope | 13 | -| no door — no carrier, ADR-0049 territory | 0 | -| no gate — carrier live, no parse | 0 | -| covered — no carrier, no parse, guarded at every consumer | 0 | - -### `studio/` — open - -**0 strip of 27**, in 0 file(s). - -This directory is closed. - -## Other directories (untriaged) - -Site totals only — these directories are classified coarsely in the ledger, per -directory rather than per file. - -| Dir | Sites | -|---|---| -| `ai/` | 78 | -| `api/` | 431 | -| `identity/` | 32 | -| `integration/` | 5 | -| `kernel/` | 247 | -| `marketplace/` | 29 | -| `qa/` | 6 | -| `shared/` | 20 | -| `system/` | 352 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ai.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ai.md new file mode 100644 index 00000000000..a9bbc5c8067 --- /dev/null +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ai.md @@ -0,0 +1,22 @@ + + + +# `ai/` — unknown-key strictness counts (generated) + +The object-site total of `packages/spec/src/ai/`, computed from the AST +(`packages/spec/scripts/lib/strictness-ledger.ts`). The directory is untriaged: +the ledger classifies it coarsely, per directory rather than per file, so this +total is the one number measured here. + +The verdicts, the evidence and the exemption rationales live in +[the ledger itself](../2026-07-unknown-key-strictness-ledger.md) and are +hand-written; **this file has no prose to preserve** and is regenerated whole. +One file per directory, and no total across directories is committed anywhere: +`check:strictness-ledger` sums the shards when it reads them. **Never +hand-patch a number here** — fix the code or the verdict and regenerate. + +## Site total (untriaged) + +| Dir | Sites | +|---|---| +| `ai/` | 78 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md new file mode 100644 index 00000000000..06c7e267624 --- /dev/null +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md @@ -0,0 +1,22 @@ + + + +# `api/` — unknown-key strictness counts (generated) + +The object-site total of `packages/spec/src/api/`, computed from the AST +(`packages/spec/scripts/lib/strictness-ledger.ts`). The directory is untriaged: +the ledger classifies it coarsely, per directory rather than per file, so this +total is the one number measured here. + +The verdicts, the evidence and the exemption rationales live in +[the ledger itself](../2026-07-unknown-key-strictness-ledger.md) and are +hand-written; **this file has no prose to preserve** and is regenerated whole. +One file per directory, and no total across directories is committed anywhere: +`check:strictness-ledger` sums the shards when it reads them. **Never +hand-patch a number here** — fix the code or the verdict and regenerate. + +## Site total (untriaged) + +| Dir | Sites | +|---|---| +| `api/` | 431 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md new file mode 100644 index 00000000000..d0e4036daaf --- /dev/null +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/automation.md @@ -0,0 +1,73 @@ + + + +# `automation/` — unknown-key strictness counts (generated) + +Every number the #4001 strictness ledger publishes about `packages/spec/src/automation/`, +computed from the AST (`packages/spec/scripts/lib/strictness-ledger.ts`). + +The verdicts, the evidence and the exemption rationales live in +[the ledger itself](../2026-07-unknown-key-strictness-ledger.md) and are +hand-written; **this file has no prose to preserve** and is regenerated whole. +One file per directory, and no total across directories is committed anywhere: +`check:strictness-ledger` sums the shards when it reads them. **Never +hand-patch a number here** — fix the code or the verdict and regenerate. + +## Posture + +The `strict` column is the one the campaign schedules against; it counts both the +`strictObject(` helper and the older `z.object(…).strict()` spelling, and — since +#5072 — no longer counts a `strictObject(…).passthrough()` chain as closed. + +| Dir | Sites | strict | passthrough | catchall | strip | +|---|---|---|---|---|---| +| `automation/` | 67 | 43 | 0 | 1 | 23 | + +## `automation/` — sites + +Object sites per file: every `z.object(` / `strictObject(` / `z.strictObject(` / +`z.looseObject(` CALL, read from the AST. A file with zero sites has nothing to +classify and is not listed (it becomes reportable the day it grows its first site). + +| File | Sites | +|---|---| +| `approval.zod.ts` | 4 | +| `bpmn-interop.zod.ts` | 5 | +| `builtin-node-config.zod.ts` | 10 | +| `control-flow.zod.ts` | 6 | +| `execution.zod.ts` | 12 | +| `flow-function.zod.ts` | 1 | +| `flow.zod.ts` | 11 | +| `io-node-config.zod.ts` | 2 | +| `node-executor.zod.ts` | 4 | +| `schemaless-node-config.zod.ts` | 4 | +| `state-machine.zod.ts` | 6 | +| `time-relative-trigger.zod.ts` | 1 | +| `webhook.zod.ts` | 1 | +| **total** | **67** | + +## `automation/` — open + +Per file, how many of its sites still silently discard unknown keys. The `Class` +column that decides the bucket split is hand-written in the ledger; the arithmetic +over it is here. + +**23 strip of 67**, in 5 file(s). + +| File | Strip | Sites | +|---|---|---| +| `bpmn-interop.zod.ts` | 5 | 5 | +| `control-flow.zod.ts` | 1 | 6 | +| `execution.zod.ts` | 12 | 12 | +| `flow.zod.ts` | 1 | 11 | +| `node-executor.zod.ts` | 4 | 4 | +| **total** | **23** | **67** | + +| Bucket | Sites | +|---|---| +| authorable — the ruling's forced scope | 0 | +| unresolved — needs a per-schema verdict | 0 | +| wire / open — out of forced scope | 23 | +| no door — no carrier, ADR-0049 territory | 0 | +| no gate — carrier live, no parse | 0 | +| covered — no carrier, no parse, guarded at every consumer | 0 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md new file mode 100644 index 00000000000..40c557476fe --- /dev/null +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md @@ -0,0 +1,91 @@ + + + +# `data/` — unknown-key strictness counts (generated) + +Every number the #4001 strictness ledger publishes about `packages/spec/src/data/`, +computed from the AST (`packages/spec/scripts/lib/strictness-ledger.ts`). + +The verdicts, the evidence and the exemption rationales live in +[the ledger itself](../2026-07-unknown-key-strictness-ledger.md) and are +hand-written; **this file has no prose to preserve** and is regenerated whole. +One file per directory, and no total across directories is committed anywhere: +`check:strictness-ledger` sums the shards when it reads them. **Never +hand-patch a number here** — fix the code or the verdict and regenerate. + +## Posture + +The `strict` column is the one the campaign schedules against; it counts both the +`strictObject(` helper and the older `z.object(…).strict()` spelling, and — since +#5072 — no longer counts a `strictObject(…).passthrough()` chain as closed. + +| Dir | Sites | strict | passthrough | catchall | strip | +|---|---|---|---|---|---| +| `data/` | 159 | 76 | 1 | 0 | 82 | + +## `data/` — sites + +Object sites per file: every `z.object(` / `strictObject(` / `z.strictObject(` / +`z.looseObject(` CALL, read from the AST. A file with zero sites has nothing to +classify and is not listed (it becomes reportable the day it grows its first site). + +| File | Sites | +|---|---| +| `analytics.zod.ts` | 7 | +| `data-engine.zod.ts` | 15 | +| `datasource.zod.ts` | 6 | +| `document.zod.ts` | 8 | +| `driver-nosql.zod.ts` | 10 | +| `driver-sql.zod.ts` | 2 | +| `driver.zod.ts` | 9 | +| `driver/memory.zod.ts` | 6 | +| `driver/mongo.zod.ts` | 1 | +| `driver/mysql.zod.ts` | 1 | +| `driver/postgres.zod.ts` | 1 | +| `driver/sqlite.zod.ts` | 2 | +| `driver/turso.zod.ts` | 2 | +| `external-catalog.zod.ts` | 4 | +| `field-value.zod.ts` | 3 | +| `field.zod.ts` | 13 | +| `filter.zod.ts` | 12 | +| `hook-body.zod.ts` | 2 | +| `hook.zod.ts` | 7 | +| `mapping.zod.ts` | 3 | +| `object.zod.ts` | 21 | +| `query.zod.ts` | 5 | +| `seed-loader.zod.ts` | 12 | +| `seed.zod.ts` | 1 | +| `validation.zod.ts` | 6 | +| **total** | **159** | + +## `data/` — open + +Per file, how many of its sites still silently discard unknown keys. The `Class` +column that decides the bucket split is hand-written in the ledger; the arithmetic +over it is here. + +**82 strip of 159**, in 11 file(s). + +| File | Strip | Sites | +|---|---|---| +| `data-engine.zod.ts` | 15 | 15 | +| `document.zod.ts` | 8 | 8 | +| `driver-nosql.zod.ts` | 10 | 10 | +| `driver-sql.zod.ts` | 2 | 2 | +| `driver.zod.ts` | 9 | 9 | +| `external-catalog.zod.ts` | 4 | 4 | +| `field.zod.ts` | 2 | 13 | +| `filter.zod.ts` | 11 | 12 | +| `hook.zod.ts` | 5 | 7 | +| `query.zod.ts` | 4 | 5 | +| `seed-loader.zod.ts` | 12 | 12 | +| **total** | **82** | **159** | + +| Bucket | Sites | +|---|---| +| authorable — the ruling's forced scope | 0 | +| unresolved — needs a per-schema verdict | 0 | +| wire / open — out of forced scope | 80 | +| no door — no carrier, ADR-0049 territory | 2 | +| no gate — carrier live, no parse | 0 | +| covered — no carrier, no parse, guarded at every consumer | 0 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/identity.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/identity.md new file mode 100644 index 00000000000..a0b9ce7a07f --- /dev/null +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/identity.md @@ -0,0 +1,22 @@ + + + +# `identity/` — unknown-key strictness counts (generated) + +The object-site total of `packages/spec/src/identity/`, computed from the AST +(`packages/spec/scripts/lib/strictness-ledger.ts`). The directory is untriaged: +the ledger classifies it coarsely, per directory rather than per file, so this +total is the one number measured here. + +The verdicts, the evidence and the exemption rationales live in +[the ledger itself](../2026-07-unknown-key-strictness-ledger.md) and are +hand-written; **this file has no prose to preserve** and is regenerated whole. +One file per directory, and no total across directories is committed anywhere: +`check:strictness-ledger` sums the shards when it reads them. **Never +hand-patch a number here** — fix the code or the verdict and regenerate. + +## Site total (untriaged) + +| Dir | Sites | +|---|---| +| `identity/` | 32 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/integration.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/integration.md new file mode 100644 index 00000000000..f4415b8e6bf --- /dev/null +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/integration.md @@ -0,0 +1,22 @@ + + + +# `integration/` — unknown-key strictness counts (generated) + +The object-site total of `packages/spec/src/integration/`, computed from the AST +(`packages/spec/scripts/lib/strictness-ledger.ts`). The directory is untriaged: +the ledger classifies it coarsely, per directory rather than per file, so this +total is the one number measured here. + +The verdicts, the evidence and the exemption rationales live in +[the ledger itself](../2026-07-unknown-key-strictness-ledger.md) and are +hand-written; **this file has no prose to preserve** and is regenerated whole. +One file per directory, and no total across directories is committed anywhere: +`check:strictness-ledger` sums the shards when it reads them. **Never +hand-patch a number here** — fix the code or the verdict and regenerate. + +## Site total (untriaged) + +| Dir | Sites | +|---|---| +| `integration/` | 5 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/kernel.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/kernel.md new file mode 100644 index 00000000000..b1fe0315f7a --- /dev/null +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/kernel.md @@ -0,0 +1,22 @@ + + + +# `kernel/` — unknown-key strictness counts (generated) + +The object-site total of `packages/spec/src/kernel/`, computed from the AST +(`packages/spec/scripts/lib/strictness-ledger.ts`). The directory is untriaged: +the ledger classifies it coarsely, per directory rather than per file, so this +total is the one number measured here. + +The verdicts, the evidence and the exemption rationales live in +[the ledger itself](../2026-07-unknown-key-strictness-ledger.md) and are +hand-written; **this file has no prose to preserve** and is regenerated whole. +One file per directory, and no total across directories is committed anywhere: +`check:strictness-ledger` sums the shards when it reads them. **Never +hand-patch a number here** — fix the code or the verdict and regenerate. + +## Site total (untriaged) + +| Dir | Sites | +|---|---| +| `kernel/` | 247 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/marketplace.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/marketplace.md new file mode 100644 index 00000000000..dbe439127ab --- /dev/null +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/marketplace.md @@ -0,0 +1,22 @@ + + + +# `marketplace/` — unknown-key strictness counts (generated) + +The object-site total of `packages/spec/src/marketplace/`, computed from the AST +(`packages/spec/scripts/lib/strictness-ledger.ts`). The directory is untriaged: +the ledger classifies it coarsely, per directory rather than per file, so this +total is the one number measured here. + +The verdicts, the evidence and the exemption rationales live in +[the ledger itself](../2026-07-unknown-key-strictness-ledger.md) and are +hand-written; **this file has no prose to preserve** and is regenerated whole. +One file per directory, and no total across directories is committed anywhere: +`check:strictness-ledger` sums the shards when it reads them. **Never +hand-patch a number here** — fix the code or the verdict and regenerate. + +## Site total (untriaged) + +| Dir | Sites | +|---|---| +| `marketplace/` | 29 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/qa.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/qa.md new file mode 100644 index 00000000000..b6d86edd72c --- /dev/null +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/qa.md @@ -0,0 +1,22 @@ + + + +# `qa/` — unknown-key strictness counts (generated) + +The object-site total of `packages/spec/src/qa/`, computed from the AST +(`packages/spec/scripts/lib/strictness-ledger.ts`). The directory is untriaged: +the ledger classifies it coarsely, per directory rather than per file, so this +total is the one number measured here. + +The verdicts, the evidence and the exemption rationales live in +[the ledger itself](../2026-07-unknown-key-strictness-ledger.md) and are +hand-written; **this file has no prose to preserve** and is regenerated whole. +One file per directory, and no total across directories is committed anywhere: +`check:strictness-ledger` sums the shards when it reads them. **Never +hand-patch a number here** — fix the code or the verdict and regenerate. + +## Site total (untriaged) + +| Dir | Sites | +|---|---| +| `qa/` | 6 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/security.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/security.md new file mode 100644 index 00000000000..fee42f41873 --- /dev/null +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/security.md @@ -0,0 +1,61 @@ + + + +# `security/` — unknown-key strictness counts (generated) + +Every number the #4001 strictness ledger publishes about `packages/spec/src/security/`, +computed from the AST (`packages/spec/scripts/lib/strictness-ledger.ts`). + +The verdicts, the evidence and the exemption rationales live in +[the ledger itself](../2026-07-unknown-key-strictness-ledger.md) and are +hand-written; **this file has no prose to preserve** and is regenerated whole. +One file per directory, and no total across directories is committed anywhere: +`check:strictness-ledger` sums the shards when it reads them. **Never +hand-patch a number here** — fix the code or the verdict and regenerate. + +## Posture + +The `strict` column is the one the campaign schedules against; it counts both the +`strictObject(` helper and the older `z.object(…).strict()` spelling, and — since +#5072 — no longer counts a `strictObject(…).passthrough()` chain as closed. + +| Dir | Sites | strict | passthrough | catchall | strip | +|---|---|---|---|---|---| +| `security/` | 20 | 7 | 0 | 0 | 13 | + +## `security/` — sites + +Object sites per file: every `z.object(` / `strictObject(` / `z.strictObject(` / +`z.looseObject(` CALL, read from the AST. A file with zero sites has nothing to +classify and is not listed (it becomes reportable the day it grows its first site). + +| File | Sites | +|---|---| +| `explain.zod.ts` | 11 | +| `permission.zod.ts` | 4 | +| `rls.zod.ts` | 3 | +| `sharing.zod.ts` | 2 | +| **total** | **20** | + +## `security/` — open + +Per file, how many of its sites still silently discard unknown keys. The `Class` +column that decides the bucket split is hand-written in the ledger; the arithmetic +over it is here. + +**13 strip of 20**, in 2 file(s). + +| File | Strip | Sites | +|---|---|---| +| `explain.zod.ts` | 11 | 11 | +| `rls.zod.ts` | 2 | 3 | +| **total** | **13** | **20** | + +| Bucket | Sites | +|---|---| +| authorable — the ruling's forced scope | 0 | +| unresolved — needs a per-schema verdict | 0 | +| wire / open — out of forced scope | 13 | +| no door — no carrier, ADR-0049 territory | 0 | +| no gate — carrier live, no parse | 0 | +| covered — no carrier, no parse, guarded at every consumer | 0 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md new file mode 100644 index 00000000000..b56db38469e --- /dev/null +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md @@ -0,0 +1,22 @@ + + + +# `shared/` — unknown-key strictness counts (generated) + +The object-site total of `packages/spec/src/shared/`, computed from the AST +(`packages/spec/scripts/lib/strictness-ledger.ts`). The directory is untriaged: +the ledger classifies it coarsely, per directory rather than per file, so this +total is the one number measured here. + +The verdicts, the evidence and the exemption rationales live in +[the ledger itself](../2026-07-unknown-key-strictness-ledger.md) and are +hand-written; **this file has no prose to preserve** and is regenerated whole. +One file per directory, and no total across directories is committed anywhere: +`check:strictness-ledger` sums the shards when it reads them. **Never +hand-patch a number here** — fix the code or the verdict and regenerate. + +## Site total (untriaged) + +| Dir | Sites | +|---|---| +| `shared/` | 20 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/studio.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/studio.md new file mode 100644 index 00000000000..40924c25dfa --- /dev/null +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/studio.md @@ -0,0 +1,47 @@ + + + +# `studio/` — unknown-key strictness counts (generated) + +Every number the #4001 strictness ledger publishes about `packages/spec/src/studio/`, +computed from the AST (`packages/spec/scripts/lib/strictness-ledger.ts`). + +The verdicts, the evidence and the exemption rationales live in +[the ledger itself](../2026-07-unknown-key-strictness-ledger.md) and are +hand-written; **this file has no prose to preserve** and is regenerated whole. +One file per directory, and no total across directories is committed anywhere: +`check:strictness-ledger` sums the shards when it reads them. **Never +hand-patch a number here** — fix the code or the verdict and regenerate. + +## Posture + +The `strict` column is the one the campaign schedules against; it counts both the +`strictObject(` helper and the older `z.object(…).strict()` spelling, and — since +#5072 — no longer counts a `strictObject(…).passthrough()` chain as closed. + +| Dir | Sites | strict | passthrough | catchall | strip | +|---|---|---|---|---|---| +| `studio/` | 27 | 27 | 0 | 0 | 0 | + +## `studio/` — sites + +Object sites per file: every `z.object(` / `strictObject(` / `z.strictObject(` / +`z.looseObject(` CALL, read from the AST. A file with zero sites has nothing to +classify and is not listed (it becomes reportable the day it grows its first site). + +| File | Sites | +|---|---| +| `flow-builder.zod.ts` | 7 | +| `object-designer.zod.ts` | 12 | +| `plugin.zod.ts` | 8 | +| **total** | **27** | + +## `studio/` — open + +Per file, how many of its sites still silently discard unknown keys. The `Class` +column that decides the bucket split is hand-written in the ledger; the arithmetic +over it is here. + +**0 strip of 27**, in 0 file(s). + +This directory is closed. diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md new file mode 100644 index 00000000000..f8d523a513b --- /dev/null +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md @@ -0,0 +1,22 @@ + + + +# `system/` — unknown-key strictness counts (generated) + +The object-site total of `packages/spec/src/system/`, computed from the AST +(`packages/spec/scripts/lib/strictness-ledger.ts`). The directory is untriaged: +the ledger classifies it coarsely, per directory rather than per file, so this +total is the one number measured here. + +The verdicts, the evidence and the exemption rationales live in +[the ledger itself](../2026-07-unknown-key-strictness-ledger.md) and are +hand-written; **this file has no prose to preserve** and is regenerated whole. +One file per directory, and no total across directories is committed anywhere: +`check:strictness-ledger` sums the shards when it reads them. **Never +hand-patch a number here** — fix the code or the verdict and regenerate. + +## Site total (untriaged) + +| Dir | Sites | +|---|---| +| `system/` | 352 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md new file mode 100644 index 00000000000..ecb6edcf963 --- /dev/null +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md @@ -0,0 +1,74 @@ + + + +# `ui/` — unknown-key strictness counts (generated) + +Every number the #4001 strictness ledger publishes about `packages/spec/src/ui/`, +computed from the AST (`packages/spec/scripts/lib/strictness-ledger.ts`). + +The verdicts, the evidence and the exemption rationales live in +[the ledger itself](../2026-07-unknown-key-strictness-ledger.md) and are +hand-written; **this file has no prose to preserve** and is regenerated whole. +One file per directory, and no total across directories is committed anywhere: +`check:strictness-ledger` sums the shards when it reads them. **Never +hand-patch a number here** — fix the code or the verdict and regenerate. + +## Posture + +The `strict` column is the one the campaign schedules against; it counts both the +`strictObject(` helper and the older `z.object(…).strict()` spelling, and — since +#5072 — no longer counts a `strictObject(…).passthrough()` chain as closed. + +| Dir | Sites | strict | passthrough | catchall | strip | +|---|---|---|---|---|---| +| `ui/` | 188 | 178 | 3 | 0 | 7 | + +## `ui/` — sites + +Object sites per file: every `z.object(` / `strictObject(` / `z.strictObject(` / +`z.looseObject(` CALL, read from the AST. A file with zero sites has nothing to +classify and is not listed (it becomes reportable the day it grows its first site). + +| File | Sites | +|---|---| +| `action-params.zod.ts` | 1 | +| `action.zod.ts` | 9 | +| `app.zod.ts` | 19 | +| `bulk-action.zod.ts` | 4 | +| `chart.zod.ts` | 8 | +| `component.zod.ts` | 56 | +| `dashboard.zod.ts` | 11 | +| `dataset.zod.ts` | 4 | +| `i18n.zod.ts` | 1 | +| `page.zod.ts` | 7 | +| `report.zod.ts` | 3 | +| `responsive.zod.ts` | 1 | +| `sharing.zod.ts` | 1 | +| `view.zod.ts` | 62 | +| `widget.zod.ts` | 1 | +| **total** | **188** | + +## `ui/` — open + +Per file, how many of its sites still silently discard unknown keys. The `Class` +column that decides the bucket split is hand-written in the ledger; the arithmetic +over it is here. + +**7 strip of 188**, in 4 file(s). + +| File | Strip | Sites | +|---|---|---| +| `action-params.zod.ts` | 1 | 1 | +| `app.zod.ts` | 1 | 19 | +| `view.zod.ts` | 4 | 62 | +| `widget.zod.ts` | 1 | 1 | +| **total** | **7** | **188** | + +| Bucket | Sites | +|---|---| +| authorable — the ruling's forced scope | 1 | +| unresolved — needs a per-schema verdict | 0 | +| wire / open — out of forced scope | 4 | +| no door — no carrier, ADR-0049 territory | 1 | +| no gate — carrier live, no parse | 0 | +| covered — no carrier, no parse, guarded at every consumer | 1 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 15b8bb124bb..5af7b11c56c 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -15,7 +15,7 @@ is wire/response shape where strictness would be a forward-compat bug. | | Where it lives | Who writes it | |---|---|---| -| Site counts, strip counts, section headers, per-class subtotals, posture totals | [**`…strictness-ledger.counts.md`**](./2026-07-unknown-key-strictness-ledger.counts.md) — generated | `pnpm --filter @objectstack/spec gen:strictness-ledger` | +| Site counts, strip counts, section headers, per-class subtotals, posture rows | [**`…strictness-ledger.counts/`**](./2026-07-unknown-key-strictness-ledger.counts/) — generated, one shard per source directory; the cross-directory totals are summed by `check:strictness-ledger` when it reads them and committed nowhere | `pnpm --filter @objectstack/spec gen:strictness-ledger` | | `Class` verdicts, evidence, findings log, exemption rationales, batch history | **this file** — hand-written | you | **Why the split.** Every merge conflict this file produced during the campaign @@ -68,7 +68,7 @@ One question decides the class: **who writes this schema's input?** | **wire** | Another machine: server responses, connector payloads, runtime envelopes, persisted runtime state | stay tolerant (`.strip` / `.passthrough`); strictness here turns an upstream *addition* into our parse crash | | **open** | Deliberately schemaless user data (record bodies, per-node-type `config`, React props) | stay open; a *sibling* contract validates it (e.g. a node executor's `configSchema`, #4027/#4040) | | **no door** | **Nobody — nothing parses it.** The shape is exported and typed, but no schema declares a carrier key for it, so it is unreachable from every metadata-type root and from `defineStack`, and nothing calls `.parse()` on it outside its own test. Added at 批 13, when the first run of files resolved its `(p)` this way | **out of this ratchet's scope.** `.strict()` is a property of a PARSE; with no parse it enforces nothing and only makes a dead slot look load-bearing — *"a precisely-validated dead slot is the more convincing lie"* (#4583). The live question is ADR-0049 enforce-or-remove — retire the vocabulary or give it a carrier — so a row here points at an issue, never at a batch (#4988, #5015) | -| **no gate** | **An author — through a carrier this protocol does not PARSE.** The carrier key exists and is live (authors write it, a renderer reads it), but no `.parse()` sits between them; whatever checking exists re-derives the schema's rules by hand. Added at 批 15 on `ChartAggregateSchema` (``); 批 17 then found the same shape at scale — all 29 sites of `ui/component.zod.ts`, behind `PageComponentSchema.properties`, which made it the largest class in `ui/` **at the time**. ⚠️ **Both exemplars have since had their parse wired and LEFT the class** (#5020 / #5068 — their strip rows carry the flips), so this bucket's current population is **ZERO**: `…counts.md` reads `no gate — carrier live, no parse | 0` globally and in all five directory subtotals. Read the exemplars as the shape's definition, not as a live inventory — there is no un-wired `no gate` site anywhere in the tree today. The verdict stays in the vocabulary regardless: an empty class is not a defect, it is a word waiting for the next site that measures this way (#5249 established exactly that when it ADDED `covered` rather than rounding an unlike shape onto a wrong-action verdict) | **out of this ratchet's scope, for the opposite reason.** Same absent parse, so closing it still enforces nothing — but the vocabulary is ALIVE, so the fix is to wire the parse at the carrier's own gate, not to retire anything. A row here points at that wiring issue | +| **no gate** | **An author — through a carrier this protocol does not PARSE.** The carrier key exists and is live (authors write it, a renderer reads it), but no `.parse()` sits between them; whatever checking exists re-derives the schema's rules by hand. Added at 批 15 on `ChartAggregateSchema` (``); 批 17 then found the same shape at scale — all 29 sites of `ui/component.zod.ts`, behind `PageComponentSchema.properties`, which made it the largest class in `ui/` **at the time**. ⚠️ **Both exemplars have since had their parse wired and LEFT the class** (#5020 / #5068 — their strip rows carry the flips), so this bucket's current population is **ZERO**: the `…counts/` shards read `no gate — carrier live, no parse | 0` in all five triaged directories, and so does the global split `check:strictness-ledger` sums from them. Read the exemplars as the shape's definition, not as a live inventory — there is no un-wired `no gate` site anywhere in the tree today. The verdict stays in the vocabulary regardless: an empty class is not a defect, it is a word waiting for the next site that measures this way (#5249 established exactly that when it ADDED `covered` rather than rounding an unlike shape onto a wrong-action verdict) | **out of this ratchet's scope, for the opposite reason.** Same absent parse, so closing it still enforces nothing — but the vocabulary is ALIVE, so the fix is to wire the parse at the carrier's own gate, not to retire anything. A row here points at that wiring issue | | **covered** | **An author — but never through THIS site.** A module-private shape FRAGMENT with no carrier key and no `.parse()` of its own, whose keys reach authors only after being copied into consumers that each gate them. The copy must be a `...X.shape` SPREAD, because a spread lands the keys in a fresh `z.object` whose posture is its own — `.extend()` / `.merge()` / `.omit()` INHERIT the base's posture, which makes the base a real door and puts it back in `authorable` (finding 16, and `view.zod.ts`'s `FormFieldBaseSchema` one directory over). Added at #5249 on `ui/app.zod.ts`'s `BaseNavItemSchema` | **out of this ratchet's scope, and the follow-up is NOTHING.** Same absent parse, so closing it enforces nothing — and unlike `no door` the vocabulary is fully ALIVE and fully GATED, at every consumer, so retirement would delete keys those consumers still accept and check. This is the one verdict that prescribes no next step, which is exactly why it needed its own word: a row here is DONE, not queued | A fourth answer to "who writes this input" is **nobody**, and it is only @@ -651,7 +651,7 @@ block) when `position` joined the ratchet. ## File-level triage — the five authorable directories **The per-file site counts live in -[the counts file](./2026-07-unknown-key-strictness-ledger.counts.md#file-level-triage--site-counts), +[the counts shards](./2026-07-unknown-key-strictness-ledger.counts/) (each directory's `— sites` table), not here** (#5107). A site is every `z.object(` / `strictObject(` / `z.strictObject(` / `z.looseObject(` CALL, read from the AST rather than matched textually (see `scripts/lib/strictness-ledger.ts` for why the textual method was @@ -832,7 +832,7 @@ every schema closed with the OLDER `z.object(…).strict()` idiom — reading This section is the WORKLIST for that number. The number itself — per file, per directory, and split by class — is -[in the counts file](./2026-07-unknown-key-strictness-ledger.counts.md#remaining-strip-sites--the-batch-planning-map), +[in the counts shards](./2026-07-unknown-key-strictness-ledger.counts/) (each directory's `— open` table), generated (#5107). What stays here is the row: which file is still open, and the per-schema verdict and evidence that say whether its remainder is work or a deliberate floor. @@ -919,7 +919,7 @@ instead: neither number is written by hand any more, so there is nothing here fo a merge to get plausibly wrong. A clean-looking merge here was evidence of nothing, eleven times, which is what finally bought the split. -**Authorable strip in `automation/`: 0** ([counts file](./2026-07-unknown-key-strictness-ledger.counts.md#automation--open); +**Authorable strip in `automation/`: 0** ([counts file](./2026-07-unknown-key-strictness-ledger.counts/automation.md#automation--open); was 41 of 67 when the ruling was written). **The ruling's `automation/` main body is complete** — every remaining strip site in this directory is wire, and none is in the forced scope: `execution`, `bpmn-interop`, `node-executor`, `etl`'s @@ -1108,7 +1108,7 @@ branch never wrote either. A reopening moves this line exactly as a closure does rather than adjusted by anyone's delta. **Authorable strip in `ui/`: -[see the counts file](./2026-07-unknown-key-strictness-ledger.counts.md#ui--open)** +[see the counts file](./2026-07-unknown-key-strictness-ledger.counts/ui.md#ui--open)** (it was 123 of 123 when the ruling was written). The subtotal is summed from the surviving rows' declared `Class` splits, never decremented by a batch's own delta — eleven instances above are why that is now a generator and not a @@ -1264,7 +1264,7 @@ triage row record which one was taken. | `driver-sql.zod.ts` | wire | **out of scope** | **Authorable strip in `data/`:** -[the counts file](./2026-07-unknown-key-strictness-ledger.counts.md#data--open) splits this +[the counts file](./2026-07-unknown-key-strictness-ledger.counts/data.md#data--open) splits this directory three ways, and the first two buckets are now **empty**: `object` was the one **firm** authorable row left, its single site deliberately HELD on #5247 — **that hold was spent by objectui#4772 and the site closed 2026-08-16 (14 of 14; the row left the @@ -1360,7 +1360,7 @@ missing one is indistinguishable from a directory nobody walked. ## Other directories (coarse; classify per schema before touching) Site totals are -[in the counts file](./2026-07-unknown-key-strictness-ledger.counts.md#other-directories-untriaged). +[in the counts shards](./2026-07-unknown-key-strictness-ledger.counts/), one file per directory. These directories were never gated — the numbers here were hand-copied and ungated, which is the same failure one level coarser, so they moved with the rest at #5107. diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 35ed7ffb701..22f686bdc1f 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -837,15 +837,20 @@ The governed set is `GOVERNED` at the top of `check-liveness.mts`. To add a type > **The count columns are gone from this table** (#7377). #7257 scoped them out > because 9 of 30 rows disagreed with the gate and reconciling each one meant > re-reading the Note beside it; that reconciliation is done, and the numbers now -> live in the generated [`state-counts.md`](./state-counts.md). `check:liveness` -> proves that artifact fresh, holds its row set to this table's in both -> directions, and fails a count column reappearing here. +> live in the generated [`state-counts/`](./state-counts/), one shard per type. +> `check:liveness` proves every shard fresh, holds their row set to this table's +> in both directions, and fails a count column reappearing here. **The counts are no longer in this table** (#7377). They live in -[`state-counts.md`](./state-counts.md), which is GENERATED and carries -`merge=os-regen`; this table holds the **Notes prose only**, which is -hand-written measurement and must never be regenerated. Regenerate the numbers -with: +[`state-counts/`](./state-counts/) — one GENERATED `.md` shard per +governed type, carrying `merge=os-regen`; this table holds the **Notes prose +only**, which is hand-written measurement and must never be regenerated. **No +total is committed anywhere** (#20361): `check:liveness` sums the shards when it +reads them and prints the sum on its success line (`--json` carries it as +`countsTotal`). A committed total was the one line every liveness PR rewrote, so +in GitHub's server-side merge — which runs no custom driver — any two of them +conflicted on it; with one file per type, PRs that move different types touch +disjoint files. Regenerate the numbers with: ```bash pnpm --filter @objectstack/spec gen:liveness-counts @@ -939,7 +944,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | rest_api | seeded 2026-09-21 (#14640) — the FIFTH `RestServerConfig` sub-object, enrolled a round after the four above and deliberately so. #14369 left `RestApiConfigSchema` out because the `api` block's consumption seam was then still VALIDATE-ONLY (#11637 ran the declared contract and discarded its output), so a census would have recorded a half that was about to move; the gate source and four rows of this table said as much. That fence was re-tested before a line of this ledger was written and it has EXPIRED: `RestServer.normalizeConfig` now BUILDS the `api` block from `parseDeclaredApiConfig`'s output — “the asymmetry is gone and all five now build from their parsed output” — and the change is RELEASED, not in flight, with `packages/rest/CHANGELOG.md` re-stating the same zero this file records. ⛔ **The ledger is `rest_api.json`, NOT `api.json`**: that name was already taken by `ApiEndpointSchema`, the registered `api` metadata type with real consumers in the matcher, executor, policy chain and mapping layer — one spelling, two unrelated meanings inside `packages/spec`, and filing here would have published one file's measurement under the other's name. Live 12 = `version` / `basePath` / `apiPath`, which `getApiBasePath` splices into the prefix of EVERY mounted route (read through a whole-block destructure, which is why the dead-key census below had to sweep destructuring shapes and not a property-access pattern alone), the eight `enable*` switches, each gating a mount and most of them also the discovery document's capability block, and `projectResolution`. Dead 12 = the `requireAuth` tombstone (#3963, still `.omit()`ed by this seam because #3963 chose warn-and-ignore and converting that to a boot failure is that decision's to make), the `responseFormat` and `documentation.enabled` tombstones (RETIRED 2026-09-27, #20295, ADR-0049 enforce-or-remove — refused at `RestServer` construction with their prescription; `responseFormat` retired whole, so its three child rows collapsed into one), plus the other nine members of `documentation` (drilled, including its nested `contact` / `license`) — normalized into `this.config.api` and read back by nothing, so `documentation.title` retitles no served document. Every zero carries a lit control on the same instrument (twelve sibling keys on the same block return 1-2 reads), each of the three shapes a spelling sweep is blind to was swept with its own control, and the backstop is structural rather than textual: `NormalizedRestServerConfig` is module-local with no `export` and `RestServer.config` is `private`, so the normalized block cannot be reached from outside that one class. ⛔ **The two dead containers do NOT share one verdict**: `documentation`'s members are OpenAPI `info` fields whose enforce route collides with a recorded ownership decision (`info` is written by `build-openapi.ts` and passed through untouched by #11646), while `responseFormat`'s enforce route means making the response envelope configurable — a larger claim. This file records status; the enforce-or-remove call per key is a follow-up on the human floor — made for `responseFormat` and `documentation.enabled` (retired, #20295), still open for `documentation`'s other members. `evidenceScope` stays `in-repo`: objectui was measured clean at the pinned sha and at head against a lit control, but the closed cloud runtime was not reachable from the measuring container, so #14796's structural reading is cited as a standing reading rather than re-claimed as a sweep | | realtime_subscription | seeded 2026-09-04 (#14446) — a TRANSPORT-PROTOCOL surface, the fifth category the `SPEC_ONLY_SCHEMAS` override has had to reach. `SubscriptionSchema` (`packages/spec/src/api/realtime.zod.ts`) is what a client declares to open a realtime subscription: the item type of `RealtimeConfigSchema.subscriptions` and the `Subscription` the generated API reference publishes. Like `query` it is a request surface rather than stored metadata, and like `query` that is exactly why it went unasked — no registry holds it, `RealtimeConfigSchema` is `.passthrough()` so nothing downstream even refuses an unknown key, and the whole vocabulary sat outside the denominator while the reference kept publishing it. Rooted on `SubscriptionSchema` rather than on `RealtimeConfigSchema` for the reason the four `RestServerConfig` sub-objects document one row up: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with the config as the root `events[].type` and `events[].filters` would inherit a container verdict instead of carrying rows of their own — #4956's shape. **Dead 6 = every key it has, and the CONTAINER is the finding**: nothing outside `packages/spec` imports `SubscriptionSchema`, `SubscriptionEventSchema` or `RealtimeConfigSchema` at all, so no key beneath them can be read (the `manifest.contributes` reasoning). The two keys the card measured are the sharp ones. `events[].type` accepts `RealtimeEventType`, whose four members (`record.created` / `record.updated` / `record.deleted` / `field.changed`) are DISJOINT from what the engine publishes (`DataEventType`'s `data.record.*`, live emitter in `service-knowledge`), so an author who writes the enum's own `record.created` gets a subscription that silently never fires — and the enum is what the API reference shows them. Its direction is settled by the 2026-09-02 triage and quoted verbatim in the row: enforce means REPOINTING THE ENUM, never changing what the runtime publishes. `field.changed` is the same spelling the sibling `DataEventType` REMOVED in 17.0.0 (#4673, PR #4685) for having no producer; it survives here only because this enum was never in a ratchet's denominator. `events[].filters` is `z.unknown().optional()` — the textbook ADR-0049 fourth state, no shape and no reader, failing in the permissive direction (a subscriber who filters receives every event). ⚠️ Three spellings of a realtime subscription exist and only the third is executed: this one, `websocket.zod.ts#EventSubscriptionSchema`, and the plain interface `contracts/realtime-service.ts#RealtimeSubscriptionOptions` that `in-memory-realtime-adapter.ts#matchesSubscription` actually reads. The file note names the same-name-different-shape traps so the next census does not mistake one for a consumer. Zero live | | sharing_rule | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, and the first one PAID (`connector` and `analytics_cube` are still owed on that card). Not a registered kind: it is bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reaches the walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so this ledger governs a type `listMetadataTypeSchemaTypes()` still does not enumerate. One shape fact decides every row: the AUTHORING shape is not the ENFORCED shape. ADR-0057 D6 makes the `sys_sharing_rule` row canonical (`object_name` + `criteria_json` + `recipient_type`/`recipient_id` + `access_level`) and `bootstrapDeclaredSharingRules` translates each authored key into it at boot — nothing re-parses `SharingRuleSchema` at enforcement time — so every consumer cited reads a COLUMN and every row carries the `producer` (#4837) that populates it, which is the `seed.env` lesson applied to a whole type rather than to one key. Preview read points ENUMERATED per the #7131 rule and the answer recorded rather than skipped: `registerBuiltinPreviews()` (objectui @dda8f381) registers twenty types and `sharing_rule` is not one of them; what objectui does consume is the whole shape, on the CREATE door only (`AUTHOR_SHAPE_ONLY_TYPES` — the EDIT door is deliberately ungated because a served body carries the `_diagnostics` decoration this `.strict()` schema rejects). The single non-`live` row is `type`, the `SharingRuleType` discriminator: one member, `criteria`, whose only reader is a defensive `=== 'owner'` comparison that is unreachable for every value the schema admits. `planned` on the `action.operation` precedent (a one-member discriminator held `planned` until a runtime half dispatched on it, #15080), and deliberately NOT an enforce-or-remove candidate: the key is required, so removing it would break every authored rule to delete nothing. | -| connector | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, paid in the same diff as `analytics_cube`, which empties that map. Not a registered kind: bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reached through `getMetadataTypeSchema`'s unregistered-kind fallback. **What the walk actually resolves, measured:** the binding names `DeclarativeConnectorEntrySchema`. ⚠️ The MECHANISM changed with the `connectionTimeoutMs` retirement and the prior sentence here is corrected rather than carried: that schema USED TO BE `ConnectorSchema.superRefine(...)`, a Zod 4 check attached to the same object def, and the key-set conclusion used to rest on that attachment. It is now a `z.preprocess` PIPE — both published carriers wrap one shared private `ConnectorBaseSchema` in the ADR-0049 retired-default residue stage, the entry schema adding the ADR-0097 cross-field rules on the base before wrapping, so the two are SIBLINGS rather than parent and child, and what preserves the walked shape is the pipe's read-through `shape`, NOT a `superRefine` attachment. The CONCLUSION is unchanged and re-measured on the built entry rather than inherited: both carriers expose 30 keys and the key sets are byte-identical, with no entry-only and no base-only key. The gate cannot tell the two schemas apart; what the entry schema buys is REFUSALS, invisible to the walk and visible only in the three rows where they are the whole verdict. **ONE SCHEMA, TWO DOORS** is the shape fact behind the 29/1/30 split (live/planned/dead; counts read from the generated `state-counts.md` row, never hand-kept here): the ledger's denominator entry exists for the AUTHORING doors (`defineStack({ connectors })`, `PUT /meta/connector/:name`), while the same `ConnectorSchema` is what `AutomationEngine.registerConnector` parses for a def a PLUGIN or an ADR-0097 provider factory builds in code — so a key can have a real consumer and still do nothing when a metadata author writes it. The keys an authored entry can reach are exactly the author-supplied `ConnectorProviderContext` fields plus `provider` and `enabled` — `name` is itself one of those fields (the former "plus `name`" tail double-counted it), `loadPackageFile` is host-injected rather than authored, and `provider` selects the factory without ever reaching the context; `type` and `icon` reach that context and are dropped by all three shipped factories, and each says so on its own row. `authentication` is the ledger's `planned`, and ⛔ NOT "refused outright" — the former tail here said exactly that and all three instruments contradict it, including the one it cites: the KEY is ACCEPTED (`connector.zod.ts` declares `authentication: ConnectorAuthConfigSchema.optional().default({ type: 'none' })`, and the accepted value does nothing); what #7990 refuses is a non-`none` VALUE (`if (entry.authentication && entry.authentication.type !== 'none')`, whose own message prescribes "drop `authentication` (or set `{ type: 'none' }`)"); and ADR-0097 §3, titled "Credentials are references", rejects **inline secrets** in stack metadata, not the key. Accepted-and-ignored, plus a loud refusal of every value but `{ type: 'none' }`, is exactly the basis of the `planned` verdict — which the row itself already stated ("the accepted value does nothing"), so the summary, not the row, was the wrong half. The 30 `dead`, re-measured at this head and partitioned so every row is counted exactly once: two declared subsystems with no engine — `syncConfig` (8), `fieldMappings` (7) — plus `triggers` (6, and the schema's own docblock says so: #3197), `metadata`, `actions.description`/`.outputSchema`, and the six top-level `retiredKey` tombstones `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status` and `webhooks`. That sums to 30, the dead count the generated `state-counts.md` row carries. ⚠️ It was 44 until the connector resilience family was retired (ADR-0049): `health` counted 15 drilled rows (both sub-blocks plus the `monitoringWindow` tombstone) and is now ONE leaf tombstone row — the gate refuses `children` under a property that is no longer a container — and `webhooks` left the undrilled baseline for the same reason; `status` and `webhooks` stayed one row each and changed only from dead-awaiting-a-decision to dead-and-tombstoned. ⚠️ `retryConfig` IS NO LONGER IN THIS LIST: all eight of its sub-keys went `live` when #18975 made the declared policy execute at the one platform fetch site, which is the same measurement the falsification note at the end of this row records — so a reader who still finds "`retryConfig` (8)" among the dead is reading a stale copy. ⚠️ Nor is it "the two timeouts" any more: `requestTimeoutMs` is `live` (it becomes `resilientFetch`'s per-attempt deadline) and `connectionTimeoutMs` is the retired tombstone named above. ⭐ EIGHT rows in this ledger are `retiredKey` tombstones that keep their rows because the key stays in the walked shape (the `rls.priority` precedent) — `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status`, `webhooks`, `fieldMappings.transform` and `triggers.interval` — but ⛔ that eight is NOT a separate addend: the first six ARE the top-level tombstones counted above and the last two are already inside the `fieldMappings` and `triggers` counts, which is exactly the double-count that made the previous "and four `retiredKey` tombstones" tail drift. (`health.circuitBreaker.monitoringWindow` was the ninth until its block left whole with `health`.) Count them by name, never by adding the tail. **A prior in-repo claim is recorded here with its DIRECTION measured rather than remembered, because this row's job is the history of how the type got here**: the conversion registry's note inside `connector-rate-limit-config-removed`'s fixture reads "`retryConfig` and the timeouts beside it are untouched by THIS conversion — a statement about its scope, not a liveness verdict. They are not live: declared, defaulted and documented, and read by nothing." ⚠️ It asserts they are NOT live, and it scopes "untouched" to that one conversion. The former tail here quoted it as asserting the OPPOSITE ("they are live") and called it false when seeded — an inversion that turned this whole passage upside down, and it is corrected rather than carried. Measured direction: the note was TRUE when this ledger was seeded (2026-09-17) and is STALE now, #18975 having made the declared policy execute at the one platform fetch site (`connectorFetchOptions` → `resilientFetch`), so `retryConfig`'s eight sub-keys are `live` on their own rows and `requestTimeoutMs` is `live` beside them; only `connectionTimeoutMs` still answers to it, as the retired tombstone. ⛔ The stale comment is not rewritten from here — it is #19729's, as a dated note beside it — and it is not a line this PR's diff touches. ⚠️ The seeding note's supporting census — "the word does not occur outside `packages/spec` at all" — is FALSE at this head and is corrected rather than carried: `git grep -n retryConfig 14fdebd766 -- . ':!packages/spec'` returns 67 **matching lines** over 15 files — `git grep -o` on the same tree and pathspec returns 77 **occurrences**, and a line is not an occurrence, which is the trap a re-measurer falls into next (26 matching lines in the materializer `packages/services/service-automation/src/plugin.ts` and its materialization test, 22 across `connector-rest` and `connector-openapi` — providers, connectors and their tests — 13 in five `.changeset` fragments, and 6 on two `content/docs` pages). ⛔ Re-read that as the standing lesson of this row: a census is a count plus the tree it was taken against, and a bare "does not occur" with no commit behind it is the shape that rots first. The timeouts half is settled on its own rows: `requestTimeoutMs` is `live`, `connectionTimeoutMs` is retired | +| connector | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, paid in the same diff as `analytics_cube`, which empties that map. Not a registered kind: bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reached through `getMetadataTypeSchema`'s unregistered-kind fallback. **What the walk actually resolves, measured:** the binding names `DeclarativeConnectorEntrySchema`. ⚠️ The MECHANISM changed with the `connectionTimeoutMs` retirement and the prior sentence here is corrected rather than carried: that schema USED TO BE `ConnectorSchema.superRefine(...)`, a Zod 4 check attached to the same object def, and the key-set conclusion used to rest on that attachment. It is now a `z.preprocess` PIPE — both published carriers wrap one shared private `ConnectorBaseSchema` in the ADR-0049 retired-default residue stage, the entry schema adding the ADR-0097 cross-field rules on the base before wrapping, so the two are SIBLINGS rather than parent and child, and what preserves the walked shape is the pipe's read-through `shape`, NOT a `superRefine` attachment. The CONCLUSION is unchanged and re-measured on the built entry rather than inherited: both carriers expose 30 keys and the key sets are byte-identical, with no entry-only and no base-only key. The gate cannot tell the two schemas apart; what the entry schema buys is REFUSALS, invisible to the walk and visible only in the three rows where they are the whole verdict. **ONE SCHEMA, TWO DOORS** is the shape fact behind the 29/1/30 split (live/planned/dead; counts read from the generated `state-counts/connector.md` shard, never hand-kept here): the ledger's denominator entry exists for the AUTHORING doors (`defineStack({ connectors })`, `PUT /meta/connector/:name`), while the same `ConnectorSchema` is what `AutomationEngine.registerConnector` parses for a def a PLUGIN or an ADR-0097 provider factory builds in code — so a key can have a real consumer and still do nothing when a metadata author writes it. The keys an authored entry can reach are exactly the author-supplied `ConnectorProviderContext` fields plus `provider` and `enabled` — `name` is itself one of those fields (the former "plus `name`" tail double-counted it), `loadPackageFile` is host-injected rather than authored, and `provider` selects the factory without ever reaching the context; `type` and `icon` reach that context and are dropped by all three shipped factories, and each says so on its own row. `authentication` is the ledger's `planned`, and ⛔ NOT "refused outright" — the former tail here said exactly that and all three instruments contradict it, including the one it cites: the KEY is ACCEPTED (`connector.zod.ts` declares `authentication: ConnectorAuthConfigSchema.optional().default({ type: 'none' })`, and the accepted value does nothing); what #7990 refuses is a non-`none` VALUE (`if (entry.authentication && entry.authentication.type !== 'none')`, whose own message prescribes "drop `authentication` (or set `{ type: 'none' }`)"); and ADR-0097 §3, titled "Credentials are references", rejects **inline secrets** in stack metadata, not the key. Accepted-and-ignored, plus a loud refusal of every value but `{ type: 'none' }`, is exactly the basis of the `planned` verdict — which the row itself already stated ("the accepted value does nothing"), so the summary, not the row, was the wrong half. The 30 `dead`, re-measured at this head and partitioned so every row is counted exactly once: two declared subsystems with no engine — `syncConfig` (8), `fieldMappings` (7) — plus `triggers` (6, and the schema's own docblock says so: #3197), `metadata`, `actions.description`/`.outputSchema`, and the six top-level `retiredKey` tombstones `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status` and `webhooks`. That sums to 30, the dead count the generated `state-counts/connector.md` shard carries. ⚠️ It was 44 until the connector resilience family was retired (ADR-0049): `health` counted 15 drilled rows (both sub-blocks plus the `monitoringWindow` tombstone) and is now ONE leaf tombstone row — the gate refuses `children` under a property that is no longer a container — and `webhooks` left the undrilled baseline for the same reason; `status` and `webhooks` stayed one row each and changed only from dead-awaiting-a-decision to dead-and-tombstoned. ⚠️ `retryConfig` IS NO LONGER IN THIS LIST: all eight of its sub-keys went `live` when #18975 made the declared policy execute at the one platform fetch site, which is the same measurement the falsification note at the end of this row records — so a reader who still finds "`retryConfig` (8)" among the dead is reading a stale copy. ⚠️ Nor is it "the two timeouts" any more: `requestTimeoutMs` is `live` (it becomes `resilientFetch`'s per-attempt deadline) and `connectionTimeoutMs` is the retired tombstone named above. ⭐ EIGHT rows in this ledger are `retiredKey` tombstones that keep their rows because the key stays in the walked shape (the `rls.priority` precedent) — `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status`, `webhooks`, `fieldMappings.transform` and `triggers.interval` — but ⛔ that eight is NOT a separate addend: the first six ARE the top-level tombstones counted above and the last two are already inside the `fieldMappings` and `triggers` counts, which is exactly the double-count that made the previous "and four `retiredKey` tombstones" tail drift. (`health.circuitBreaker.monitoringWindow` was the ninth until its block left whole with `health`.) Count them by name, never by adding the tail. **A prior in-repo claim is recorded here with its DIRECTION measured rather than remembered, because this row's job is the history of how the type got here**: the conversion registry's note inside `connector-rate-limit-config-removed`'s fixture reads "`retryConfig` and the timeouts beside it are untouched by THIS conversion — a statement about its scope, not a liveness verdict. They are not live: declared, defaulted and documented, and read by nothing." ⚠️ It asserts they are NOT live, and it scopes "untouched" to that one conversion. The former tail here quoted it as asserting the OPPOSITE ("they are live") and called it false when seeded — an inversion that turned this whole passage upside down, and it is corrected rather than carried. Measured direction: the note was TRUE when this ledger was seeded (2026-09-17) and is STALE now, #18975 having made the declared policy execute at the one platform fetch site (`connectorFetchOptions` → `resilientFetch`), so `retryConfig`'s eight sub-keys are `live` on their own rows and `requestTimeoutMs` is `live` beside them; only `connectionTimeoutMs` still answers to it, as the retired tombstone. ⛔ The stale comment is not rewritten from here — it is #19729's, as a dated note beside it — and it is not a line this PR's diff touches. ⚠️ The seeding note's supporting census — "the word does not occur outside `packages/spec` at all" — is FALSE at this head and is corrected rather than carried: `git grep -n retryConfig 14fdebd766 -- . ':!packages/spec'` returns 67 **matching lines** over 15 files — `git grep -o` on the same tree and pathspec returns 77 **occurrences**, and a line is not an occurrence, which is the trap a re-measurer falls into next (26 matching lines in the materializer `packages/services/service-automation/src/plugin.ts` and its materialization test, 22 across `connector-rest` and `connector-openapi` — providers, connectors and their tests — 13 in five `.changeset` fragments, and 6 on two `content/docs` pages). ⛔ Re-read that as the standing lesson of this row: a census is a count plus the tree it was taken against, and a bare "does not occur" with no commit behind it is the shape that rots first. The timeouts half is settled on its own rows: `requestTimeoutMs` is `live`, `connectionTimeoutMs` is retired | | analytics_cube | seeded 2026-09-17 (#18582) — the third debt, paid in the same diff as `connector`. Not a registered kind either: bound in `UNREGISTERED_KIND_SCHEMAS` by #10194 and reached through the same unregistered-kind fallback. **ONE Cube shape, THREE producers, one registry** is what decides every row: `cube-registry.ts` names them itself — authored cubes (`analyticsCubes[]` / `defineCube()`, threaded by the CLI into `AnalyticsServiceConfig.cubes`), COMPILED DATASETS (ADR-0021, where `dataset-compiler` mints a Cube), and ad-hoc query inference. Only the first is the authoring door governed here, so a key whose only reader sits on the compiled-dataset path is not live for an authored cube however busy that reader is — the #4837 producer rule on a shape with three producers. That is `dimensions.granularities` (read by `dataset-executor#granularityOf`, whose argument is a `CompiledDataset` an authored cube never becomes) and `measures.format` (written by the compiler, threaded to the wire from the DATASET measure instead). The query path is genuinely live: `sql` is the FROM table AND the object whose RLS read scope is injected, `measures.type` picks the aggregate, `measures.sql`/`dimensions.sql` the column, `joins[].name` the joined table. The 9 `dead` are the caching block (`refreshKey.every`/`.sql` — no refresh scheduler exists anywhere), the three `description`s, `measures.format`, `dimensions.granularities`, and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity. **#20282** flips the tenth, the visibility flag `public`, `dead` → `live` 2026-09-27: seeded as a knob that was never wired (three internal mints wrote `false`, nothing read it), it is now read by `service-analytics`' `cube-visibility.ts#isCubePublic` — `getMeta` omits a hidden cube and `query()` / `generateSql()` refuse it — in the same change that moved its default from `false` to the Cube.dev `true`, since enforcing the old default would have hidden every authored cube. It was 12 until #18612 RETIRED `joins[].relationship` and the REQUIRED `joins[].sql` (ADR-0049 enforce-or-remove, maintainer-ruled batch #154): the ON clause is SYNTHESISED as an FK equality and the authored one was never consulted, so a declared join condition came back REPLACED under a 200. `CubeJoinSchema` is a `strictObject`, so the route was strict deletion plus a `guidance` prescription and the two rows left this ledger with the keys — not the `retiredKey()` route, which keeps the row. **#10238 is not prejudged**: whether cube authoring is live end to end is still its own measurement — this ledger answers the per-key question only | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every diff --git a/packages/spec/liveness/book.json b/packages/spec/liveness/book.json index 0392560b392..07a35813ce5 100644 --- a/packages/spec/liveness/book.json +++ b/packages/spec/liveness/book.json @@ -1,6 +1,6 @@ { "type": "book", - "_note": "BookSchema (ADR-0046 §6 documentation spine). Consumers: the REST `/meta/book/:name/tree` endpoint (`packages/rest/src/rest-server.ts`, the `book/:name/tree` branch of `registerMetadataEndpointsInner`) driving the spec's pure `resolveBookTree` / `audienceAllows` (packages/spec/src/system/book.zod.ts), and objectui's console docs portal (apps/console/src/pages/book-nav.ts @940ba24 — a faithful resolver port rendering the reader UI, plus portal-only consumers for slug/icon/order). `groups[].translations` is dead: an inline map that LOOKS like the doc-level mechanism that works (`doc.translations`, resolveDocLocale) but has no resolver anywhere. Its book-level twin was the same trap and was retired in the same pass (#4667) — that one is a strict deletion, so it left the walked shape and holds no row here, while the group-level key is TOMBSTONED (`retiredKey`; BookGroupSchema is a plain z.object with no `.strict()`) and stays in the shape this ledger walks. No total is restated in this header: `state-counts.md` publishes the counts and `check:liveness` proves that artifact fresh on every run. Seeded 2026-08-01 (#4488). 2026-08-28 (#13003): every LOCAL citation in this file was re-anchored to its consuming symbol. The four objectui-only entries (`description`, `slug`, `icon`, `order`) are left BYTE-FOR-BYTE UNTOUCHED and undated-forward on purpose: their evidence is pinned at `objectui @940ba24`, a commit this container cannot reproduce, and the anchor grammar deliberately never collects foreign anchors — re-stamping `verifiedAt` on a call graph nobody re-closed is exactly the false confidence this ledger exists to prevent (the `tool.json` precedent).", + "_note": "BookSchema (ADR-0046 §6 documentation spine). Consumers: the REST `/meta/book/:name/tree` endpoint (`packages/rest/src/rest-server.ts`, the `book/:name/tree` branch of `registerMetadataEndpointsInner`) driving the spec's pure `resolveBookTree` / `audienceAllows` (packages/spec/src/system/book.zod.ts), and objectui's console docs portal (apps/console/src/pages/book-nav.ts @940ba24 — a faithful resolver port rendering the reader UI, plus portal-only consumers for slug/icon/order). `groups[].translations` is dead: an inline map that LOOKS like the doc-level mechanism that works (`doc.translations`, resolveDocLocale) but has no resolver anywhere. Its book-level twin was the same trap and was retired in the same pass (#4667) — that one is a strict deletion, so it left the walked shape and holds no row here, while the group-level key is TOMBSTONED (`retiredKey`; BookGroupSchema is a plain z.object with no `.strict()`) and stays in the shape this ledger walks. No total is restated in this header: `state-counts/book.md` publishes the counts and `check:liveness` proves that shard fresh on every run. Seeded 2026-08-01 (#4488). 2026-08-28 (#13003): every LOCAL citation in this file was re-anchored to its consuming symbol. The four objectui-only entries (`description`, `slug`, `icon`, `order`) are left BYTE-FOR-BYTE UNTOUCHED and undated-forward on purpose: their evidence is pinned at `objectui @940ba24`, a commit this container cannot reproduce, and the anchor grammar deliberately never collects foreign anchors — re-stamping `verifiedAt` on a call graph nobody re-closed is exactly the false confidence this ledger exists to prevent (the `tool.json` precedent).", "props": { "name": { "status": "live", diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index 462bffef36f..9b4401c242c 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -1,6 +1,6 @@ { "type": "translation", - "_note": "TranslationItemSchema (#3778 — one locale's translations, the SAME groups the file-authored bundles use). NO LONGER A PIPE: the schema was a z.preprocess wrapping the retired object-first-dialect guard, which the gate's walker could not see through until #4488 fixed unwrap() to take the OUT side of a transform-input pipe — `translation` was literally unwalkable before this ledger. #4001 closed the shape with `.strict()` and folded the guard's ten prescriptions into the unknown-key `guidance`, so the preprocess is gone and the registered schema is a plain strict object. Consumer chain: runtime-authored items sync into the i18n adapter's authored layer (packages/core/src/fallbacks/authored-translation-sync.ts — at kernel:ready, on metadata:reloaded, and on translation mutations; #2591 closed the publish dead-end), file bundles load via service-i18n; both merge into ONE tree read by the spec resolvers (packages/spec/src/system/i18n-resolver.ts), the REST localization layer (translateMetaItem/translateMetaTypes), objectui's client resolvers (useObjectLabel/useSettingsLabel), and plugin-audit's summary localizer. WALK BOUNDARY: every group but `settingsCommon` is a z.record keyed by target names — the drill sees each record's VALUE shape one level; the deeper per-key conventions (objects..fields..label, apps..navigation..label, …) are governed by the resolvers cited per row, not by ledger rows. `settingsCommon` is a fixed strictObject with no target names, so the drill's one level lands on its own named member `sourceLabels` — itself a fixed strictObject whose keys are the ADR-0010 resolution layers (`env`, `global`, `tenant`, `user`, `default`), a closed set the schema holds closed: the retired spellings (`org`/`workspace`, `system`, `fallback`, `environment`) are rejected with a pointer to the layer each meant, never accepted as a layer. Those layer keys sit below the boundary: they are read as one unit by `resolveSettingsSourceLabel` (packages/spec/src/system/i18n-resolver.ts) and objectui's `useSettingsLabel().sourceLabel`, and the blanket verdict `settingsCommon` carries over them is the DECLARED kind — `translation/settingsCommon` is a row of scripts/liveness/undrilled-containers.baseline.json, the same standing as the record groups whose value shapes are not drilled. Note also the sync merges the RAW stored payload (authored-translation-sync.ts:155, not a schema re-parse), so the declared groups below are the CONTRACT while undeclared keys technically flow through on rows already stored — the resolvers read only the declared conventions. Since #4001 no NEW row can acquire one: the metadata door rejects an undeclared key instead of stripping it, so that residue is a finite set that only shrinks. `settings` is NOT a group of this ledger any more: its row was DELETED 2026-09-24 (#19620, ruling batch #210 item 2 letter B) by the strict-delete route — TranslationItemSchema no longer declares it and refuses it by name with the platform-only prescription, so the key left the walked shape and a surviving row would be an ORPHAN. That row was `live` on evidence reading the SERVED tree (objectui useSettingsLabel), which the PLATFORM bundle feeds; the deletion retires the key from this item ledger ONLY and says nothing about the platform capability, which stays declared on PlatformTranslationDataSchema (not this ledger’s subject) and read by the resolveSettings* family. Stored rows written before the door closed are converted, not read raw: authored-translation-sync replays the ADR-0087 chain (translation-per-app-settings-removed) over each row before merging it, which retires the `settings` clause of the RAW-payload note above for that key. Every group but `flows` is live — `flows` is the one that is `planned`, and `datasets` was seeded LIVE and DRILLED by #14253 with its reader (`translateDataset`) in the same change. A BOUNDARY and not a total, deliberately: the per-prop rows below carry the verdicts, the generated `state-counts.md` carries this type's totals (#7377), and `props` holds the groups PLUS `locale` and the item-identity keys `name`/`label` — so no total taken over `props` is a total of groups, which is how both totals this sentence has carried came to be wrong. ⚠️ It first read \"10 of 11 groups live; the one dead group (`validationMessages`) is pointed at by #3778's own legacy-key migration table, making it a shipped false signpost\" — describing a group REMOVED in 17.0.0 (#4667), i.e. prose outliving its subject in the header of the very file whose rows warn about that; corrected 2026-09-02 (#14253) to \"11 of 12 groups live; the twelfth, `datasets`, …\", which matched no reading of `props` at all — `datasets` is one of the groups, never a twelfth. Corrected again 2026-09-06 (#15775) by deleting the integers rather than re-deriving them, on #7377's precedent for this ledger family's other hand-maintained counts. Seeded 2026-08-01 (#4488). 2026-08-28 (#13003): all nine `path:NNN` citations in this file were re-anchored to their consuming symbols; EIGHT of the nine were wrong and every one of those was IN RANGE (the exception is `locale`, whose range still lands inside its reader). This ledger carried the batch's heaviest load of the OTHER silent class as well — nine further positions written as bare `:NNN` suffixes with no path in front of them, which `PATH_RE` never matches, so they degraded to prose that no check has ever resolved, bounded or key-checked.", + "_note": "TranslationItemSchema (#3778 — one locale's translations, the SAME groups the file-authored bundles use). NO LONGER A PIPE: the schema was a z.preprocess wrapping the retired object-first-dialect guard, which the gate's walker could not see through until #4488 fixed unwrap() to take the OUT side of a transform-input pipe — `translation` was literally unwalkable before this ledger. #4001 closed the shape with `.strict()` and folded the guard's ten prescriptions into the unknown-key `guidance`, so the preprocess is gone and the registered schema is a plain strict object. Consumer chain: runtime-authored items sync into the i18n adapter's authored layer (packages/core/src/fallbacks/authored-translation-sync.ts — at kernel:ready, on metadata:reloaded, and on translation mutations; #2591 closed the publish dead-end), file bundles load via service-i18n; both merge into ONE tree read by the spec resolvers (packages/spec/src/system/i18n-resolver.ts), the REST localization layer (translateMetaItem/translateMetaTypes), objectui's client resolvers (useObjectLabel/useSettingsLabel), and plugin-audit's summary localizer. WALK BOUNDARY: every group but `settingsCommon` is a z.record keyed by target names — the drill sees each record's VALUE shape one level; the deeper per-key conventions (objects..fields..label, apps..navigation..label, …) are governed by the resolvers cited per row, not by ledger rows. `settingsCommon` is a fixed strictObject with no target names, so the drill's one level lands on its own named member `sourceLabels` — itself a fixed strictObject whose keys are the ADR-0010 resolution layers (`env`, `global`, `tenant`, `user`, `default`), a closed set the schema holds closed: the retired spellings (`org`/`workspace`, `system`, `fallback`, `environment`) are rejected with a pointer to the layer each meant, never accepted as a layer. Those layer keys sit below the boundary: they are read as one unit by `resolveSettingsSourceLabel` (packages/spec/src/system/i18n-resolver.ts) and objectui's `useSettingsLabel().sourceLabel`, and the blanket verdict `settingsCommon` carries over them is the DECLARED kind — `translation/settingsCommon` is a row of scripts/liveness/undrilled-containers.baseline.json, the same standing as the record groups whose value shapes are not drilled. Note also the sync merges the RAW stored payload (authored-translation-sync.ts:155, not a schema re-parse), so the declared groups below are the CONTRACT while undeclared keys technically flow through on rows already stored — the resolvers read only the declared conventions. Since #4001 no NEW row can acquire one: the metadata door rejects an undeclared key instead of stripping it, so that residue is a finite set that only shrinks. `settings` is NOT a group of this ledger any more: its row was DELETED 2026-09-24 (#19620, ruling batch #210 item 2 letter B) by the strict-delete route — TranslationItemSchema no longer declares it and refuses it by name with the platform-only prescription, so the key left the walked shape and a surviving row would be an ORPHAN. That row was `live` on evidence reading the SERVED tree (objectui useSettingsLabel), which the PLATFORM bundle feeds; the deletion retires the key from this item ledger ONLY and says nothing about the platform capability, which stays declared on PlatformTranslationDataSchema (not this ledger’s subject) and read by the resolveSettings* family. Stored rows written before the door closed are converted, not read raw: authored-translation-sync replays the ADR-0087 chain (translation-per-app-settings-removed) over each row before merging it, which retires the `settings` clause of the RAW-payload note above for that key. Every group but `flows` is live — `flows` is the one that is `planned`, and `datasets` was seeded LIVE and DRILLED by #14253 with its reader (`translateDataset`) in the same change. A BOUNDARY and not a total, deliberately: the per-prop rows below carry the verdicts, the generated `state-counts/translation.md` carries this type's totals (#7377), and `props` holds the groups PLUS `locale` and the item-identity keys `name`/`label` — so no total taken over `props` is a total of groups, which is how both totals this sentence has carried came to be wrong. ⚠️ It first read \"10 of 11 groups live; the one dead group (`validationMessages`) is pointed at by #3778's own legacy-key migration table, making it a shipped false signpost\" — describing a group REMOVED in 17.0.0 (#4667), i.e. prose outliving its subject in the header of the very file whose rows warn about that; corrected 2026-09-02 (#14253) to \"11 of 12 groups live; the twelfth, `datasets`, …\", which matched no reading of `props` at all — `datasets` is one of the groups, never a twelfth. Corrected again 2026-09-06 (#15775) by deleting the integers rather than re-deriving them, on #7377's precedent for this ledger family's other hand-maintained counts. Seeded 2026-08-01 (#4488). 2026-08-28 (#13003): all nine `path:NNN` citations in this file were re-anchored to their consuming symbols; EIGHT of the nine were wrong and every one of those was IN RANGE (the exception is `locale`, whose range still lands inside its reader). This ledger carried the batch's heaviest load of the OTHER silent class as well — nine further positions written as bare `:NNN` suffixes with no path in front of them, which `PATH_RE` never matches, so they degraded to prose that no check has ever resolved, bounded or key-checked.", "props": { "name": { "status": "live", diff --git a/packages/spec/scripts/build-strictness-ledger-counts.mts b/packages/spec/scripts/build-strictness-ledger-counts.mts index df5328a3988..afd49c736f4 100644 --- a/packages/spec/scripts/build-strictness-ledger-counts.mts +++ b/packages/spec/scripts/build-strictness-ledger-counts.mts @@ -2,8 +2,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Writes `docs/audits/2026-07-unknown-key-strictness-ledger.counts.md` — every - * number the #4001 strictness ledger publishes (#5107). + * Writes `docs/audits/2026-07-unknown-key-strictness-ledger.counts/.md` — + * every number the #4001 strictness ledger publishes (#5107), one shard per + * directory of `packages/spec/src` with object sites (#20361). * * The ledger's prose merged cleanly all through the campaign; its NUMBERS were * the whole conflict surface, and they merged in the worst way available — two @@ -17,6 +18,14 @@ * remembered. Regeneration is WHOLESALE — this script never patches a number in * place, and neither should you. * + * The single file carried cross-directory totals — the global section and the + * posture total row — that every schema-touching PR rewrote, and the driver that + * defers the path runs only in a LOCAL merge: GitHub's server-side merge runs + * none, so two PRs adding sites in different directories conflicted there + * (#20361). So each directory is its own shard, a shard whose bytes did not + * change is not rewritten, the totals are printed below and committed nowhere, + * and the retired single file is deleted if a merge carried it back. + * * The verdicts stay hand-written in the ledger. This script reads them (a * subtotal is arithmetic over a judgement) and never writes to that file. * @@ -31,22 +40,29 @@ import fs from 'node:fs'; import path from 'node:path'; import url from 'node:url'; -import { COUNTS_PATH, loadLedger } from './lib/strictness-ledger-doc'; +import { writeTextShardDir } from './lib/sharded-artifacts'; +import { COUNTS_DIR, LEGACY_COUNTS_PATH, formatGlobalCounts, loadLedger } from './lib/strictness-ledger-doc'; const HERE = path.dirname(url.fileURLToPath(import.meta.url)); const SPEC = path.resolve(HERE, '..'); const REPO = path.resolve(SPEC, '../..'); const SRC = path.join(SPEC, 'src'); -const { countsPath, rendered, problems, model } = loadLedger(REPO, SRC); +const { countsDir, legacyCountsPath, shards, problems, model } = loadLedger(REPO, SRC); -fs.writeFileSync(countsPath, rendered); +const { written, removed } = writeTextShardDir(countsDir, shards); +const legacyRemoved = fs.existsSync(legacyCountsPath); +if (legacyRemoved) fs.rmSync(legacyCountsPath); -console.log(`✓ wrote ${COUNTS_PATH}`); +console.log(`✓ wrote ${COUNTS_DIR}/ — ${shards.size} shard(s), one per source directory with sites.`); console.log( - ` ${model.global.sites} site(s) across ${model.global.dirs} triaged director(ies); ` + - `${model.global.strip} strip site(s) in ${model.global.openFiles} file(s).`, + ` ${written.length} shard(s) rewritten${written.length ? ` (${written.join(', ')})` : ''}, ` + + `${removed.length} pruned${removed.length ? ` (${removed.join(', ')})` : ''}` + + (legacyRemoved ? `, and the retired ${path.basename(LEGACY_COUNTS_PATH)} deleted` : '') + + '.', ); +console.log(' totals, summed here and committed nowhere:'); +for (const line of formatGlobalCounts(model)) console.log(` ${line}`); if (problems.length) { // Written anyway, on purpose: the post-merge regeneration this whole scheme diff --git a/packages/spec/scripts/check-generated.ts b/packages/spec/scripts/check-generated.ts index 179f8b78ed4..c6e6df5ea5c 100644 --- a/packages/spec/scripts/check-generated.ts +++ b/packages/spec/scripts/check-generated.ts @@ -139,10 +139,13 @@ const GATED: ReadonlyArray<{ // audits source too (a hand-written row must name a live sited file), which is // why the `gen:` fixes only half of what it can report — the other half is a // ledger edit, and the failure says which. + // + // A DIRECTORY since #20361, one shard per source directory and no committed + // cross-directory total, for the reason its liveness neighbour below is one. { check: 'check:strictness-ledger', gen: 'gen:strictness-ledger', - artifact: 'docs/audits/2026-07-unknown-key-strictness-ledger.counts.md', + artifact: 'docs/audits/2026-07-unknown-key-strictness-ledger.counts/', }, // Moved out of NO_GENERATOR at #7377, by the same precedent as its neighbour // above and for the same measured reason: the liveness README's "Current state" diff --git a/packages/spec/scripts/check-strictness-ledger.mts b/packages/spec/scripts/check-strictness-ledger.mts index c204f843c71..011f6293352 100644 --- a/packages/spec/scripts/check-strictness-ledger.mts +++ b/packages/spec/scripts/check-strictness-ledger.mts @@ -26,7 +26,11 @@ // So the numbers moved into a generated artifact carrying `merge=os-regen`, and // this gate proves two things instead of one: // -// A. the generated artifact is FRESH — it equals what the AST says right now; +// A. the generated artifact is FRESH — every shard equals what the AST says +// right now, none is missing or stray, and the retired single file is gone +// (#20361: one shard per source directory, and the cross-directory totals +// are summed here, at read time, instead of committed where two PRs both +// rewrite them); // B. the hand-written prose is CONSISTENT with it — every row names a real file // with real sites, and every file with sites has a row. // @@ -61,14 +65,17 @@ import fs from 'node:fs'; import path from 'node:path'; import url from 'node:url'; +import { readTextShardDir, reconcileTextShardDir } from './lib/sharded-artifacts'; import { analyzeSites, countSites, countStripSites, listSchemaFiles } from './lib/strictness-ledger'; import { BUCKETS, - COUNTS_PATH, + COUNTS_DIR, GEN_COMMAND, LEDGER_PATH, + LEGACY_COUNTS_PATH, VERDICTS, bucketize, + formatGlobalCounts, loadLedger, parseClassCell, } from './lib/strictness-ledger-doc'; @@ -79,7 +86,7 @@ const REPO = path.resolve(SPEC, '../..'); const SRC = path.join(SPEC, 'src'); const LIST = process.argv.includes('--list'); -const { parsed, model, problems, rendered, countsPath, ledgerText } = loadLedger(REPO, SRC); +const { parsed, model, problems, shards, countsDir, legacyCountsPath, ledgerText } = loadLedger(REPO, SRC); if (LIST) { for (const t of model.triaged) { @@ -113,22 +120,39 @@ function firstDifferences(actual: string, expected: string, limit = 6): string { return out.length ? ` first difference(s) (- on disk, + expected):\n${out.join('\n')}\n` : ''; } -if (!fs.existsSync(countsPath)) { - errors.push(`${COUNTS_PATH} is MISSING.\n → ${GEN_COMMAND}`); -} else { - const onDisk = fs.readFileSync(countsPath, 'utf-8'); - if (onDisk !== rendered) { - errors.push( - `${COUNTS_PATH} is STALE — it does not match what the AST says right now.\n` + - ` → ${GEN_COMMAND}\n` + - ` Then READ the diff: a count that moved means a schema was added, removed or\n` + - ` re-postured under a \`Class\` verdict nobody re-examined. Confirm the verdict in\n` + - ` ${LEDGER_PATH} still covers it — that re-read is what\n` + - ` the old hand-written counts bought, and it is the half of them worth keeping.\n` + - ` ⛔ Never hand-patch a number in the artifact. Regeneration is wholesale.\n` + - firstDifferences(onDisk, rendered), - ); - } +const freshness = reconcileTextShardDir({ + displayDir: `${COUNTS_DIR}/`, + rendered: shards, + onDisk: readTextShardDir(countsDir), +}); +if (freshness.missingDir) errors.push(`${COUNTS_DIR}/ is MISSING.\n → ${GEN_COMMAND}`); +for (const p of freshness.missing) { + errors.push(`${p} is MISSING — a directory with sites whose counts nothing publishes.\n → ${GEN_COMMAND}`); +} +for (const { name, onDisk, expected } of freshness.stale) { + errors.push( + `${name} is STALE — it does not match what the AST says right now.\n` + + ` → ${GEN_COMMAND}\n` + + ` Then READ the diff: a count that moved means a schema was added, removed or\n` + + ` re-postured under a \`Class\` verdict nobody re-examined. Confirm the verdict in\n` + + ` ${LEDGER_PATH} still covers it — that re-read is what\n` + + ` the old hand-written counts bought, and it is the half of them worth keeping.\n` + + ` ⛔ Never hand-patch a number in a shard, and never commit a total. Regeneration is wholesale.\n` + + firstDifferences(onDisk, expected), + ); +} +for (const p of freshness.stray) { + errors.push( + `${p} is STRAY — no directory with sites renders it (the directory was deleted or emptied,\n` + + ` or the file was written by hand). The shard directory is generator-owned.\n → ${GEN_COMMAND}`, + ); +} +if (fs.existsSync(legacyCountsPath)) { + errors.push( + `${LEGACY_COUNTS_PATH} is RETIRED — the counts are one shard per source directory under\n` + + ` ${COUNTS_DIR}/, and this file's committed totals are the lines every schema PR rewrote.\n` + + ` A branch cut before the split keeps it through a merge; delete it.\n → ${GEN_COMMAND}`, + ); } /* ── B. the hand-written prose is consistent with the code ─────────────────── */ @@ -290,7 +314,7 @@ if (errors.length) { console.error(`\n✗ strictness ledger: ${errors.length} drift(s)\n`); for (const e of errors) console.error(` ${e}\n`); console.error(` The ledger is ${LEDGER_PATH} (prose — hand-written).`); - console.error(` The counts are ${COUNTS_PATH} (generated — ${GEN_COMMAND}).\n`); + console.error(` The counts are ${COUNTS_DIR}/ (generated — ${GEN_COMMAND}).\n`); process.exit(1); } @@ -305,6 +329,8 @@ console.log( `no closed file still carries one, every Class cell resolves.`, ); console.log( - `✓ ${COUNTS_PATH} is current — ${model.global.sites} site(s) measured, ` + - `${model.global.buckets.authorable} authorable strip site(s) left.`, + `✓ ${COUNTS_DIR}/ is current — ${shards.size} shard(s), one per source directory with sites, ` + + `${model.global.sites} triaged site(s) measured, ${model.global.buckets.authorable} authorable strip site(s) left.`, ); +console.log(' totals across the shards, summed at read time and committed nowhere:'); +for (const line of formatGlobalCounts(model)) console.log(` ${line}`); diff --git a/packages/spec/scripts/count-shards-merge.test.ts b/packages/spec/scripts/count-shards-merge.test.ts new file mode 100644 index 00000000000..45ea79dbd7a --- /dev/null +++ b/packages/spec/scripts/count-shards-merge.test.ts @@ -0,0 +1,313 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The two sharded count artifacts, asked of git the way GitHub asks it (#20361). +// +// WHY THIS TEST SPAWNS GIT. The defect the shards cure was never in a renderer: +// it was in a MERGE. `liveness/state-counts.md` and the strictness ledger's +// `….counts.md` were each ONE generated file carrying per-unit rows AND shared +// totals that every PR moving a unit rewrote. `merge=os-regen` defers those +// paths in a LOCAL merge only; GitHub's server-side merge — the one that decides +// `mergeable` and builds the ref CI runs on — has no custom driver. Measured on +// the dispatch base `2b24b8b823`, in a bare probe clone with no driver +// registered, each side regenerated with the real generator: +// +// - liveness, `field.useGrouping` planned→dead against `sharing_rule.type` +// planned→live: `git merge-tree` exit 1, CONFLICT (content) in +// `state-counts.md` — the two rows are 36 lines apart and the only overlap +// is the total row; +// - liveness, the same `field` move against `sharing_rule.type` planned→dead, +// an EQUAL delta: exit 0 and WRONG — both sides wrote the identical total, +// git took it once, and the merged table said `dead 149` where the two moves +// make 150; +// - strictness, one strict site added in `ui/` against two in `data/`: exit 1, +// CONFLICT (content) in `….counts.md`, while both source files merged clean. +// +// So the question a unit test of a renderer cannot answer — "do two PRs that +// move different units merge, and merge RIGHT, with no driver?" — is asked here +// of `git merge-tree` over each real renderer's output, in a throwaway +// repository that carries the real attribute line and no driver. A same-unit +// pair is the lit control in each block: it MUST still conflict (one file's own +// rows changed twice), or a clean result would be equally explained by a +// harness that cannot see a conflict at all. + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { gitFreeEnv } from '../../../scripts/git-env.mjs'; + +import { writeTextShardDir } from './lib/sharded-artifacts'; +import { emptyBuckets, renderCountShards, type CountsModel } from './lib/strictness-ledger-doc'; +import { + STATE_COUNTS_DIR, + renderStateCountShards, + type StateCountsRow, + type StatusColumn, +} from './liveness/readme-table.mts'; + +/** Every fixture git is LOCAL-ONLY and hermetic: no inherited `GIT_*`, no global or system config. */ +const HERMETIC_ENV: NodeJS.ProcessEnv = (() => { + const env = gitFreeEnv(); + env.GIT_CONFIG_GLOBAL = '/dev/null'; + env.GIT_CONFIG_SYSTEM = '/dev/null'; + env.GIT_CONFIG_NOSYSTEM = '1'; + return env; +})(); + +const GIT_ARGS = ['-c', 'user.name=t', '-c', 'user.email=t@example.invalid', '-c', 'gc.auto=0', '-c', 'maintenance.auto=false']; + +/** + * A throwaway repository holding one shard directory, `dir`, routed to + * `merge=os-regen` exactly as `.gitattributes` routes the real one — and with + * no driver registered, which is what GitHub's merge sees. + */ +class ShardRepo { + readonly root = mkdtempSync(path.join(tmpdir(), 'os-count-shards-merge-')); + readonly attributes: string; + + constructor(readonly dir: string, base: ReadonlyMap) { + this.attributes = `${dir}/** merge=os-regen\n`; + this.must('init', '-q', '-b', 'base'); + writeFileSync(path.join(this.root, '.gitattributes'), this.attributes); + writeTextShardDir(path.join(this.root, dir), base); + this.must('add', '-A'); + this.must('commit', '-q', '-m', 'base'); + } + + git(...args: string[]): { status: number | null; stdout: string; stderr: string } { + const r = spawnSync('git', [...GIT_ARGS, ...args], { cwd: this.root, encoding: 'utf8', env: HERMETIC_ENV }); + if (r.error) throw r.error; + return { status: r.status, stdout: r.stdout, stderr: r.stderr }; + } + + must(...args: string[]): string { + const r = this.git(...args); + expect(r.status, `git ${args.join(' ')}\n${r.stderr}`).toBe(0); + return r.stdout; + } + + /** Commit `shards` on a new branch cut from `base`, exactly as a generator writes them. */ + branch(name: string, shards: ReadonlyMap): void { + this.must('checkout', '-q', '-b', name, 'base'); + writeTextShardDir(path.join(this.root, this.dir), shards); + this.must('add', '-A'); + this.must('commit', '-q', '-m', name); + } + + /** `git merge-tree --write-tree` of two branches: exit code, tree, and the paths it names on a conflict. */ + merge(a: string, b: string): { status: number | null; tree: string; conflicted: string[] } { + const r = this.git('merge-tree', '--write-tree', '--name-only', '--no-messages', a, b); + const [tree = '', ...rest] = r.stdout.trim().split('\n'); + return { status: r.status, tree, conflicted: rest.filter(Boolean) }; + } + + /** Every file of a tree, as `path -> bytes`. */ + files(tree: string): Map { + const out = new Map(); + for (const p of this.must('ls-tree', '-r', '--name-only', tree).trim().split('\n')) { + out.set(p, this.must('show', `${tree}:${p}`)); + } + return out; + } + + /** What the generator would leave in the tree for `shards`. */ + expected(shards: ReadonlyMap): Map { + const out = new Map([['.gitattributes', this.attributes]]); + for (const [name, text] of shards) out.set(`${this.dir}/${name}`, text); + return out; + } + + dispose(): void { + rmSync(this.root, { recursive: true, force: true }); + } +} + +/* ══════════════════════════════════════════════════════════════════════════ + * liveness/state-counts/ — one shard per governed type + * ══════════════════════════════════════════════════════════════════════════ */ + +/** The dispatch base's rows for the types the moves below touch. */ +const LIVENESS_BASE: readonly StateCountsRow[] = [ + { type: 'object', live: 50, experimental: 0, 'live-elsewhere': 0, dead: 0, planned: 1 }, + { type: 'field', live: 91, experimental: 0, 'live-elsewhere': 0, dead: 1, planned: 1 }, + { type: 'sharing_rule', live: 16, experimental: 0, 'live-elsewhere': 0, dead: 0, planned: 1 }, + { type: 'connector', live: 29, experimental: 0, 'live-elsewhere': 0, dead: 30, planned: 1 }, +]; + +interface Move { type: string; from: StatusColumn; to: StatusColumn } + +/** Ledger verdicts moved — what a PR that flips rows does to the fold — rendered as the generator writes them. */ +function liveness(...moves: Move[]): Map { + return renderStateCountShards( + LIVENESS_BASE.map((row) => { + const next = { ...row }; + for (const m of moves) { + if (m.type !== row.type) continue; + next[m.from] -= 1; + next[m.to] += 1; + } + return next; + }), + ); +} + +const FIELD_PLANNED_TO_DEAD: Move = { type: 'field', from: 'planned', to: 'dead' }; +const FIELD_LIVE_TO_DEAD: Move = { type: 'field', from: 'live', to: 'dead' }; +const SHARING_PLANNED_TO_LIVE: Move = { type: 'sharing_rule', from: 'planned', to: 'live' }; +const SHARING_PLANNED_TO_DEAD: Move = { type: 'sharing_rule', from: 'planned', to: 'dead' }; +const OBJECT_PLANNED_TO_DEAD: Move = { type: 'object', from: 'planned', to: 'dead' }; + +describe('liveness/state-counts/ — two PRs, no merge driver (#20361)', () => { + let repo: ShardRepo; + + beforeAll(() => { + repo = new ShardRepo(STATE_COUNTS_DIR, liveness()); + repo.branch('field-planned-dead', liveness(FIELD_PLANNED_TO_DEAD)); + repo.branch('field-live-dead', liveness(FIELD_LIVE_TO_DEAD)); + repo.branch('sharing-planned-live', liveness(SHARING_PLANNED_TO_LIVE)); + repo.branch('sharing-planned-dead', liveness(SHARING_PLANNED_TO_DEAD)); + repo.branch('object-planned-dead', liveness(OBJECT_PLANNED_TO_DEAD)); + }); + afterAll(() => repo.dispose()); + + // The premise of every case below: this is GitHub's merge, not ours. A + // registered driver would defer the path and make "clean" mean nothing. + it('merges with the attribute present and NO driver registered — the server-side shape', () => { + expect(repo.git('config', '--get', 'merge.os-regen.driver').status).toBe(1); + expect(repo.git('check-attr', 'merge', '--', `${STATE_COUNTS_DIR}/field.md`).stdout.trim()).toBe( + `${STATE_COUNTS_DIR}/field.md: merge: os-regen`, + ); + }); + + it('a regeneration touches only the shard of the type that moved', () => { + expect(repo.must('diff', '--name-only', 'base', 'field-planned-dead').trim()).toBe(`${STATE_COUNTS_DIR}/field.md`); + }); + + // THE CARD'S REPRODUCTION, now clean: the pair that conflicted on the total row. + it('two moves of DIFFERENT types, different deltas: merges clean, and the result is the regeneration of both', () => { + const m = repo.merge('field-planned-dead', 'sharing-planned-live'); + expect(m.status, m.conflicted.join('\n')).toBe(0); + expect(repo.files(m.tree)).toEqual(repo.expected(liveness(FIELD_PLANNED_TO_DEAD, SHARING_PLANNED_TO_LIVE))); + }); + + // The pair the single file merged CLEAN AND WRONG. Clean is not enough here: + // the merged tree must equal what regenerating the merged ledgers writes. + it('two moves of different types with an EQUAL delta: merges clean AND right — no shared line to double-count', () => { + const m = repo.merge('field-planned-dead', 'sharing-planned-dead'); + expect(m.status, m.conflicted.join('\n')).toBe(0); + expect(repo.files(m.tree)).toEqual(repo.expected(liveness(FIELD_PLANNED_TO_DEAD, SHARING_PLANNED_TO_DEAD))); + }); + + // Adjacent rows conflicted in the single file even with no total (git refuses + // two edits on touching lines). Different files cannot touch. + it('two moves of ADJACENT types merge clean — the rows no longer share a file', () => { + const m = repo.merge('object-planned-dead', 'field-planned-dead'); + expect(m.status, m.conflicted.join('\n')).toBe(0); + expect(repo.files(m.tree)).toEqual(repo.expected(liveness(OBJECT_PLANNED_TO_DEAD, FIELD_PLANNED_TO_DEAD))); + }); + + it('no merged file carries a total row — the sum is the reader\'s, not a file\'s', () => { + const m = repo.merge('field-planned-dead', 'sharing-planned-live'); + for (const [p, text] of repo.files(m.tree)) expect(text, p).not.toMatch(/^\|\s*\**total/im); + }); + + // THE LIT CONTROL. A same-type pair changes one file's one row twice, which is + // a real conflict and must stay one — the residue sharding cannot remove, and + // what the local driver still owns. + it('two moves of the SAME type still conflict, on that type\'s shard and nowhere else', () => { + const m = repo.merge('field-planned-dead', 'field-live-dead'); + expect(m.status).toBe(1); + expect(m.conflicted).toEqual([`${STATE_COUNTS_DIR}/field.md`]); + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * the strictness ledger's ….counts/ — one shard per source directory + * ══════════════════════════════════════════════════════════════════════════ */ + +const STRICTNESS_DIR = '2026-07-unknown-key-strictness-ledger.counts'; + +/** + * A two-triaged-directory model with `ui` and `data` strict sites added on top of + * the dispatch base's shapes, plus two untriaged directories. `renderCountShards` + * reads `triaged` and `other` only; `global` is what the gate SUMS at read time, + * which is precisely what no shard may carry. + */ +function strictness({ ui = 0, data = 0, api = 0, kernel = 0 } = {}): Map { + const dir = (name: string, strictAdded: number, files: Array<{ file: string; sites: number; strip: number }>) => { + const withAdded = files.map((f, i) => (i === 0 ? { ...f, sites: f.sites + strictAdded } : f)); + const sites = withAdded.reduce((a, f) => a + f.sites, 0); + const strip = withAdded.reduce((a, f) => a + f.strip, 0); + const openFiles = withAdded.filter((f) => f.strip > 0); + return { + dir: name, + files: withAdded, + sites, + strip, + posture: { strict: sites - strip, passthrough: 0, catchall: 0, strip }, + openFiles, + buckets: { ...emptyBuckets(), 'wire/open': strip }, + }; + }; + const model: CountsModel = { + triaged: [ + dir('ui', ui, [{ file: 'component.zod.ts', sites: 56, strip: 0 }, { file: 'view.zod.ts', sites: 62, strip: 4 }]), + dir('data', data, [{ file: 'mapping.zod.ts', sites: 3, strip: 0 }, { file: 'filter.zod.ts', sites: 12, strip: 11 }]), + ], + other: [ + { dir: 'api', sites: 431 + api }, + { dir: 'identity', sites: 32 }, + { dir: 'integration', sites: 5 }, + { dir: 'kernel', sites: 247 + kernel }, + ], + // Not rendered — see above. Filled so the fixture is a whole model. + global: { + sites: 0, strip: 0, openFiles: 0, dirs: 2, + posture: { strict: 0, passthrough: 0, catchall: 0, strip: 0 }, + buckets: emptyBuckets(), + }, + }; + return renderCountShards(model); +} + +describe('the strictness ledger\'s ….counts/ — two PRs, no merge driver (#20361)', () => { + let repo: ShardRepo; + + beforeAll(() => { + repo = new ShardRepo(STRICTNESS_DIR, strictness()); + repo.branch('ui-plus-one', strictness({ ui: 1 })); + repo.branch('ui-plus-two', strictness({ ui: 2 })); + repo.branch('data-plus-two', strictness({ data: 2 })); + repo.branch('api-plus-one', strictness({ api: 1 })); + repo.branch('kernel-minus-four', strictness({ kernel: -4 })); + }); + afterAll(() => repo.dispose()); + + it('a regeneration touches only the shard of the directory that moved', () => { + expect(repo.must('diff', '--name-only', 'base', 'ui-plus-one').trim()).toBe(`${STRICTNESS_DIR}/ui.md`); + }); + + // THE MEASURED REPRODUCTION, now clean: one site in `ui/` against two in `data/`. + it('sites added in two DIFFERENT triaged directories: merges clean, and the result is the regeneration of both', () => { + const m = repo.merge('ui-plus-one', 'data-plus-two'); + expect(m.status, m.conflicted.join('\n')).toBe(0); + expect(repo.files(m.tree)).toEqual(repo.expected(strictness({ ui: 1, data: 2 }))); + }); + + // The untriaged table was one block of adjacent rows; two PRs moving + // neighbouring directories met on it even though no total sat under them. + it('two UNTRIAGED directories moving at once merge clean', () => { + const m = repo.merge('api-plus-one', 'kernel-minus-four'); + expect(m.status, m.conflicted.join('\n')).toBe(0); + expect(repo.files(m.tree)).toEqual(repo.expected(strictness({ api: 1, kernel: -4 }))); + }); + + // THE LIT CONTROL: the same directory twice is still a conflict on its shard. + it('two moves in the SAME directory still conflict, on that directory\'s shard and nowhere else', () => { + const m = repo.merge('ui-plus-one', 'ui-plus-two'); + expect(m.status).toBe(1); + expect(m.conflicted).toEqual([`${STRICTNESS_DIR}/ui.md`]); + }); +}); diff --git a/packages/spec/scripts/lib/sharded-artifacts.ts b/packages/spec/scripts/lib/sharded-artifacts.ts index 6dffdf804e0..139be977e9a 100644 --- a/packages/spec/scripts/lib/sharded-artifacts.ts +++ b/packages/spec/scripts/lib/sharded-artifacts.ts @@ -214,6 +214,89 @@ export function writeShards( return { written, removed }; } +// ─── Text-shard directories (#20361) ────────────────────────────────── + +/** + * Read a generator-owned directory of TEXT shards — the two count artifacts + * (`liveness/state-counts/`, the strictness ledger's `….counts/`) that were + * single markdown files with a committed total row until #20361 — as + * `file name -> bytes`, or `null` when the directory does not exist. + * + * Unlike `readShards` above there is no parse and no `.json` rule: those + * shards are compared as BYTES against a fresh render, so the reader's only job + * is to hand back everything that is there. A subdirectory is keyed with a + * trailing `/` and no bytes, so it can only surface as a stray — nothing in a + * generator-owned directory is skipped silently. + */ +export function readTextShardDir(dir: string): Map | null { + if (!fs.existsSync(dir)) return null; + const out = new Map(); + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isFile()) out.set(entry.name, fs.readFileSync(path.join(dir, entry.name), 'utf-8')); + else out.set(`${entry.name}/`, ''); + } + return out; +} + +/** + * Write every text shard whose bytes changed, and prune everything else in the + * directory. An unchanged shard is not rewritten, so a regeneration touches + * exactly the shards whose numbers moved — the locality the layout is for — and + * the returned lists say which, so a generator can print them. + */ +export function writeTextShardDir( + dir: string, + shards: ReadonlyMap, +): { written: string[]; removed: string[] } { + fs.mkdirSync(dir, { recursive: true }); + const written: string[] = []; + for (const [name, text] of shards) { + const file = path.join(dir, name); + if (fs.existsSync(file) && fs.readFileSync(file, 'utf-8') === text) continue; + fs.writeFileSync(file, text); + written.push(name); + } + const removed: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (shards.has(entry.name)) continue; + fs.rmSync(path.join(dir, entry.name), { recursive: true, force: true }); + removed.push(entry.isFile() ? entry.name : `${entry.name}/`); + } + return { written, removed }; +} + +/** + * Reconcile a directory of text shards against a fresh render — the freshness + * leg both count gates share (#20361). Each finding names one path, so a stale + * shard sends the reader to the one file that moved rather than to a directory. + * + * `onDisk === null` is the missing directory. A shard present but not rendered + * is STRAY: in a generator-owned directory it is either a unit that stopped + * existing (a type left the governed list, a source directory was deleted) or a + * hand-written file, and either way it publishes numbers nothing re-renders. + */ +export function reconcileTextShardDir({ + displayDir, + rendered, + onDisk, +}: { + /** The directory as a failure message should name it, trailing slash included. */ + displayDir: string; + rendered: ReadonlyMap; + onDisk: ReadonlyMap | null; +}): { missingDir: boolean; missing: string[]; stale: Array<{ name: string; onDisk: string; expected: string }>; stray: string[] } { + if (onDisk === null) return { missingDir: true, missing: [], stale: [], stray: [] }; + const missing: string[] = []; + const stale: Array<{ name: string; onDisk: string; expected: string }> = []; + for (const [name, text] of rendered) { + const current = onDisk.get(name); + if (current === undefined) missing.push(`${displayDir}${name}`); + else if (current !== text) stale.push({ name: `${displayDir}${name}`, onDisk: current, expected: text }); + } + const stray = [...onDisk.keys()].filter((name) => !rendered.has(name)).sort().map((name) => `${displayDir}${name}`); + return { missingDir: false, missing, stale, stray }; +} + // ─── Per-artifact shapes ────────────────────────────────────────────── /** One category's slice of the authorable-key ratchet. */ diff --git a/packages/spec/scripts/lib/strictness-ledger-doc.ts b/packages/spec/scripts/lib/strictness-ledger-doc.ts index 3f7b97cd340..81486ebb52f 100644 --- a/packages/spec/scripts/lib/strictness-ledger-doc.ts +++ b/packages/spec/scripts/lib/strictness-ledger-doc.ts @@ -495,8 +495,42 @@ export function buildCounts( /* ------------------------------------------------------------------ rendering */ -/** Where the generated artifact lives, relative to the repo root. */ -export const COUNTS_PATH = 'docs/audits/2026-07-unknown-key-strictness-ledger.counts.md'; +/** + * Where the generated counts live, relative to the repo root: a DIRECTORY with + * one `.md` shard per directory of `packages/spec/src` that has object + * sites, triaged or not (#20361). + * + * ## Why a directory, and why no file carries a total + * + * #5107 moved the numbers out of the ledger into ONE generated file, and + * `merge=os-regen` made a LOCAL merge of it regenerate instead of text-merge. + * GitHub's server-side merge — the one that decides a PR's `mergeable` state and + * builds the ref CI runs on — runs no custom driver. That file carried a global + * section and a posture `**total**` row that every schema-touching PR rewrote, + * so two PRs that added sites in different directories conflicted on them, and + * the moment one landed the other went `dirty` with no CI run. Measured on + * `2b24b8b823` in a bare probe clone with no driver: one strict site in `ui/` + * against two in `data/`, each regenerated, CONFLICT (content) on the counts + * file while both source files merged clean. + * + * So each directory's numbers are their own file, carrying only that + * directory's rows, and the cross-directory totals — the global measures, the + * posture total, the global bucket split — are summed where they are read + * (`check:strictness-ledger` and `gen:strictness-ledger` print them; see + * `formatGlobalCounts`) and committed nowhere. Two PRs touching different + * directories now touch disjoint files; a same-directory pair still meets on + * that directory's rows, which is the residue the local driver still owns. + */ +export const COUNTS_DIR = 'docs/audits/2026-07-unknown-key-strictness-ledger.counts'; + +/** + * The single file the shards replaced (#20361). Named for one live reason: a + * branch cut before the split meets its deletion as a modify/delete on its next + * base merge, and keeping it would publish a stale table and stale totals beside + * the shards, re-rendered by nothing. Its presence is a gate failure and the + * generator deletes it. + */ +export const LEGACY_COUNTS_PATH = 'docs/audits/2026-07-unknown-key-strictness-ledger.counts.md'; /** The ledger it belongs to, relative to the repo root. */ export const LEDGER_PATH = 'docs/audits/2026-07-unknown-key-strictness-ledger.md'; @@ -504,6 +538,14 @@ export const LEDGER_PATH = 'docs/audits/2026-07-unknown-key-strictness-ledger.md /** The command that rewrites the artifact. */ export const GEN_COMMAND = 'pnpm --filter @objectstack/spec gen:strictness-ledger'; +/** The shard carrying one source directory's numbers. */ +export function countsShardName(dir: string): string { + if (!/^[a-z][a-z0-9-]*$/.test(dir)) { + throw new Error(`cannot shard the strictness counts for "${dir}/": not a plain directory name`); + } + return `${dir}.md`; +} + const BUCKET_LABEL: Record = { authorable: 'authorable — the ruling\'s forced scope', unresolved: 'unresolved — needs a per-schema verdict', @@ -515,53 +557,38 @@ const BUCKET_LABEL: Record = { }; /** - * Render the artifact. Pure function of the model, so `check:strictness-ledger` - * proves freshness by rendering and comparing bytes — there is no second parser - * to disagree with this writer. + * The lines every shard opens with. Everything here names only the shard's own + * directory: a line that named a sibling directory, the triaged-directory count + * or a total would be a line two PRs touching different directories both + * rewrite — the conflict the shard exists to remove. */ -export function renderCounts(model: CountsModel): string { - const L: string[] = []; - const g = model.global; - - L.push(''); - L.push(``); - L.push(''); - L.push('# Unknown-key strictness ledger — the counts (generated)'); - L.push(''); - L.push('Every number the #4001 strictness ledger publishes, computed from the AST'); - L.push(`(\`packages/spec/scripts/lib/strictness-ledger.ts\`). The verdicts, the evidence and`); - L.push(`the exemption rationales live in [the ledger itself](./${path.basename(LEDGER_PATH)}) and`); - L.push('are hand-written; **this file has no prose to preserve** and is regenerated whole.'); - L.push(''); - L.push('Split out at #5107. These numbers were the ledger\'s entire merge-conflict surface:'); - L.push('two batches each decrement a header by their own delta, git merges the rows cleanly,'); - L.push('and the subtotal — which conflicts with nothing — merges clean and wrong. Seven cases'); - L.push('in one day. The correct resolution was always "recompute from the merged tree", so the'); - L.push('path carries `merge=os-regen` (#4675) and the recomputation is now mandatory rather'); - L.push('than remembered. **Never hand-patch a number here** — fix the code or the verdict and'); - L.push('regenerate.'); - L.push(''); +function shardHeader(dir: string, lead: string[]): string[] { + return [ + '', + ``, + '', + `# \`${dir}/\` — unknown-key strictness counts (generated)`, + '', + ...lead, + '', + 'The verdicts, the evidence and the exemption rationales live in', + `[the ledger itself](../${path.basename(LEDGER_PATH)}) and are`, + 'hand-written; **this file has no prose to preserve** and is regenerated whole.', + 'One file per directory, and no total across directories is committed anywhere:', + '`check:strictness-ledger` sums the shards when it reads them. **Never', + 'hand-patch a number here** — fix the code or the verdict and regenerate.', + '', + ]; +} - L.push('## Global'); - L.push(''); - L.push('| Measure | Value |'); - L.push('|---|---|'); - L.push(`| Triaged directories | ${g.dirs} |`); - L.push(`| Object sites in them | ${g.sites} |`); - L.push(`| Still-open (strip) sites | ${g.strip} |`); - L.push(`| Files carrying at least one | ${g.openFiles} |`); - L.push(''); - L.push('Remaining strip sites by class:'); - L.push(''); - L.push('| Bucket | Sites |'); - L.push('|---|---|'); - for (const b of BUCKETS) { - if (!g.buckets[b] && b === 'unclassified') continue; - L.push(`| ${BUCKET_LABEL[b]} | ${g.buckets[b]} |`); - } - L.push(''); +/** One triaged directory's shard: its posture row, its per-file sites, its open files and buckets. */ +function renderTriagedShard(t: TriagedDirCounts): string { + const L = shardHeader(t.dir, [ + `Every number the #4001 strictness ledger publishes about \`packages/spec/src/${t.dir}/\`,`, + 'computed from the AST (`packages/spec/scripts/lib/strictness-ledger.ts`).', + ]); - L.push('## Posture, per triaged directory'); + L.push('## Posture'); L.push(''); L.push('The `strict` column is the one the campaign schedules against; it counts both the'); L.push('`strictObject(` helper and the older `z.object(…).strict()` spelling, and — since'); @@ -569,78 +596,107 @@ export function renderCounts(model: CountsModel): string { L.push(''); L.push('| Dir | Sites | strict | passthrough | catchall | strip |'); L.push('|---|---|---|---|---|---|'); - for (const t of model.triaged) { - L.push( - `| \`${t.dir}/\` | ${t.sites} | ${t.posture.strict} | ${t.posture.passthrough} | ` + - `${t.posture.catchall} | ${t.posture.strip} |`, - ); - } L.push( - `| **total** | **${g.sites}** | **${g.posture.strict}** | **${g.posture.passthrough}** | ` + - `**${g.posture.catchall}** | **${g.posture.strip}** |`, + `| \`${t.dir}/\` | ${t.sites} | ${t.posture.strict} | ${t.posture.passthrough} | ` + + `${t.posture.catchall} | ${t.posture.strip} |`, ); L.push(''); - L.push('## File-level triage — site counts'); + // Headers carry no numbers, so their anchors are stable across every batch — + // the ledger links into these files, and a link that breaks whenever a count + // moves is a link that will be wrong exactly when someone follows it. + L.push(`## \`${t.dir}/\` — sites`); L.push(''); L.push('Object sites per file: every `z.object(` / `strictObject(` / `z.strictObject(` /'); L.push('`z.looseObject(` CALL, read from the AST. A file with zero sites has nothing to'); L.push('classify and is not listed (it becomes reportable the day it grows its first site).'); L.push(''); - for (const t of model.triaged) { - // Headers carry no numbers, so their anchors are stable across every batch — - // the ledger links into this file, and a link that breaks whenever a count - // moves is a link that will be wrong exactly when someone follows it. - L.push(`### \`${t.dir}/\` — sites`); - L.push(''); - L.push('| File | Sites |'); - L.push('|---|---|'); - for (const f of t.files) L.push(`| \`${f.file}\` | ${f.sites} |`); - L.push(`| **total** | **${t.sites}** |`); - L.push(''); - } + L.push('| File | Sites |'); + L.push('|---|---|'); + for (const f of t.files) L.push(`| \`${f.file}\` | ${f.sites} |`); + L.push(`| **total** | **${t.sites}** |`); + L.push(''); - L.push('## Remaining strip sites — the batch-planning map'); + L.push(`## \`${t.dir}/\` — open`); L.push(''); L.push('Per file, how many of its sites still silently discard unknown keys. The `Class`'); L.push('column that decides the bucket split is hand-written in the ledger; the arithmetic'); L.push('over it is here.'); L.push(''); - for (const t of model.triaged) { - L.push(`### \`${t.dir}/\` — open`); - L.push(''); - L.push(`**${t.strip} strip of ${t.sites}**, in ${t.openFiles.length} file(s).`); - L.push(''); - if (!t.openFiles.length) { - L.push('This directory is closed.'); - L.push(''); - continue; - } - L.push('| File | Strip | Sites |'); - L.push('|---|---|---|'); - for (const f of t.openFiles) L.push(`| \`${f.file}\` | ${f.strip} | ${f.sites} |`); - L.push(`| **total** | **${t.strip}** | **${t.sites}** |`); - L.push(''); - L.push('| Bucket | Sites |'); - L.push('|---|---|'); - for (const b of BUCKETS) { - if (!t.buckets[b] && b === 'unclassified') continue; - L.push(`| ${BUCKET_LABEL[b]} | ${t.buckets[b]} |`); - } + L.push(`**${t.strip} strip of ${t.sites}**, in ${t.openFiles.length} file(s).`); + L.push(''); + if (!t.openFiles.length) { + L.push('This directory is closed.'); L.push(''); + return L.join('\n'); } - - L.push('## Other directories (untriaged)'); + L.push('| File | Strip | Sites |'); + L.push('|---|---|---|'); + for (const f of t.openFiles) L.push(`| \`${f.file}\` | ${f.strip} | ${f.sites} |`); + L.push(`| **total** | **${t.strip}** | **${t.sites}** |`); L.push(''); - L.push('Site totals only — these directories are classified coarsely in the ledger, per'); - L.push('directory rather than per file.'); + L.push('| Bucket | Sites |'); + L.push('|---|---|'); + for (const b of BUCKETS) { + if (!t.buckets[b] && b === 'unclassified') continue; + L.push(`| ${BUCKET_LABEL[b]} | ${t.buckets[b]} |`); + } + L.push(''); + return L.join('\n'); +} + +/** One untriaged directory's shard: its site total, which is all the ledger measures of it. */ +function renderUntriagedShard(o: { dir: string; sites: number }): string { + const L = shardHeader(o.dir, [ + `The object-site total of \`packages/spec/src/${o.dir}/\`, computed from the AST`, + '(`packages/spec/scripts/lib/strictness-ledger.ts`). The directory is untriaged:', + 'the ledger classifies it coarsely, per directory rather than per file, so this', + 'total is the one number measured here.', + ]); + L.push('## Site total (untriaged)'); L.push(''); L.push('| Dir | Sites |'); L.push('|---|---|'); - for (const o of model.other) L.push(`| \`${o.dir}/\` | ${o.sites} |`); + L.push(`| \`${o.dir}/\` | ${o.sites} |`); L.push(''); + return L.join('\n'); +} - return `${L.join('\n')}`; +/** + * Render every shard, keyed by file name: triaged directories in the ledger's + * order, then the untriaged ones. Pure function of the model, so + * `check:strictness-ledger` proves freshness by rendering and comparing bytes — + * there is no second parser to disagree with this writer. + */ +export function renderCountShards(model: CountsModel): Map { + const out = new Map(); + const put = (dir: string, text: string) => { + const name = countsShardName(dir); + if (out.has(name)) throw new Error(`two directories render the same strictness shard ${name}`); + out.set(name, text); + }; + for (const t of model.triaged) put(t.dir, renderTriagedShard(t)); + for (const o of model.other) put(o.dir, renderUntriagedShard(o)); + return out; +} + +/** + * The cross-directory totals, summed at READ time (#20361). These are the + * numbers the single file committed as its `## Global` section and its posture + * `**total**` row — the lines every schema-touching PR rewrote. They are printed + * by whoever reads the model (`gen:` and `check:`) and written nowhere. + */ +export function formatGlobalCounts(model: CountsModel): string[] { + const g = model.global; + const buckets = BUCKETS.filter((b) => b !== 'unclassified' || g.buckets[b]) + .map((b) => `${b} ${g.buckets[b]}`) + .join(' · '); + return [ + `${g.dirs} triaged director(ies), ${g.sites} object site(s): strict ${g.posture.strict} · ` + + `passthrough ${g.posture.passthrough} · catchall ${g.posture.catchall} · strip ${g.posture.strip}`, + `${g.strip} strip site(s) in ${g.openFiles} file(s), by class: ${buckets}`, + `untriaged: ${model.other.reduce((a, o) => a + o.sites, 0)} object site(s) across ${model.other.length} director(ies)`, + ]; } /* ------------------------------------------------------------------ plumbing */ @@ -648,15 +704,17 @@ export function renderCounts(model: CountsModel): string { export interface LoadedLedger { /** Absolute path of the hand-written ledger. */ ledgerPath: string; - /** Absolute path of the generated counts artifact. */ - countsPath: string; + /** Absolute path of the generated counts directory — one shard per source directory. */ + countsDir: string; + /** Absolute path of the retired single-file artifact, which must not exist. */ + legacyCountsPath: string; ledgerText: string; parsed: ParsedLedger; model: CountsModel; /** Ledger defects found while computing the model (bad `Class` cells). */ problems: string[]; - /** The artifact as it SHOULD be on disk right now. */ - rendered: string; + /** Every shard as it SHOULD be on disk right now, keyed by file name. */ + shards: Map; } /** @@ -669,7 +727,8 @@ export interface LoadedLedger { */ export function loadLedger(repoRoot: string, specSrc: string): LoadedLedger { const ledgerPath = path.join(repoRoot, LEDGER_PATH); - const countsPath = path.join(repoRoot, COUNTS_PATH); + const countsDir = path.join(repoRoot, COUNTS_DIR); + const legacyCountsPath = path.join(repoRoot, LEGACY_COUNTS_PATH); const ledgerText = fs.readFileSync(ledgerPath, 'utf-8'); const parsed = parseLedger(ledgerText); @@ -686,5 +745,14 @@ export function loadLedger(repoRoot: string, specSrc: string): LoadedLedger { allDirs, ); - return { ledgerPath, countsPath, ledgerText, parsed, model, problems, rendered: renderCounts(model) }; + return { + ledgerPath, + countsDir, + legacyCountsPath, + ledgerText, + parsed, + model, + problems, + shards: renderCountShards(model), + }; } diff --git a/packages/spec/scripts/liveness/build-state-counts.mts b/packages/spec/scripts/liveness/build-state-counts.mts index 5215b4e90e2..a7fc7847235 100644 --- a/packages/spec/scripts/liveness/build-state-counts.mts +++ b/packages/spec/scripts/liveness/build-state-counts.mts @@ -82,8 +82,8 @@ import { reconcileStateCountTotals, renderStateCountShards, sumStateCounts, - writeStateCountShards, } from './readme-table.mts'; +import { writeTextShardDir } from '../lib/sharded-artifacts'; const here = dirname(fileURLToPath(import.meta.url)); const specRoot = resolve(here, '../..'); // packages/spec @@ -148,7 +148,7 @@ if (totalErrors.length) { process.exit(1); } -const { written, removed } = writeStateCountShards(join(ledgerRoot, STATE_COUNTS_DIR), renderStateCountShards(rows)); +const { written, removed } = writeTextShardDir(join(ledgerRoot, STATE_COUNTS_DIR), renderStateCountShards(rows)); const legacy = join(ledgerRoot, LEGACY_STATE_COUNTS_FILE); const legacyRemoved = existsSync(legacy); if (legacyRemoved) rmSync(legacy); diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index 9f6c11062c8..a3db3ccc8bd 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -245,7 +245,6 @@ import { foldStateCounts, formatStateCountsTotal, parseStateTable, - readStateCountShards, reconcileReadmeTable, reconcileStateCountTotals, reconcileStateCounts, @@ -253,6 +252,7 @@ import { sumStateCounts, type StateCountsTotal, } from './readme-table.mts'; +import { readTextShardDir } from '../lib/sharded-artifacts'; const here = dirname(fileURLToPath(import.meta.url)); const specRoot = resolve(here, '../..'); // packages/spec @@ -1250,7 +1250,7 @@ if (!existsSync(readmeFile)) { const counts = reconcileStateCounts({ table: stateTable, rendered: renderStateCountShards(countRows), - onDisk: readStateCountShards(join(ledgerRoot, STATE_COUNTS_DIR)), + onDisk: readTextShardDir(join(ledgerRoot, STATE_COUNTS_DIR)), legacyOnDisk: existsSync(join(ledgerRoot, LEGACY_STATE_COUNTS_FILE)), }); report.countsArtifactErrors = counts.artifactErrors; diff --git a/packages/spec/scripts/liveness/readme-table.mts b/packages/spec/scripts/liveness/readme-table.mts index e6705338cfd..98833513c17 100644 --- a/packages/spec/scripts/liveness/readme-table.mts +++ b/packages/spec/scripts/liveness/readme-table.mts @@ -72,8 +72,7 @@ // STILL NOT CHECKED, and it must stay that way: the Notes cell's CONTENT. A // manufactured Note is worse than a missing row. -import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { reconcileTextShardDir } from '../lib/sharded-artifacts'; /** One parsed row of the "Current state" table. */ export interface StateTableRow { @@ -531,49 +530,6 @@ export function formatStateCountsTotal(total: StateCountsTotal): string { return `${STATUS_COLUMNS.map((c) => `${total[c]} ${c}`).join(' · ')} = ${total.classified} classified`; } -/** - * Read the shard directory as the gate compares it: every entry's bytes, keyed by - * name, or `null` when the directory does not exist. A subdirectory is keyed with - * a trailing `/` and no bytes, so it can only ever surface as a stray — nothing in - * a generator-owned directory is skipped silently. - */ -export function readStateCountShards(dir: string): Map | null { - if (!existsSync(dir)) return null; - const out = new Map(); - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (entry.isFile()) out.set(entry.name, readFileSync(join(dir, entry.name), 'utf8')); - else out.set(`${entry.name}/`, ''); - } - return out; -} - -/** - * Write every shard whose bytes changed, and prune everything else in the - * directory. An unchanged shard is not rewritten, so a regeneration touches - * exactly the types whose counts moved — the locality this layout is for — and - * the returned lists say which, so the generator can print them. - */ -export function writeStateCountShards( - dir: string, - shards: ReadonlyMap, -): { written: string[]; removed: string[] } { - mkdirSync(dir, { recursive: true }); - const written: string[] = []; - for (const [name, text] of shards) { - const file = join(dir, name); - if (existsSync(file) && readFileSync(file, 'utf8') === text) continue; - writeFileSync(file, text); - written.push(name); - } - const removed: string[] = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (shards.has(entry.name)) continue; - rmSync(join(dir, entry.name), { recursive: true, force: true }); - removed.push(entry.isFile() ? entry.name : `${entry.name}/`); - } - return { written, removed }; -} - /** What `reconcileStateCounts` found. Separate from `ReadmeReconciliation` on purpose — one population per failure heading. */ export interface StateCountsReconciliation { /** A shard is absent, stale or stray, the directory is gone, or the retired single file came back. */ @@ -622,7 +578,7 @@ export function reconcileStateCounts({ table: ParsedStateTable; /** What `renderStateCountShards` produces from the gate's report right now. */ rendered: ReadonlyMap; - /** The shard directory as `readStateCountShards` reads it, or `null` when it does not exist. */ + /** The shard directory as `readTextShardDir` reads it, or `null` when it does not exist. */ onDisk: ReadonlyMap | null; /** Whether the retired single-file artifact is still on disk beside the shards. */ legacyOnDisk: boolean; @@ -631,28 +587,24 @@ export function reconcileStateCounts({ const rowSetErrors: string[] = []; const handCountErrors: string[] = []; - if (onDisk === null) { + const shards = reconcileTextShardDir({ displayDir: STATE_COUNTS_PATH, rendered, onDisk }); + if (shards.missingDir) { artifactErrors.push(`${STATE_COUNTS_PATH} is MISSING — the table's numbers are published by nothing.`); - } else { - for (const [name, text] of rendered) { - const current = onDisk.get(name); - if (current === undefined) { - artifactErrors.push(`${STATE_COUNTS_PATH}${name} is MISSING — a governed type whose counts nothing publishes.`); - } else if (current !== text) { - artifactErrors.push( - `${STATE_COUNTS_PATH}${name} is STALE — it does not match what the gate measures right now.\n` + - ` ${firstStateCountsDifference(current, text)}`, - ); - } - } - for (const name of [...onDisk.keys()].sort()) { - if (rendered.has(name)) continue; - artifactErrors.push( - `${STATE_COUNTS_PATH}${name} is STRAY — no governed type renders it. The directory is ` + - 'generator-owned: a type that left GOVERNED, or a file written by hand, and either way ' + - 'numbers nothing re-renders.', - ); - } + } + for (const p of shards.missing) { + artifactErrors.push(`${p} is MISSING — a governed type whose counts nothing publishes.`); + } + for (const { name, onDisk: current, expected } of shards.stale) { + artifactErrors.push( + `${name} is STALE — it does not match what the gate measures right now.\n` + + ` ${firstStateCountsDifference(current, expected)}`, + ); + } + for (const p of shards.stray) { + artifactErrors.push( + `${p} is STRAY — no governed type renders it. The directory is generator-owned: a type ` + + 'that left GOVERNED, or a file written by hand, and either way numbers nothing re-renders.', + ); } if (legacyOnDisk) { artifactErrors.push( diff --git a/packages/spec/scripts/liveness/readme-table.test.ts b/packages/spec/scripts/liveness/readme-table.test.ts index 2680044b8f4..8c250babc2c 100644 --- a/packages/spec/scripts/liveness/readme-table.test.ts +++ b/packages/spec/scripts/liveness/readme-table.test.ts @@ -26,15 +26,14 @@ import { foldStateCounts, formatStateCountsTotal, parseStateTable, - readStateCountShards, reconcileReadmeTable, reconcileStateCountTotals, reconcileStateCounts, renderStateCountShard, renderStateCountShards, sumStateCounts, - writeStateCountShards, } from './readme-table.mts'; +import { readTextShardDir, writeTextShardDir } from '../lib/sharded-artifacts'; /** A miniature README with the same section shape as the real one. */ function readme({ @@ -466,7 +465,7 @@ describe('reconcileStateCounts — what it must catch', () => { // The shard directory on a real disk: the writer the generator calls and the // reader the gate calls, round-tripped, so the two cannot disagree about what // "the directory" contains. -describe('writeStateCountShards / readStateCountShards', () => { +describe('the shard directory on disk — writeTextShardDir / readTextShardDir', () => { let dir: string; beforeEach(() => { dir = mkdtempSync(path.join(tmpdir(), 'os-state-count-shards-')); @@ -474,26 +473,26 @@ describe('writeStateCountShards / readStateCountShards', () => { afterEach(() => rmSync(dir, { recursive: true, force: true })); it('reads a missing directory as null, never as an empty set', () => { - expect(readStateCountShards(path.join(dir, 'absent'))).toBeNull(); + expect(readTextShardDir(path.join(dir, 'absent'))).toBeNull(); }); it('writes every shard once, then rewrites ONLY the shard whose bytes moved', () => { - const first = writeStateCountShards(dir, renderStateCountShards(COUNTS)); + const first = writeTextShardDir(dir, renderStateCountShards(COUNTS)); expect(first.written.sort()).toEqual(['api.md', 'field.md', 'object.md']); - expect(readStateCountShards(dir)).toEqual(renderStateCountShards(COUNTS)); + expect(readTextShardDir(dir)).toEqual(renderStateCountShards(COUNTS)); const moved = COUNTS.map((r) => (r.type === 'field' ? { ...r, live: r.live - 1, dead: r.dead + 1 } : r)); - const second = writeStateCountShards(dir, renderStateCountShards(moved)); + const second = writeTextShardDir(dir, renderStateCountShards(moved)); expect(second).toEqual({ written: ['field.md'], removed: [] }); }); it('prunes a shard no type renders, and anything else in the directory', () => { - writeStateCountShards(dir, renderStateCountShards(COUNTS)); + writeTextShardDir(dir, renderStateCountShards(COUNTS)); writeFileSync(path.join(dir, 'ghost.md'), 'stray'); mkdirSync(path.join(dir, 'nested')); - const r = writeStateCountShards(dir, renderStateCountShards(COUNTS.slice(0, 2))); + const r = writeTextShardDir(dir, renderStateCountShards(COUNTS.slice(0, 2))); expect(r.removed.sort()).toEqual(['api.md', 'ghost.md', 'nested/']); - expect([...readStateCountShards(dir)!.keys()].sort()).toEqual(['field.md', 'object.md']); + expect([...readTextShardDir(dir)!.keys()].sort()).toEqual(['field.md', 'object.md']); }); }); diff --git a/packages/spec/scripts/liveness/state-counts-merge.test.ts b/packages/spec/scripts/liveness/state-counts-merge.test.ts deleted file mode 100644 index 4cc46f37270..00000000000 --- a/packages/spec/scripts/liveness/state-counts-merge.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// The sharded liveness counts, asked of git the way GitHub asks it (#20361). -// -// WHY THIS TEST SPAWNS GIT. The defect the shards cure was never in a renderer: -// it was in a MERGE. `state-counts.md` was one generated file with a row per -// type and a shared `**total**` row, so every PR that moved a verdict rewrote -// that total. `merge=os-regen` defers the file in a LOCAL merge only; GitHub's -// server-side merge — the one that decides `mergeable` and builds the ref CI -// runs on — has no custom driver. Measured on the dispatch base `2b24b8b823`, -// in a bare probe clone with no driver registered, each side regenerated with -// the real `gen:liveness-counts`: -// -// - `field.useGrouping` planned→dead against `sharing_rule.type` -// planned→live: `git merge-tree` exit 1, CONFLICT (content) in -// `packages/spec/liveness/state-counts.md` — the two rows are 36 lines -// apart and the only overlap is the total row; -// - the same `field` move against `sharing_rule.type` planned→dead, i.e. an -// EQUAL delta: exit 0 and WRONG — both sides wrote the identical total, git -// took it once, and the merged table said `dead 149` where the two moves -// make 150. -// -// So the question a unit test of the renderer cannot answer — "do two PRs that -// move different types merge, and merge RIGHT, with no driver?" — is asked here -// of `git merge-tree` over the real renderer's output, in a throwaway repository -// that carries the real attribute line and no driver. The same-type pair is the -// lit control: it MUST still conflict (one file's own row changed twice), or a -// clean result above would be equally explained by a harness that cannot see a -// conflict at all. - -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { spawnSync } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; - -import { gitFreeEnv } from '../../../../scripts/git-env.mjs'; - -import { - STATE_COUNTS_DIR, - renderStateCountShards, - writeStateCountShards, - type StateCountsRow, - type StatusColumn, -} from './readme-table.mts'; - -/** Every fixture git is LOCAL-ONLY and hermetic: no inherited `GIT_*`, no global or system config. */ -const HERMETIC_ENV: NodeJS.ProcessEnv = (() => { - const env = gitFreeEnv(); - env.GIT_CONFIG_GLOBAL = '/dev/null'; - env.GIT_CONFIG_SYSTEM = '/dev/null'; - env.GIT_CONFIG_NOSYSTEM = '1'; - return env; -})(); - -const GIT_ARGS = ['-c', 'user.name=t', '-c', 'user.email=t@example.invalid', '-c', 'gc.auto=0', '-c', 'maintenance.auto=false']; - -/** The dispatch base's rows for the types the moves below touch, plus an untouched neighbour. */ -const BASE: readonly StateCountsRow[] = [ - { type: 'object', live: 50, experimental: 0, 'live-elsewhere': 0, dead: 0, planned: 1 }, - { type: 'field', live: 91, experimental: 0, 'live-elsewhere': 0, dead: 1, planned: 1 }, - { type: 'sharing_rule', live: 16, experimental: 0, 'live-elsewhere': 0, dead: 0, planned: 1 }, - { type: 'connector', live: 29, experimental: 0, 'live-elsewhere': 0, dead: 30, planned: 1 }, -]; - -interface Move { type: string; from: StatusColumn; to: StatusColumn } - -/** One ledger verdict moved — what a PR that flips a row does to the fold. */ -function apply(rows: readonly StateCountsRow[], ...moves: Move[]): StateCountsRow[] { - return rows.map((row) => { - const next = { ...row }; - for (const m of moves) { - if (m.type !== row.type) continue; - next[m.from] -= 1; - next[m.to] += 1; - } - return next; - }); -} - -let repo: string; - -function git(...args: string[]): { status: number | null; stdout: string; stderr: string } { - const r = spawnSync('git', [...GIT_ARGS, ...args], { cwd: repo, encoding: 'utf8', env: HERMETIC_ENV }); - if (r.error) throw r.error; - return { status: r.status, stdout: r.stdout, stderr: r.stderr }; -} - -function mustGit(...args: string[]): string { - const r = git(...args); - expect(r.status, `git ${args.join(' ')}\n${r.stderr}`).toBe(0); - return r.stdout; -} - -/** Commit the shards for `rows` on a new branch cut from `base`, exactly as the generator writes them. */ -function branch(name: string, rows: readonly StateCountsRow[]): void { - mustGit('checkout', '-q', '-b', name, 'base'); - writeStateCountShards(path.join(repo, STATE_COUNTS_DIR), renderStateCountShards(rows)); - mustGit('add', '-A'); - mustGit('commit', '-q', '-m', name); -} - -/** `git merge-tree --write-tree` of two branches: its exit code, and the paths it names on a conflict. */ -function mergeTree(a: string, b: string): { status: number | null; tree: string; conflicted: string[] } { - const r = git('merge-tree', '--write-tree', '--name-only', '--no-messages', a, b); - const [tree = '', ...rest] = r.stdout.trim().split('\n'); - return { status: r.status, tree, conflicted: rest.filter(Boolean) }; -} - -/** Every file of the merged tree, as `path -> bytes`. */ -function treeFiles(tree: string): Map { - const out = new Map(); - for (const p of mustGit('ls-tree', '-r', '--name-only', tree).trim().split('\n')) { - out.set(p, mustGit('show', `${tree}:${p}`)); - } - return out; -} - -/** What the generator would write for `rows`, as the tree paths it would occupy. */ -function expectedFiles(rows: readonly StateCountsRow[]): Map { - const out = new Map([['.gitattributes', ATTRIBUTES]]); - for (const [name, text] of renderStateCountShards(rows)) out.set(`${STATE_COUNTS_DIR}/${name}`, text); - return out; -} - -// The real routing line, relative to this fixture's root. Carried so the fixture -// is what GitHub sees — the attribute present, its driver absent — rather than a -// repository that never asked for a driver at all. -const ATTRIBUTES = `${STATE_COUNTS_DIR}/** merge=os-regen\n`; - -const FIELD_PLANNED_TO_DEAD: Move = { type: 'field', from: 'planned', to: 'dead' }; -const FIELD_LIVE_TO_DEAD: Move = { type: 'field', from: 'live', to: 'dead' }; -const SHARING_PLANNED_TO_LIVE: Move = { type: 'sharing_rule', from: 'planned', to: 'live' }; -const SHARING_PLANNED_TO_DEAD: Move = { type: 'sharing_rule', from: 'planned', to: 'dead' }; -const OBJECT_PLANNED_TO_DEAD: Move = { type: 'object', from: 'planned', to: 'dead' }; - -describe('state-counts/ shards — two PRs, no merge driver (#20361)', () => { - beforeAll(() => { - repo = mkdtempSync(path.join(tmpdir(), 'os-state-counts-merge-')); - mustGit('init', '-q', '-b', 'base'); - writeFileSync(path.join(repo, '.gitattributes'), ATTRIBUTES); - writeStateCountShards(path.join(repo, STATE_COUNTS_DIR), renderStateCountShards(BASE)); - mustGit('add', '-A'); - mustGit('commit', '-q', '-m', 'base'); - - branch('field-planned-dead', apply(BASE, FIELD_PLANNED_TO_DEAD)); - branch('field-live-dead', apply(BASE, FIELD_LIVE_TO_DEAD)); - branch('sharing-planned-live', apply(BASE, SHARING_PLANNED_TO_LIVE)); - branch('sharing-planned-dead', apply(BASE, SHARING_PLANNED_TO_DEAD)); - branch('object-planned-dead', apply(BASE, OBJECT_PLANNED_TO_DEAD)); - }); - afterAll(() => rmSync(repo, { recursive: true, force: true })); - - // The premise of every case below: this is GitHub's merge, not ours. A - // registered driver would defer the path and make "clean" mean nothing. - it('merges with the attribute present and NO driver registered — the server-side shape', () => { - expect(git('config', '--get', 'merge.os-regen.driver').status).toBe(1); - expect(git('check-attr', 'merge', '--', `${STATE_COUNTS_DIR}/field.md`).stdout.trim()).toBe( - `${STATE_COUNTS_DIR}/field.md: merge: os-regen`, - ); - }); - - it('a regeneration touches only the shard of the type that moved', () => { - expect(mustGit('diff', '--name-only', 'base', 'field-planned-dead').trim()).toBe(`${STATE_COUNTS_DIR}/field.md`); - expect(mustGit('diff', '--name-only', 'base', 'sharing-planned-live').trim()).toBe( - `${STATE_COUNTS_DIR}/sharing_rule.md`, - ); - }); - - // THE CARD'S REPRODUCTION, now clean: the pair that conflicted on the total row. - it('two moves of DIFFERENT types, different deltas: merges clean, and the result is the regeneration of both', () => { - const m = mergeTree('field-planned-dead', 'sharing-planned-live'); - expect(m.status, m.conflicted.join('\n')).toBe(0); - expect(treeFiles(m.tree)).toEqual(expectedFiles(apply(BASE, FIELD_PLANNED_TO_DEAD, SHARING_PLANNED_TO_LIVE))); - }); - - // The pair the single file merged CLEAN AND WRONG. Clean is not enough here: - // the merged tree must equal what regenerating the merged ledgers writes. - it('two moves of different types with an EQUAL delta: merges clean AND right — no shared line to double-count', () => { - const m = mergeTree('field-planned-dead', 'sharing-planned-dead'); - expect(m.status, m.conflicted.join('\n')).toBe(0); - expect(treeFiles(m.tree)).toEqual(expectedFiles(apply(BASE, FIELD_PLANNED_TO_DEAD, SHARING_PLANNED_TO_DEAD))); - }); - - // Adjacent rows conflicted in the single file even with no total (git refuses - // two edits on touching lines). Different files cannot touch. - it('two moves of ADJACENT types merge clean — the rows no longer share a file', () => { - const m = mergeTree('object-planned-dead', 'field-planned-dead'); - expect(m.status, m.conflicted.join('\n')).toBe(0); - expect(treeFiles(m.tree)).toEqual(expectedFiles(apply(BASE, OBJECT_PLANNED_TO_DEAD, FIELD_PLANNED_TO_DEAD))); - }); - - it('no merged tree carries a total — the sum is the reader\'s, not a file\'s', () => { - const m = mergeTree('field-planned-dead', 'sharing-planned-live'); - for (const [p, text] of treeFiles(m.tree)) expect(text, p).not.toMatch(/^\|\s*\**total/im); - }); - - // THE LIT CONTROL. A same-type pair changes one file's one row twice, which is - // a real conflict and must stay one — the residue sharding cannot remove, and - // what the local driver still owns. Without it, every "exit 0" above is also - // explained by a merge harness that never reports a conflict. - it('two moves of the SAME type still conflict, on that type\'s shard and nowhere else', () => { - const m = mergeTree('field-planned-dead', 'field-live-dead'); - expect(m.status).toBe(1); - expect(m.conflicted).toEqual([`${STATE_COUNTS_DIR}/field.md`]); - }); -}); diff --git a/packages/spec/scripts/strictness-ledger-doc.test.ts b/packages/spec/scripts/strictness-ledger-doc.test.ts index 9c67f751130..6953e48f832 100644 --- a/packages/spec/scripts/strictness-ledger-doc.test.ts +++ b/packages/spec/scripts/strictness-ledger-doc.test.ts @@ -19,16 +19,20 @@ import path from 'node:path'; import url from 'node:url'; import { describe, expect, it } from 'vitest'; +import { readTextShardDir } from './lib/sharded-artifacts'; import { analyzeTree } from './lib/strictness-ledger'; import { BUCKETS, - COUNTS_PATH, + COUNTS_DIR, LEDGER_PATH, + LEGACY_COUNTS_PATH, VERDICTS, bucketize, + countsShardName, + formatGlobalCounts, loadLedger, parseClassCell, - renderCounts, + renderCountShards, } from './lib/strictness-ledger-doc'; const HERE = path.dirname(url.fileURLToPath(import.meta.url)); @@ -101,8 +105,8 @@ describe('`covered` — the ninth verdict (#5249)', () => { // The counts artifact is read by people who never open this file, so the // bucket label has to carry the three-part test (`no carrier, no parse, // guarded at every consumer`) rather than a bare `covered`. - const { rendered } = loadLedger(REPO, SRC); - expect(rendered).toContain('covered — no carrier, no parse, guarded at every consumer'); + const { shards } = loadLedger(REPO, SRC); + expect(shards.get('ui.md')).toContain('covered — no carrier, no parse, guarded at every consumer'); }); it('has exactly one instance in the tree, and it is `ui/app.zod.ts`', () => { @@ -182,29 +186,81 @@ describe('the ledger and the generated counts agree', () => { } }); - it('is checked in current — the artifact on disk equals a fresh render', () => { + it('is checked in current — every shard on disk equals a fresh render, and nothing else is there', () => { // The same comparison `check:strictness-ledger` makes. Duplicated here on // purpose: a stale artifact should be visible from `pnpm test` too, because // the failure it stands for ("a schema moved under a verdict nobody // re-examined") is a code change, and code changes run the suite. - const { rendered } = loaded(); - expect(fs.readFileSync(path.join(REPO, COUNTS_PATH), 'utf-8')).toBe(rendered); + const { shards } = loaded(); + expect(readTextShardDir(path.join(REPO, COUNTS_DIR))).toEqual(shards); + }); + + // The transition hazard (#20361): a branch cut before the split meets the + // single file's deletion as a modify/delete, and keeping it would publish + // stale totals beside the shards. + it('the retired single-file artifact is gone', () => { + expect(fs.existsSync(path.join(REPO, LEGACY_COUNTS_PATH))).toBe(false); }); it('renders deterministically', () => { const { model } = loaded(); - expect(renderCounts(model)).toBe(renderCounts(model)); + expect(renderCountShards(model)).toEqual(renderCountShards(model)); + }); + + it('shards every triaged and every untriaged directory with sites, one file each', () => { + const { model, shards } = loaded(); + expect([...shards.keys()].sort()).toEqual( + [...model.triaged.map((t) => t.dir), ...model.other.map((o) => o.dir)].map(countsShardName).sort(), + ); + expect(() => countsShardName('../x')).toThrow(/not a plain directory name/); + }); + + // THE LOCALITY CLAIM (#20361), asserted on the bytes. A shard that named a + // sibling directory or carried a cross-directory total would carry a line two + // PRs touching different directories both rewrite — the exact conflict the + // split removes. So every backticked `dir/` a shard names is its own. + it('each shard names only its own directory — no sibling, no cross-directory total', () => { + const { shards } = loaded(); + for (const [name, text] of shards) { + const own = `${name.slice(0, -'.md'.length)}/`; + const named = new Set([...text.matchAll(/`([a-z][a-z0-9-]*\/)`/g)].map((m) => m[1])); + expect([...named], name).toEqual([own]); + } + }); + + // PARITY, the pin the split owes: the totals the single file COMMITTED are now + // summed at read time, and they must be the sums of what the shards on disk + // actually say — read back here row by row, not a second copy of the model's + // arithmetic. `formatGlobalCounts` is what gen: and check: print. + it('the read-time totals equal the sums of the shards\' own rows', () => { + const { model } = loaded(); + const onDisk = readTextShardDir(path.join(REPO, COUNTS_DIR))!; + const posture = [0, 0, 0, 0, 0]; + let untriaged = 0; + for (const text of onDisk.values()) { + const row = text.split('\n').find((l) => /^\| `[a-z][a-z0-9-]*\/` \| \d+ \| \d+ \| \d+ \| \d+ \| \d+ \|$/.test(l)); + if (row) { + row.split('|').slice(2, -1).forEach((c, i) => (posture[i] += Number(c.trim()))); + continue; + } + const site = text.split('\n').find((l) => /^\| `[a-z][a-z0-9-]*\/` \| \d+ \|$/.test(l)); + expect(site, 'every shard carries a posture row or a site-total row').toBeDefined(); + untriaged += Number(site!.split('|')[2].trim()); + } + const g = model.global; + expect(posture).toEqual([g.sites, g.posture.strict, g.posture.passthrough, g.posture.catchall, g.posture.strip]); + const [first, , third] = formatGlobalCounts(model); + expect(first).toContain(`${g.sites} object site(s): strict ${posture[1]} · passthrough ${posture[2]}`); + expect(third).toBe(`untriaged: ${untriaged} object site(s) across ${model.other.length} director(ies)`); }); it('writes headers that carry no numbers, so links into it cannot rot', () => { - // The ledger links into this file by anchor. Number-bearing headings + // The ledger links into these files by anchor. Number-bearing headings // (`### \`ui/\` — 76 strip of 198`) change their anchor on every batch, i.e. // exactly when someone follows the link. - const headings = fs - .readFileSync(path.join(REPO, COUNTS_PATH), 'utf-8') - .split('\n') - .filter((l) => /^#{2,3} /.test(l)); - expect(headings.length).toBeGreaterThan(4); + const { shards } = loaded(); + const headings = [...shards.values()].flatMap((t) => t.split('\n').filter((l) => /^#{1,3} /.test(l))); + expect(headings.length).toBeGreaterThan(shards.size); for (const h of headings) expect(h, `${h} must not carry a count`).not.toMatch(/\d/); }); }); @@ -228,7 +284,7 @@ describe('the ledger documents the grammar the parser enforces', () => { it('tells the reader the artifact is generated and how to regenerate it', () => { const md = fs.readFileSync(path.join(REPO, LEDGER_PATH), 'utf-8'); expect(md).toContain('gen:strictness-ledger'); - expect(md).toContain(path.basename(COUNTS_PATH)); + expect(md).toContain(path.basename(COUNTS_DIR)); }); }); diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index cceb22f1821..4977e98410e 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -1,6 +1,7 @@ [ "scripts/build-schemas-check-mode.test.ts", "scripts/category-title.test.ts", + "scripts/count-shards-merge.test.ts", "scripts/def-key-collisions.test.ts", "scripts/dist-freshness-adoption.test.ts", "scripts/dist-freshness.test.ts", @@ -9,7 +10,6 @@ "scripts/file-description.test.ts", "scripts/liveness/evidence.test.ts", "scripts/liveness/proof-registry.test.ts", - "scripts/liveness/state-counts-merge.test.ts", "scripts/publish-smoke-boot-failure.test.ts", "scripts/publish-smoke-port-collision.test.ts", "scripts/published-projection-choke-point.test.ts", diff --git a/scripts/regen-artifacts.mjs b/scripts/regen-artifacts.mjs index 20be940b194..e0aea908232 100644 --- a/scripts/regen-artifacts.mjs +++ b/scripts/regen-artifacts.mjs @@ -195,8 +195,14 @@ export const REGEN_ARTIFACTS = Object.freeze([ // the arithmetic composes on a merge, the judgement does not, so they had to // stop living in one file. The ledger itself stays hand-written and is NOT // driver-managed — regenerating prose would delete somebody's evidence. + // + // #20361 SHARDED it — one `.md` per source directory, the cross-directory + // totals summed by the gate instead of committed — because the single file's + // global section and posture total were rewritten by every schema PR, and in + // the driver-less server-side merge two PRs in different directories conflicted + // on them. A same-directory pair is the residue this row still routes. { - path: 'docs/audits/2026-07-unknown-key-strictness-ledger.counts.md', + path: 'docs/audits/2026-07-unknown-key-strictness-ledger.counts/**', gen: 'gen:strictness-ledger', check: 'check:strictness-ledger', }, From 1be2134dc1b4dbde3c1a2679215ce12a370ba258 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 21:20:29 +0000 Subject: [PATCH 4/6] chore(changeset): liveness counts ship as one shard per governed type Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- .changeset/20361-liveness-counts-sharded.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/20361-liveness-counts-sharded.md diff --git a/.changeset/20361-liveness-counts-sharded.md b/.changeset/20361-liveness-counts-sharded.md new file mode 100644 index 00000000000..1a4cb23024e --- /dev/null +++ b/.changeset/20361-liveness-counts-sharded.md @@ -0,0 +1,13 @@ +--- +"@objectstack/spec": patch +--- + +`liveness/state-counts.md` is replaced by `liveness/state-counts/.md`: the generated liveness counts are one shard per governed type, and no total is committed anywhere. + +Clause-②: no — no schema key moves, no accept set widens or narrows, no export changes. What changes is the layout of a generated table that ships in the tarball, and no count in it moves. + +The ledgers ship inside this package (`files[]` includes `liveness`), so the changed tarball bytes are: `liveness/state-counts.md` removed, forty `liveness/state-counts/.md` shards added (each carries exactly the row that file published for its type), and the prose in `liveness/README.md`, `liveness/book.json` and `liveness/translation.json` that named the removed file. + +- **Where a count now lives.** A type's row is `liveness/state-counts/.md`, byte-for-byte the row the single file carried. The table's total is not in any file: `pnpm --filter @objectstack/spec check:liveness` sums the shards when it reads them, prints the sum on its success line, and carries it in `--json` as `countsTotal`. At this release the sum is the total the removed file published: 940 live · 5 experimental · 1 live-elsewhere · 148 dead · 9 planned = 1103 classified. +- **Why.** Every change that moved a liveness verdict rewrote the single file's total row, and GitHub's server-side merge runs no custom merge driver, so any two such changes in flight conflicted on that one line. With one file per type, changes that move different types touch different files. +- **Anything that read `liveness/state-counts.md` from the published package** reads the shard for the type it wants, or sums the shards for the total. `gen:liveness-counts` rewrites only the shards whose counts moved and deletes the removed file if a merge brings it back; `check:liveness` fails while it is present. From ac64401fecff12ea6b307d43cb7f8032be3e6651 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 22:59:57 +0000 Subject: [PATCH 5/6] chore(spec): regenerate the qa liveness shard over the merged tree Main moved qa's requires row to live (the single-file table it edited is retired here, so the merge kept the deletion); the regeneration carries that move into state-counts/qa.md. Sum of the shards equals main's last committed total: 941 live, 5 experimental, 1 live-elsewhere, 147 dead, 9 planned, 1103 classified. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- packages/spec/liveness/state-counts/qa.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/liveness/state-counts/qa.md b/packages/spec/liveness/state-counts/qa.md index e8a11badf74..c93c10334bd 100644 --- a/packages/spec/liveness/state-counts/qa.md +++ b/packages/spec/liveness/state-counts/qa.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `qa` | 8 | 0 | 0 | 1 | 0 | 9 | +| `qa` | 9 | 0 | 0 | 0 | 0 | 9 | From 0190e0e55fa1a1838420545d0265b5b260da8d6a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 00:02:26 +0000 Subject: [PATCH 6/6] chore(spec): regenerate the rest_api liveness shard over the merged tree Main moved rest_api rows to live while editing the retired single-file table; the merge kept the deletion and this regeneration carries the move into state-counts/rest_api.md. Sum of the shards equals main's last committed total: 949 live, 5 experimental, 1 live-elsewhere, 139 dead, 9 planned, 1103 classified. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- packages/spec/liveness/state-counts/rest_api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/liveness/state-counts/rest_api.md b/packages/spec/liveness/state-counts/rest_api.md index 3cff11186b9..2a905cc1be2 100644 --- a/packages/spec/liveness/state-counts/rest_api.md +++ b/packages/spec/liveness/state-counts/rest_api.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `rest_api` | 12 | 0 | 0 | 12 | 0 | 24 | +| `rest_api` | 20 | 0 | 0 | 4 | 0 | 24 |