From 5f82434c2fc0a9ac98c2b6463ffb66cfec127e80 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:28:13 +0000 Subject: [PATCH] Take re-typed counts out of the docs and retire the gates that only held them The lockstep "110 packages" sentences and the publish-smoke-pack header, the quick-reference "(N of M schemas)" headings, the two registry counts under the overlay whitelist table, and the glossary's "15 protocol namespaces" now state no number. check-lockstep-package-count and check-quick-reference-counts go with their aliases and lint steps; the overlay gate loses only its third leg; the spec-enumerations gate keeps its lists and still holds any count written back in. The route-ledger census stays: its number is the deletion guard, and an ablation shows no other suite sees three of its rows go. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01ADHkmQq9EYQdTQwmRutQTS --- .github/workflows/lint.yml | 37 +- content/docs/concepts/metadata-lifecycle.mdx | 2 +- content/docs/getting-started/glossary.mdx | 8 +- .../docs/getting-started/quick-reference.mdx | 50 +- .../docs/protocol/backward-compatibility.mdx | 4 +- package.json | 2 - scripts/check-changeset-fixed.mjs | 3 - scripts/check-docs-spec-enumerations.mjs | 61 +- scripts/check-lockstep-package-count.mjs | 334 ------ scripts/check-overlay-whitelist-table.mjs | 372 +----- scripts/check-quick-reference-counts.mjs | 1022 ----------------- scripts/check-route-ledger-census.mjs | 5 +- scripts/publish-smoke-pack.mjs | 2 +- 13 files changed, 80 insertions(+), 1822 deletions(-) delete mode 100644 scripts/check-lockstep-package-count.mjs delete mode 100644 scripts/check-quick-reference-counts.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a381e1a1d74..7e93969bc5f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2265,23 +2265,6 @@ jobs: continue-on-error: true run: pnpm check:i18n-stale-fill - # #6319: content/docs/getting-started/quick-reference.mdx is the protocol - # index, and each "## Protocol (N schemas)" heading is a DECLARATION - # about the table under it. The page is hand-written (build-docs.ts writes - # only content/docs/references/), so the two drift one edit at a time — - # #6319 found Kernel claiming 17 over a 15-row table because two kernel - # rows had migrated into the Cloud section and only Cloud's heading was - # updated. Nothing breaks at runtime; the cost is that content/docs/ is the - # corpus humans and AIs copy from, so a miscounted index is read as a fact - # about the schema catalog. Declared = enforced. - # It lives in this job with the other docs guards: the change that breaks - # the count is a docs edit, so a packages/** paths filter would blind it to - # its own failure mode. - - name: Quick-reference section counts match their tables - id: quick-reference-section-counts-match - continue-on-error: true - run: pnpm check:quick-reference-counts - # #17388: three hand-written pages enumerate @objectstack/spec's importable # subpaths or its protocol namespaces, and nothing read any of them. When # #16325 step 3 deleted the ./cloud exports entry and added ./marketplace, @@ -2291,8 +2274,8 @@ jobs: # kept advertising cloud and never listed marketplace, until a human swept # for them. Step 2 of that same chain was FAILed in review by exactly this # class of unlisted counter. - # It holds TWO derived sets, not one: the exports map's 16 bare ./ - # entries, and the 15 of those whose declared CATEGORY_TITLES title ends in + # It holds TWO derived sets, not one: the exports map's bare ./ + # entries, and those of them whose declared CATEGORY_TITLES title ends in # " Protocol" (meta-spelling is a subpath and is titled "Vocabulary", not a # protocol). Display names come from that same declaration, so the #5853 # "Qa Protocol" class of wrong-but-stable spelling is a finding here too. @@ -2998,22 +2981,6 @@ jobs: - name: Setup workspace uses: ./.github/actions/setup-workspace - # Lockstep package-count guard (#17039). `.changeset/config.json`'s - # `fixed` array is the one true count of packages one stray `major` - # promotes; check-changeset-fixed.mjs (release/RC workflows) already - # gates that array against the real workspace. Nothing gated the THREE - # prose restatements of the array's length — two sentences in - # content/docs/protocol/backward-compatibility.mdx and one header - # comment in scripts/publish-smoke-pack.mjs — so all three drifted - # silently (69 written, 70 true) the day a package joined the group. - # Full-repo state, not diff-shaped, so — unlike the changeset-family - # self-tests (`lint-docs-code`) — - # this runs its real check every time, unconditionally. - - name: Lockstep package-count guard - id: lockstep-package-count-guard - continue-on-error: true - run: pnpm check:lockstep-package-count - # Route-ledger census guard (#16758). `packages/runtime/src/route-ledger.ts` # is census-shaped: its value is the completeness of a list, and nothing in # this tree read its row COUNT. An index-slice edit meant to add two rows diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index a562fd41b0d..f1f947ca949 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -120,7 +120,7 @@ later ships. See [ADR-0070](https://github.com/objectstack-ai/objectstack/blob/m | `job` | ❌ | **Also `allowRuntimeCreate: false` since protocol 17** (#4509). `JobSchema.handler` names a function in the compiled bundle's function table, which a runtime writer has no way to reach — so a job created in Studio or through `PUT /meta` parsed, saved, reported success and was never scheduled. The door is closed rather than bridged: `job` stays first-class through `*.job.ts` / `defineStack({ jobs, functions })`, where every schedule shape, `retryPolicy` and `timeout` does reach the scheduler. Existing rows are untouched — they were never scheduled — and `migrateStoredMetadata` reports them `skipped`. | | `sharing_rule` | ❌ | **Also `allowRuntimeCreate: false` since protocol 18** (ADR-0131 D6, regime C). A sharing rule has two sources: a package declares it (the stack's `sharingRules`, switched off through the activation ledger), or an organization authors its own through `POST /api/v1/sharing/rules`, behind the same-name guard and the organization wall. A runtime `sys_metadata` item was a third source that bypassed both, so `PUT /meta/sharing_rule/:name` now answers `403 NOT_CREATABLE` (`NOT_OVERRIDABLE` over a declared rule) and names the two sanctioned paths: author an organization rule, or clone the declared one and switch it off. Items stored before the change are still served until deleted. | -Those five are the **complete** `allowEnvironmentOverlay: true` set: of the 29 types in `DEFAULT_METADATA_TYPE_REGISTRY`, every other one is `false`. The ❌ rows above are the `false` types whose *second* tier (`allowRuntimeCreate`) is worth calling out; any type not listed is `allowEnvironmentOverlay: false`. +The types in the ✅ row are the **complete** `allowEnvironmentOverlay: true` set: every other type in `DEFAULT_METADATA_TYPE_REGISTRY` is `false`. The ❌ rows above are the `false` types whose *second* tier (`allowRuntimeCreate`) is worth calling out; any type not listed is `allowEnvironmentOverlay: false`. There is no `workflow` metadata type (per [ADR-0020](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0020-state-machine-converge-and-enforce.md), record state machines are a `state_machine` validation). Nor is there a standalone `validation` type any more — it was retired in protocol 17 under [ADR-0088](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0088-metadata-kind-admission-and-retirement.md) because `ValidationRuleSchema` carries no object-binding key, so a rule authored through that door could never say what it protected; author rules in the object's own `validations[]` instead. The runtime gate is implemented in `OVERLAY_ALLOWED_TYPES` (derived from the registry) and enforced by `SysMetadataRepository.put()`. diff --git a/content/docs/getting-started/glossary.mdx b/content/docs/getting-started/glossary.mdx index b2b073e2437..79376124602 100644 --- a/content/docs/getting-started/glossary.mdx +++ b/content/docs/getting-started/glossary.mdx @@ -9,16 +9,16 @@ To navigate the ObjectStack ecosystem effectively, it is helpful to understand t ## The Ecosystem ### ObjectStack -The umbrella term for the entire suite of protocols and reference implementations. It is organized into **15 protocol namespaces** grouped into three architectural layers. +The umbrella term for the entire suite of protocols and reference implementations. It is organized into **protocol namespaces** grouped into three architectural layers. ### Protocol Namespace -A logical grouping of related schemas and types defined with Zod. ObjectStack has 15 protocol namespaces: Data, UI, System, Automation, AI, API, Identity, Security, Kernel, Marketplace, QA, Contracts, Integration, Studio, and Shared. +A logical grouping of related schemas and types defined with Zod. ObjectStack has these protocol namespaces: Data, UI, System, Automation, AI, API, Identity, Security, Kernel, Marketplace, QA, Contracts, Integration, Studio, and Shared. --- ## The Three Architectural Layers -The 15 namespaces collapse into three top-level layers: +The namespaces collapse into three top-level layers: | Layer | Also called | Namespaces it includes | Purpose | | :--- | :--- | :--- | :--- | @@ -30,7 +30,7 @@ Older docs and talks used **ObjectOS** as the name of this control layer. That n --- -## The 15 Protocol Namespaces +## The Protocol Namespaces ### Data Protocol Defines the core business data model. Includes Object schema, Field types, Validation rules, Query AST, and Filter conditions. This is the foundation of ObjectQL. diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index a82cd4927fb..96f95843456 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -11,18 +11,15 @@ Fast lookup for the ObjectStack protocols organized by category. Click on any protocol name to view its complete API reference. -**Reading the counts.** Every heading below says `(N of M schemas)`. **N** is how many -protocols this table lists; **M** is how many reference pages the matching -`content/docs/references//` directory publishes. These tables are a *curated* -fast lookup, not a mirror of the reference tree — `N < M` is normal and intended. Both -numbers are checked by `pnpm check:quick-reference-counts`, so a category that gains or -loses a page shows up here instead of drifting unnoticed. A row marked ↗ links outside -its category's reference tree: it is one of the N rows, but it is not one of the M pages. -Categories that have no section here at all are named under +**Reading the tables.** Each table below is a *curated* fast lookup, not a mirror of the +reference tree: it lists the protocols a beginner reaches for first, and a category usually +publishes more reference pages than its table shows. The complete set for every category is on +the [reference index](/docs/references). A row marked ↗ links outside its category's reference +tree. Categories that have no section here at all are named under [Categories Without a Section](#categories-without-a-section) — that curation is stated, not left implicit. -## Data Protocol (15 of 31 schemas) +## Data Protocol Core business logic and data modeling schemas. @@ -44,7 +41,7 @@ Core business logic and data modeling schemas. | **[Postgres Driver](/docs/references/data/driver-postgres)** | `driver/postgres.zod.ts` | PostgresConfig | PostgreSQL configuration | | **[Mongo Driver](/docs/references/data/driver-mongo)** | `driver/mongo.zod.ts` | MongoConfig | MongoDB configuration | -## UI Protocol (10 of 16 schemas) +## UI Protocol Presentation layer - views, forms, dashboards, and app branding. @@ -61,7 +58,7 @@ Presentation layer - views, forms, dashboards, and app branding. | **[Chart](/docs/references/ui/chart)** | `chart.zod.ts` | Chart, ChartType | Chart definitions | | **[Widget Contract](/docs/protocol/objectui/widget-contract)** ↗ | `widget.zod.ts` | FieldWidgetProps | Props a custom field widget receives — the contract is documented with ObjectUI, outside `references/ui/` | -## Kernel Protocol (17 of 29 schemas) +## Kernel Protocol Plugin architecture, manifests, and kernel runtime. @@ -85,7 +82,7 @@ Plugin architecture, manifests, and kernel runtime. | **[Metadata Loader](/docs/references/kernel/metadata-loader)** | `metadata-loader.zod.ts` | MetadataLoaderContract | Metadata loading | | **[Package Registry](/docs/references/kernel/package-registry)** | `package-registry.zod.ts` | InstalledPackage, InstallPackageRequest | Package resolution | -## System Protocol (16 of 37 schemas) +## System Protocol Runtime environment, logging, jobs, caching, and observability. @@ -108,7 +105,7 @@ Runtime environment, logging, jobs, caching, and observability. | **[Translation](/docs/references/system/translation)** | `translation.zod.ts` | Translation | i18n support | | **[Worker](/docs/references/system/worker)** | `worker.zod.ts` | Worker | Background workers | -## AI Protocol (11 of 12 schemas) +## AI Protocol AI/ML capabilities - agents, skills, tools, MCP exposure, RAG, and cost tracking. @@ -126,7 +123,7 @@ AI/ML capabilities - agents, skills, tools, MCP exposure, RAG, and cost tracking | **[Usage](/docs/references/ai/usage)** | `usage.zod.ts` | AIUsageRecord, TokenUsage | AI usage and cost tracking | | **[Solution Blueprint](/docs/references/ai/solution-blueprint)** | `solution-blueprint.zod.ts` | BlueprintObject, BlueprintApp | Blueprint format for AI app generation | -## API Protocol (17 of 32 schemas) +## API Protocol REST endpoints, real-time subscriptions, and discovery. @@ -150,7 +147,7 @@ REST endpoints, real-time subscriptions, and discovery. | **[Metadata](/docs/references/api/metadata)** | `metadata.zod.ts` | Metadata | API metadata endpoints | | **[Storage](/docs/references/api/storage)** | `storage.zod.ts` | Storage | API storage operations | -## Automation Protocol (3 of 14 schemas) +## Automation Protocol Flows, approvals, and integrations. A record's lifecycle transitions are a `state_machine` validation rule on the object (see [State Machine](/docs/protocol/objectql/state-machine)). @@ -160,7 +157,7 @@ Flows, approvals, and integrations. A record's lifecycle transitions are a `stat | **[Approval](/docs/references/automation/approval)** | `approval.zod.ts` | ApprovalNodeConfig | Flow approval-node config | | **[Webhook](/docs/references/automation/webhook)** | `webhook.zod.ts` | Webhook | Outbound webhooks | -## Security Protocol (3 of 5 schemas) +## Security Protocol Access control, permissions, and row-level security. @@ -170,7 +167,7 @@ Access control, permissions, and row-level security. | **[RLS](/docs/references/security/rls)** | `rls.zod.ts` | RowLevelSecurityPolicy | Row-level security filters | | **[Sharing](/docs/references/security/sharing)** | `sharing.zod.ts` | SharingRule | Record sharing rules | -## Identity Protocol (4 of 5 schemas) +## Identity Protocol User identity, organizations, and position management. @@ -181,7 +178,7 @@ User identity, organizations, and position management. | **[Position](/docs/references/identity/position)** | `position.zod.ts` | Position | Permission-set distribution (岗位, ADR-0090) | | **[SCIM](/docs/references/identity/scim)** | `scim.zod.ts` | SCIMUser, SCIMGroup | SCIM 2.0 provisioning | -## Marketplace Protocol (2 of 4 schemas) +## Marketplace Protocol The package & marketplace format — what a package author publishes and what the marketplace answers with. (The cloud control plane's own contracts — environments, @@ -194,7 +191,7 @@ cloud repo beside their producer.) | **[Package](/docs/references/marketplace/package)** | `package.zod.ts` | Package, CreatePackageRequest, PackageTranslations | Package identity and listing text | | **[Marketplace](/docs/references/marketplace/marketplace)** | `marketplace.zod.ts` | MarketplaceListing, PackageSubmission | Plugin marketplace | -## Integration Protocol (1 of 1 schema) +## Integration Protocol External system connectors — one protocol (ADR-0097): a connector entry is either a catalog descriptor or a provider-bound instance that a generic @@ -207,7 +204,7 @@ from the provider itself, not from hand-written spec files. |:---------|:-----------|:------------|:--------| | **[Connector](/docs/references/integration/connector)** | `connector.zod.ts` | Connector | The connector protocol — auth, sync, webhooks, rate limiting | -## Shared Protocol (5 of 10 schemas) +## Shared Protocol Common utilities used across all protocols. @@ -219,7 +216,7 @@ Common utilities used across all protocols. | **[Mapping](/docs/references/shared/mapping)** | `mapping.zod.ts` | FieldMapping | Field mapping utilities | | **[Connector Auth](/docs/references/integration/connector)** ↗ | `connector-auth.zod.ts` | ConnectorInstanceAuth | Declarative connector auth (ADR-0097). The file sits in `src/shared/` but `@objectstack/spec/shared` does not publish it — it reaches consumers through `@objectstack/spec/integration`, so it is documented on the Connector page | -## QA Protocol (1 of 1 schema) +## QA Protocol Testing and quality assurance. @@ -229,14 +226,13 @@ Testing and quality assurance. ## Categories Without a Section -`content/docs/references/` holds one more category directory that deliberately gets no +These category directories under `content/docs/references/` deliberately get no section above. Curation happens at the category level as well as inside each table, and -this is where it is stated. The same gate reads this table, so a new category directory — -or a page landing in this one — goes red until this page is updated. +this is where it is stated. -| Category directory | Pages | Why it has no section | -|:---|---:|:---| -| [`studio`](/docs/references/studio) | 3 | Designer-facing metadata (`flow-builder`, `object-designer`, `plugin`) — Studio's own authoring surfaces, not protocols an app declares. Reach them from the [reference index](/docs/references). | +| Category directory | Why it has no section | +|:---|:---| +| [`studio`](/docs/references/studio) | Designer-facing metadata (`flow-builder`, `object-designer`, `plugin`) — Studio's own authoring surfaces, not protocols an app declares. Reach them from the [reference index](/docs/references). | --- diff --git a/content/docs/protocol/backward-compatibility.mdx b/content/docs/protocol/backward-compatibility.mdx index 29423b59a0c..a2f7cb45336 100644 --- a/content/docs/protocol/backward-compatibility.mdx +++ b/content/docs/protocol/backward-compatibility.mdx @@ -116,7 +116,7 @@ Read the **Breaking?** column, not the version number: in the 17.x minors every 1. **RFC (Request for Comments)** — Breaking changes are proposed as GitHub issues with the `protocol:breaking` label. 2. **Deprecation** — The old behavior is deprecated in a MINOR release (see timeline above). 3. **Migration Guide** — A detailed migration guide is published before the removal lands, in the release notes for the version that carries it. -4. **Release** — In the 17.x minors the breaking change shipped in the next **MINOR** version, carrying a changeset entry marked `**BREAKING**`, and `scripts/check-changeset-no-major.mjs` failed a pull request that declared a `major` bump, because under lockstep one `major` would promote all 110 published packages. During the v18 prerelease it shipped in the next `18.0.0-next.N`, because the guard stood aside in pre mode and a pull request could declare a major bump. Changesets has exited pre mode, so the guard fails a `major` bump again. See [how a breaking stage lands on the v18 line](/docs/releases/v18#how-a-breaking-stage-lands-on-this-line). +4. **Release** — In the 17.x minors the breaking change shipped in the next **MINOR** version, carrying a changeset entry marked `**BREAKING**`, and `scripts/check-changeset-no-major.mjs` failed a pull request that declared a `major` bump, because under lockstep one `major` would promote every published package. During the v18 prerelease it shipped in the next `18.0.0-next.N`, because the guard stood aside in pre mode and a pull request could declare a major bump. Changesets has exited pre mode, so the guard fails a `major` bump again. See [how a breaking stage lands on the v18 line](/docs/releases/v18#how-a-breaking-stage-lands-on-this-line). --- @@ -196,7 +196,7 @@ The `@objectstack/spec` package provides additional stability guarantees: ### Which surfaces this covers -**All of them.** It was not scoped to an experimental corner or a pre-release channel: all **110** packages published from this repository belong to a single Changesets `fixed` group, so they share one version number and one policy. No published surface was exempt. +**All of them.** It was not scoped to an experimental corner or a pre-release channel: every package published from this repository belongs to a single Changesets `fixed` group, so they all share one version number and one policy. No published surface was exempt. The convention was enforced rather than informal — `scripts/check-changeset-no-major.mjs` failed a pull request that introduced a `major` bump, because under lockstep a single `major` on one package would promote the entire stack. During the v18 prerelease Changesets was in pre mode with the tag `next` and the guard stood aside, so a pull request on the v18 line could declare a major bump. Changesets has exited pre mode, and the guard fails a `major` bump again. diff --git a/package.json b/package.json index aab6db3d8f5..0e1f3edcfee 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ "check:role-word": "node scripts/check-role-word.mjs --self-test && node scripts/check-role-word.mjs", "check:corpus-claim-drift": "node scripts/check-corpus-claim-drift.mjs --self-test && node scripts/check-corpus-claim-drift.mjs", "check:skill-identifier-liveness": "node scripts/check-skill-identifier-liveness.mjs --self-test && node scripts/check-skill-identifier-liveness.mjs", - "check:quick-reference-counts": "node scripts/check-quick-reference-counts.mjs --self-test && node scripts/check-quick-reference-counts.mjs", "check:docs-spec-enumerations": "node scripts/check-docs-spec-enumerations.mjs --self-test && node scripts/check-docs-spec-enumerations.mjs", "check:overlay-whitelist-table": "node scripts/check-overlay-whitelist-table.mjs --self-test && node scripts/check-overlay-whitelist-table.mjs", "check:runtime-services-index": "node scripts/check-runtime-services-index.mjs --self-test && node scripts/check-runtime-services-index.mjs", @@ -149,7 +148,6 @@ "check:changeset-fixed": "node scripts/check-changeset-fixed.mjs --self-test && node scripts/check-changeset-fixed.mjs", "check:adr-0087-registration": "node scripts/check-adr-0087-registration.mjs --self-test && node scripts/check-adr-0087-registration.mjs", "check:changeset-gate-self-tests": "node scripts/check-empty-changeset.mjs --self-test && node scripts/check-adr-0087-registration.mjs --self-test && node scripts/check-changeset-no-major.mjs --self-test", - "check:lockstep-package-count": "node scripts/check-lockstep-package-count.mjs --self-test && node scripts/check-lockstep-package-count.mjs", "check:route-ledger-census": "node scripts/check-route-ledger-census.mjs --self-test && node scripts/check-route-ledger-census.mjs", "check:override-consistency": "node scripts/check-override-consistency.mjs --self-test && node scripts/check-override-consistency.mjs", "check:vendor-export-contract": "node scripts/check-vendor-export-contract.mjs --self-test && node scripts/check-vendor-export-contract.mjs", diff --git a/scripts/check-changeset-fixed.mjs b/scripts/check-changeset-fixed.mjs index 205d8ee3ad0..d2294d8cbfb 100644 --- a/scripts/check-changeset-fixed.mjs +++ b/scripts/check-changeset-fixed.mjs @@ -42,9 +42,6 @@ const repoRoot = resolve(__dirname, '..'); * group had. A private package outside this list is still refused in the group, * so a package marked private by mistake cannot hide in it. * - * Readers that count "published packages" (`check-lockstep-package-count.mjs`) - * subtract these names. - * * @type {Readonly>} */ export const PRIVATE_FIXED_MEMBERS = Object.freeze({ diff --git a/scripts/check-docs-spec-enumerations.mjs b/scripts/check-docs-spec-enumerations.mjs index a93b867459a..adb84410a96 100644 --- a/scripts/check-docs-spec-enumerations.mjs +++ b/scripts/check-docs-spec-enumerations.mjs @@ -70,7 +70,7 @@ * 3. `content/docs/getting-started/glossary.mdx`, three times over: the * `protocol namespaces:` sentence, the union of the layers table's * `Namespaces it includes` column, and the `### Protocol` sections - * under the `## The N Protocol Namespaces` heading. Sets, plus the table's + * under the `## The Protocol Namespaces` heading. Sets, plus the table's * own no-duplicate rule -- a namespace listed in two layers is a finding * in its own right, because that table is a partition. * 4. Every numeric namespace-count CLAIM in PROSE. `content/docs/**` is swept @@ -79,6 +79,10 @@ * for the loose phrasing (`the N namespaces`), which is too weak to run * over the whole corpus without meeting sentences about some other kind of * namespace. Fenced code blocks are NOT prose and are skipped -- see below. + * The pages state no count today: #26441 took the numbers out of the + * glossary, because the lists above already carry the set and a sentence + * with no number in it cannot drift. The sweep stays so that a count + * written back in anywhere is held to the exports map, not taken on trust. * * Every one of those is INDEPENDENTLY load-bearing, and battery 4 mutates them * one at a time to prove it: a gate that reads five enumerations and reports @@ -119,8 +123,8 @@ * An unterminated fence would hide the rest of a page from the sweep, so on * the three governed pages it is a structural REFUSAL rather than a page read * short. Elsewhere in the corpus it is not policed -- this gate is not a - * markdown linter for 387 pages, and every live count claim is on a governed - * page. + * markdown linter for 387 pages, and the governed pages are where a count + * claim has lived. * - **Display spelling outside these enumerations.** `Data` in a heading this * gate does not read is compared with nothing. * - **Whether a subpath RESOLVES.** That is the alias-coverage pin's job. This @@ -171,8 +175,8 @@ const SELF_TEST_BATTERIES = Object.freeze({ '4. Each enumeration is load-bearing ON ITS OWN.': 10, '5. The CONFLATION pin: the two sets are not interchangeable.': 4, '6. Order belongs to the subpath sentence, and only to that one.': 3, - '7. Count claims: digits, words, drift, fenced code, and the drain refusal.': 15, - [BATTERY_REFUSALS]: 20, + '7. Count claims: digits, words, drift, fenced code, and a count-free page.': 17, + [BATTERY_REFUSALS]: 18, '9. The layers table carries its own no-duplicate rule.': 2, '10. A finding NAMES the real shape, not a nonsense entry.': 2, }); @@ -222,7 +226,7 @@ const NAMESPACE_COLON_PREFIX = 'protocol namespaces:'; /** The layers table's column of namespaces. A rename or a reorder is a finding. */ const COL_NAMESPACES = 'Namespaces it includes'; /** The h2 that owns the per-namespace sections. Its number is a count claim too. */ -const SECTIONS_HEADING = /^##\s+The\s+(\S+)\s+Protocol\s+Namespaces\s*$/i; +const SECTIONS_HEADING = /^##\s+The\s+(?:\S+\s+)?Protocol\s+Namespaces\s*$/i; /** A per-namespace section heading inside that h2. */ const SECTION_HEADING = /^###\s+(.+?)\s*$/; /** Any h2 -- what ends the sections block. */ @@ -250,15 +254,6 @@ const COUNT_CLAIM_STRICT = /\b([A-Za-z0-9-]+)\s+protocol\s+namespaces\b/gi; /** `The 15 namespaces` -- only run over the governed pages; too weak elsewhere. */ const COUNT_CLAIM_LOOSE = /\bthe\s+([A-Za-z0-9-]+)\s+namespaces\b/gi; -/** - * How many numeric count claims the glossary must still carry. - * - * The page states its total in several places. A claim set that drains to - * nothing is a check that passes by having nothing left to read, which is the - * phantom-check failure this gate exists to prevent, one level up. - */ -const GLOSSARY_COUNT_CLAIM_FLOOR = 3; - // ------------------------------------------------------------------------- // Number words, so an evasion by spelling is still a claim // ------------------------------------------------------------------------- @@ -691,7 +686,7 @@ export function readLayersTable(text, fileLabel = PAGE_GLOSSARY) { } /** - * The `### Protocol` sections under the `## The N Protocol Namespaces` h2. + * The `### Protocol` sections under the `## The Protocol Namespaces` h2. * * Scoped to that h2 deliberately: the page carries an unrelated * `### Protocol Namespace` definition further up, and a document-wide sweep of @@ -704,7 +699,7 @@ export function readProtocolSections(text, fileLabel = PAGE_GLOSSARY) { const { hit, findings } = soleLine( lines, (l) => SECTIONS_HEADING.test(l), - 'a `## The N Protocol Namespaces` heading', + 'a `## The Protocol Namespaces` heading', fileLabel, ); if (hit === null) return { line: 1, items: [], findings }; @@ -932,17 +927,6 @@ export function run(tree) { } const glossaryClaims = readCountClaims(tree.glossary, true); - // Reported only when the claims are genuinely absent. With a fence left open - // above them the shortfall is a SYMPTOM, and the refusal above already names - // the cause; two findings for one defect send the reader to the wrong line. - if (openFences.get(PAGE_GLOSSARY) === null && glossaryClaims.length < GLOSSARY_COUNT_CLAIM_FLOOR) { - structural.push({ - kind: 'structure', - file: PAGE_GLOSSARY, - line: 1, - message: `only ${glossaryClaims.length} numeric namespace-count claim(s) found, below the floor of ${GLOSSARY_COUNT_CLAIM_FLOOR} -- the page states its total in several places, and a claim set that drains to nothing is a check that passes by having nothing left to read.`, - }); - } if (structural.length > 0) { return { structural, findings: [], subpaths, namespaces: derived.namespaces, claims: [], read }; @@ -1256,7 +1240,7 @@ export function selfTest() { run(tree({ packages: shuffledParen })).findings.length === 0); // ---- 7. Count claims. - battery('7. Count claims: digits, words, drift, fenced code, and the drain refusal.'); + battery('7. Count claims: digits, words, drift, fenced code, and a count-free page.'); const drifted = FIXTURE_GLOSSARY.replace('**4 protocol namespaces**', '**5 protocol namespaces**'); const driftRun = run(tree({ glossary: drifted })); check('count/a stale digit reds', driftRun.findings.some((f) => f.kind === 'count-drift'), @@ -1271,6 +1255,19 @@ export function selfTest() { check('count/compounds parse', parseCountToken('twenty-one') === 21, String(parseCountToken('twenty-one'))); check('count/the heading number is a claim', readCountClaims(FIXTURE_GLOSSARY, true).some((c) => c.line === 18), JSON.stringify(readCountClaims(FIXTURE_GLOSSARY, true))); + // A page that states no count at all is green, and its sections are still + // read: the counts are optional prose, the lists are what this gate holds. + const countless = FIXTURE_GLOSSARY + .replace('**4 protocol namespaces**', '**protocol namespaces**') + .replace('has 4 protocol namespaces:', 'has these protocol namespaces:') + .replace('The 4 namespaces collapse', 'The namespaces collapse') + .replace('## The 4 Protocol Namespaces', '## The Protocol Namespaces'); + const countlessRun = run(tree({ glossary: countless })); + check('count/a glossary that states NO count is green', + countlessRun.structural.length === 0 && countlessRun.findings.length === 0 && countlessRun.claims.length === 0, + JSON.stringify({ structural: countlessRun.structural, findings: countlessRun.findings, claims: countlessRun.claims })); + check('count/and its uncounted sections heading is still read', + countlessRun.read?.sections.items.length === 4, JSON.stringify(countlessRun.read?.sections)); check('count/the loose phrasing is read on a governed page', readCountClaims(FIXTURE_GLOSSARY, true).length > readCountClaims(FIXTURE_GLOSSARY, false).length, `${readCountClaims(FIXTURE_GLOSSARY, true).length} vs ${readCountClaims(FIXTURE_GLOSSARY, false).length}`); @@ -1343,12 +1340,6 @@ export function selfTest() { refused('the sections heading is gone', { glossary: FIXTURE_GLOSSARY.replace('## The 4 Protocol Namespaces', '## The Namespaces'), }); - refused('the count claims drained away', { - glossary: FIXTURE_GLOSSARY - .replace('**4 protocol namespaces**', '**several protocol namespaces**') - .replace('has 4 protocol namespaces:', 'has these protocol namespaces:') - .replace('The 4 namespaces collapse', 'The namespaces collapse'), - }, `floor of ${GLOSSARY_COUNT_CLAIM_FLOOR}`); // Skipping fenced code buys a way to hide claims: open a fence and never // close it. On a governed page that is refused, on BOTH the page that holds // the claims and one that does not -- otherwise the guard reads as diff --git a/scripts/check-lockstep-package-count.mjs b/scripts/check-lockstep-package-count.mjs deleted file mode 100644 index 637f92b2179..00000000000 --- a/scripts/check-lockstep-package-count.mjs +++ /dev/null @@ -1,334 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * check-lockstep-package-count -- holds every PROSE restatement of "how many - * packages sit in the lockstep Changesets `fixed` group" to the group itself. - * - * node scripts/check-lockstep-package-count.mjs # verify - * node scripts/check-lockstep-package-count.mjs --fix # regenerate - * node scripts/check-lockstep-package-count.mjs --self-test # verify the checker itself - * - * ## The defect (#17039) - * - * `.changeset/config.json`'s `fixed` array is the one true count: Changesets - * applies the highest bump found anywhere in the group to EVERY name in it, so - * the array's length IS the number of packages one stray `major` would - * promote. `check-changeset-fixed.mjs` already gates that array against the - * real workspace on every release. Nothing gated the THREE places that repeat - * its length back to a reader in prose: - * - * - content/docs/protocol/backward-compatibility.mdx, twice ("...would - * promote all 69 published packages", "all **69** packages published - * from this repository...") - * - scripts/publish-smoke-pack.mjs's own header comment ("enumerates the - * same 69 names") - * - * All three were written when the group had 69 members and went silently - * wrong the day a 70th package joined it -- nothing reds when `fixed[0]` - * grows. This is the second confirmed instance of the class #16919 was filed - * for (content/docs/permissions/system-context.mdx's hand-maintained counts, - * generated by check-system-context-census.mjs): same discipline -- generate - * the fact, verify the anchor still resolves, fail loud on drift -- but - * deliberately NOT the same machinery. That script's job is an open-ended - * CENSUS over the whole repo (call sites it has to go find); this job's - * source of truth is a one-line read - * (`require('.changeset/config.json').fixed[0].length`), and the population - * of sentences that repeat it is small, closed and enumerated by hand once, - * in OCCURRENCES below. Building a second census-shaped script for a - * one-line fact would be the same fragility in a nicer coat. - * - * ## Why one script for three occurrences, not three patches - * - * All three sentences restate the SAME fact from the SAME source. Hand-fixing - * the digit in each would leave three places to remember next time the group - * grows -- exactly the failure this card exists to close. OCCURRENCES is the - * one shared mechanism's only extension point: a fourth prose restatement - * elsewhere gets a fourth row, not a fourth script. - * - * ## What "check" verifies, and the failure mode #17041 named - * - * A gate that recomputes the truth but can never disagree with what is on - * disk is not a gate. So every occurrence's ANCHOR must resolve to EXACTLY - * ONE match in its file before the number it captured is compared to the - * truth: zero matches (the sentence was reworded) or more than one both - * report ROTTED_ANCHOR rather than being silently skipped as agreement -- - * silence on a stale number is the bug this exists to fix, and silence on a - * rotted anchor is the same bug one level up. - * - * `--fix` rewrites ONLY the digits inside a matched anchor -- never anything - * else in the file -- and only for occurrences that disagree; an occurrence - * already correct is left byte-identical, so `--fix` on an already-clean tree - * is a no-op (checked by --self-test). The narrowness follows AGENTS.md's - * `packages/spec` `--fix` precedent: regenerating what nobody proved stale - * lets a real, unrelated edit hide inside a mechanical-looking diff. - */ - -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import * as os from 'node:os'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { PRIVATE_FIXED_MEMBERS } from './check-changeset-fixed.mjs'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const repoRoot = resolve(__dirname, '..'); - -/** - * Reads the one true count: the length of the lockstep `fixed` group, less the - * private members `check-changeset-fixed.mjs` declares (`PRIVATE_FIXED_MEMBERS`, - * the VS Code extension since objectstack#25711). Every prose occurrence counts - * PUBLISHED packages, and those members ship at the release's version without - * ever reaching npm. - */ -function trueCount(root, privateMembers = PRIVATE_FIXED_MEMBERS) { - const configPath = resolve(root, '.changeset/config.json'); - const config = JSON.parse(readFileSync(configPath, 'utf8')); - if (!Array.isArray(config.fixed) || !Array.isArray(config.fixed[0])) { - throw new Error(`${configPath}: "fixed" is not an array containing a group array`); - } - return config.fixed[0].filter((name) => !Object.hasOwn(privateMembers, name)).length; -} - -// Every prose restatement of the lockstep package count, as of #17039. Each -// pattern brackets a run of digits with enough literal context on both sides -// to be unambiguous; the digits are the ONLY thing --fix ever rewrites. -const OCCURRENCES = [ - { - file: 'content/docs/protocol/backward-compatibility.mdx', - label: 'Breaking Change Process, step 4', - pattern: /(would promote all )(\d+)( published packages\.)/, - }, - { - file: 'content/docs/protocol/backward-compatibility.mdx', - label: '"Which surfaces this covers" section', - pattern: /(all \*\*)(\d+)(\*\* packages published from this repository)/, - }, - { - file: 'scripts/publish-smoke-pack.mjs', - label: 'header comment, "Source of truth" paragraph', - pattern: /(enumerates the same )(\d+)( names)/, - }, -]; - -function globalOf(pattern) { - return new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`); -} - -function readOccurrence(root, occurrence) { - const path = resolve(root, occurrence.file); - const text = readFileSync(path, 'utf8'); - const matches = [...text.matchAll(globalOf(occurrence.pattern))]; - if (matches.length !== 1) { - return { path, text, rotted: true, matchCount: matches.length }; - } - return { path, text, rotted: false, current: Number(matches[0][2]) }; -} - -/** - * Checks (and optionally fixes) every occurrence against the true count. - * Pure with respect to `fix`: false never touches disk; `fix: true` rewrites - * only the occurrences it finds stale, byte-for-byte identical to the - * original elsewhere in the file. - */ -function run(root, { fix = false } = {}) { - const truth = trueCount(root); - const results = []; - - for (const occurrence of OCCURRENCES) { - const read = readOccurrence(root, occurrence); - if (read.rotted) { - results.push({ ...occurrence, status: 'ROTTED_ANCHOR', matchCount: read.matchCount }); - continue; - } - if (read.current === truth) { - results.push({ ...occurrence, status: 'OK', current: read.current }); - continue; - } - results.push({ ...occurrence, status: 'STALE', current: read.current, truth }); - if (fix) { - const next = read.text.replace(occurrence.pattern, `$1${truth}$3`); - writeFileSync(read.path, next); - } - } - - const dirty = results.some((r) => r.status !== 'OK'); - return { truth, results, dirty }; -} - -function formatReport({ truth, results }) { - const lines = [ - `lockstep package count (source: .changeset/config.json fixed[0].length, less the declared private members) = ${truth}`, - ]; - for (const r of results) { - if (r.status === 'OK') { - lines.push(` OK ${r.file} -- ${r.label} (reads ${r.current})`); - } else if (r.status === 'STALE') { - lines.push(` STALE ${r.file} -- ${r.label}: reads ${r.current}, truth is ${r.truth}`); - } else { - lines.push( - ` ROTTED ${r.file} -- ${r.label}: anchor matched ${r.matchCount} time(s), expected exactly 1`, - ); - } - } - return lines.join('\n'); -} - -// --------------------------------------------------------------------------- -// --self-test: fixture-based, touches no real repo file. Builds a throwaway -// root with the same relative paths OCCURRENCES names, so the real anchors -// are exercised against synthetic content. -// --------------------------------------------------------------------------- - -function selfTest() { - const write = writeFileSync; - - const failures = []; - const t = (name, ok) => { - if (!ok) failures.push(name); - }; - - const tmp = mkdtempSync(join(os.tmpdir(), 'check-lockstep-package-count-')); - try { - mkdirSync(join(tmp, 'content/docs/protocol'), { recursive: true }); - mkdirSync(join(tmp, 'scripts'), { recursive: true }); - - const configOf = (n) => - JSON.stringify({ fixed: [Array.from({ length: n }, (_, i) => `@objectstack/pkg-${i}`)] }); - - const docOf = (n) => - [ - '4. **Release** -- ... because under lockstep one `major` would promote all ' + - `${n} published packages.`, - '', - `**All of them.** ... all **${n}** packages published from this repository ...`, - ].join('\n'); - - const scriptCommentOf = (n) => ` * .changeset/config.json enumerates the same ${n} names, but ...`; - - const writeFixture = (n) => { - write(join(tmp, '.changeset', 'config.json'), configOf(n)); - }; - mkdirSync(join(tmp, '.changeset'), { recursive: true }); - - // Case 1: everything stale (69 written everywhere, truth is 70) -- check - // must red and NAME both numbers, never silently pass. - writeFixture(70); - write(join(tmp, 'content/docs/protocol/backward-compatibility.mdx'), docOf(69)); - write(join(tmp, 'scripts/publish-smoke-pack.mjs'), scriptCommentOf(69)); - - const before = run(tmp, { fix: false }); - t('stale fixture: truth read as 70', before.truth === 70); - t('stale fixture: reports dirty', before.dirty === true); - t( - 'stale fixture: every occurrence reported STALE with both numbers named', - before.results.every((r) => r.status === 'STALE' && r.current === 69 && r.truth === 70), - ); - t( - 'stale fixture: check-only (no fix) touched no file', - readFileSync(join(tmp, 'content/docs/protocol/backward-compatibility.mdx'), 'utf8') === - docOf(69), - ); - - // Case 2: --fix converges, and a second run is idempotent (byte-identical). - const fixed = run(tmp, { fix: true }); - t('fix run: no longer dirty after fix', fixed.dirty === false || run(tmp, { fix: false }).dirty === false); - const afterFirstFix = { - doc: readFileSync(join(tmp, 'content/docs/protocol/backward-compatibility.mdx'), 'utf8'), - script: readFileSync(join(tmp, 'scripts/publish-smoke-pack.mjs'), 'utf8'), - }; - t('fix run: doc now reads the true count', afterFirstFix.doc === docOf(70)); - t('fix run: script comment now reads the true count', afterFirstFix.script === scriptCommentOf(70)); - - run(tmp, { fix: true }); // second fix pass - const afterSecondFix = { - doc: readFileSync(join(tmp, 'content/docs/protocol/backward-compatibility.mdx'), 'utf8'), - script: readFileSync(join(tmp, 'scripts/publish-smoke-pack.mjs'), 'utf8'), - }; - t('idempotence: doc byte-identical after a second --fix pass', afterSecondFix.doc === afterFirstFix.doc); - t( - 'idempotence: script comment byte-identical after a second --fix pass', - afterSecondFix.script === afterFirstFix.script, - ); - - // Case 3: a reworded sentence rots the anchor -- must report ROTTED, never OK. - write( - join(tmp, 'content/docs/protocol/backward-compatibility.mdx'), - 'This sentence no longer mentions any package count at all.', - ); - const rotted = run(tmp, { fix: false }); - const rottedRows = rotted.results.filter( - (r) => r.file === 'content/docs/protocol/backward-compatibility.mdx', - ); - t('rotted anchor: reported as ROTTED_ANCHOR, not OK', rottedRows.every((r) => r.status === 'ROTTED_ANCHOR')); - t('rotted anchor: reports dirty (never a silent pass)', rotted.dirty === true); - - // Case 4: a duplicated anchor (two matches) is ALSO rotted, not "pick the first". - write( - join(tmp, 'content/docs/protocol/backward-compatibility.mdx'), - `${docOf(70)}\n${docOf(70)}`, - ); - const duped = run(tmp, { fix: false }); - const dupedRows = duped.results.filter( - (r) => r.file === 'content/docs/protocol/backward-compatibility.mdx', - ); - t( - 'duplicated anchor: reported as ROTTED_ANCHOR with matchCount 2', - dupedRows.every((r) => r.status === 'ROTTED_ANCHOR' && r.matchCount === 2), - ); - - // Case 5: a declared private member sits in the group but is not counted - // (objectstack#25711); an undeclared name is counted like any other. - write( - join(tmp, '.changeset', 'config.json'), - JSON.stringify({ fixed: [['@objectstack/pkg-0', '@objectstack/pkg-1', 'private-ext']] }), - ); - t('declared private member: not counted', trueCount(tmp, { 'private-ext': 'fixture' }) === 2); - t('the same name undeclared: counted', trueCount(tmp, {}) === 3); - t('the real declaration is what the gate subtracts by default', trueCount(tmp) === 3); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } - - if (failures.length > 0) { - console.error(`✗ check:lockstep-package-count --self-test -- ${failures.length} failure(s):`); - for (const f of failures) console.error(` - ${f}`); - process.exit(1); - } - console.log(`✓ check:lockstep-package-count --self-test -- all cases passed.`); -} - -function main() { - const args = process.argv.slice(2); - - if (args.includes('--self-test')) { - selfTest(); - return; - } - - const fix = args.includes('--fix'); - const outcome = run(repoRoot, { fix }); - console.log(formatReport(outcome)); - - if (fix) { - const after = run(repoRoot, { fix: false }); - if (after.dirty) { - console.error( - `\n✗ check:lockstep-package-count --fix did not converge -- rerun and inspect ROTTED_ANCHOR rows by hand.`, - ); - process.exit(1); - } - console.log(`\n✓ check:lockstep-package-count --fix -- all occurrences now read ${outcome.truth}.`); - return; - } - - if (outcome.dirty) { - const staleCount = outcome.results.filter((r) => r.status !== 'OK').length; - console.error( - `\n✗ check:lockstep-package-count -- ${staleCount} occurrence(s) disagree with the true count (${outcome.truth}). Run \`node scripts/check-lockstep-package-count.mjs --fix\`.`, - ); - process.exit(1); - } - console.log(`\n✓ check:lockstep-package-count -- all ${OCCURRENCES.length} occurrence(s) agree with the true count (${outcome.truth}).`); -} - -main(); diff --git a/scripts/check-overlay-whitelist-table.mjs b/scripts/check-overlay-whitelist-table.mjs index d27fbe06117..523f5653542 100644 --- a/scripts/check-overlay-whitelist-table.mjs +++ b/scripts/check-overlay-whitelist-table.mjs @@ -5,8 +5,12 @@ * check-overlay-whitelist-table (#11752, #11763) -- the "Overlay whitelist * (shared-DB tenancy invariant)" section of * `content/docs/concepts/metadata-lifecycle.mdx` must agree with - * `DEFAULT_METADATA_TYPE_REGISTRY`: the TABLE in both directions (legs 1-2), - * and the two registry-derived COUNTS in the sentence under it (leg 3). + * `DEFAULT_METADATA_TYPE_REGISTRY`: the TABLE in both directions (legs 1-2). + * + * A third leg once held two registry counts the page restated in prose (#11763). + * #26441 took the numbers out of that sentence instead: it now says the ✅ row + * is the complete `true` set without counting it, and legs 1-2 are what make + * that true. A sentence with no count in it cannot drift. * * The flag this reads is `allowEnvironmentOverlay`, renamed from * `allowOrgOverride` in protocol 18 (ADR-0131 C5); the page's column header, @@ -59,63 +63,9 @@ * Leg 2 is deliberately asymmetric: it demands rows only for the `true` set. * The table is a WHITELIST -- it enumerates what may be overridden, plus the * `false` types whose second tier (`allowRuntimeCreate`) is worth calling out. - * Requiring a row for all 27 types would demand a table nobody wants and would + * Requiring a row for every type would demand a table nobody wants and would * be the kind of gate that gets deleted rather than obeyed. * - * ## LEG 3: the two counts in the sentence under the table (#11763) - * - * #11750 corrected the table and, in the same PR, added a sentence under it so - * a human could check the completeness claim without opening the registry: - * - * "Those five are the **complete** `allowEnvironmentOverlay: true` set: of the 27 - * types in `DEFAULT_METADATA_TYPE_REGISTRY`, every other one is `false`." - * - * Both numbers are derived from the registry, and both were hand-kept. That is - * the SAME defect class the table had -- one measurement written down twice - * with nothing holding the copies together -- moved one line down and left - * unguarded by the very gate (#11759, this file) that shipped alongside the - * correction. Register a 28th type and the page asserted "27" with legs 1 and 2 - * green: leg 2 only demands a table ROW for each `true` type, and a new `false` - * type moves neither leg. - * - * Leg 3 reads both numbers OUT OF THE PROSE and compares them against the - * counts this gate already holds -- `entries.length` and the size of the `true` - * filter, the two numbers `--list` has printed since day one. Nothing on either - * side is a literal in this file. A `27` written into this script would have - * moved the hand-kept copy one file to the left and closed nothing. - * - * Scanned over the WHOLE page, not just the whitelist section: a claim ABOUT - * the registry is no less a hand-kept copy for having drifted into a - * neighbouring section. Both patterns are anchored on backticked identifiers - * (`DEFAULT_METADATA_TYPE_REGISTRY`, `allowEnvironmentOverlay: true`), so they cannot - * collide with ordinary English. - * - * ### "five" is a WORD, and the prose keeps it - * - * The true-set count is spelled out, not digitised. The cheap route is to - * rewrite the sentence to "Those 5 are ..." and match `\d+` -- but that is a - * docs edit made to suit a parser, and this gate's own VERDICT block already - * tells the next reader that a gate "fixed" by editing the thing it measures - * has inverted its point. So `parseCountToken` reads digits AND English - * number-words 0..99 (`five`, `twenty-seven`): 30 lines and two Maps, measured - * against the one-word docs edit it replaces. English style also spells small - * numbers, so a digit here would have been reverted by the next prose editor - * and the leg would have gone quietly red for a reason nobody wanted. - * - * The long tail people (rightly) fear from number-word parsing is bounded by - * REFUSING: above 99, or on any token the two Maps do not cover, the gate goes - * red and says to write digits or extend the Maps. It never guesses and never - * skips. - * - * ### An absent or reworded claim is RED, not a no-op - * - * If either pattern matches nothing, the gate refuses -- the same rule as the - * missing heading, for the same reason. A leg whose subject sentence was - * deleted must not keep printing a clean line about it. Removing the numbers - * from the prose is a legitimate docs decision (`"every other one is false"` is - * true without a count); it just has to be made HERE too, rather than silently - * draining this leg to vacuum. - * * ## Why AST, not regex (constraint 2, and the whole reason this file is 400 * lines instead of a grep) * @@ -224,18 +174,11 @@ const SELF_TEST_BATTERIES = Object.freeze({ '10. A duplicated type in the table is caught.': 1, '11. A retired type left behind in the table is caught.': 1, '12. An entry that OMITS the optional flag counts as false, not as a': 1, - '13a. Number-word reading: both spellings, the compound, and the refusal.': 20, - '13b. POSITIVE CONTROL for green: a sentence true of FIXTURE_REGISTRY': 3, - '13c. THE CARD\'S SCENARIO, both halves — and each isolated by the other': 2, - '13d. Digits and words are interchangeable on both claims, and dropping the': 3, - '13e. EVERY match is checked, not just the first. A second copy of the same': 1, - '13f. STRUCTURAL refusals. An absent or unreadable claim is RED — draining': 5, - '13g. The refusals above must be refusals, not silent empties: a fixture': 1, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 19; +const SELF_TEST_BATTERY_FLOOR = 12; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -591,124 +534,7 @@ export function readTable(text) { } // --------------------------------------------------------------------------- -// Doc side -- the registry counts stated in prose (leg 3, #11763) -// --------------------------------------------------------------------------- - -/** English number-words 0..19. */ -const ONES_WORDS = new Map([ - ['zero', 0], ['one', 1], ['two', 2], ['three', 3], ['four', 4], - ['five', 5], ['six', 6], ['seven', 7], ['eight', 8], ['nine', 9], - ['ten', 10], ['eleven', 11], ['twelve', 12], ['thirteen', 13], ['fourteen', 14], - ['fifteen', 15], ['sixteen', 16], ['seventeen', 17], ['eighteen', 18], ['nineteen', 19], -]); - -/** The tens, 20..90. A compound is `-`: `twenty-seven`. */ -const TENS_WORDS = new Map([ - ['twenty', 20], ['thirty', 30], ['forty', 40], ['fifty', 50], - ['sixty', 60], ['seventy', 70], ['eighty', 80], ['ninety', 90], -]); - -/** - * Read a count written as digits or as an English number-word, 0..99. - * - * @returns {number | null} `null` means UNREADABLE, and every caller must turn - * it into a finding. It never means zero, and it never means "skip this - * claim" -- an unreadable count that read as clean would be this gate - * committing the defect it was built to catch. - */ -export function parseCountToken(token) { - if (/^\d+$/.test(token)) return Number(token); - const t = token.toLowerCase(); - if (ONES_WORDS.has(t)) return ONES_WORDS.get(t); - if (TENS_WORDS.has(t)) return TENS_WORDS.get(t); - const compound = /^([a-z]+)-([a-z]+)$/.exec(t); - if (compound !== null) { - const tens = TENS_WORDS.get(compound[1]); - const ones = ONES_WORDS.get(compound[2]); - if (tens !== undefined && ones !== undefined && ones >= 1 && ones <= 9) return tens + ones; - } - return null; -} - -/** - * The two count claims this page makes about the registry. - * - * Rebuilt on every call so no `/g` regex ever carries `lastIndex` between runs - * -- a module-level one would make the second call in the same process read a - * different, shorter page than the first. - */ -function proseClaimPatterns() { - return [ - { - what: 'total', - subject: `the number of types in \`${REGISTRY_CONST}\``, - re: new RegExp('\\bof the ([\\w-]+) types in `' + REGISTRY_CONST + '`', 'g'), - }, - { - what: 'true-set', - subject: `the size of the \`${COL_FLAG}: true\` set`, - // `**complete**` is optional so dropping the bold is not a false red. - re: new RegExp( - '\\bThose ([\\w-]+) are the (?:\\*\\*)?complete(?:\\*\\*)? `' + COL_FLAG + ': true` set', - 'g', - ), - }, - ]; -} - -/** - * Read every registry-derived count the page states in prose. - * - * @returns {{ claims: Array<{what: string, value: number, token: string, line: number}>, - * findings: Array<{kind: string, message: string, line: number}> }} - * A pattern that matches NOTHING is a finding, not an empty result: see the - * header's "An absent or reworded claim is RED". - */ -export function readProseCounts(text) { - const claims = []; - const findings = []; - const lineAt = (index) => text.slice(0, index).split('\n').length; - - for (const pattern of proseClaimPatterns()) { - let hits = 0; - let m; - while ((m = pattern.re.exec(text)) !== null) { - hits += 1; - const line = lineAt(m.index); - const value = parseCountToken(m[1]); - if (value === null) { - findings.push({ - kind: 'structure', - line, - message: - `the prose states ${pattern.subject} as ${JSON.stringify(m[1])}, which this gate ` + - `cannot read as a number. Digits and English number-words 0..99 (\`five\`, ` + - `\`twenty-seven\`) are understood; write it as digits, or extend ` + - `\`parseCountToken\`. An unreadable count is refused rather than skipped.`, - }); - continue; - } - claims.push({ what: pattern.what, value, token: m[1], line }); - } - if (hits === 0) { - findings.push({ - kind: 'structure', - line: 1, - message: - `nothing in this page states ${pattern.subject} in the shape this gate reads ` + - `(/${pattern.re.source}/). The claim was there when leg 3 was written (#11763). ` + - `If it was reworded, re-point the pattern; if the count was removed on purpose, ` + - `remove this claim from \`proseClaimPatterns()\` in the same edit — a leg whose ` + - `subject sentence vanished must not keep printing a clean line about it.`, - }); - } - } - - return { claims, findings }; -} - -// --------------------------------------------------------------------------- -// The three legs +// The two legs // --------------------------------------------------------------------------- /** @@ -777,41 +603,6 @@ export function compare(entries, rows) { return { leg1, leg2, tableTypes }; } -/** - * LEG 3 -- prose → registry. Every count the page states about the registry - * must be the count this gate parsed out of the registry. - * - * Both sides are DERIVED: `entries` comes from the AST walk, the claims come - * from the page. No count is written down in this file -- that is the whole - * point of the leg. - * - * @returns {Array<{kind: string, what: string, line: number, message: string}>} - */ -export function compareProse(entries, claims) { - const actual = new Map([ - ['total', entries.length], - ['true-set', entries.filter((e) => e.allowEnvironmentOverlay).length], - ]); - const leg3 = []; - for (const claim of claims) { - const want = actual.get(claim.what); - if (want === undefined || claim.value === want) continue; - const subject = - claim.what === 'total' - ? `the number of types in \`${REGISTRY_CONST}\`` - : `the size of the \`${COL_FLAG}: true\` set`; - leg3.push({ - kind: 'count-drift', - what: claim.what, - line: claim.line, - message: - `the prose states ${subject} as ${JSON.stringify(claim.token)} (${claim.value}); ` + - `the registry declares ${want}.`, - }); - } - return leg3; -} - // --------------------------------------------------------------------------- // Self-test -- the non-vacuity battery // --------------------------------------------------------------------------- @@ -1093,116 +884,6 @@ export function selfTest() { omitted.entries.some((e) => e.type === 'agent' && e.allowEnvironmentOverlay === false), JSON.stringify(omitted.findings)); - // ---- 13. LEG 3 (#11763): the two registry counts stated in the prose. - // FIXTURE_REGISTRY declares 14 entries, 5 of them `true` — so a - // sentence carrying the REAL page's numbers (27 / five) is half - // right against it, which makes it a ready-made one-sided control. - const proseSentence = (those, total) => - `Those ${those} are the **complete** \`${COL_FLAG}: true\` set: of the ${total} types in ` + - `\`${REGISTRY_CONST}\`, every other one is \`false\`.`; - - // 13a. Number-word reading: both spellings, the compound, and the refusal. - // `null` is the refusal channel and must never collide with 0. - battery('13a. Number-word reading: both spellings, the compound, and the refusal.'); - const tokenCases = [ - ['0', 0], ['5', 5], ['27', 27], ['28', 28], - ['zero', 0], ['five', 5], ['Five', 5], ['six', 6], ['nineteen', 19], ['twenty', 20], - ['twenty-seven', 27], ['twenty-eight', 28], ['ninety-nine', 99], - ['several', null], ['many', null], ['twenty-zero', null], ['twenty-twenty', null], - ['one hundred', null], ['', null], ['5.5', null], - ]; - for (const [token, want] of tokenCases) { - const got = parseCountToken(token); - check( - `prose/parseCountToken(${JSON.stringify(token)}) = ${want}`, - got === want, - `got ${JSON.stringify(got)}`, - ); - } - - // 13b. POSITIVE CONTROL for green: a sentence true of FIXTURE_REGISTRY - // parses cleanly, yields BOTH claims, and drifts zero. - battery('13b. POSITIVE CONTROL for green: a sentence true of FIXTURE_REGISTRY'); - const proseOk = readProseCounts(proseSentence('five', 14)); - check('prose/ok structure-clean', proseOk.findings.length === 0, JSON.stringify(proseOk.findings)); - check( - 'prose/ok reads both claims', - proseOk.claims.length === 2 && - proseOk.claims.some((c) => c.what === 'total' && c.value === 14) && - proseOk.claims.some((c) => c.what === 'true-set' && c.value === 5), - JSON.stringify(proseOk.claims.map((c) => `${c.what}=${c.value}`)), - ); - const okDrift = compareProse(before.entries, proseOk.claims); - check('prose/ok leg3=0', okDrift.length === 0, JSON.stringify(okDrift)); - - // 13c. THE CARD'S SCENARIO, both halves — and each isolated by the other - // staying right, so neither red can be produced by the wrong claim. - battery('13c. THE CARD\'S SCENARIO, both halves — and each isolated by the other'); - const staleTotal = compareProse(before.entries, readProseCounts(proseSentence('five', 27)).claims); - check( - 'prose/a stale TOTAL is caught, alone', - staleTotal.length === 1 && staleTotal[0].what === 'total' && staleTotal[0].kind === 'count-drift', - JSON.stringify(staleTotal.map((f) => `${f.what}:${f.message}`)), - ); - const staleTrue = compareProse(before.entries, readProseCounts(proseSentence('six', 14)).claims); - check( - 'prose/a stale TRUE-SET is caught, alone', - staleTrue.length === 1 && staleTrue[0].what === 'true-set' && staleTrue[0].kind === 'count-drift', - JSON.stringify(staleTrue.map((f) => `${f.what}:${f.message}`)), - ); - - // 13d. Digits and words are interchangeable on both claims, and dropping the - // bold from `**complete**` is not a false red. - battery('13d. Digits and words are interchangeable on both claims, and dropping the'); - check( - 'prose/digit true-set accepted', - compareProse(before.entries, readProseCounts(proseSentence('5', 14)).claims).length === 0, - ); - check( - 'prose/spelled total accepted', - compareProse(before.entries, readProseCounts(proseSentence('five', 'fourteen')).claims).length === 0, - ); - check( - 'prose/unbolded "complete" still matches', - readProseCounts(proseSentence('five', 14).replace('**complete**', 'complete')).claims.length === 2, - ); - - // 13e. EVERY match is checked, not just the first. A second copy of the same - // claim elsewhere on the page is a second hand-kept copy. - battery('13e. EVERY match is checked, not just the first. A second copy of the same'); - const twoCopies = readProseCounts( - `${proseSentence('five', 14)}\n\nElsewhere: of the 27 types in \`${REGISTRY_CONST}\`, most are inert.`, - ); - const twoCopiesDrift = compareProse(before.entries, twoCopies.claims); - check( - 'prose/a second, stale copy of the claim is caught too', - twoCopies.claims.length === 3 && twoCopiesDrift.length === 1 && twoCopiesDrift[0].line === 3, - `${twoCopies.claims.length} claims, ${JSON.stringify(twoCopiesDrift.map((f) => `${f.what}@${f.line}`))}`, - ); - - // 13f. STRUCTURAL refusals. An absent or unreadable claim is RED — draining - // this leg to vacuum is precisely the failure it exists to stop. - battery('13f. STRUCTURAL refusals. An absent or unreadable claim is RED — draining'); - const proseStructural = [ - ['claim sentence deleted', 'The table above is the whitelist. Nothing else to say.'], - ['total claim missing', `Those five are the **complete** \`${COL_FLAG}: true\` set.`], - ['true-set claim missing', `Of the 14 types in \`${REGISTRY_CONST}\`, every other one is \`false\`.`], - ['unreadable total', proseSentence('five', 'several')], - ['unreadable true-set', proseSentence('a handful of', 14)], - ]; - for (const [name, text] of proseStructural) { - const res = readProseCounts(text); - check(`prose/${name} is refused`, res.findings.length > 0, 'read clean'); - } - - // 13g. The refusals above must be refusals, not silent empties: a fixture - // that refuses must also surrender no claim it could not check. - battery('13g. The refusals above must be refusals, not silent empties: a fixture'); - check( - 'prose/an unreadable count yields NO claim (never a default of 0)', - readProseCounts(proseSentence('five', 'several')).claims.every((c) => c.what !== 'total'), - ); - // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── // // Evaluated after every battery has had its chance and BEFORE the verdict, so @@ -1256,15 +937,13 @@ export function selfTest() { process.exit(1); } // The refusal count is DERIVED from the case arrays, not hand-kept. It used - // to be a literal `21` in this sentence, which is the same defect class as - // the two prose counts leg 3 now pins (#11763) — a gate whose own summary - // line carries a hand-copied number cannot argue the point it is making. - const refusals = structural.length + registryStructural.length + proseStructural.length; + // to be a literal `21` in this sentence — a gate whose own summary line + // carries a hand-copied number cannot argue the point it is making. + const refusals = structural.length + registryStructural.length; console.log( '✓ check-overlay-whitelist-table self-test: positive control reproduces the 4 known ' + 'divergences (leg 1: flow, permission, position; leg 2: translation), the corrected ' + - 'table is green on all three legs, leg 3 goes red on a stale total and on a stale ' + - `true-set independently, and ${refusals} structural/parser cases are refused.`, + `table is green on both legs, and ${refusals} structural/parser cases are refused.`, ); selfTestReachedVerdict = true; } @@ -1292,12 +971,10 @@ function main() { const reg = readRegistry(registryText); const tab = readTable(docText); - const prose = readProseCounts(docText); const structural = [ ...reg.findings.map((f) => ({ ...f, file: REGISTRY_FILE })), ...tab.findings.map((f) => ({ ...f, file: DOC_FILE })), - ...prose.findings.map((f) => ({ ...f, file: DOC_FILE })), ]; if (structural.length > 0) { @@ -1312,9 +989,7 @@ function main() { } const { leg1, leg2, tableTypes } = compare(reg.entries, tab.rows); - const leg3 = compareProse(reg.entries, prose.claims); const trueSet = reg.entries.filter((e) => e.allowEnvironmentOverlay).map((e) => e.type); - const claimList = prose.claims.map((c) => `${c.what}=${c.value} ("${c.token}" @:${c.line})`); if (process.argv.includes('--list')) { console.log(`${REGISTRY_FILE} — ${reg.entries.length} types, ${trueSet.length} overridable:\n`); @@ -1326,18 +1001,16 @@ function main() { `${row ? `table:${row.line}` : 'table: (not listed)'}`, ); } - console.log(`\n prose counts pinned by leg 3 — ${claimList.join(', ')}\n`); + console.log(); } - if (leg1.length === 0 && leg2.length === 0 && leg3.length === 0) { + if (leg1.length === 0 && leg2.length === 0) { console.log( - `✓ ${DOC_FILE}: the overlay whitelist table AND the counts in the sentence under it ` + - `agree with ${REGISTRY_CONST} — leg 1 (table → registry) 0 divergence(s) over ` + + `✓ ${DOC_FILE}: the overlay whitelist table ` + + `agrees with ${REGISTRY_CONST} — leg 1 (table → registry) 0 divergence(s) over ` + `${tableTypes.size} type(s) named in ${tab.rows.length} row(s); leg 2 ` + `(registry → table) 0 divergence(s) over ${trueSet.length} \`${COL_FLAG}: true\` ` + - `type(s) [${trueSet.join(', ')}] out of ${reg.entries.length} declared; leg 3 ` + - `(prose → registry) 0 divergence(s) over ${prose.claims.length} count claim(s) ` + - `[${claimList.join(', ')}].`, + `type(s) [${trueSet.join(', ')}] out of ${reg.entries.length} declared.`, ); return; } @@ -1349,10 +1022,8 @@ function main() { for (const f of leg1) console.error(` ${DOC_FILE}:${f.line} [${f.kind}] ${f.message}`); console.error(`\n LEG 2 registry → table: ${leg2.length} divergence(s)`); for (const f of leg2) console.error(` ${REGISTRY_FILE}:${f.line} [${f.kind}] ${f.message}`); - console.error(`\n LEG 3 prose → registry: ${leg3.length} divergence(s)`); - for (const f of leg3) console.error(` ${DOC_FILE}:${f.line} [${f.kind}] ${f.message}`); console.error(` -VERDICT: ${leg1.length + leg2.length + leg3.length} DIVERGENCE(S) +VERDICT: ${leg1.length + leg2.length} DIVERGENCE(S) ⛔ Fix the TABLE, not the registry. \`${REGISTRY_CONST}\` is the single machine-readable whitelist and the page says so in its own first sentence. @@ -1368,11 +1039,6 @@ VERDICT: ${leg1.length + leg2.length + leg3.length} DIVERGENCE(S) leg 2 [missing-row] an overridable type the table never names -> add it. This is the \`translation\` shape from #11664: invisible to any gate that only checks the rows it can see. - leg 3 [count-drift] the sentence under the table states a registry-derived count - that the registry no longer bears -> update the WORD or DIGIT - in the prose. Registering a new type moves the total; flipping - a flag moves the true-set. Both are hand-kept copies (#11763), - and this leg is the only thing making them true. `); process.exit(1); } diff --git a/scripts/check-quick-reference-counts.mjs b/scripts/check-quick-reference-counts.mjs deleted file mode 100644 index 19d42057d3f..00000000000 --- a/scripts/check-quick-reference-counts.mjs +++ /dev/null @@ -1,1022 +0,0 @@ -#!/usr/bin/env node -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Quick-reference section-count guard (#6319, #6530). - * - * ## What it guards - * - * `content/docs/getting-started/quick-reference.mdx` is the protocol index: one - * `## Protocol (N of M schemas)` section per namespace, each followed by - * a table with one row per protocol. Both numbers in that heading are - * DECLARATIONS, and this gate is what makes each of them true: - * - * - **N** — how many protocols the table under the heading lists. Compared - * with the table itself. - * - **M** — how many reference pages the matching - * `content/docs/references//` directory publishes (`.mdx`, minus - * `index.mdx`). Compared with the real directory. - * - * That page is hand-written — `packages/spec/scripts/build-docs.ts` writes only - * `content/docs/references/`, and AGENTS.md's Documentation Guardrails table - * lists `content/docs/getting-started/` as hand-written. So the declaration and - * the table drift apart one edit at a time: #6319 found the Kernel heading - * claiming 17 while its table held 15, because two kernel rows had migrated - * into the Cloud section and only Cloud's heading was updated. Nobody hit a bug - * — the cost is that `content/docs/` is the corpus humans and AIs copy from, so - * a miscounted index is read as a fact about the schema catalog and propagates. - * - * Declared = enforced: the heading now has to be true. - * - * ## Why the second number exists (#6530) - * - * The heading used to read `(N schemas)`, and that check is SELF-REFERENTIAL: N - * is compared with the table right under it and with nothing else. #6530 - * measured what that leaves unwatched — `## Data Protocol (17 schemas)` sitting - * over a directory that publishes 29 (now 30) schema pages, with 13 of them - * having no row anywhere on the page. The gate was green the whole time and - * always would have been: a curated table and its own heading agree with each - * other no matter how far both drift from the reference tree. - * - * The maintainer's ruling (2026-08-08, option C) is that the table STAYS - * curated — a beginner's fast lookup that mirrored 30 data pages would stop - * being quick — but that the wording must carry the truth and the drift must be - * watched. Hence `(N of M schemas)`: N is still the table, M is the real - * directory, and `N < M` is the normal, gate-checked state. - * - * ## The row rule, and the three rows that point outside `references/` - * - * A section's category is not guessed from its title: it is DERIVED from the - * rows, as the single `` every unmarked row links into - * (`/docs/references//`). That derivation is what keeps #6319's - * defect caught — a kernel row that migrates into the Cloud section makes that - * section point into two categories, and the gate names both. - * - * Three rows legitimately document their protocol somewhere else: UI's - * `Widget Contract` (ObjectUI's own contract page), Kernel's `Events` (the - * hand-written kernel guide), and Shared's `Connector Auth` (a `src/shared/` - * file that `@objectstack/spec/shared` does not publish — it reaches consumers - * through `@objectstack/spec/integration`, so `build-docs.ts` documents it on - * the Connector page; see that script's `integration:` comment and - * `scripts/lib/root-index.ts`, which names a `shared/connector-auth.zod.ts` row - * as a defect class). Those rows carry a `↗` marker, and the marker is what - * reconciles N with M: a marked row is one of the N rows but is NOT one of the - * M pages. The rule, in both directions: - * - * - every row must link somewhere — a bare unlinked name is refused; - * - an UNMARKED row must link into this section's own category directory, at - * a page that exists, and no two unmarked rows may claim the same page; - * - a row that links anywhere else MUST carry `↗`; - * - a row that carries `↗` must NOT link into its own category — otherwise - * the marker silently subtracts a page from the coverage it advertises. - * - * (There is deliberately no `unmarked rows <= M` check: unmarked rows are - * distinct, existing pages of that directory, so the inequality is already a - * theorem of the three rules above. A check that can never go red reads as - * coverage and is not.) - * - * ## Curation also happens one level up - * - * `references/studio/` (3 pages) has no section on the page at all. Left - * implicit, that is the same unwatched drift one level up, so the page carries - * a `## Categories Without a Section` table and this gate reads it: every - * category directory under `content/docs/references/` must either be a - * section's derived category or be declared there with its real page count — - * never both, never neither. - * - * ## Absence must be loud - * - * A count checker that stops recognising the page silently passes — it finds no - * sections, compares nothing, and reports success, which is the "declared but - * not enforced" failure this gate exists to prevent, one level up. So every - * structural surprise is an error, not a skip: zero counted sections, a - * `## ... Protocol` heading with no count (which is also what refuses the old - * `(N schemas)` spelling), a counted section with no table or with more than - * one, a counted section whose table has no rows, a missing - * `## Categories Without a Section` block. - * - * Run `--self-test` to check the parser against known-good and known-bad pages - * before trusting a green run. - */ - -import { readFileSync, readdirSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { isEntrypoint } from './invoked-as.mjs'; - -// ── The self-test's own battery roster and floor (#13489) ────────────────── -// -// `failures.length === 0` used to be this self-test's ONLY success condition, so -// "every case held" and "the cases never ran" printed the same line. Closed the -// way PR #13487 validated on check-doc-authoring: what is pinned is the -// registered NAMES, not a number. Every section opens with `battery('')`, -// every assertion is attributed to the battery most recently opened, and the -// floor requires the OPENED set to equal the DECLARED set with each battery at -// or above its own count. -// -// ⛔ A pinned TOTAL is not the repair: a battery dropping from 9 cases to 3 -// keeps a total "right" the moment a sibling grows. -// -// The counts are a FLOOR, not an equality — adding cases is ordinary work and -// must not red. A battery BELOW its floor means cases stopped running; the -// remedy is to find what stopped registering. -const SELF_TEST_BATTERIES = Object.freeze({ - '1. POSITIVE — the measurement itself, per section, on BOTH numbers. A': 2, - '2. POSITIVE — a wrong N is caught, and the message names the section and': 5, - '3. POSITIVE — the M-ASSERTION, and the case that makes this gate more than': 4, - '4. POSITIVE — a deleted table row is caught.': 2, - '5. POSITIVE — the SINGULAR "(1 of 1 schema)" heading is a real section.': 2, - '6. POSITIVE — a section ends at the next `##` even when that heading has no': 1, - '7. POSITIVE — the heading FORMAT changing is loud, not silent. Both halves': 2, - '8. POSITIVE — the OLD `(N schemas)` spelling is refused rather than silently': 1, - '9. POSITIVE — a counted section whose table vanished.': 2, - '10. POSITIVE — a page the parser no longer recognises at all fails loudly': 2, - '11. POSITIVE — two tables in one counted section is ambiguous, not silently': 1, - '12. POSITIVE — a bare unlinked name is refused. This is the shape Shared\'s': 1, - '13. POSITIVE — a row that documents its protocol outside the reference tree': 1, - '14. POSITIVE — and the mirror: marking a row that IS in its own tree hides a': 1, - '15. POSITIVE — a row pointing at a reference page that no longer exists.': 1, - '16. POSITIVE — #6319\'s actual defect, caught by LINK rather than by count: a': 1, - '17. POSITIVE — one page listed twice inflates N while every count still': 1, - '18. POSITIVE — a category directory that is neither sectioned nor declared.': 2, - '19. POSITIVE — the declared page count of an unsectioned category is checked': 1, - '20. POSITIVE — declaring a category as unsectioned while it HAS a section is': 2, - '21. POSITIVE — a declared directory that does not exist. The symmetric': 1, - '22. POSITIVE — deleting the whole block is loud. Without this, "state the': 1, -}); - -// DELETING an entry silences that battery's floor exactly as effectively as -// zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 22; - -// The key an assertion is filed under when no battery is open. It is not a -// declared battery, so it reds by the same set difference rather than silently -// inflating whichever battery happened to run last. -const UNATTRIBUTED_BATTERY = '(no battery open)'; - -const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, ''); -const TARGET = 'content/docs/getting-started/quick-reference.mdx'; -const REFERENCES = 'content/docs/references'; - -/** `## Kernel Protocol (17 of 31 schemas)` / `## QA Protocol (1 of 1 schema)` */ -const COUNTED_HEADING = /^##\s+(.+?)\s+\((\d+)\s+of\s+(\d+)\s+schemas?\)\s*$/; -/** Any `##` heading — what ends a section, counted or not. */ -const ANY_H2 = /^##\s+/; -/** A heading that names a protocol section but carries no count. */ -const PROTOCOL_HEADING = /^##\s+(.*\bProtocol\b.*)$/; -/** The block that states which reference categories deliberately have no section. */ -const UNSECTIONED_HEADING = /^##\s+Categories Without a Section\s*$/; -/** `|:---|:---|` — the alignment row that proves the line above was a header. */ -const TABLE_DIVIDER = /^\|[\s:|-]+\|\s*$/; -const TABLE_LINE = /^\|/; -/** A row link that lands inside the generated reference tree. */ -const REFERENCE_ROUTE = /^\/docs\/references\/([^/]+)\/([^/#?]+)$/; -/** Marks a row whose protocol is documented outside its category's reference tree. */ -const OUT_OF_TREE_MARK = '↗'; - -/** Split a markdown table line into its cells (an escaped `\|` is not a separator). */ -function splitCells(line) { - const parts = line.split(/(? c.trim()); -} - -/** The first cell of a row, read as `{ name, link, marked }`. */ -function readRowCell(cell) { - const marked = cell.includes(OUT_OF_TREE_MARK); - const link = /\[([^\]]*)\]\(([^)]*)\)/.exec(cell); - const name = link - ? link[1].trim() - : cell.replace(new RegExp(`[*\`${OUT_OF_TREE_MARK}]`, 'g'), '').trim(); - return { name: name || '(empty)', link: link ? link[2].trim() : null, marked }; -} - -/** - * Parse the page into counted sections, the unsectioned-category declarations, - * and structural complaints. - * - * @returns {{ sections: Array<{title: string, declared: number, declaredTotal: number, - * actual: number, rows: Array, line: number}>, - * unsectioned: {line: number, entries: Array<{category: string, pages: number, line: number}>} | null, - * structural: Array<{line: number, message: string}> }} - */ -export function parsePage(text) { - const lines = text.split('\n'); - const sections = []; - const structural = []; - let unsectioned = null; - - /** @type {{title: string, declared: number, declaredTotal: number, line: number, body: string[], bodyStart: number} | null} */ - let current = null; - /** @type {{line: number, body: string[], bodyStart: number} | null} */ - let unsectionedBlock = null; - - const closeSection = () => { - if (!current) return; - const { rows, tables, rowLines } = countTableRows(current.body, current.bodyStart); - if (tables === 0) { - structural.push({ - line: current.line, - message: `section "${current.title}" declares ${current.declared} but has no table under it`, - }); - } else if (tables > 1) { - structural.push({ - line: current.line, - message: `section "${current.title}" has ${tables} tables; the count is ambiguous (expected exactly 1)`, - }); - } else if (rows.length === 0) { - structural.push({ - line: current.line, - message: `section "${current.title}" has a table with no rows; the row format may have changed`, - }); - } - sections.push({ - title: current.title, - declared: current.declared, - declaredTotal: current.declaredTotal, - actual: rows.length, - rows: rows.map((r, i) => ({ ...readRowCell(splitCells(r)[0] ?? ''), line: rowLines[i] })), - line: current.line, - }); - current = null; - }; - - const closeUnsectioned = () => { - if (!unsectionedBlock) return; - const { rows, rowLines } = countTableRows(unsectionedBlock.body, unsectionedBlock.bodyStart); - const entries = []; - rows.forEach((row, i) => { - const cells = splitCells(row); - const category = /`([^`]+)`/.exec(cells[0] ?? ''); - const pages = /^\d+$/.test(cells[1] ?? '') ? Number(cells[1]) : null; - if (!category || pages === null) { - structural.push({ - line: rowLines[i], - message: - 'a "Categories Without a Section" row must read "| `` | | |"; ' + - `got "${row.trim()}"`, - }); - return; - } - entries.push({ category: category[1], pages, line: rowLines[i] }); - }); - unsectioned = { line: unsectionedBlock.line, entries }; - unsectionedBlock = null; - }; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const counted = COUNTED_HEADING.exec(line); - if (counted) { - closeSection(); - closeUnsectioned(); - current = { - title: counted[1], - declared: Number(counted[2]), - declaredTotal: Number(counted[3]), - line: i + 1, - body: [], - bodyStart: i + 1, - }; - continue; - } - if (ANY_H2.test(line)) { - // A `##` heading ALWAYS ends the current section — including one with no - // count. This is the containment that stops a section from running to the - // end of the file and counting unrelated tables (#6319's Shared/12). - closeSection(); - closeUnsectioned(); - if (UNSECTIONED_HEADING.test(line)) { - unsectionedBlock = { line: i + 1, body: [], bodyStart: i + 1 }; - continue; - } - const protocolish = PROTOCOL_HEADING.exec(line); - if (protocolish) { - structural.push({ - line: i + 1, - message: `heading "${protocolish[1]}" names a protocol section but declares no "(N of M schemas)" count`, - }); - } - continue; - } - if (current) current.body.push(line); - else if (unsectionedBlock) unsectionedBlock.body.push(line); - } - closeSection(); - closeUnsectioned(); - - if (sections.length === 0) { - structural.push({ - line: 1, - message: - 'no "## (N of M schemas)" sections found at all — the page structure or the heading format changed', - }); - } - if (!unsectioned) { - structural.push({ - line: 1, - message: - 'no "## Categories Without a Section" block found — category-level curation must be stated on the page, not left implicit', - }); - } - - return { sections, unsectioned, structural }; -} - -/** Count body rows of the markdown table(s) in a section body. */ -function countTableRows(body, bodyStart) { - const rows = []; - const rowLines = []; - let tables = 0; - for (let i = 0; i < body.length; i++) { - // A table is a header line immediately followed by an alignment divider. - if (!TABLE_LINE.test(body[i]) || !TABLE_DIVIDER.test(body[i + 1] ?? '')) continue; - tables++; - let j = i + 2; - while (j < body.length && TABLE_LINE.test(body[j])) { - rows.push(body[j]); - rowLines.push(bodyStart + j + 1); - j++; - } - i = j - 1; - } - return { rows, tables, rowLines }; -} - -/** - * @param {string} text the page - * @param {Record} catalog category -> published page slugs - * (`content/docs/references//*.mdx` minus `index.mdx`) - * @returns {{ findings: Array<{kind: string, line: number, message: string}>, sections: Array }} - */ -export function checkPage(text, catalog) { - const { sections, unsectioned, structural } = parsePage(text); - const findings = structural.map((s) => ({ ...s, kind: 'structure' })); - const add = (kind, line, message) => findings.push({ kind, line, message }); - - /** @type {Map} derived category -> section title */ - const sectioned = new Map(); - - for (const s of sections) { - if (s.declared !== s.actual) { - add( - 'count', - s.line, - `section "${s.title}" declares ${s.declared} schema(s) but its table has ${s.actual} row(s)`, - ); - } - - // --- rows: link shape, and the ↗ marker in both directions --------------- - const linked = []; - for (const row of s.rows) { - if (!row.link) { - add( - 'row', - row.line, - `row "${row.name}" in section "${s.title}" has no link; every row must link to the page that documents it`, - ); - continue; - } - const route = REFERENCE_ROUTE.exec(row.link); - linked.push({ ...row, category: route?.[1] ?? null, page: route?.[2] ?? null }); - if (!route && !row.marked) { - add( - 'row', - row.line, - `row "${row.name}" in section "${s.title}" links to ${row.link}, outside content/docs/references/, ` + - `but is not marked ${OUT_OF_TREE_MARK} — the marker is what tells the reader it is not one of the M pages`, - ); - } - } - - // --- the section's category is DERIVED from its unmarked rows ------------ - const own = new Set(linked.filter((r) => !r.marked && r.category).map((r) => r.category)); - if (own.size > 1) { - add( - 'row', - s.line, - `section "${s.title}" has unmarked rows pointing into ${own.size} different reference categories ` + - `(${[...own].sort().join(', ')}); a row belongs to the section its /docs/references// link names`, - ); - continue; - } - if (own.size === 0) { - add( - 'structure', - s.line, - `section "${s.title}" has no unmarked /docs/references// row, so its category cannot be ` + - 'derived and its "of M" number cannot be checked against anything', - ); - continue; - } - const category = [...own][0]; - if (sectioned.has(category)) { - add( - 'coverage', - s.line, - `sections "${sectioned.get(category)}" and "${s.title}" both cover reference category \`${category}\``, - ); - } - sectioned.set(category, s.title); - - // --- M: the declared total against the real directory -------------------- - const pages = catalog[category]; - if (!pages) { - add( - 'total', - s.line, - `section "${s.title}" derives category \`${category}\`, but ${REFERENCES}/${category}/ does not exist`, - ); - continue; - } - if (s.declaredTotal !== pages.length) { - add( - 'total', - s.line, - `section "${s.title}" declares "of ${s.declaredTotal} schemas" but ${REFERENCES}/${category}/ ` + - `publishes ${pages.length} page(s)`, - ); - } - - // --- unmarked rows must name real, distinct pages of that directory ------ - const claimed = new Map(); - for (const row of linked) { - if (row.marked) { - if (row.category === category) { - add( - 'row', - row.line, - `row "${row.name}" in section "${s.title}" is marked ${OUT_OF_TREE_MARK} but links into its own ` + - `category \`${category}\`; drop the marker or the row stops counting toward coverage`, - ); - } - continue; - } - if (!pages.includes(row.page)) { - add( - 'row', - row.line, - `row "${row.name}" in section "${s.title}" links to ${row.link}, but ` + - `${REFERENCES}/${category}/${row.page}.mdx does not exist`, - ); - continue; - } - if (claimed.has(row.page)) { - add( - 'row', - row.line, - `rows "${claimed.get(row.page)}" and "${row.name}" in section "${s.title}" both link to ` + - `${row.link}; one page, two rows inflates N`, - ); - continue; - } - claimed.set(row.page, row.name); - } - } - - // --- category-level curation: every directory is sectioned or declared ----- - if (unsectioned) { - const declared = new Map(); - for (const entry of unsectioned.entries) { - if (declared.has(entry.category)) { - add('coverage', entry.line, `category \`${entry.category}\` is declared twice`); - continue; - } - declared.set(entry.category, entry); - const pages = catalog[entry.category]; - if (!pages) { - add( - 'coverage', - entry.line, - `\`${entry.category}\` is declared as having no section, but ${REFERENCES}/${entry.category}/ does not exist`, - ); - continue; - } - if (entry.pages !== pages.length) { - add( - 'coverage', - entry.line, - `\`${entry.category}\` is declared with ${entry.pages} page(s) but ${REFERENCES}/${entry.category}/ ` + - `publishes ${pages.length}`, - ); - } - if (sectioned.has(entry.category)) { - add( - 'coverage', - entry.line, - `\`${entry.category}\` is declared as having no section, but "${sectioned.get(entry.category)}" covers it`, - ); - } - } - for (const category of Object.keys(catalog).sort()) { - if (sectioned.has(category) || declared.has(category)) continue; - add( - 'coverage', - unsectioned.line, - `${REFERENCES}/${category}/ (${catalog[category].length} page(s)) has no section on this page and is not ` + - 'declared under "Categories Without a Section"', - ); - } - } - - findings.sort((a, b) => a.line - b.line); - return { findings, sections }; -} - -/** Read `content/docs/references/` into `{ category: [pageSlug, ...] }`. */ -export function readCatalog(referencesDir) { - const catalog = {}; - for (const entry of readdirSync(referencesDir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - catalog[entry.name] = readdirSync(join(referencesDir, entry.name)) - .filter((f) => f.endsWith('.mdx') && f !== 'index.mdx') - .map((f) => f.slice(0, -'.mdx'.length)) - .sort(); - } - return catalog; -} - -const GOOD_CATALOG = { - data: ['field', 'filter', 'object', 'query'], - qa: ['testing'], - studio: ['flow-builder', 'object-designer', 'plugin'], - contracts: [], -}; - -const GOOD_PAGE = [ - '# Quick Reference Guide', - '', - '## Data Protocol (3 of 4 schemas)', - '', - 'Core business logic.', - '', - '| Protocol | Source File | Key Schemas | Purpose |', - '|:---------|:-----------|:------------|:--------|', - '| **[Field](/docs/references/data/field)** | `field.zod.ts` | Field | Field types |', - '| **[Object](/docs/references/data/object)** | `object.zod.ts` | Object | Object defs |', - '| **[Events](/docs/kernel/events)** ↗ | `events.zod.ts` | Event | Documented outside the tree |', - '', - '## QA Protocol (1 of 1 schema)', - '', - '| Protocol | Source File | Key Schemas | Purpose |', - '|:---------|:-----------|:------------|:--------|', - '| **[Testing](/docs/references/qa/testing)** | `testing.zod.ts` | TestSuite | Tests |', - '', - '## Categories Without a Section', - '', - '| Category directory | Pages | Why it has no section |', - '|:---|---:|:---|', - '| [`studio`](/docs/references/studio) | 3 | Designer-facing metadata. |', - '| `contracts` | 0 | Publishes no reference page at all. |', - '', - '## Common Patterns', - '', - '### Declarative Endpoints (`apis:`)', - '', - '| Rule | Value |', - '|:---|:---|', - '| **Path shape** | only the subpath is yours |', - '| **Namespace** | must be declared explicitly |', - '', -].join('\n'); - -// Set by `selfTest()` only after its verdict is printed, and read at the -// dispatch: a `return` that leaves the function above that line prints nothing -// and still exits 0 — a self-test that never finished, reported as one that -// passed (#13798). The self-test's own exit code stays load-bearing, so the -// handshake is a flag rather than a returned sentinel. -let selfTestReachedVerdict = false; - -function selfTest() { - // The battery ledger this self-test's floor is evaluated against (#13489). - // `battery()` opens a battery; every assertion below is attributed to the one - // most recently opened, so a section that stops running stops registering and - // names ITSELF at the floor rather than going quiet. - const batterySeen = new Map(); - let openBattery = null; - const battery = (name) => { - openBattery = name; - }; - const registerCase = () => { - const b = openBattery ?? UNATTRIBUTED_BATTERY; - batterySeen.set(b, (batterySeen.get(b) ?? 0) + 1); - }; - - const failures = []; - const expect = (label, got, want) => { - registerCase(); - const g = JSON.stringify(got); - const w = JSON.stringify(want); - if (g !== w) failures.push(` ✗ ${label}: expected ${w}, got ${g}`); - }; - const has = (findings, re) => findings.some((f) => re.test(f.message)); - - // 1. POSITIVE — the measurement itself, per section, on BOTH numbers. A - // gutted checker that returns nothing fails here, and so does one that - // stops resolving the directory side: the fourth column is the catalog's - // real size, not the page's claim about it. - battery('1. POSITIVE — the measurement itself, per section, on BOTH numbers. A'); - { - const { sections, findings } = checkPage(GOOD_PAGE, GOOD_CATALOG); - expect( - 'good page measures every section (title, N, rows, declared M)', - sections.map((s) => [s.title, s.declared, s.actual, s.declaredTotal]), - [ - ['Data Protocol', 3, 3, 4], - ['QA Protocol', 1, 1, 1], - ], - ); - // NEGATIVE (declared): "a correct page is clean". Trivially true for a - // checker that reports nothing — it is here to catch false positives, and - // it can never be the case that turns this gate red. - expect('good page is clean', findings.length, 0); - } - - // 2. POSITIVE — a wrong N is caught, and the message names the section and - // BOTH numbers. A gate that merely says "mismatch" fails this. - battery('2. POSITIVE — a wrong N is caught, and the message names the section and'); - { - const bad = GOOD_PAGE.replace('## Data Protocol (3 of 4 schemas)', '## Data Protocol (5 of 4 schemas)'); - const { findings } = checkPage(bad, GOOD_CATALOG); - expect('wrong N produces exactly one finding', findings.length, 1); - expect('wrong N is a count finding', findings[0]?.kind, 'count'); - expect('names the section', /"Data Protocol"/.test(findings[0]?.message ?? ''), true); - expect('names the declared number', /\b5 schema/.test(findings[0]?.message ?? ''), true); - expect('names the actual number', /\b3 row/.test(findings[0]?.message ?? ''), true); - } - - // 3. POSITIVE — the M-ASSERTION, and the case that makes this gate more than - // self-referential (#6530). The page's table is internally consistent and - // only the directory disagrees. Delete the `declaredTotal !== pages.length` - // comparison in checkPage and this case measures zero findings — which is - // exactly the green-forever state #6530 documented. - battery('3. POSITIVE — the M-ASSERTION, and the case that makes this gate more than'); - { - const bad = GOOD_PAGE.replace('## Data Protocol (3 of 4 schemas)', '## Data Protocol (3 of 9 schemas)'); - const { findings } = checkPage(bad, GOOD_CATALOG); - expect('wrong M produces exactly one finding', findings.length, 1); - expect('wrong M is a total finding', findings[0]?.kind, 'total'); - expect('names the declared total', /of 9 schemas/.test(findings[0]?.message ?? ''), true); - expect('names the real page count', /publishes 4 page/.test(findings[0]?.message ?? ''), true); - } - - // 4. POSITIVE — a deleted table row is caught. - battery('4. POSITIVE — a deleted table row is caught.'); - { - const bad = GOOD_PAGE.replace( - '| **[Object](/docs/references/data/object)** | `object.zod.ts` | Object | Object defs |\n', - '', - ); - const { findings } = checkPage(bad, GOOD_CATALOG); - expect('deleted row produces one finding', findings.length, 1); - expect('deleted row names both numbers', /3 schema\(s\).*2 row\(s\)/.test(findings[0]?.message ?? ''), true); - } - - // 5. POSITIVE — the SINGULAR "(1 of 1 schema)" heading is a real section. - // #6319's report came from a plural-only regex; under one, this page yields - // no finding at all because QA is not a section and its row is charged to - // Data. - battery('5. POSITIVE — the SINGULAR "(1 of 1 schema)" heading is a real section.'); - { - const bad = GOOD_PAGE.replace('## QA Protocol (1 of 1 schema)', '## QA Protocol (2 of 1 schema)'); - const { findings } = checkPage(bad, GOOD_CATALOG); - expect('singular heading is parsed as a section', findings.length, 1); - expect('singular section named in the finding', /"QA Protocol"/.test(findings[0]?.message ?? ''), true); - } - - // 6. POSITIVE — a section ends at the next `##` even when that heading has no - // count, so neither the "Categories Without a Section" table nor the - // Declarative Endpoints rule table is charged to QA. Asserted as a measured - // value (1), not as "no findings" — the leaky scanner of #6319 would - // measure 5 here. - battery('6. POSITIVE — a section ends at the next `##` even when that heading has no'); - { - const { sections } = checkPage(GOOD_PAGE, GOOD_CATALOG); - const qa = sections.find((s) => s.title === 'QA Protocol'); - expect('last section stops at the uncounted heading', qa?.actual, 1); - } - - // 7. POSITIVE — the heading FORMAT changing is loud, not silent. Both halves - // matter: the section stops being counted (so nothing is compared) and the - // gate must say so — including that its category then has no section. - battery('7. POSITIVE — the heading FORMAT changing is loud, not silent. Both halves'); - { - const bad = GOOD_PAGE.replace('## Data Protocol (3 of 4 schemas)', '## Data Protocol - 3 schemas'); - const { findings } = checkPage(bad, GOOD_CATALOG); - expect('reformatted heading is reported', has(findings, /declares no "\(N of M schemas\)" count/), true); - expect('and its category falls out of coverage', has(findings, /references\/data\/ \(4 page\(s\)\) has no section/), true); - } - - // 8. POSITIVE — the OLD `(N schemas)` spelling is refused rather than silently - // accepted. Without this, #6530's migration could be reverted one heading - // at a time and the M-assertion would quietly stop applying to it. - battery('8. POSITIVE — the OLD `(N schemas)` spelling is refused rather than silently'); - { - const bad = GOOD_PAGE.replace('## Data Protocol (3 of 4 schemas)', '## Data Protocol (3 schemas)'); - const { findings } = checkPage(bad, GOOD_CATALOG); - expect('bare "(N schemas)" is not a counted heading', has(findings, /declares no "\(N of M schemas\)" count/), true); - } - - // 9. POSITIVE — a counted section whose table vanished. - battery('9. POSITIVE — a counted section whose table vanished.'); - { - const bad = [ - '## Data Protocol (2 of 4 schemas)', - '', - 'Core business logic.', - '', - '## Categories Without a Section', - '', - '| Category directory | Pages | Why it has no section |', - '|:---|---:|:---|', - '| `contracts` | 0 | none. |', - '', - ].join('\n'); - const { findings } = checkPage(bad, GOOD_CATALOG); - expect('missing table is a structure finding', findings[0]?.kind, 'structure'); - expect('missing table says so', has(findings, /has no table under it/), true); - } - - // 10. POSITIVE — a page the parser no longer recognises at all fails loudly - // instead of passing with zero comparisons. - battery('10. POSITIVE — a page the parser no longer recognises at all fails loudly'); - { - const { findings } = checkPage('# Quick Reference Guide\n\nnothing here.\n', GOOD_CATALOG); - expect('unrecognised page is reported', has(findings, /no "## \(N of M schemas\)" sections found at all/), true); - expect('unrecognised page is a structure finding', findings[0]?.kind, 'structure'); - } - - // 11. POSITIVE — two tables in one counted section is ambiguous, not silently - // summed. - battery('11. POSITIVE — two tables in one counted section is ambiguous, not silently'); - { - const bad = GOOD_PAGE.replace( - '| **[Object](/docs/references/data/object)** | `object.zod.ts` | Object | Object defs |', - [ - '| **[Object](/docs/references/data/object)** | `object.zod.ts` | Object | Object defs |', - '', - '| A | B |', - '|:--|:--|', - '| x | y |', - ].join('\n'), - ); - const { findings } = checkPage(bad, GOOD_CATALOG); - expect('two tables is a structure finding', has(findings, /has 2 tables/), true); - } - - // 12. POSITIVE — a bare unlinked name is refused. This is the shape Shared's - // `Connector Auth` row had before #6530. - battery('12. POSITIVE — a bare unlinked name is refused. This is the shape Shared\'s'); - { - const bad = GOOD_PAGE.replace( - '| **[Field](/docs/references/data/field)** | `field.zod.ts` | Field | Field types |', - '| **Field** | `field.zod.ts` | Field | Field types |', - ); - const { findings } = checkPage(bad, GOOD_CATALOG); - expect('unlinked row is a row finding', has(findings, /row "Field" .* has no link/), true); - } - - // 13. POSITIVE — a row that documents its protocol outside the reference tree - // must carry the marker; unmarked, it silently claims to be one of the M. - battery('13. POSITIVE — a row that documents its protocol outside the reference tree'); - { - const bad = GOOD_PAGE.replace( - '| **[Events](/docs/kernel/events)** ↗ |', - '| **[Events](/docs/kernel/events)** |', - ); - const { findings } = checkPage(bad, GOOD_CATALOG); - expect('unmarked foreign row is reported', has(findings, /outside content\/docs\/references\/, but is not marked/), true); - } - - // 14. POSITIVE — and the mirror: marking a row that IS in its own tree hides a - // page from the coverage the heading advertises. - battery('14. POSITIVE — and the mirror: marking a row that IS in its own tree hides a'); - { - const bad = GOOD_PAGE.replace( - '| **[Field](/docs/references/data/field)** |', - '| **[Field](/docs/references/data/field)** ↗ |', - ); - const { findings } = checkPage(bad, GOOD_CATALOG); - expect('mismarked in-tree row is reported', has(findings, /is marked ↗ but links into its own category `data`/), true); - } - - // 15. POSITIVE — a row pointing at a reference page that no longer exists. - // Nothing else on the page changes, so only the directory can catch it. - battery('15. POSITIVE — a row pointing at a reference page that no longer exists.'); - { - const bad = GOOD_PAGE.replace('/docs/references/data/object)', '/docs/references/data/ghost)'); - const { findings } = checkPage(bad, GOOD_CATALOG); - expect('dead reference row is reported', has(findings, /references\/data\/ghost\.mdx does not exist/), true); - } - - // 16. POSITIVE — #6319's actual defect, caught by LINK rather than by count: a - // row that migrated into the wrong section. The counts still agree. - battery('16. POSITIVE — #6319\'s actual defect, caught by LINK rather than by count: a'); - { - const bad = GOOD_PAGE.replace('/docs/references/data/object)', '/docs/references/qa/testing)'); - const { findings } = checkPage(bad, GOOD_CATALOG); - expect('cross-category rows are reported', has(findings, /pointing into 2 different reference categories \(data, qa\)/), true); - } - - // 17. POSITIVE — one page listed twice inflates N while every count still - // agrees with every other count. - battery('17. POSITIVE — one page listed twice inflates N while every count still'); - { - const bad = GOOD_PAGE.replace('/docs/references/data/object)', '/docs/references/data/field)'); - const { findings } = checkPage(bad, GOOD_CATALOG); - expect('duplicate row is reported', has(findings, /both link to .*data\/field; one page, two rows inflates N/), true); - } - - // 18. POSITIVE — a category directory that is neither sectioned nor declared. - // This is the category-level half of #6530's drift. - battery('18. POSITIVE — a category directory that is neither sectioned nor declared.'); - { - const bad = GOOD_PAGE.replace('| [`studio`](/docs/references/studio) | 3 | Designer-facing metadata. |\n', ''); - const { findings } = checkPage(bad, GOOD_CATALOG); - expect('undeclared category is reported', has(findings, /references\/studio\/ \(3 page\(s\)\) has no section/), true); - expect('undeclared category is a coverage finding', findings.find((f) => /studio/.test(f.message))?.kind, 'coverage'); - } - - // 19. POSITIVE — the declared page count of an unsectioned category is checked - // against the directory too, so `studio` growing a page is not silent. - battery('19. POSITIVE — the declared page count of an unsectioned category is checked'); - { - const bad = GOOD_PAGE.replace('](/docs/references/studio) | 3 |', '](/docs/references/studio) | 5 |'); - const { findings } = checkPage(bad, GOOD_CATALOG); - expect('wrong unsectioned count is reported', has(findings, /`studio` is declared with 5 page\(s\) but .* publishes 3/), true); - } - - // 20. POSITIVE — declaring a category as unsectioned while it HAS a section is - // a contradiction, not a harmless duplicate. - battery('20. POSITIVE — declaring a category as unsectioned while it HAS a section is'); - { - const bad = GOOD_PAGE.replace('| `contracts` | 0 |', '| `data` | 4 |'); - const { findings } = checkPage(bad, GOOD_CATALOG); - expect('contradictory declaration is reported', has(findings, /`data` is declared as having no section, but "Data Protocol" covers it/), true); - expect('and contracts then falls out of coverage', has(findings, /references\/contracts\/ \(0 page\(s\)\) has no section/), true); - } - - // 21. POSITIVE — a declared directory that does not exist. The symmetric - // rot: the block outliving the tree it describes. - battery('21. POSITIVE — a declared directory that does not exist. The symmetric'); - { - const bad = GOOD_PAGE.replace('| `contracts` | 0 |', '| `gone` | 0 |'); - const { findings } = checkPage(bad, GOOD_CATALOG); - expect('stale declaration is reported', has(findings, /`gone` is declared as having no section, but .* does not exist/), true); - } - - // 22. POSITIVE — deleting the whole block is loud. Without this, "state the - // curation on the page" is enforced only while someone keeps it there. - battery('22. POSITIVE — deleting the whole block is loud. Without this, "state the'); - { - const bad = GOOD_PAGE.split('## Categories Without a Section')[0] + '## Common Patterns\n'; - const { findings } = checkPage(bad, GOOD_CATALOG); - expect('missing block is reported', has(findings, /no "## Categories Without a Section" block found/), true); - } - - // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── - // - // Evaluated after every battery has had its chance and BEFORE the verdict, so - // the success line below can only be printed by a run in which the set of - // batteries that registered assertions EQUALS the set declared. A set - // difference names WHICH battery stopped; a count says only that something did. - const floorFailure = (message) => { - failures.push(message); - }; - const declaredBatteries = Object.keys(SELF_TEST_BATTERIES); - let floorBreached = false; - if (declaredBatteries.length < SELF_TEST_BATTERY_FLOOR) { - floorBreached = true; - floorFailure( - `SELF_TEST_BATTERIES declares ${declaredBatteries.length} batteries, below the pinned ` + - `${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`, - ); - } - for (const [name, count] of batterySeen) { - if (declaredBatteries.includes(name)) continue; - floorBreached = true; - floorFailure( - `self-test battery "${name}" registered ${count} case(s) but is not declared in ` + - 'SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.', - ); - } - for (const name of declaredBatteries) { - const count = batterySeen.get(name) ?? 0; - if (count >= SELF_TEST_BATTERIES[name]) continue; - floorBreached = true; - floorFailure( - count === 0 - ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. ` + - 'The verdict below would have claimed those cases hold.' - : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ` + - `${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`, - ); - } - if (floorBreached) { - floorFailure( - 'A battery at or below its floor means cases STOPPED RUNNING — the battery is the bug, not the ' + - 'number. Find what stopped registering (an early return, a deleted block, a guard that now ' + - 'skips) and restore it.', - ); - } - - if (failures.length) { - console.error('\n✗ check-quick-reference-counts self-test failed:\n'); - for (const f of failures) console.error(f); - process.exit(1); - } - // The verdict DERIVES its number from `batterySeen` — the very ledger the - // floor above just evaluated — so the printed count and the floor can never - // disagree. What "cases" counts here is BATTERIES that actually ran (the - // named scenarios), not `expect(` call sites: the floor above guarantees - // `batterySeen.size` equals `declaredBatteries.length` by this point, so - // reading it off the runtime ledger rather than the static roster is a - // distinction without a difference — and it is the ledger, not the roster, - // that a stopped-running battery would actually shrink (#16664). - console.log(`✓ check-quick-reference-counts self-test: ${batterySeen.size} cases pass.`); - selfTestReachedVerdict = true; -} - -function main() { - if (process.argv.includes('--self-test')) { - const selfTestCode = selfTest(); - if (!selfTestReachedVerdict) { - console.error( - '\n✗ check-quick-reference-counts self-test: selfTest() returned without reaching its verdict,\n' - + 'so no success line was printed. Exiting 0 here would report a self-test\n' - + 'that never finished as a self-test that passed.\n', - ); - process.exit(1); - } - return selfTestCode; - } - - const full = join(ROOT, TARGET); - if (!existsSync(full)) { - // The page moving is itself a finding: a silent skip would retire the gate. - console.error(`\n✗ ${TARGET} not found.\n`); - console.error( - 'This guard exists because that page\'s "(N of M schemas)" headings are a\n' + - 'declaration about the table under each one AND about the reference\n' + - 'directory it curates. If the page moved, point TARGET in\n' + - 'scripts/check-quick-reference-counts.mjs at its new home; if it is gone,\n' + - 'delete this gate and its package.json / lint.yml wiring.\n', - ); - process.exit(1); - } - - const referencesDir = join(ROOT, REFERENCES); - if (!existsSync(referencesDir)) { - console.error(`\n✗ ${REFERENCES}/ not found.\n`); - console.error( - 'The "of M" half of every heading is measured against that tree. Without\n' + - 'it there is nothing to compare, and passing anyway would put this gate\n' + - 'back where #6530 found it: green by vacancy.\n', - ); - process.exit(1); - } - - const catalog = readCatalog(referencesDir); - const { findings, sections } = checkPage(readFileSync(full, 'utf8'), catalog); - - if (findings.length === 0) { - console.log( - `✓ ${TARGET}: ${sections.length} section(s), every "(N of M schemas)" heading matches its table AND ` + - `${REFERENCES}/ (${Object.keys(catalog).length} categories, all sectioned or declared).`, - ); - return; - } - - console.error(`\n✗ ${TARGET} — the declared sections do not match the page or the reference tree:\n`); - for (const f of findings) { - console.error(` ${TARGET}:${f.line} [${f.kind}] ${f.message}`); - } - console.error(` -Each "## Protocol (N of M schemas)" heading declares two things: N, how -many rows its table has, and M, how many pages -${REFERENCES}// publishes. Decide which side is wrong before -editing — the heading is not automatically the stale one: - - - [count] the section really did gain or lose a row -> update N; - - [count] a row is missing, or sits in the wrong section -> restore or move - the row (its /docs/references// link names the section it belongs - to); - - [total] the category gained or lost a reference page -> update M. The table - is a CURATED subset (#6530): N < M is the normal state, and adding rows to - close the gap is a decision, not a fix; - - [row] a row points outside its category's reference tree -> mark it ${OUT_OF_TREE_MARK}; a row - with no link at all is refused outright; - - [coverage] a category directory appeared or vanished -> give it a section, - or state it under "## Categories Without a Section". - -A [structure] finding means the page no longer looks the way this gate reads -it. That is reported rather than skipped on purpose: a count checker that -quietly stops recognising its page reports success forever. -`); - process.exit(1); -} - -// Exports bindings, so an import for those exports alone must run nothing (#10667). -if (isEntrypoint(import.meta.url)) { - main(); -} diff --git a/scripts/check-route-ledger-census.mjs b/scripts/check-route-ledger-census.mjs index fe2ccaac7b7..770d8bc83cb 100644 --- a/scripts/check-route-ledger-census.mjs +++ b/scripts/check-route-ledger-census.mjs @@ -69,9 +69,8 @@ * and this lane spent one day deleting three instances of it (#16919, #17039 -> * #17055, #17075 -- where re-typed counts went wrong within hours and the fix * was to remove the digits rather than correct them). So the digits in - * `route-ledger.ts` are written by `--fix` and read by nobody's memory. The - * shape is `check-lockstep-package-count.mjs`'s, including its ROTTED_ANCHOR - * behaviour: the anchor must resolve to EXACTLY ONE match before the number it + * `route-ledger.ts` are written by `--fix` and read by nobody's memory. It + * has a ROTTED_ANCHOR rule: the anchor must resolve to EXACTLY ONE match before the number it * captured is compared to the truth, because zero matches (the sentence was * reworded, or the slice took the comment with the rows) and more than one are * both failures to report rather than agreements to assume. Silence on a stale diff --git a/scripts/publish-smoke-pack.mjs b/scripts/publish-smoke-pack.mjs index 63610cb68b1..9c3046537df 100644 --- a/scripts/publish-smoke-pack.mjs +++ b/scripts/publish-smoke-pack.mjs @@ -33,7 +33,7 @@ * a future exclusion cannot re-open the hole silently. * * Source of truth: the workspace itself. The Changesets `fixed` group in - * .changeset/config.json enumerates the same 110 names, but it is a DERIVED + * .changeset/config.json enumerates the same names, but it is a DERIVED * declaration validated against the workspace by scripts/check-changeset-fixed.mjs * (which reddens both when a public package is missing from the group and * when a group name no longer exists), and it also holds the private members