From 5298f952cff8c2929cc2d3eb63ec996340834ac7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 12:51:48 +0000 Subject: [PATCH 1/3] =?UTF-8?q?docs(governance):=20objectui=20is=20a=20pro?= =?UTF-8?q?duct-neutral=20renderer=20=E2=80=94=20AGENTS.md=20rule,=20ADR-0?= =?UTF-8?q?060=20and=20its=20ratchet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A product composes its pages from generic blocks; a page that cannot be composed is fixed by a generic block here or a contract door in @objectstack/spec, never by a widget, dialog, hook or i18n namespace named for the product. What exists stays until its page migrates and may only shrink. - AGENTS.md: a "Product-neutral renderer" section in §1. - docs/adr/0060-product-neutral-renderer.md: the ruling, the readings taken, and the frozen-baseline block the ratchet reads as its ceiling. - app-shell: product-neutral-renderer.ratchet.test.ts and its live list product-specific.allowlist.json (delete-only; must stay inside the ADR's ceiling). - scripts/markdown-test-inputs.mjs: the test reads the ADR. - .github/pull_request_template.md: one checkbox. - An empty-frontmatter changeset: nothing publishes. Claude-Session: https://claude.ai/code/session_012dGTuJen61ECNHXYXKChoC Co-authored-by: Claude --- .changeset/12224-product-neutral-renderer.md | 4 + .github/pull_request_template.md | 1 + AGENTS.md | 10 + docs/adr/0060-product-neutral-renderer.md | 234 ++++++++++ .../product-neutral-renderer.ratchet.test.ts | 416 ++++++++++++++++++ .../__tests__/product-specific.allowlist.json | 153 +++++++ scripts/markdown-test-inputs.mjs | 12 + 7 files changed, 830 insertions(+) create mode 100644 .changeset/12224-product-neutral-renderer.md create mode 100644 .github/pull_request_template.md create mode 100644 docs/adr/0060-product-neutral-renderer.md create mode 100644 packages/app-shell/src/__tests__/product-neutral-renderer.ratchet.test.ts create mode 100644 packages/app-shell/src/__tests__/product-specific.allowlist.json diff --git a/.changeset/12224-product-neutral-renderer.md b/.changeset/12224-product-neutral-renderer.md new file mode 100644 index 0000000000..c1be59a0d8 --- /dev/null +++ b/.changeset/12224-product-neutral-renderer.md @@ -0,0 +1,4 @@ +--- +--- + +No package released: governance, a test and its data, no behaviour change. objectui is now a product-neutral renderer (ADR-0060, and a "Product-neutral renderer" section in `AGENTS.md`). `@object-ui/app-shell` gains a ratchet test under `src/__tests__/` that fails on a new product-named registry key, a new file in the frozen `environment/` and `console/organizations/` directories or named for a product, and a new key under a frozen locale namespace. Beside it sits the live list of today's product-specific surfaces, which only shrinks. The package tsconfig excludes `__tests__` from the build, so nothing under it reaches `dist`. The repository also gains a pull-request template with one checkbox. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..d1d1220f04 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1 @@ +- [ ] Adds no product-specific widget, dialog, hook or string (see AGENTS.md › Product-neutral renderer) diff --git a/AGENTS.md b/AGENTS.md index c6e65b3418..1c16aa522b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,16 @@ You don't just build components — you build a **Renderer** that interprets JSO - **The "JSON-to-Shadcn" bridge** — combine low-code speed with Shadcn/Tailwind design quality. - **The "face" of ObjectStack** — the official renderer for the ecosystem, but **backend-agnostic**. +### Product-neutral renderer + +objectui renders for every product the same way — ObjectStack Cloud, HotCRM, any app built on the stack. Three rules, recorded in ADR-0060 (`docs/adr/0060-product-neutral-renderer.md`): + +1. **A product composes its pages.** It builds them from the generic blocks of `@objectstack/spec`'s page vocabulary and keeps their copy in its own bundles. +2. **A page that cannot be composed is fixed generically:** a **generic** block here, named for what it draws (`stat`, `usage-bar`, `checklist`), or a **contract door** in `@objectstack/spec` (`needs:contract-review`). ⛔ Never a widget, dialog, hook or i18n namespace named for the product that needed it. +3. **What already exists stays until its page migrates, and may only shrink.** + +`packages/app-shell/src/__tests__/product-neutral-renderer.ratchet.test.ts` holds rules 2 and 3 for registry keys, file names and locale keys led by a product word, and for the frozen directories and namespaces. Its live list, `product-specific.allowlist.json`, takes deletions only: the pull request that migrates a surface out deletes its entries. ⛔ Never append to that list to turn the test green. ADR-0060's frozen-baseline block is the ceiling, and growing it is an ADR amendment the maintainer approves. The test reads names, not intent, so a product surface under a neutral name is left to review and the pull-request template's checkbox. + --- ## 2. Tech Stack (strict) diff --git a/docs/adr/0060-product-neutral-renderer.md b/docs/adr/0060-product-neutral-renderer.md new file mode 100644 index 0000000000..fa1cd3299d --- /dev/null +++ b/docs/adr/0060-product-neutral-renderer.md @@ -0,0 +1,234 @@ +# ADR-0060: objectui is a product-neutral renderer + +**Status**: Accepted — maintainer ruling (2026-10-12); this record and its ratchet land on the maintainer's approval +**Consumers**: every product that renders through objectui (ObjectStack Cloud, HotCRM, any app built on the stack); `@object-ui/app-shell` and `@object-ui/types`, which carry today's product-specific surfaces; `@object-ui/i18n`, whose built-in catalogues carry their copy +**Enforced by**: `packages/app-shell/src/__tests__/product-neutral-renderer.ratchet.test.ts`, its live list `product-specific.allowlist.json` beside it, and the frozen-baseline block at the end of this record + +--- + +## The ruling + +Two answers, given in chat on 2026-10-12, quoted verbatim and untranslated: + +> 现在转向 + +> 规则应该写入 objectui 仓库,要不然每个项目都会有页面定制的需求 + +The first turned the cloud console's pages away from objectui widgets: those pages stay metadata, objectui +ships only product-agnostic blocks, and no new `cloud:*` widget is added. The second placed the rule here, +in the renderer's own repository rather than in any consumer's, because every product would otherwise bring +its own page customisations to it. + +## Decision + +1. **objectui is a product-neutral renderer.** A product — cloud, HotCRM, any app — composes its pages from + the generic blocks of `@objectstack/spec`'s page vocabulary and keeps its copy in its own bundles. +2. **A page that cannot be composed is fixed generically.** The fix is a **generic** block in objectui, named + for what it draws (`stat`, `usage-bar`, `checklist`), or a **contract door** in `@objectstack/spec` + (`needs:contract-review`). Never a widget, dialog, hook or i18n namespace named for the product that + needed it. +3. **What already exists stays until its page migrates, and may only shrink.** + +`AGENTS.md` › "Product-neutral renderer" carries the same three rules for every agent that edits this +repository. + +## Why + +The cloud control plane had accreted into this repository: registered widgets, the environment pages under +`app-shell/src/environment/` (the environment list toolbar, the paywall dialog, the entitlements hook), the +organization pages under `app-shell/src/console/organizations/`, and the copy all of them read from the +built-in locale catalogues. Every cloud wording change became an objectui pull request plus a pin bump; one +lane waited on cloud for a single plan-tier string. The next consumer would have done the same. + +## The ratchet + +The test named above runs in the root `unit` project, so every `pnpm test` runs it; because it reads the +tree off disk, the affected-test selector puts it in every pull request's fast set as well. It measures +three populations from the tree on every run: + +- **Registrations** — every component registry key that `deriveRegistryKeys` + (`scripts/check-doc-component-types.mjs`, the one registration reader `objectui check` and the doc-type + gate already share) produces, whose colon-separated segment leads with a product word. +- **Files** — every non-test file under `packages/`, `apps/` and `examples/` that sits in a frozen + directory, or whose path has a segment led by a product word. +- **Locale keys** — in every built-in locale, every leaf key under a frozen namespace, and every key, at any + depth, whose own name leads with a product word. + +Two lists bound them: + +- **The live list**, `product-specific.allowlist.json`, must equal the measurement exactly. An unlisted hit + is a new product-specific surface and turns the test red; a listed entry the tree no longer has is stale and + turns it red too, which is what makes the list shrink as pages migrate. It is edited only to delete. +- **The ceiling** is the frozen-baseline block below. Every live entry must be in it, and it also carries the + rules: the product words, the frozen directories and the frozen namespaces. This file is a governed + surface, so the one way a new product-specific entry can turn the test green is an amendment to this + record that the maintainer approves. Appending to the live list alone stays red. The block is the gate's + input, not a census: the census is what the test derives. + +What the ratchet cannot see, stated so that a green run is not read as more than it is: + +- product code under a neutral name outside the frozen directories — a `PaywallDialog` in `views/`. Names + are all it reads; the pull-request template's checkbox and review catch the rest; +- growth inside an existing file — the file half counts files, not lines; +- registration spellings the derivation reads under a premise that does not hold, which its own header lists + as KNOWN GAP readings. A spelling it does not recognise is a finding, and a finding turns the ratchet red + instead of shrinking the population; +- re-adding something the live list already deleted, because the ceiling keeps the entry until an amendment + drops it. + +## Readings taken where the card was literal + +### Product words + +`cloud` and `hotcrm`, the two products the ruling's own text names. A name is product-named when a product +word **leads** it at a word boundary: `cloud`, `cloud-connection`, `cloudOnboarding`, `CloudPlanStatus.tsx`, +`HotCRM`. A hook's leading `use` is skipped first, so `useCloudThing` counts, because the ruling names hooks. +Leading, not anywhere: `word-cloud` names a chart, and `cloudy` is not `cloud`. A generic block whose natural +name starts with a product word takes another name. The vocabulary grows by amending this record, and +shrinking it is a weakening that needs the same approval. + +### Registrations: both spellings, from the one reader + +A registration stores its key in two spellings: `register('cloud:x', C)`, and +`register('x', C, { namespace: 'cloud', skipFallback: true })`, which stores `cloud:x` and no bare `x`. Two +of today's four cloud widgets use the second spelling, so a scan for the literal `register('cloud:` would +have seen half of them. The ratchet does not scan at all; it reads the keys `deriveRegistryKeys` already +derives for `objectui check`, which resolves both spellings and treats an options argument it cannot read as +a finding. A fixture control in the test pins both spellings against a synthetic tree, so the namespace half +stays proven after the last product widget has migrated away. The same reader found a fifth product +registration the card's inventory did not list, `cloud-connection:panel`; it and the `app-shell:`-namespaced +twin each registration stores are all in the baseline. + +### Frozen directories, test files, and product-named files + +`app-shell/src/environment/` and `app-shell/src/console/organizations/` are frozen **recursively**: +`organizations/manage/` holds the organization-management pages and is inside. Test files are outside the +file half — anything under `__tests__`, `__mocks__`, `__fixtures__`, `__benchmarks__`, `test`, `tests` or +`e2e`, and any `*.test.*`, `*.spec.*` or `*.stories.*` file. A test adds no product surface, since no test +ships in a package's `dist`, and a regression test on frozen code has to stay writable until that code +migrates. + +The card named `console/home/Cloud*.tsx`. The ratchet generalises it to any non-test file whose path has a +segment led by a product word, because rule 2 is about names, not about one directory. At this record's head +that predicate also finds `console/cloud-connection/CloudConnectionPanel.tsx`, +`console/diagnostics/CloudAiModelStatus.tsx`, and `@object-ui/types`' `cloud.ts` and `zod/cloud.zod.ts`, +which declare the cloud widgets' node types. All of them are in the baseline. + +### Locale keys: what "a new top-level namespace" means here + +Read literally, the card's clause fails on **any** new top-level namespace, and its list may only lose +entries. Taken together, those two sentences would forbid every new namespace forever, including the copy +for the generic blocks rule 2 asks for. That contradicts the rule the clause enforces. The reading taken +instead follows rule 2's own words, "never an i18n namespace named for the product": + +- a namespace, or a key at any depth, whose name leads with a product word fails unless a frozen namespace + holds it; +- under a frozen namespace, no key may be added in any locale; +- a new namespace with a neutral name passes, and generic block copy goes there. + +The price is the first bound above: product copy filed under a neutral name, outside the frozen namespaces, +is review's to catch, not the test's. + +### Frozen namespaces + +- **Led by a product word**: `cloudConnection`, `cloudOnboarding`, `cloudPlanStatus`, + `cloudWorkspaceTimezoneNotice`. +- **Named by the card's inventory**: `organizations` and `workspace`, and `environment`. The card named + `environment.entitlement`; the whole `environment` namespace is frozen because every source consumer of + its keys sits in the frozen `environment/` directory, which makes the namespace that directory's copy. +- **Measured, beyond the card's inventory**: `planTier`, whose one consumer is the environment list toolbar + in the frozen directory, and `aiModelStatus`, whose one consumer is the `cloud:ai-model-status` widget. +- **Considered and not frozen**: `organization` (singular), whose keys are also read by the layout's + organization indicator and switcher and by `@object-ui/auth`; `chatbotQuota`, which + `@object-ui/plugin-chatbot` reads for a quota-exhausted state any backend can raise; and `marketplace` and + `connectAgent`, features of the open stack. Freezing any of them is an amendment to this record. + +The consumer readings above were taken once, at this record's head, and nothing re-derives them. They +explain the choice; the block below is the frozen set. + +## Escalation path + +A page that cannot be composed from the generic blocks is a card that names what it needs: either the generic +block, which is built here, named for what it draws, with its copy in a neutral namespace, or the contract +door in `@objectstack/spec`, which goes through `needs:contract-review` in objectstack. A card that asks +objectui for a product-named widget, dialog, hook or copy namespace is refiled in one of those two shapes. + +## Consequences + +- The live list shrinks as cloud's pages migrate. Each migrating pull request deletes its entries, and the + test's stale half makes it. +- A new product-specific surface needs an amendment to this record, which only lands with the maintainer's + approval. Appending to the live list does not turn the test green. +- Every pull request's description carries the template's checkbox: "Adds no product-specific widget, + dialog, hook or string". +- The residuals are the four bounds listed under "The ratchet" above. + +## Provenance + +objectstack-ai/objectui#12224, filed from the cloud maintainer's session on 2026-10-12, carries the ruling +and the card's inventory; the pull request that adds this record carries the mutation proofs. + +## Frozen baseline + +The ratchet test reads this block. Edit it only by amending this record; the maintainer approves the change. + +```json +{ + "productWords": ["cloud", "hotcrm"], + "frozenDirectories": [ + "packages/app-shell/src/console/organizations", + "packages/app-shell/src/environment" + ], + "registrations": [ + "app-shell:cloud-connection:panel", + "app-shell:cloud:ai-model-status", + "app-shell:cloud:onboarding-next", + "cloud-connection:panel", + "cloud:ai-model-status", + "cloud:onboarding-next", + "cloud:plan-status", + "cloud:workspace-timezone-notice" + ], + "files": [ + "packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx", + "packages/app-shell/src/console/diagnostics/CloudAiModelStatus.tsx", + "packages/app-shell/src/console/home/CloudOnboardingNext.tsx", + "packages/app-shell/src/console/home/CloudPlanStatus.tsx", + "packages/app-shell/src/console/home/CloudWorkspaceTimezoneNotice.tsx", + "packages/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx", + "packages/app-shell/src/console/organizations/OrganizationsLayout.tsx", + "packages/app-shell/src/console/organizations/OrganizationsPage.tsx", + "packages/app-shell/src/console/organizations/index.ts", + "packages/app-shell/src/console/organizations/manage/AcceptInvitationPage.tsx", + "packages/app-shell/src/console/organizations/manage/InvitationsPage.tsx", + "packages/app-shell/src/console/organizations/manage/InviteMemberDialog.tsx", + "packages/app-shell/src/console/organizations/manage/MembersPage.tsx", + "packages/app-shell/src/console/organizations/manage/OrganizationLayout.tsx", + "packages/app-shell/src/console/organizations/manage/SettingsPage.tsx", + "packages/app-shell/src/console/organizations/manage/orgCapabilities.ts", + "packages/app-shell/src/console/organizations/manage/orgContext.ts", + "packages/app-shell/src/console/organizations/manage/orgEnvironments.ts", + "packages/app-shell/src/console/organizations/orgErrorMessage.ts", + "packages/app-shell/src/console/organizations/orgRoleLabel.ts", + "packages/app-shell/src/console/organizations/provisionEnvironment.ts", + "packages/app-shell/src/console/organizations/resolveHomeUrl.ts", + "packages/app-shell/src/environment/EnvironmentEntitlementDialog.tsx", + "packages/app-shell/src/environment/EnvironmentListToolbar.tsx", + "packages/app-shell/src/environment/entitlements.ts", + "packages/app-shell/src/environment/useEnvironmentEntitlements.ts", + "packages/types/src/cloud.ts", + "packages/types/src/zod/cloud.zod.ts" + ], + "i18n": { + "aiModelStatus": ["adapter", "modelUnknown", "noOverrides", "overridesInEffect", "readFailed", "readFailedWithStatus", "rowConversational", "rowReasoning", "rowStructured", "sourceCodeDefault", "sourceInherits", "sourcePinned", "sourceUnknown", "summary", "summaryRouting"], + "cloudConnection": ["bound.approvedBy", "bound.disconnect", "bound.environment", "bound.organization", "bound.privatePackages", "bound.runtime", "bound.runtimeId", "bound.since", "bound.title", "checking", "errors.accessDenied", "errors.bindFailed", "errors.deviceCodeFailed", "errors.expired", "retry", "unbound.body", "unbound.connect", "unbound.title", "waiting.cancel", "waiting.codePrefilled", "waiting.copied", "waiting.copy", "waiting.openApproval", "waiting.openItHere", "waiting.polling", "waiting.popupOpened"], + "cloudOnboarding": ["createEnvironment", "hintCreate", "hintReady", "manageEnvironments", "openWorkspace"], + "cloudPlanStatus": ["current"], + "cloudWorkspaceTimezoneNotice": ["seeded"], + "environment": ["addDevelopment", "developmentLocked", "entitlement.contactSalesCta", "entitlement.freePlan", "entitlement.limitBody", "entitlement.limitBodyWithCount", "entitlement.limitTitle", "entitlement.namedPlan", "entitlement.planLockedBody", "entitlement.planLockedTitle", "entitlement.productionLimitBody", "entitlement.productionLimitTitle", "entitlement.upgradeCta", "setUpProduction"], + "organizations": ["create", "current", "emptyDescription", "emptyTitle", "heading", "manage", "mine", "new", "noMatches", "searchPlaceholder", "subtitle", "title"], + "planTier": ["teamAndUp"], + "workspace": ["createButton", "createDescription", "createFailed", "createTitle", "default", "invite", "label", "members", "multiOrgDisabled", "nameLabel", "namePlaceholder", "settings", "slugHint", "slugLabel", "switch"] + } +} +``` diff --git a/packages/app-shell/src/__tests__/product-neutral-renderer.ratchet.test.ts b/packages/app-shell/src/__tests__/product-neutral-renderer.ratchet.test.ts new file mode 100644 index 0000000000..52feb5acb7 --- /dev/null +++ b/packages/app-shell/src/__tests__/product-neutral-renderer.ratchet.test.ts @@ -0,0 +1,416 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The product-neutral renderer ratchet (ADR-0060; AGENTS.md › Product-neutral + * renderer). + * + * objectui renders for every product the same way. A product composes its + * pages from generic blocks and keeps its copy in its own bundles; a page that + * cannot be composed is fixed by a GENERIC block here or by a contract door in + * `@objectstack/spec` — never by a widget, dialog, hook or i18n namespace named + * for the product that needed it. What already exists stays until its page + * migrates, and may only shrink. This file holds that, mechanically, for the + * three things it can read: registry keys, file names, and locale keys. + * + * ## What is measured, on every run + * + * REGISTRATIONS every component registry key the repository's ONE + * registration derivation (`deriveRegistryKeys`, the reader + * behind `objectui check`'s known-type list and the doc-type + * gate) produces whose colon-separated segment leads with a + * product word. Both spellings of a registration land here, + * because both store the same key: `register('cloud:x', C)` + * and `register('x', C, { namespace: 'cloud' })`. A literal + * `register('cloud:` grep sees only the first; the fixture + * control below pins that this reader sees both. + * FILES every non-test file under `packages/`, `apps/` and + * `examples/` that sits in a frozen directory, or whose path + * has a segment led by a product word (`CloudPlanStatus.tsx`, + * `cloud-connection/`, `useCloudThing.ts`). + * LOCALE KEYS in every built-in locale, every leaf key under a frozen + * namespace; and, anywhere in the catalogue, every key whose + * own name leads with a product word. + * + * ## Two lists, because the live one must shrink and must never grow + * + * `product-specific.allowlist.json` (beside this file) is the LIVE list. It + * must equal the measurement exactly: an unlisted hit is a new + * product-specific surface, and a listed entry the tree no longer has is + * stale — delete it. Deleting is the only edit it takes, and the stale half + * is what makes it shrink as product pages migrate out. + * + * ADR-0060's "Frozen baseline" block is the CEILING and the RULES — the + * product words, the frozen directories and the frozen namespaces. Every + * live entry must be in the ceiling. The ADR is a governed surface, so the + * one way a new product-specific entry turns this file green is an ADR + * amendment the maintainer approves; appending to the live list alone stays + * red. The ceiling is this gate's INPUT, not a census of the tree: the + * census is what the code below derives. + * + * ## What it cannot see — said here so nobody reads green as more than it is + * + * - A product surface under a neutral name outside the frozen directories (a + * `PaywallDialog` in `views/`). Names are all this reads; the pull-request + * template's checkbox and review are what catch the rest. + * - Growth INSIDE an existing file: the file half counts files, not lines. + * - Registration spellings `deriveRegistryKeys` reads under a premise that + * does not hold — the KNOWN GAP readings its own header enumerates. A + * spelling it does not RECOGNISE is a finding, and a finding reds this + * file rather than shrinking the population. + * - Re-adding something the live list already deleted: the ceiling keeps the + * entry until an ADR amendment drops it. + */ + +import { describe, expect, it } from 'vitest'; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { builtInLocales } from '@object-ui/i18n/locales'; +// @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) +import { deriveRegistryKeys } from '../../../../scripts/check-doc-component-types.mjs'; + +/** Local annotation, since the import above is untyped — the call sites stay checked. */ +type Finding = { reason: string; site: string; detail: string }; +type Derivation = { keys: Map; findings: Finding[] }; +const derive: ( + root: string, + options?: { indirectRegistrations?: unknown[]; openRegistrationSites?: unknown[] }, +) => Derivation = deriveRegistryKeys; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, '../../../..'); + +const ADR = 'docs/adr/0060-product-neutral-renderer.md'; +const ADR_HEADING = '## Frozen baseline'; +const ALLOWLIST = 'packages/app-shell/src/__tests__/product-specific.allowlist.json'; + +const REMEDY = + 'objectui is a product-neutral renderer (AGENTS.md › Product-neutral renderer, ADR-0060): ' + + 'compose the page from generic blocks, add a GENERIC block named for what it draws, or open a ' + + 'contract door in @objectstack/spec. ⛔ Do not add it to product-specific.allowlist.json — that ' + + 'list only shrinks, and ADR-0060’s ceiling refuses an entry it does not carry.'; + +// ── The two lists ──────────────────────────────────────────────────────────── + +interface Lists { + registrations: string[]; + files: string[]; + i18n: Record; +} +interface Ceiling extends Lists { + productWords: string[]; + frozenDirectories: string[]; +} + +const isStringArray = (v: unknown): v is string[] => + Array.isArray(v) && v.every((x) => typeof x === 'string'); + +/** The keys each list may carry; a misspelt key would otherwise read as an empty list. */ +const LIST_KEYS = ['$comment', 'registrations', 'files', 'i18n']; +const CEILING_KEYS = [...LIST_KEYS, 'productWords', 'frozenDirectories']; + +function assertLists(value: unknown, origin: string, allowed: readonly string[]): asserts value is Lists { + const unknownKeys = Object.keys(value as object).filter((k) => !allowed.includes(k)); + if (unknownKeys.length > 0) throw new Error(`${origin}: unknown key(s) ${unknownKeys.join(', ')}`); + const v = value as Partial; + if (!isStringArray(v.registrations) || !isStringArray(v.files)) { + throw new Error(`${origin}: \`registrations\` and \`files\` must be string arrays`); + } + if (!v.i18n || typeof v.i18n !== 'object' || !Object.values(v.i18n).every(isStringArray)) { + throw new Error(`${origin}: \`i18n\` must map each namespace to a string array of its keys`); + } +} + +/** + * The ceiling: the one fenced `json` block under ADR-0060's frozen-baseline + * heading. Read off the ADR rather than copied, so the governed text and the + * gate's input are the same bytes. + */ +function readCeiling(): Ceiling { + const lines = readFileSync(path.join(repoRoot, ADR), 'utf8').split('\n'); + const heading = lines.findIndex((l) => l.trim() === ADR_HEADING); + if (heading === -1) throw new Error(`${ADR}: no \`${ADR_HEADING}\` heading`); + const open = lines.findIndex((l, i) => i > heading && l.trim() === '```json'); + const close = lines.findIndex((l, i) => i > open && l.trim() === '```'); + if (open === -1 || close === -1) throw new Error(`${ADR}: no fenced json block under \`${ADR_HEADING}\``); + const value = JSON.parse(lines.slice(open + 1, close).join('\n')) as Ceiling; + assertLists(value, ADR, CEILING_KEYS); + if (!isStringArray(value.productWords) || !isStringArray(value.frozenDirectories)) { + throw new Error(`${ADR}: \`productWords\` and \`frozenDirectories\` must be string arrays`); + } + return value; +} + +function readAllowlist(): Lists { + const value = JSON.parse(readFileSync(path.join(repoRoot, ALLOWLIST), 'utf8')) as unknown; + assertLists(value, ALLOWLIST, LIST_KEYS); + return value; +} + +const CEILING = readCeiling(); +const LIVE = readAllowlist(); +const WORDS = CEILING.productWords.map((w) => w.toLowerCase()); + +// ── The one predicate: does a name lead with a product word? ──────────────── + +/** + * A name is product-named when a product word LEADS it, at a word boundary: + * `cloud`, `cloud-connection`, `cloudOnboarding`, `CloudPlanStatus.tsx`, + * `HotCRM`. A hook's `use` is skipped first, so `useCloudThing` leads with + * `cloud` too. Leading, not anywhere: `word-cloud` names a chart, and + * `cloudy` is not `cloud`. + */ +function leadsWithProductWord(name: string, words: readonly string[] = WORDS): boolean { + const subject = /^use[A-Z]/.test(name) ? name.slice(3) : name; + const lower = subject.toLowerCase(); + return words.some((word) => { + if (!lower.startsWith(word)) return false; + const next = subject.charAt(word.length); + return next === '' || !/[a-z0-9]/.test(next); + }); +} + +// ── The three measurements ─────────────────────────────────────────────────── + +function productRegistrations(root: string): { keys: Map; findings: Finding[] } { + const { keys, findings } = derive(root); + const product = new Map(); + for (const [key, sites] of keys) { + if (key.split(':').some((segment) => leadsWithProductWord(segment))) product.set(key, sites); + } + return { keys: product, findings }; +} + +const SOURCE_ROOTS = ['packages', 'apps', 'examples']; +const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage', 'out', 'storybook-static']); +const TEST_DIRS = new Set(['__tests__', '__mocks__', '__fixtures__', '__benchmarks__', 'test', 'tests', 'e2e']); +const TEST_FILE = /\.(?:test|spec|stories)\.[cm]?[jt]sx?$/; + +/** + * A test adds no product surface — it is excluded from every published `dist` + * — and a regression test on frozen code must stay writable until that code + * migrates, so test paths are outside the file half (ADR-0060, "Frozen + * directories"). + */ +const isTestPath = (rel: string): boolean => + rel.split('/').some((segment) => TEST_DIRS.has(segment)) || TEST_FILE.test(rel); + +function walk(dirRel: string, out: string[]): void { + let entries; + try { + entries = readdirSync(path.join(repoRoot, dirRel), { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue; + const rel = `${dirRel}/${entry.name}`; + if (entry.isDirectory()) walk(rel, out); + else out.push(rel); + } +} + +const ALL_FILES = (() => { + const out: string[] = []; + for (const root of SOURCE_ROOTS) walk(root, out); + return out.sort(); +})(); + +function productFiles(): string[] { + return ALL_FILES.filter((rel) => { + if (isTestPath(rel)) return false; + if (CEILING.frozenDirectories.some((dir) => rel.startsWith(`${dir}/`))) return true; + return rel.split('/').some((segment) => leadsWithProductWord(segment)); + }); +} + +type Catalogue = Record; +const isNode = (v: unknown): v is Record => typeof v === 'object' && v !== null; + +function leafKeys(node: unknown, prefix = '', out: string[] = []): string[] { + if (!isNode(node)) return out; + for (const [key, value] of Object.entries(node)) { + const at = prefix ? `${prefix}.${key}` : key; + if (isNode(value)) leafKeys(value, at, out); + else out.push(at); + } + return out; +} + +const nodeAt = (catalogue: Catalogue, dotted: string): unknown => + dotted.split('.').reduce((node, key) => (isNode(node) ? node[key] : undefined), catalogue); + +/** Every key path, at any depth, whose own name leads with a product word and no frozen namespace holds. */ +function productNamedKeysOutsideFrozen(catalogue: Catalogue): string[] { + const frozen = Object.keys(CEILING.i18n); + const out: string[] = []; + const visit = (node: unknown, prefix: string) => { + if (!isNode(node)) return; + for (const [key, value] of Object.entries(node)) { + const at = prefix ? `${prefix}.${key}` : key; + const held = frozen.some((ns) => at === ns || at.startsWith(`${ns}.`)); + if (held) continue; + if (leadsWithProductWord(key)) out.push(at); + visit(value, at); + } + }; + visit(catalogue, ''); + return out; +} + +const LOCALES = Object.entries(builtInLocales as unknown as Record); + +// ── Helpers for the verdict text ───────────────────────────────────────────── + +const minus = (a: readonly string[], b: readonly string[]) => { + const drop = new Set(b); + return [...new Set(a)].filter((x) => !drop.has(x)).sort(); +}; + +// ── The pins ───────────────────────────────────────────────────────────────── + +describe('product-neutral renderer ratchet (ADR-0060)', () => { + describe('the lists themselves', () => { + it('the ceiling declares product words and frozen directories — an empty rule set would pass everything', () => { + expect(WORDS.length).toBeGreaterThan(0); + expect(CEILING.frozenDirectories.length).toBeGreaterThan(0); + expect(Object.keys(CEILING.i18n).length).toBeGreaterThan(0); + }); + + it('every live entry is inside ADR-0060’s ceiling — the live list takes deletions only', () => { + const grown: string[] = [ + ...minus(LIVE.registrations, CEILING.registrations).map((k) => `registration ${k}`), + ...minus(LIVE.files, CEILING.files).map((f) => `file ${f}`), + ]; + for (const [ns, keys] of Object.entries(LIVE.i18n)) { + if (!(ns in CEILING.i18n)) { + grown.push(`i18n namespace ${ns}`); + continue; + } + grown.push(...minus(keys, CEILING.i18n[ns]).map((k) => `i18n ${ns}.${k}`)); + } + expect( + grown, + `${ALLOWLIST} carries entries ADR-0060's frozen baseline does not. The live list is edited only to ` + + `DELETE; a new product-specific entry is an amendment to ADR-0060 that the maintainer approves. ${REMEDY}`, + ).toEqual([]); + }); + }); + + describe('registrations', () => { + const measured = productRegistrations(repoRoot); + + it('the registration derivation recognised every call it read — an unread spelling would shrink the population silently', () => { + expect(measured.findings.map((f) => `${f.reason} at ${f.site}: ${f.detail}`)).toEqual([]); + }); + + it('no registry key leads with a product word unless the live list carries it', () => { + const added = minus([...measured.keys.keys()], LIVE.registrations).map( + (key) => `${key} (registered at ${measured.keys.get(key)!.join(', ')})`, + ); + expect(added, `new product-named registry key(s). ${REMEDY}`).toEqual([]); + }); + + it('every listed registration still exists — a migrated one is deleted from the live list', () => { + expect( + minus(LIVE.registrations, [...measured.keys.keys()]), + `${ALLOWLIST} lists registrations the tree no longer has; delete them (the list shrinks as pages migrate).`, + ).toEqual([]); + }); + + it('CONTROL — the reader sees BOTH spellings of a product registration', () => { + // A synthetic tree, so this keeps proving the namespace half after every + // product widget in the real tree has migrated away. + const root = mkdtempSync(path.join(tmpdir(), 'product-neutral-')); + try { + mkdirSync(path.join(root, 'packages/probe/src'), { recursive: true }); + writeFileSync( + path.join(root, 'packages/probe/src/probe.tsx'), + [ + "ComponentRegistry.register('cloud:literal-probe', () => null);", + "ComponentRegistry.register('namespaced-probe', () => null, { namespace: 'cloud', skipFallback: true });", + "ComponentRegistry.register('word-cloud', () => null);", + '', + ].join('\n'), + ); + const { keys, findings } = derive(root, { indirectRegistrations: [], openRegistrationSites: [] }); + expect(findings).toEqual([]); + expect([...keys.keys()].sort()).toEqual(['cloud:literal-probe', 'cloud:namespaced-probe', 'word-cloud']); + expect([...keys.keys()].filter((k) => k.split(':').some((s) => leadsWithProductWord(s))).sort()).toEqual([ + 'cloud:literal-probe', + 'cloud:namespaced-probe', + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + }); + + describe('files', () => { + const measured = productFiles(); + + it('the walk reached the source roots — a walk of nothing would pass everything', () => { + expect(ALL_FILES.length).toBeGreaterThan(1000); + expect(ALL_FILES).toContain('packages/app-shell/src/__tests__/product-neutral-renderer.ratchet.test.ts'); + }); + + it('no file is added under a frozen directory, or named for a product, unless the live list carries it', () => { + expect(minus(measured, LIVE.files), `new product-specific file(s). ${REMEDY}`).toEqual([]); + }); + + it('every listed file still exists — a migrated one is deleted from the live list', () => { + expect( + minus(LIVE.files, measured), + `${ALLOWLIST} lists files the tree no longer has (or no longer counts); delete them.`, + ).toEqual([]); + }); + }); + + describe('locale catalogues', () => { + it('every built-in locale is read', () => { + expect(LOCALES.map(([code]) => code)).toContain('en'); + expect(LOCALES.length).toBeGreaterThanOrEqual(10); + }); + + it.each(LOCALES)('%s: no key is added under a frozen namespace unless the live list carries it', (code, catalogue) => { + const added: string[] = []; + for (const ns of Object.keys(CEILING.i18n)) { + const allowed = LIVE.i18n[ns] ?? []; + added.push(...minus(leafKeys(nodeAt(catalogue, ns)), allowed).map((k) => `${ns}.${k}`)); + } + expect(added, `new key(s) under a frozen product namespace in the ${code} catalogue. ${REMEDY}`).toEqual([]); + }); + + it.each(LOCALES)('%s: no key outside the frozen namespaces leads with a product word', (code, catalogue) => { + expect( + productNamedKeysOutsideFrozen(catalogue), + `product-named i18n key(s) in the ${code} catalogue. ${REMEDY}`, + ).toEqual([]); + }); + + it('every listed key still exists in some locale — a migrated one is deleted from the live list', () => { + const stale: string[] = []; + for (const [ns, keys] of Object.entries(LIVE.i18n)) { + const present = new Set(LOCALES.flatMap(([, catalogue]) => leafKeys(nodeAt(catalogue, ns)))); + stale.push(...keys.filter((k) => !present.has(k)).map((k) => `${ns}.${k}`)); + } + expect(stale, `${ALLOWLIST} lists i18n keys no locale has any more; delete them.`).toEqual([]); + }); + }); + + it('CONTROL — the product-word predicate', () => { + for (const name of ['cloud', 'cloud-connection', 'cloudOnboarding', 'CloudPlanStatus.tsx', 'cloud.ts', 'useCloudThing', 'hotcrm-deals', 'HotCRM']) { + expect(leadsWithProductWord(name, ['cloud', 'hotcrm']), name).toBe(true); + } + for (const name of ['word-cloud', 'cloudy', 'clouds', 'onboarding', 'user', 'useCloudy', 'hotcrmx']) { + expect(leadsWithProductWord(name, ['cloud', 'hotcrm']), name).toBe(false); + } + }); +}); diff --git a/packages/app-shell/src/__tests__/product-specific.allowlist.json b/packages/app-shell/src/__tests__/product-specific.allowlist.json new file mode 100644 index 0000000000..8c10754e4d --- /dev/null +++ b/packages/app-shell/src/__tests__/product-specific.allowlist.json @@ -0,0 +1,153 @@ +{ + "$comment": "The LIVE list for product-neutral-renderer.ratchet.test.ts (ADR-0060). It must equal what the test measures, and it is edited ONLY TO DELETE: remove an entry when its product surface migrates out. A new product-specific entry is not added here; it is an amendment to ADR-0060 that the maintainer approves.", + "registrations": [ + "app-shell:cloud-connection:panel", + "app-shell:cloud:ai-model-status", + "app-shell:cloud:onboarding-next", + "cloud-connection:panel", + "cloud:ai-model-status", + "cloud:onboarding-next", + "cloud:plan-status", + "cloud:workspace-timezone-notice" + ], + "files": [ + "packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx", + "packages/app-shell/src/console/diagnostics/CloudAiModelStatus.tsx", + "packages/app-shell/src/console/home/CloudOnboardingNext.tsx", + "packages/app-shell/src/console/home/CloudPlanStatus.tsx", + "packages/app-shell/src/console/home/CloudWorkspaceTimezoneNotice.tsx", + "packages/app-shell/src/console/organizations/CreateWorkspaceDialog.tsx", + "packages/app-shell/src/console/organizations/OrganizationsLayout.tsx", + "packages/app-shell/src/console/organizations/OrganizationsPage.tsx", + "packages/app-shell/src/console/organizations/index.ts", + "packages/app-shell/src/console/organizations/manage/AcceptInvitationPage.tsx", + "packages/app-shell/src/console/organizations/manage/InvitationsPage.tsx", + "packages/app-shell/src/console/organizations/manage/InviteMemberDialog.tsx", + "packages/app-shell/src/console/organizations/manage/MembersPage.tsx", + "packages/app-shell/src/console/organizations/manage/OrganizationLayout.tsx", + "packages/app-shell/src/console/organizations/manage/SettingsPage.tsx", + "packages/app-shell/src/console/organizations/manage/orgCapabilities.ts", + "packages/app-shell/src/console/organizations/manage/orgContext.ts", + "packages/app-shell/src/console/organizations/manage/orgEnvironments.ts", + "packages/app-shell/src/console/organizations/orgErrorMessage.ts", + "packages/app-shell/src/console/organizations/orgRoleLabel.ts", + "packages/app-shell/src/console/organizations/provisionEnvironment.ts", + "packages/app-shell/src/console/organizations/resolveHomeUrl.ts", + "packages/app-shell/src/environment/EnvironmentEntitlementDialog.tsx", + "packages/app-shell/src/environment/EnvironmentListToolbar.tsx", + "packages/app-shell/src/environment/entitlements.ts", + "packages/app-shell/src/environment/useEnvironmentEntitlements.ts", + "packages/types/src/cloud.ts", + "packages/types/src/zod/cloud.zod.ts" + ], + "i18n": { + "aiModelStatus": [ + "adapter", + "modelUnknown", + "noOverrides", + "overridesInEffect", + "readFailed", + "readFailedWithStatus", + "rowConversational", + "rowReasoning", + "rowStructured", + "sourceCodeDefault", + "sourceInherits", + "sourcePinned", + "sourceUnknown", + "summary", + "summaryRouting" + ], + "cloudConnection": [ + "bound.approvedBy", + "bound.disconnect", + "bound.environment", + "bound.organization", + "bound.privatePackages", + "bound.runtime", + "bound.runtimeId", + "bound.since", + "bound.title", + "checking", + "errors.accessDenied", + "errors.bindFailed", + "errors.deviceCodeFailed", + "errors.expired", + "retry", + "unbound.body", + "unbound.connect", + "unbound.title", + "waiting.cancel", + "waiting.codePrefilled", + "waiting.copied", + "waiting.copy", + "waiting.openApproval", + "waiting.openItHere", + "waiting.polling", + "waiting.popupOpened" + ], + "cloudOnboarding": [ + "createEnvironment", + "hintCreate", + "hintReady", + "manageEnvironments", + "openWorkspace" + ], + "cloudPlanStatus": [ + "current" + ], + "cloudWorkspaceTimezoneNotice": [ + "seeded" + ], + "environment": [ + "addDevelopment", + "developmentLocked", + "entitlement.contactSalesCta", + "entitlement.freePlan", + "entitlement.limitBody", + "entitlement.limitBodyWithCount", + "entitlement.limitTitle", + "entitlement.namedPlan", + "entitlement.planLockedBody", + "entitlement.planLockedTitle", + "entitlement.productionLimitBody", + "entitlement.productionLimitTitle", + "entitlement.upgradeCta", + "setUpProduction" + ], + "organizations": [ + "create", + "current", + "emptyDescription", + "emptyTitle", + "heading", + "manage", + "mine", + "new", + "noMatches", + "searchPlaceholder", + "subtitle", + "title" + ], + "planTier": [ + "teamAndUp" + ], + "workspace": [ + "createButton", + "createDescription", + "createFailed", + "createTitle", + "default", + "invite", + "label", + "members", + "multiOrgDisabled", + "nameLabel", + "namePlaceholder", + "settings", + "slugHint", + "slugLabel", + "switch" + ] + } +} diff --git a/scripts/markdown-test-inputs.mjs b/scripts/markdown-test-inputs.mjs index 4277efc151..5d972fb678 100644 --- a/scripts/markdown-test-inputs.mjs +++ b/scripts/markdown-test-inputs.mjs @@ -226,6 +226,18 @@ export const ADJUDICATED = new Map([ 'examples/schema-catalog/test/slotted-pages-header-example-11165.test.ts', { reads: ['content/docs/guide/slotted-pages.md'] }, ], + // ADR-0060. The product-neutral renderer ratchet reads its CEILING -- the + // product words, the frozen directories and namespaces, and the most its live + // list may ever hold -- out of that ADR's frozen-baseline block, so an edit to + // the ADR is an edit to this test's input. Its walk collects file NAMES under + // `packages`, `apps` and `examples`, never markdown content. + [ + 'packages/app-shell/src/__tests__/product-neutral-renderer.ratchet.test.ts', + { + reads: ['docs/adr/0060-product-neutral-renderer.md'], + walker: 'not-markdown: walks `packages`, `apps` and `examples` for file paths, reading no file it finds', + }, + ], [ 'packages/app-shell/src/views/metadata-admin/previews/readme-flow-canvas-draft.test.ts', { reads: ['packages/app-shell/README.md'] }, From 27c30c10ae8992bc43855d2cfdf4475495c335f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:02:27 +0000 Subject: [PATCH 2/3] test(markdown-test-inputs): the installed-pin-claims gate reads the new pull-request template check-installed-spec-pin-claims scans every tracked file, so the new .github/pull_request_template.md is its input; the ledger is declared total over tracked markdown and goes red until that read is recorded. Claude-Session: https://claude.ai/code/session_012dGTuJen61ECNHXYXKChoC Co-authored-by: Claude --- scripts/markdown-test-inputs.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/markdown-test-inputs.mjs b/scripts/markdown-test-inputs.mjs index 5d972fb678..e53e8faed4 100644 --- a/scripts/markdown-test-inputs.mjs +++ b/scripts/markdown-test-inputs.mjs @@ -897,7 +897,7 @@ export const ADJUDICATED = new Map([ [ 'scripts/__tests__/check-installed-spec-pin-claims.test.ts', { - reads: ['.claude/skills/**', '.github/prompts/component.prompt.md', '.github/prompts/engine.prompt.md', '.github/prompts/ui-library.prompt.md', 'AGENTS.md', 'CLAUDE.md', 'CONTRIBUTING.md', 'LICENSE-THIRD-PARTY.md', 'QUICK_REFERENCE.md', 'README.md', 'ROADMAP.md', 'apps/**', 'content/docs/**', 'docs/ARCHITECTURE.md', 'docs/CONSOLE-STREAMLINING-SUMMARY.md', 'docs/adr/**', 'docs/audits/**', 'examples/**', 'packages/**', 'patches/README.md', 'skills/objectui/**'], + reads: ['.claude/skills/**', '.github/prompts/component.prompt.md', '.github/prompts/engine.prompt.md', '.github/prompts/ui-library.prompt.md', '.github/pull_request_template.md', 'AGENTS.md', 'CLAUDE.md', 'CONTRIBUTING.md', 'LICENSE-THIRD-PARTY.md', 'QUICK_REFERENCE.md', 'README.md', 'ROADMAP.md', 'apps/**', 'content/docs/**', 'docs/ARCHITECTURE.md', 'docs/CONSOLE-STREAMLINING-SUMMARY.md', 'docs/adr/**', 'docs/audits/**', 'examples/**', 'packages/**', 'patches/README.md', 'skills/objectui/**'], notRead: ['CHANGELOG.md'], }, ], From 5fed8ccc464cb4f3f8b408eb7efa2dd8efe3e1be Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 23:14:43 +0000 Subject: [PATCH 3/3] docs(adr-0060): the template's checkbox prompts web-form authors; it is not a gate GitHub prefills .github/pull_request_template.md only for pull requests opened through the web form, so a PR opened through the API carries no checkbox and nothing checks for one. The Consequences bullet claimed every pull request carries it; it now says which ones do and that it is a prompt. The bounds list's "the checkbox and review catch the rest" is narrowed the same way: the rest is left to review. The frozen-baseline block is unchanged. Claude-Session: https://claude.ai/code/session_012dGTuJen61ECNHXYXKChoC Co-authored-by: Claude --- docs/adr/0060-product-neutral-renderer.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/0060-product-neutral-renderer.md b/docs/adr/0060-product-neutral-renderer.md index fa1cd3299d..eccb7f1399 100644 --- a/docs/adr/0060-product-neutral-renderer.md +++ b/docs/adr/0060-product-neutral-renderer.md @@ -68,7 +68,7 @@ Two lists bound them: What the ratchet cannot see, stated so that a green run is not read as more than it is: - product code under a neutral name outside the frozen directories — a `PaywallDialog` in `views/`. Names - are all it reads; the pull-request template's checkbox and review catch the rest; + are all it reads; the rest is left to review, which the template's checkbox prompts only on web-form PRs; - growth inside an existing file — the file half counts files, not lines; - registration spellings the derivation reads under a premise that does not hold, which its own header lists as KNOWN GAP readings. A spelling it does not recognise is a finding, and a finding turns the ratchet red @@ -159,8 +159,8 @@ objectui for a product-named widget, dialog, hook or copy namespace is refiled i test's stale half makes it. - A new product-specific surface needs an amendment to this record, which only lands with the maintainer's approval. Appending to the live list does not turn the test green. -- Every pull request's description carries the template's checkbox: "Adds no product-specific widget, - dialog, hook or string". +- A pull request opened through the web form starts with the template's checkbox; one opened through the API, + as the fleet's are, does not. The checkbox prompts human authors; it is not a gate. - The residuals are the four bounds listed under "The ratchet" above. ## Provenance