From ba5c07304ee67dd2904264dbcc4743b6256aa603 Mon Sep 17 00:00:00 2001 From: os-warren Date: Tue, 25 Aug 2026 20:06:58 +0000 Subject: [PATCH 1/3] feat(devx): weigh the docs-site eager closure structurally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:eager-closure` reads `apps/console/dist/eager-closure.json`, so the budget objectui#4616 set over `/docs/[[...slug]]` — the route every docs page shares, and the one `registerCatalogBlocks.ts` adds side-effect imports to — was governed by nothing. `scripts/check-docs-route-eager-closure.mjs` walks that route's static module graph from source (no install, no build, ~1.3 s) and classifies every package the registrar names: RECORDED payload, FREE (already reachable, so the import adds a declaration and no bytes), or NEW GRAPH, which fails. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012CZgmFFzqA9cX8tBMhvpFe --- .../6316-docs-route-eager-closure-gate.md | 15 + .../workflows/docs-route-eager-closure.yml | 88 ++ .../app/components/registerCatalogBlocks.ts | 20 + package.json | 1 + .../check-docs-route-eager-closure.test.ts | Bin 0 -> 15512 bytes scripts/check-docs-route-eager-closure.mjs | 819 ++++++++++++++++++ scripts/dependabot-merge-gate.mjs | 2 + 7 files changed, 945 insertions(+) create mode 100644 .changeset/6316-docs-route-eager-closure-gate.md create mode 100644 .github/workflows/docs-route-eager-closure.yml create mode 100644 scripts/__tests__/check-docs-route-eager-closure.test.ts create mode 100644 scripts/check-docs-route-eager-closure.mjs diff --git a/.changeset/6316-docs-route-eager-closure-gate.md b/.changeset/6316-docs-route-eager-closure-gate.md new file mode 100644 index 0000000000..d39e34ce12 --- /dev/null +++ b/.changeset/6316-docs-route-eager-closure-gate.md @@ -0,0 +1,15 @@ +--- +--- + +Tooling-only change; no published behaviour changes. The docs route's eager closure now has +an instrument. `check:eager-closure` reads `apps/console/dist/eager-closure.json` and weighs +the console, so the budget objectui#4616 set over `/docs/[[...slug]]` — the route every one +of the docs pages shares, and the one `apps/site/app/components/registerCatalogBlocks.ts` +adds side-effect imports to — was governed by nothing, and its only measurement was +reconstructed by hand, once. `pnpm check:docs-route-closure` weighs it structurally instead +of in bytes (objectui#6316, triage ruling shape 2, so no docs-site build in CI): every +package the registrar names must be already reachable from that route's static module graph +— a declaration and no payload, which the gate proves for `@object-ui/plugin-form` and +`@object-ui/plugin-grid` through `@object-ui/plugin-view` — or recorded in the script's +`MEASURED_PAYLOAD` with what it is for. Anything else is a genuinely new graph, and it fails +so that a human argues for it in review. diff --git a/.github/workflows/docs-route-eager-closure.yml b/.github/workflows/docs-route-eager-closure.yml new file mode 100644 index 0000000000..6eb46712e9 --- /dev/null +++ b/.github/workflows/docs-route-eager-closure.yml @@ -0,0 +1,88 @@ +name: Docs Route Eager Closure + +# The gate `check:eager-closure` is not (objectui#6316). +# +# `scripts/check-eager-closure-budget.mjs` reads +# `apps/console/dist/eager-closure.json` and `performance-budget.yml` builds +# `@object-ui/console`, so that budget governs the console bundle. The Next docs +# site is weighed by nothing — and `apps/site/app/components/registerCatalogBlocks.ts` +# adds side-effect imports to `/docs/[[...slug]]`, a route shared by every docs +# page. The only measurement of it that has ever existed was reconstructed by +# hand, once, from the prerendered route on disk. +# +# `scripts/check-docs-route-eager-closure.mjs` is the cheap instrument that +# ruling chose over a second byte budget: it asserts structurally that every +# package the registrar names is already reachable from that route's module +# graph, so a genuinely NEW graph becomes something a human argues for in review +# instead of something that lands unmeasured. No docs build, no ceiling, no +# bytes. +# +# ## Why this is its own workflow, and why it is unfiltered +# +# The gate's inputs are the route's whole module graph — `apps/site/**`, +# `content/docs/**` (the compiled MDX modules are most of that graph) and +# `packages/**` (a refactor that drops `import { ObjectGrid }` from +# `packages/plugin-view/src/ObjectView.tsx` is exactly the change that turns a +# FREE declaration into a new graph) — plus the gate's own closure in +# `scripts/`. A `paths:` filter listing all of that is indistinguishable from no +# filter, and one that misses a directory cannot be exercised by the pull +# request that changes it, which is the defect objectui#6321 records. +# +# Hence: no `paths` and no `paths-ignore` here, deliberately. +# `scripts/__tests__/check-docs-route-eager-closure.test.ts` fails if either is +# added, and `scripts/dependabot-merge-gate.mjs` classifies `Docs Route Eager +# Closure Check` as a required context — an unclassified blocking check is one a +# Dependabot merge would be let past (objectui#6135). +# +# It needs no install and no build — a checkout plus one `node` call over the +# source tree, ~1.3 s measured on `b116a0684` — so keep it that way if you add +# checks to it. The moment it needs `pnpm install` it stops being affordable +# unfiltered, and the argument above stops holding. + +on: + pull_request: + branches: [main, develop] + push: + branches: [main, develop] + # Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note + # and the measurements behind it). A required check that does not report on a + # queue build stalls the queue until the ruleset's 60-minute timeout fails it, + # so an unfiltered gate that can become required subscribes here from the + # start. `types:` is named although `checks_requested` is currently the only + # activity type GitHub defines for `merge_group`. + merge_group: + types: [checks_requested] + workflow_dispatch: + +concurrency: + group: docs-route-eager-closure-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + docs-route-eager-closure: + name: Docs Route Eager Closure Check + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Enable Corepack + run: corepack enable + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22.x' + + # Reads source only — no `node_modules`, no `dist`, no `.source`. The three + # halves (declarations, ledger, gauge) are all printed before any of them + # decides the exit code: exit 1 is a verdict about the registrar, exit 2 + # says the gauge itself is not trustworthy and is never reported as the + # former. + - name: Weigh the docs route eager closure + run: pnpm check:docs-route-closure diff --git a/apps/site/app/components/registerCatalogBlocks.ts b/apps/site/app/components/registerCatalogBlocks.ts index 6aa4a78d97..595a019c5e 100644 --- a/apps/site/app/components/registerCatalogBlocks.ts +++ b/apps/site/app/components/registerCatalogBlocks.ts @@ -147,6 +147,26 @@ * category) and `catalog-gallery-render.test.tsx` (objectui#4616, every * category), both of which mirror this list and fail if it stops loading a * package. + * + * ## What weighs an ADDITION to this list (objectui#6316) + * + * Not `check:eager-closure`, whatever the cards that added to this list said: + * that gate reads `apps/console/dist/eager-closure.json` and weighs the CONSOLE. + * The figures above were taken by hand, once, and nothing re-takes them. + * + * `pnpm check:docs-route-closure` + * (`scripts/check-docs-route-eager-closure.mjs`) is what governs a new line + * here. It walks the `/docs/[[...slug]]` route's STATIC module graph — the + * entries, plus every compiled `content/docs/**\/*.mdx` module — and requires + * each package named below to be either already reachable without this file + * naming it (a declaration and no payload: measurably the case for the last two + * imports, through `@object-ui/plugin-view`) or recorded in that script's + * `MEASURED_PAYLOAD` with what it is for. A package that is neither is a + * genuinely new graph on a route all 181 docs pages share, and it fails there + * so that someone argues for it in review. + * + * It is structural, not byte-level, and deliberately so: it costs no docs + * build, and it does not re-take the measurement above or claim to. */ import '@object-ui/plugin-dashboard'; import '@object-ui/plugin-charts'; diff --git a/package.json b/package.json index 3ba40db411..988c493105 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "check:doc-snippets": "node scripts/check-doc-snippet-types.mjs", "check:doc-fences": "node scripts/check-doc-fence-languages.mjs", "check:eager-closure": "node scripts/check-eager-closure-budget.mjs", + "check:docs-route-closure": "node scripts/check-docs-route-eager-closure.mjs", "check:entry-guard": "node scripts/check-entry-guard.mjs", "check:pre-install-import-graph": "node scripts/check-pre-install-import-graph.mjs", "check:vi-mock-specifiers": "node scripts/check-vi-mock-specifiers.mjs", diff --git a/scripts/__tests__/check-docs-route-eager-closure.test.ts b/scripts/__tests__/check-docs-route-eager-closure.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9fbb4f76cdd82ceb3b45edf7e4f292a977e98077 GIT binary patch literal 15512 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 XR^{qE^1tuhUnpg_H_RIKpl|v=ATl_9 literal 0 HcmV?d00001 diff --git a/scripts/check-docs-route-eager-closure.mjs b/scripts/check-docs-route-eager-closure.mjs new file mode 100644 index 0000000000..85fd5ac767 --- /dev/null +++ b/scripts/check-docs-route-eager-closure.mjs @@ -0,0 +1,819 @@ +#!/usr/bin/env node +/** + * The docs route's eager closure, weighed STRUCTURALLY — no build, no bytes. + * + * ## The hole this fills (objectui#6316) + * + * `apps/site/app/components/registerCatalogBlocks.ts` is a list of side-effect + * imports. Every one of them pulls its package's module graph into the Next + * docs route `/docs/[[...slug]]`, which is shared by every docs page — so an + * import added there is paid on 181 pages, not on the gallery alone. The cards + * that added to that list (objectui#4600, objectui#4616, objectui#6167, + * objectui#6025) all said the cost was governed by `check:eager-closure`. + * + * It was not, and this is the measurement that says so, re-taken on `b116a0684` + * rather than inherited from the card: + * + * - `scripts/check-eager-closure-budget.mjs` reads + * `apps/console/dist/eager-closure.json` (`DEFAULT_REPORT_PATH`, line 314 on + * that commit — the card quoted line 292, which PR #6315 moved). That report + * is written by `emitEagerClosureReport` in `apps/console/vite.config.ts`, + * from rolldown's `chunk.imports`, for THE CONSOLE. + * - `.github/workflows/performance-budget.yml` triggers on `packages/**`, + * `apps/console/**` and `pnpm-lock.yaml`, and builds `@object-ui/console`. + * + * `apps/site` is weighed by neither. The only measurement of the docs route + * that has ever existed is the one in `registerCatalogBlocks.ts`'s own header + * (`7738.7 kB / 29 chunks` -> `9542.6 kB / 40 chunks`), reconstructed BY HAND + * from the `script src` set of the prerendered route because Next 16.3 + + * Turbopack prints no Size / First Load JS columns. It was taken once. Nothing + * re-takes it, and objectui#4616's `+50%` stop condition has no gauge behind it. + * + * ## What this gate asserts, and what it deliberately does not + * + * It does NOT weigh bytes. Ruled on objectui#6316: a second byte budget would + * cost a 556-page docs build in CI, and the cheap structural question catches + * the change that matters anyway. Every package named in the registrar is + * classified into exactly one of three buckets: + * + * RECORDED it is in {@link MEASURED_PAYLOAD} — its eager cost was argued for + * and written down when it landed. + * FREE it is already reachable from the route's module graph without the + * registrar naming it, so the import adds a DECLARATION and no + * payload. This is the objectui#6314 case, and this gate is what + * turns that claim from prose into a measurement: `plugin-form` and + * `plugin-grid` are reachable through `@object-ui/plugin-view`'s + * module-scope `import { ObjectForm }` / `import { ObjectGrid }` + * in `packages/plugin-view/src/ObjectView.tsx`. + * NEW GRAPH neither — the import pulls a graph this route has never carried. + * That is the change nobody can currently measure, so this gate + * fails and a human argues for it in review. + * + * The third bucket is the whole point. It converts "an unmeasured hazard" into + * "a review event", which is all a cheap instrument can honestly do. + * + * ## Why {@link MEASURED_PAYLOAD} is a ledger and not a ceiling + * + * The eleven packages in it are NOT reachable any other way — measured, and + * re-measured on every run by {@link evaluateLedger}. They are the payload + * objectui#4600 and objectui#4616 measured and argued for. Without the ledger + * this gate would have to be red at rest, and a gate that is red at rest is a + * gate someone deletes. With it, the assertion is exact: today's list is + * accounted for, and tomorrow's addition is either free or argued. + * + * It carries no bytes and no threshold, so it is not the "second ceiling + * constant" objectui#6316's ruling closed. Adding an entry is a claim about a + * graph, in a diff a reviewer reads. + * + * ## Source, not dist — and what that approximation costs + * + * The traversal reads `src/` and resolves workspace packages to their source + * entry, so the gate needs no `pnpm install` and no build: a checkout plus one + * `node` call. `dist/` is generated from `src/` by vite, and rollup preserves + * static imports it cannot prove dead, so source-level reachability is a + * SUPERSET of the built closure: this gate can call a package reachable that + * tree-shaking would have dropped. That direction is the safe one — it can + * fail to flag a new graph, never invent one — and side-effect imports, which + * is what every line in the registrar is, are never shaken out at all. + * + * ## The MDX half is not optional + * + * `apps/site/.source/server.ts` — generated by `fumadocs-mdx`, gitignored — is + * a static `import` of every `content/docs/**\/*.mdx` file, and `lib/source.ts` + * pulls it into the route. So the compiled MDX modules ARE in this route's + * eager graph, and this gate walks `content/docs` directly rather than the + * generated file, so that it stays a checkout-only gate. + * + * That is load-bearing rather than thorough: measured on `b116a0684`, the + * registrar is reachable from the route entries ONLY through MDX — + * `content/docs/guide/schema-catalog.mdx` imports `SchemaCatalogIndex`, which + * imports `SchemaThumbnail`, which imports the registrar. Drop the MDX half and + * this gate judges a file the route it names does not load. {@link evaluateGauge} + * asserts that reachability on every run for exactly that reason. + * + * Fenced code blocks in MDX are masked before the scan. Not cosmetic: 87 files + * under `content/docs` open a line with `import`, and the ones naming + * `@object-ui/plugin-grid`, `@object-ui/plugin-view` and `@object-ui/plugin-calendar` + * are all inside ``` fences — documentation OF an import, not an import. Count + * them and `plugin-grid` reads FREE for a reason that does not exist. + * + * ## Exit codes + * + * 0 every declared package is accounted for and the ledger is honest. + * 1 a real verdict about the registrar: a new graph, or a stale ledger. + * 2 the GAUGE is broken — a specifier this gate must resolve did not, the + * registrar is no longer on the route, or the traversal stopped + * discriminating. Never reported as a size verdict, and never green: a + * structural gate that cannot fail is worse than no gate, because it turns + * an unmeasured hazard into a false assurance. + * + * All three halves are evaluated and printed before any of them decides the + * code, the way `check-eager-closure-budget.mjs` prints its four. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { maskComments } from './js-comment-mask.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; + +export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +/** The file this gate judges, relative to the repo root. */ +export const REGISTRAR = 'apps/site/app/components/registerCatalogBlocks.ts'; + +/** + * The `/docs/[[...slug]]` route's own entry modules. `app/layout.tsx` is the + * root layout every route renders inside, `mdx-components.tsx` is what the page + * hands each compiled MDX module. + */ +export const ROUTE_ENTRIES = Object.freeze([ + 'apps/site/app/layout.tsx', + 'apps/site/app/docs/layout.tsx', + 'apps/site/app/docs/[[...slug]]/page.tsx', + 'apps/site/mdx-components.tsx', +]); + +/** Where the MDX modules the route compiles live. */ +export const MDX_CONTENT_DIR = 'content/docs'; + +/** The alias `apps/site/tsconfig.json` declares as `"@/*": ["./*"]`. */ +export const SITE_DIR = 'apps/site'; + +/** + * Packages whose eager cost on this route is ALREADY ARGUED FOR AND RECORDED. + * + * Every entry is a package the docs route pulls in ONLY because the registrar + * names it — checked on every run by {@link evaluateLedger}, which fails if an + * entry has become reachable some other way (the ledger would then overstate) or + * has stopped being named at all (a dead entry that would silently pardon a + * re-added import). + * + * SHRINK-ONLY in spirit: adding an entry is the review event this gate exists to + * create. Say what the package is for and what it cost, the way these do. + */ +export const MEASURED_PAYLOAD = Object.freeze({ + '@object-ui/plugin-dashboard': + 'objectui#4600 — the dashboard category the gallery could not render at all; one of the two the route was first measured with.', + '@object-ui/plugin-charts': + 'objectui#4600 — `chart` and its variants, the second of that card’s original two.', + '@object-ui/plugin-calendar': + 'objectui#4616 census — 2 `calendar-view` entries painted OBJUI-001 without it.', + '@object-ui/plugin-chatbot': 'objectui#4616 census — 3 `chatbot` entries.', + '@object-ui/plugin-editor': 'objectui#4616 census — 3 `code-editor` entries.', + '@object-ui/plugin-gantt': 'objectui#4616 census — 3 `object-gantt` entries.', + '@object-ui/plugin-kanban': 'objectui#4616 census — 2 `kanban` entries.', + '@object-ui/plugin-map': 'objectui#4616 census — 3 `object-map` entries.', + '@object-ui/plugin-markdown': 'objectui#4616 census — 3 `markdown` entries.', + '@object-ui/plugin-timeline': 'objectui#4616 census — 3 `timeline` entries.', + '@object-ui/plugin-view': + 'objectui#4616 census — filter-ui, sort-ui and view-switcher entries. It is also the package the two FREE declarations below reach their code through.', +}); + +/** + * Extensions this gate parses. Anything else resolves but is a leaf — a `.css` + * or `.json` import is an edge with no further edges of its own. + * + * `.mdx` is here and not in {@link TRY_EXTS}: an MDX file is never the TARGET of + * a specifier in this tree (the generated `.source/server.ts` is), but it is a + * seed whose own imports are most of the route. Leaving it out is not a smaller + * graph, it is a different one — the registrar drops off the route entirely, + * which is exactly what half 3 caught while this file was being written. + */ +const PARSED = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.mdx']); +const TRY_EXTS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']; + +// --------------------------------------------------------------------------- +// Reading the workspace +// --------------------------------------------------------------------------- + +/** + * Every workspace package, name -> absolute directory, from the globs + * `pnpm-workspace.yaml` actually declares. Reading the file rather than + * hard-coding `packages/`, `apps/`, `examples/` keeps one declaration of what + * the workspace is; an unsupported glob shape throws instead of being skipped, + * because a silently narrower workspace makes every package in it read as + * external — which is to say, as unreachable. + */ +export function readWorkspacePackages(root = REPO_ROOT) { + const yaml = fs.readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8'); + const block = yaml.slice(yaml.indexOf('packages:')); + const globs = []; + for (const line of block.split('\n').slice(1)) { + if (/^\S/.test(line)) break; + const m = /^\s*-\s*'?"?([^'"\s]+)'?"?\s*$/.exec(line); + if (m) globs.push(m[1]); + } + if (!globs.length) throw new Error('pnpm-workspace.yaml declared no package globs'); + + const dirs = []; + for (const glob of globs) { + if (glob.endsWith('/*')) { + const parent = path.join(root, glob.slice(0, -2)); + if (!fs.existsSync(parent)) continue; + for (const entry of fs.readdirSync(parent, { withFileTypes: true })) { + if (entry.isDirectory()) dirs.push(path.join(parent, entry.name)); + } + } else if (!glob.includes('*')) { + dirs.push(path.join(root, glob)); + } else { + throw new Error(`unsupported workspace glob: ${glob}`); + } + } + + const byName = new Map(); + for (const dir of dirs) { + const manifest = path.join(dir, 'package.json'); + if (!fs.existsSync(manifest)) continue; + const name = JSON.parse(fs.readFileSync(manifest, 'utf8')).name; + if (name) byName.set(name, dir); + } + return byName; +} + +// --------------------------------------------------------------------------- +// Resolution +// --------------------------------------------------------------------------- + +function resolveFile(candidate) { + // `moduleResolution: bundler` sources in this tree write `./foo.js` for + // `./foo.ts`. Resolving the literal path first and only then rewriting the + // extension would answer with a stale build artefact if one were ever + // committed; source wins. + if (/\.(js|jsx|mjs|cjs)$/.test(candidate)) { + const stem = candidate.replace(/\.(js|jsx|mjs|cjs)$/, ''); + for (const ext of ['.ts', '.tsx', '.mts', '.cts']) { + if (fs.existsSync(stem + ext)) return stem + ext; + } + } + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate; + for (const ext of TRY_EXTS) if (fs.existsSync(candidate + ext)) return candidate + ext; + for (const ext of TRY_EXTS) { + const index = path.join(candidate, `index${ext}`); + if (fs.existsSync(index)) return index; + } + return null; +} + +/** The `src/` file behind a package `exports` target such as `./dist/index.js`. */ +function sourceBehind(pkgDir, target) { + const rel = target.replace(/^\.\//, '').replace(/^dist\//, ''); + return resolveFile(path.join(pkgDir, 'src', rel)); +} + +/** + * A workspace package's source entry for `subpath` (undefined = the `.` entry). + * Returns `{ kind: 'file' | 'unresolved' }`; never a silent null, because a + * package entry that cannot be found is precisely the shape that would make + * everything downstream of it read as unreachable. + */ +export function packageEntry(pkgDir, subpath) { + const manifest = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')); + const key = subpath ? `./${subpath}` : '.'; + const declared = manifest.exports?.[key]; + let target = null; + if (typeof declared === 'string') target = declared; + else if (declared && typeof declared === 'object') { + target = declared.import?.default ?? declared.import ?? declared.default ?? declared.require ?? null; + } + if (typeof target !== 'string' && !subpath) target = manifest.module ?? manifest.main ?? null; + if (typeof target !== 'string') { + return { kind: 'unresolved', why: `no exports["${key}"] in ${manifest.name}` }; + } + const file = sourceBehind(pkgDir, target); + return file + ? { kind: 'file', file } + : { kind: 'unresolved', why: `no source behind ${manifest.name} -> ${target}` }; +} + +/** + * A resolver over one tree. External (non-workspace) specifiers are LEAVES, not + * failures: `react`, `next/navigation` and the virtual + * `fumadocs-mdx:collections/server` are all real edges out of this repository, + * and following them would answer a different question. Everything the gate + * claims to walk — relative paths, the `@/` alias, workspace packages — must + * resolve or be reported. + */ +export function createResolver({ root = REPO_ROOT, packages = readWorkspacePackages(root) } = {}) { + const siteDir = path.join(root, SITE_DIR); + return function resolve(spec, fromFile) { + if (spec.startsWith('.')) { + const file = resolveFile(path.resolve(path.dirname(fromFile), spec)); + return file ? { kind: 'file', file } : { kind: 'unresolved', why: 'relative specifier' }; + } + if (spec.startsWith('@/')) { + const file = resolveFile(path.join(siteDir, spec.slice(2))); + return file ? { kind: 'file', file } : { kind: 'unresolved', why: 'the `@/` site alias' }; + } + const m = /^(@[^/]+\/[^/]+|[^@./][^/]*)(?:\/(.+))?$/.exec(spec); + if (!m) return { kind: 'external' }; + const [, name, subpath] = m; + const dir = packages.get(name); + if (!dir) return { kind: 'external' }; + return packageEntry(dir, subpath); + }; +} + +// --------------------------------------------------------------------------- +// Import extraction +// --------------------------------------------------------------------------- + +/** + * MDX with its fenced code blocks blanked, line count preserved. + * + * A fence opens with three or more backticks or tildes and closes with the same + * character; the content between them is documentation, not module graph. + */ +export function maskFences(mdx) { + let fence = null; + return mdx + .split('\n') + .map((line) => { + const opener = /^\s*(`{3,}|~{3,})/.exec(line); + if (fence === null) { + if (opener) { + fence = opener[1][0]; + return ''; + } + return line; + } + if (opener && opener[1][0] === fence) fence = null; + return ''; + }) + .join('\n'); +} + +/** + * The STATIC module specifiers a source pulls in eagerly. + * + * Deliberately excluded, each for its own reason: + * + * - `import type` / `export type` — erased, so no graph is pulled. A bare + * `import { type X } from 'y'` is NOT excluded: under `verbatimModuleSyntax` + * it keeps a side-effect load, and over-counting is the safe direction here. + * - `import('x')` — the lazy form. `PluginLoader` is built on it precisely so + * those graphs stay OFF this route; counting them would erase the + * distinction this gate exists to police. + * + * Statements are anchored to the start of a line, which is where every module- + * scope import in this tree sits. That is what keeps a JSX text node or an MDX + * sentence that happens to contain the word `import` from fabricating an edge. + */ +export function extractImports(source, { mdx = false } = {}) { + const text = mdx ? maskFences(source) : maskComments(source); + const specs = []; + const bound = /^[ \t]*(import|export)\s+([^;'"]*?)\bfrom\s*['"]([^'"]+)['"]/gm; + let m; + while ((m = bound.exec(text)) !== null) { + if (/^\s*type\b/.test(m[2])) continue; + specs.push({ spec: m[3], index: m.index }); + } + const sideEffect = /^[ \t]*import\s*['"]([^'"]+)['"]/gm; + while ((m = sideEffect.exec(text)) !== null) specs.push({ spec: m[1], index: m.index }); + return specs.sort((a, b) => a.index - b.index).map(({ spec, index }) => ({ + spec, + line: text.slice(0, index).split('\n').length, + })); +} + +/** The workspace packages the registrar names, in file order. */ +export function readDeclared(registrarPath) { + const source = fs.readFileSync(registrarPath, 'utf8'); + // maskComments is doing real work here: this file's header QUOTES + // `import { ObjectForm } from '@object-ui/plugin-form'` in prose, and an + // unmasked scan reads that sentence as a declaration. + return extractImports(source); +} + +// --------------------------------------------------------------------------- +// The graph +// --------------------------------------------------------------------------- + +/** + * Crawl every module reachable from `seeds`, recording the edges rather than + * only the set. One crawl answers every reachability question below without + * re-reading a file, and the recorded edges are what let a green run print WHY + * a package is free instead of asserting that it is. + */ +export function crawl({ seeds, resolve }) { + const edges = new Map(); + const unresolved = []; + const queue = [...seeds]; + const seen = new Set(seeds); + while (queue.length) { + const file = queue.shift(); + const out = []; + edges.set(file, out); + if (!PARSED.has(path.extname(file))) continue; + let source; + try { + source = fs.readFileSync(file, 'utf8'); + } catch (error) { + unresolved.push({ spec: file, from: '(seed)', why: `unreadable: ${error.message}` }); + continue; + } + for (const { spec, line } of extractImports(source, { mdx: file.endsWith('.mdx') })) { + const target = resolve(spec, file); + if (target.kind === 'external') continue; + if (target.kind === 'unresolved') { + unresolved.push({ spec, from: file, line, why: target.why }); + continue; + } + out.push(target.file); + if (!seen.has(target.file)) { + seen.add(target.file); + queue.push(target.file); + } + } + } + return { edges, unresolved }; +} + +/** + * Breadth-first reachability over an already-crawled graph. + * + * `blocked` files are not entered AND not reported: that is how the base graph + * is taken — the route exactly as it would be with the registrar's import list + * deleted, everything else still walked. + */ +export function reachable({ edges, roots, blocked = new Set() }) { + const seen = new Set(); + const via = new Map(); + const queue = []; + for (const root of roots) { + if (blocked.has(root) || seen.has(root)) continue; + seen.add(root); + queue.push(root); + } + while (queue.length) { + const file = queue.shift(); + for (const next of edges.get(file) ?? []) { + if (blocked.has(next) || seen.has(next)) continue; + seen.add(next); + via.set(next, file); + queue.push(next); + } + } + return { files: seen, via }; +} + +/** name -> directory, as a longest-prefix lookup from a file to its package. */ +function packageOfFile(packages) { + const entries = [...packages].map(([name, dir]) => [name, dir + path.sep]); + return (file) => { + for (const [name, prefix] of entries) if (file.startsWith(prefix)) return name; + return null; + }; +} + +// --------------------------------------------------------------------------- +// The model +// --------------------------------------------------------------------------- + +/** + * Everything the three verdicts are computed from, taken once. + * + * `root` is a parameter so the unit test can drive the whole thing over a + * fixture tree — including a tree where the answer must be RED. A gate whose + * failing direction is only ever asserted by reading it is not verified. + */ +export function analyse({ root = REPO_ROOT, ledger = MEASURED_PAYLOAD } = {}) { + const packages = readWorkspacePackages(root); + const resolve = createResolver({ root, packages }); + const toPackage = packageOfFile(packages); + const registrar = path.join(root, REGISTRAR); + + const entrySeeds = ROUTE_ENTRIES.map((rel) => path.join(root, rel)).filter((file) => + fs.existsSync(file), + ); + const missingEntries = ROUTE_ENTRIES.filter((rel) => !fs.existsSync(path.join(root, rel))); + + const mdxDir = path.join(root, MDX_CONTENT_DIR); + const mdxSeeds = fs.existsSync(mdxDir) ? listMdx(mdxDir) : []; + + const ledgerNames = Object.keys(ledger); + const ledgerSeeds = new Map(); + const ledgerUnresolved = []; + for (const name of ledgerNames) { + const dir = packages.get(name); + if (!dir) { + ledgerUnresolved.push({ spec: name, from: '(ledger)', why: 'not a workspace package' }); + continue; + } + const entry = packageEntry(dir, undefined); + if (entry.kind !== 'file') { + ledgerUnresolved.push({ spec: name, from: '(ledger)', why: entry.why }); + continue; + } + ledgerSeeds.set(name, entry.file); + } + + const declared = readDeclared(registrar); + const declaredSeeds = new Map(); + for (const { spec } of declared) { + const dir = packages.get(spec); + if (!dir) continue; + const entry = packageEntry(dir, undefined); + if (entry.kind === 'file') declaredSeeds.set(spec, entry.file); + } + + const seeds = [ + ...entrySeeds, + ...mdxSeeds, + registrar, + ...ledgerSeeds.values(), + ...declaredSeeds.values(), + ]; + const { edges, unresolved } = crawl({ seeds: [...new Set(seeds)], resolve }); + + const routeRoots = [...entrySeeds, ...mdxSeeds]; + const full = reachable({ edges, roots: routeRoots }); + const blocked = new Set([registrar]); + const base = reachable({ edges, roots: routeRoots, blocked }); + const withLedger = reachable({ + edges, + roots: [...routeRoots, ...ledgerSeeds.values()], + blocked, + }); + + const packagesIn = (files) => { + const names = new Set(); + for (const file of files) { + const name = toPackage(file); + if (name) names.add(name); + } + return names; + }; + + return { + root, + ledger, + packages, + registrar, + declared, + declaredSeeds, + ledgerSeeds, + ledgerUnresolved, + missingEntries, + routeRootCount: routeRoots.length, + mdxCount: mdxSeeds.length, + edges, + unresolved, + full, + base, + withLedger, + basePackages: packagesIn(base.files), + reachablePackages: packagesIn(withLedger.files), + toPackage, + }; +} + +function listMdx(dir, acc = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) listMdx(full, acc); + else if (entry.name.endsWith('.mdx')) acc.push(full); + } + return acc; +} + +// --------------------------------------------------------------------------- +// The three halves +// --------------------------------------------------------------------------- + +/** + * HALF 1 — every package the registrar names is accounted for. + * + * `recorded` and `free` are both passes and are reported separately, because + * they are different claims: one says "argued for in a card", the other says + * "measured to add nothing". Collapsing them would hide the day a FREE package + * quietly became payload. + */ +export function evaluateDeclared(model) { + const recorded = []; + const free = []; + const newGraph = []; + + for (const { spec, line } of model.declared) { + if (Object.hasOwn(model.ledger, spec)) { + recorded.push({ spec, line, reason: model.ledger[spec] }); + continue; + } + if (!model.packages.has(spec)) { + newGraph.push({ spec, line, why: 'not a workspace package — its graph cannot be weighed here at all' }); + continue; + } + if (model.reachablePackages.has(spec)) { + const entry = model.declaredSeeds.get(spec); + free.push({ spec, line, via: model.withLedger.via.get(entry) ?? null }); + continue; + } + newGraph.push({ spec, line, why: 'nothing else on the route imports it' }); + } + + const rel = (file) => (file ? path.relative(model.root, file) : 'a route entry'); + if (newGraph.length) { + return { + status: 'fail', + recorded, + free, + newGraph, + message: + `${newGraph.length} package(s) named in ${REGISTRAR} add a NEW graph to the ` + + `/docs/[[...slug]] eager closure:\n` + + newGraph.map((e) => ` ${e.spec} (line ${e.line}) — ${e.why}`).join('\n') + + `\n That route is shared by every docs page, and the budget over it ` + + `(objectui#4616's +50% stop condition) has no instrument — which is why this ` + + `is a review event and not a number. Either reach the code through a package the ` + + `route already carries, or argue for the payload and record it in MEASURED_PAYLOAD ` + + `in scripts/check-docs-route-eager-closure.mjs with what it is for.`, + }; + } + + return { + status: 'pass', + recorded, + free, + newGraph, + message: + `all ${model.declared.length} packages named in ${REGISTRAR} are accounted for: ` + + `${recorded.length} recorded payload, ${free.length} already reachable ` + + `(${free.map((e) => `${e.spec} <- ${rel(e.via)}`).join(', ') || 'none'}).`, + }; +} + +/** + * HALF 2 — the ledger still describes the tree. + * + * An entry that is no longer named by the registrar is dead weight that would + * pardon a future re-import without anyone arguing for it again; an entry that + * has become reachable on its own is a claim the tree has outgrown. Both shrink + * the ledger, and both are the author's to make deliberately. + */ +export function evaluateLedger(model) { + const named = new Set(model.declared.map((d) => d.spec)); + const stale = Object.keys(model.ledger).filter((name) => !named.has(name)); + const noLongerPayload = [...model.ledgerSeeds.keys()].filter((name) => + model.basePackages.has(name), + ); + + const problems = []; + if (stale.length) { + problems.push( + `${stale.length} entr${stale.length === 1 ? 'y is' : 'ies are'} no longer named by the ` + + `registrar (${stale.join(', ')}) — delete them, or a re-added import inherits a ` + + `pardon nobody argued for.`, + ); + } + if (noLongerPayload.length) { + problems.push( + `${noLongerPayload.length} entr${noLongerPayload.length === 1 ? 'y is' : 'ies are'} ` + + `now reachable from the route WITHOUT the registrar (${noLongerPayload.join(', ')}) — ` + + `they are no longer payload, so the ledger overstates. Delete the entries; the ` + + `packages will classify as FREE on their own.`, + ); + } + + return problems.length + ? { status: 'fail', message: `MEASURED_PAYLOAD has drifted:\n ${problems.join('\n ')}` } + : { + status: 'pass', + message: + `MEASURED_PAYLOAD is honest: all ${model.ledgerSeeds.size} entries are still named by ` + + `the registrar and none is reachable from the route any other way.`, + }; +} + +/** + * HALF 3 — the gauge itself. + * + * This is the half that answers "could this gate ever go red?". Each case is a + * way the reachability computation could return "everything is reachable" — or + * judge a file nothing loads — while every other line of output still read like + * a pass. + */ +export function evaluateGauge(model) { + const problems = []; + + if (model.missingEntries.length) { + problems.push( + `ROUTE_ENTRIES names ${model.missingEntries.length} file(s) that do not exist ` + + `(${model.missingEntries.join(', ')}) — the route moved and this gate is walking a ` + + `graph smaller than the real one.`, + ); + } + + const unresolved = [...model.unresolved, ...model.ledgerUnresolved]; + if (unresolved.length) { + const shown = unresolved.slice(0, 10).map((u) => { + const from = u.from === '(ledger)' || u.from === '(seed)' ? u.from : path.relative(model.root, u.from); + return `${u.spec} <- ${from}${u.line ? `:${u.line}` : ''} (${u.why})`; + }); + problems.push( + `${unresolved.length} specifier(s) this gate must resolve did not:\n ` + + shown.join('\n ') + + (unresolved.length > shown.length ? `\n …and ${unresolved.length - shown.length} more` : '') + + `\n Every one is a subgraph that silently reads as UNREACHABLE, so a package ` + + `behind it would be reported as a new graph — or, if the failure is on the other ` + + `side, would never be reported at all.`, + ); + } + + if (!model.full.files.has(model.registrar)) { + problems.push( + `${REGISTRAR} is NOT reachable from the /docs/[[...slug]] route entries. This gate ` + + `judges a file the route it names does not load, so its verdict is about nothing. ` + + `On b116a0684 the only path was content/docs/guide/schema-catalog.mdx -> ` + + `SchemaCatalogIndex -> SchemaThumbnail; if that chain was deliberately cut, this ` + + `gate has to be re-derived rather than relaxed.`, + ); + } + + if (!model.mdxCount) { + problems.push( + `no MDX modules were found under ${MDX_CONTENT_DIR} — the compiled docs pages are ` + + `most of this route's graph, and without them the closure is not the route's.`, + ); + } + + const outside = [...model.packages.keys()].filter((name) => !model.reachablePackages.has(name)); + if (!outside.length) { + problems.push( + `every one of the ${model.packages.size} workspace packages reads as reachable. A ` + + `traversal that reaches everything cannot distinguish a new graph from a free one, ` + + `so this gate would pass on every input — the shape it exists to rule out.`, + ); + } + + return problems.length + ? { status: 'error', outside, message: `the gauge is not trustworthy:\n ${problems.join('\n ')}` } + : { + status: 'pass', + outside, + message: + `gauge: ${model.edges.size} modules crawled from ${model.routeRootCount} route roots ` + + `(${model.mdxCount} MDX), every specifier resolved, the registrar is on the route, and ` + + `${outside.length} of ${model.packages.size} workspace packages stay OUTSIDE the ` + + `closure — so the traversal still discriminates.`, + }; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +export function render(model, declaredVerdict) { + const lines = []; + const rel = (file) => (file ? path.relative(model.root, file) : 'a route entry'); + lines.push('The /docs/[[...slug]] eager closure, as declared:'); + for (const entry of declaredVerdict.recorded) { + lines.push(` RECORDED ${entry.spec}`); + } + for (const entry of declaredVerdict.free) { + lines.push(` FREE ${entry.spec} <- already imported by ${rel(entry.via)}`); + } + for (const entry of declaredVerdict.newGraph) { + lines.push(` NEW GRAPH ${entry.spec} (line ${entry.line})`); + } + return lines.join('\n'); +} + +export function main(argv = process.argv.slice(2), env = process.env) { + const rootFlag = argv.indexOf('--root'); + const root = rootFlag === -1 ? REPO_ROOT : path.resolve(argv[rootFlag + 1]); + const model = analyse({ root }); + + const declared = evaluateDeclared(model); + const ledger = evaluateLedger(model); + const gauge = evaluateGauge(model); + + for (const verdict of [declared, ledger]) { + if (verdict.status === 'pass') console.log(`✅ ${verdict.message}`); + else console.error(`❌ ${verdict.message}`); + } + // The gauge is printed even when it passes, and never silently: a half that + // says nothing is indistinguishable from a half that was switched off. + if (gauge.status === 'pass') console.log(`✅ ${gauge.message}`); + else console.error(`❌ ${gauge.message}`); + + console.log(''); + console.log(render(model, declared)); + + if (env?.GITHUB_STEP_SUMMARY) { + try { + fs.appendFileSync( + env.GITHUB_STEP_SUMMARY, + `### Docs route eager closure\n\n\`\`\`\n${render(model, declared)}\n\`\`\`\n`, + ); + } catch { + // A summary that cannot be written is not a verdict about the route. + } + } + + if (gauge.status === 'error') return 2; + return declared.status === 'fail' || ledger.status === 'fail' ? 1 : 0; +} + +if (isEntrypoint(import.meta.url)) { + process.exit(main()); +} diff --git a/scripts/dependabot-merge-gate.mjs b/scripts/dependabot-merge-gate.mjs index 5de2d9e1ef..706f150217 100644 --- a/scripts/dependabot-merge-gate.mjs +++ b/scripts/dependabot-merge-gate.mjs @@ -130,6 +130,7 @@ import { isEntrypoint } from './invoked-as.mjs'; * vi-mock-specifiers.yml Inert vi.mock Specifier Check * shell-escape-residue.yml Shell Escape Residue Scan * readme-exports.yml README Export Check + * docs-route-eager-closure.yml Docs Route Eager Closure Check * * The four shards are spelled out individually on purpose. A single `Test` * entry, or any pattern match, would be satisfied by whichever shard happened @@ -156,6 +157,7 @@ export const REQUIRED_CONTEXTS = Object.freeze([ 'Inert vi.mock Specifier Check', 'Shell Escape Residue Scan', 'README Export Check', + 'Docs Route Eager Closure Check', ]); /** From 16c36fbb92adeb6215f2323e59077c36463e41f0 Mon Sep 17 00:00:00 2001 From: os-warren Date: Tue, 25 Aug 2026 20:12:26 +0000 Subject: [PATCH 2/3] test(devx): drop the impossible-character sentinel from the closure fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm check:control-bytes` found two raw U+0000 bytes in the new test's absent-file sentinel — the gate's own failure mode, in the file adding a gate. The fixture map now types an absent file as `null`, which needs no impossible character at all, and `analyse()` declares its `ledger` parameter so a fixture ledger is assignable under `allowJs` inference. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012CZgmFFzqA9cX8tBMhvpFe --- .../check-docs-route-eager-closure.test.ts | Bin 15512 -> 15915 bytes scripts/check-docs-route-eager-closure.mjs | 8 ++++++++ 2 files changed, 8 insertions(+) diff --git a/scripts/__tests__/check-docs-route-eager-closure.test.ts b/scripts/__tests__/check-docs-route-eager-closure.test.ts index 9fbb4f76cdd82ceb3b45edf7e4f292a977e98077..32048b9c55e156e02b670a6efe8d50bc227b8ef3 100644 GIT binary patch delta 664 zcmZ{i!HU#C5QbrKbP@63suwTCh%k&vX3wjPcwW2+d(x4qbXO+L^mJl(4cWjh^9+)U z&!86}@8TPH(|52sGvb1nLqd1e|Mg#g=X3k@>f49WZZ@6Xg(!iLZV7;|ZYNGySTF%Qx+P5|rL8qyJn1(OFZ333d3DufCUR9q@_ zz_=#Z;husGwwdUn^l%b74X$F51oppE^^GK zu0k-mrBs#ZQAJ08AYxYdyc2`mRB-Y9R36A%rT~V)5orN=LRIbu(k83{19)r>ZD}fV zY*!`A8kV)IHz3VweHKIWl}!iQGJ&Qvs*FL=rI@g7G}n~K;&>tN74i^cQTa?Rg4On1IS4-h*>+_(BLdcFgM1FyuTBQ5Uq`2&br0u=`mbEyt;Y!Yos2(B~^L-a-V2& zNOs;|-2O58U+vYsPe->Rq5ppW`egDh%Q8*Mq9>jC0Y^H4o4bYlLW}PGgVX-YxIelR ewQ!wuAD^6bvq#6>`DESyeD>m>LVrGa^YAxx0phd( delta 245 zcmZ2oGox}t9P4CDcBOhdYc4K*EiHxM)Vz|+ywn_pwEQ9krIL)yVuiHKijvZzR3M#` zssQ9CCKUq}Dd{LA78K-Urlu$)rj?`?DU@WSDkLQqrz(^brKajBXz6n$=jRodD7d)# zxQ4hY*ea+qq@?DgmZUPML+x@@$j!{l%uUQuD4AT!9?0qjw4iwN9rmZJQ08^siA-z| z{hMiEYV0wP0mRy0`k*<_G)exRCx^3Gx>mK=jKLr MYZ!m?0nKa?0LKtf2mk;8 diff --git a/scripts/check-docs-route-eager-closure.mjs b/scripts/check-docs-route-eager-closure.mjs index 85fd5ac767..bd8e220154 100644 --- a/scripts/check-docs-route-eager-closure.mjs +++ b/scripts/check-docs-route-eager-closure.mjs @@ -476,6 +476,14 @@ function packageOfFile(packages) { * `root` is a parameter so the unit test can drive the whole thing over a * fixture tree — including a tree where the answer must be RED. A gate whose * failing direction is only ever asserted by reading it is not verified. + * + * The `ledger` parameter is typed as a plain record rather than left to be + * inferred from {@link MEASURED_PAYLOAD}: inference would freeze the fixture + * ledger out of the signature (`allowJs` reads the default's literal keys as + * the type), and a test that cannot pass its own ledger cannot exercise the + * ledger halves at all. + * + * @param {{ root?: string, ledger?: Record }} [options] */ export function analyse({ root = REPO_ROOT, ledger = MEASURED_PAYLOAD } = {}) { const packages = readWorkspacePackages(root); From 638d706b0d899f952c66db20b34fa16133d5f150 Mon Sep 17 00:00:00 2001 From: os-warren Date: Tue, 25 Aug 2026 20:37:18 +0000 Subject: [PATCH 3/3] docs(ci): document docs-route-eager-closure.yml on the pipeline page `scripts/__tests__/ci-cd-pipeline-doc.test.ts` pins `content/docs/guide/ ci-cd-pipeline.md` against `.github/workflows/` in both directions, so a new workflow with no section is a red by design (objectui#3212: `lint.yml` gated PRs for months while the page never mentioned it). Adds the section (triggers, why it is unfiltered, the three buckets, and why exit 1 and exit 2 must not be read as one), the Workflow Inventory row, and the path-filter bullet. The merge-queue paragraph teaches the requirable direction without naming the workflow: that section may not enumerate current `merge_group` subscribers (objectui#4154), which the full scripts/__tests__ run caught. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012CZgmFFzqA9cX8tBMhvpFe --- content/docs/guide/ci-cd-pipeline.md | 81 +++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 708913b39a..7d62414c79 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -37,6 +37,7 @@ one has its own section below. | `vi-mock-specifiers.yml` | Inert vi.mock Specifier Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `vi.mock` / `vi.doMock` relative specifier resolves to no file, or the scan's population collapses | | `shell-escape-residue.yml` | Shell Escape Residue Scan | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a fenced block in `AGENTS.md`, `CLAUDE.md`, `skills/**` or `content/docs/**` carries the enumerated machine-produced shell escape, or a scan root fails to resolve | | `readme-exports.yml` | README Export Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `packages/**/README.md` imports a name from its own package that the package does not export, or the scan's population collapses | +| `docs-route-eager-closure.yml` | Docs Route Eager Closure Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a package named in `apps/site/app/components/registerCatalogBlocks.ts` is not already reachable from the docs route's module graph (exit 1), or when the gate's own gauge cannot be trusted (exit 2) | | `performance-budget.yml` | Bundle Analysis | Push / PR touching `packages/**`, `apps/console/**`, `pnpm-lock.yaml` | **Yes** — the console entry gzip budget | | `live-e2e.yml` | Live E2E (informational) | PR to `main`, `develop` (code paths); nightly cron `30 6 * * *`; manual | No — informational lane, `continue-on-error` | | `labeler.yml` | Auto Label PRs | PR `opened`, `synchronize`, `reopened` | No | @@ -76,6 +77,16 @@ The path filters explain most "why did nothing run on my PR?" questions: - `control-bytes.yml` and `docs-links.yml` carry **no** filter of any kind, which is equally deliberate: both guard markdown, and a gate that a markdown-only PR cannot start is no gate on the change most likely to trip it. Both cost a checkout plus one `node` call. +- `docs-route-eager-closure.yml` carries **no** filter for the opposite reason — not that its + subject is invisible to a filter, but that a filter naming everything it reads would be + indistinguishable from having none. Its inputs are the whole `/docs/[[...slug]]` module graph: + `apps/site/**`, `content/docs/**` (the compiled MDX modules are most of that graph) and + `packages/**` — a refactor dropping an import from `packages/plugin-view/src/ObjectView.tsx` is + exactly what turns a free declaration into a new graph — plus the gate's own closure under + `scripts/`. A filter that then *missed* one of those directories could not be exercised by the + pull request that changed it, which is the defect + [#6321](https://github.com/objectstack-ai/objectui/issues/6321) records. It too costs a checkout + plus one `node` call. ## Merge Queue @@ -130,7 +141,12 @@ Two things follow for anyone editing this directory: inside the derived floor from that moment. "May this context be required?" is still a property of the repository's settings that no test here can read — `REQUIRED_CONTEXTS` is a human's answer to it, and deriving from that answer beats writing it down a second time and watching the copies - drift ([#6160](https://github.com/objectstack-ai/objectui/issues/6160)). + drift ([#6160](https://github.com/objectstack-ai/objectui/issues/6160)). A gate that carries no path filter + *precisely so that it can be required* is the mirror image of the bullet below, and the sequence + matters there too: name its context in `REQUIRED_CONTEXTS` and subscribe `merge_group` in the + same commit that creates the workflow, rather than acquiring either afterwards + ([#6316](https://github.com/objectstack-ai/objectui/issues/6316) is a worked example — see its + own section for which gate that was). - **Some contexts can never be required, structurally**, and no amount of triggering changes that. Each line below is blocked by a *different* property, which is why they are all worth reading; they are examples rather than a census, so a further workflow carrying any of these @@ -970,6 +986,69 @@ reject while `BaseSchema` carries an index signature and its Zod mirror is `.pas exports the name. Run it locally with `pnpm check:readme-exports` after a build, or `node scripts/check-readme-exports.mjs --list` to see every self-import it judged. +## Docs Route Eager Closure (`docs-route-eager-closure.yml`) + +**Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no +path filter at all** (the reason is in the [inventory](#workflow-inventory) bullets above). It +appears in the checks list as **Docs Route Eager Closure Check**, and `REQUIRED_CONTEXTS` in +`scripts/dependabot-merge-gate.mjs` declares that context blocking — which is also what puts this +workflow inside the derived `merge_group` floor, because a required check that never reports on a +queue build does not fail it, it stalls it for the ruleset's 60 minutes. + +Runs `scripts/check-docs-route-eager-closure.mjs` (`pnpm check:docs-route-closure`): a checkout plus +one `node` call over the source tree, **no install and no build**, ~1.3 s. + +**What it weighs, and what was not weighing it.** +`apps/site/app/components/registerCatalogBlocks.ts` is a list of side-effect imports, and each one +pulls its package's module graph into the Next docs route `/docs/[[...slug]]` — a route **all 181 +docs pages share**, not just the catalog gallery. The cards that added to that list said the cost +was governed by `check:eager-closure`. It was not: +`scripts/check-eager-closure-budget.mjs` reads `apps/console/dist/eager-closure.json` and +`performance-budget.yml` builds `@object-ui/console`, so that budget weighs the **console**. The +only measurement of the docs route that has ever existed was reconstructed by hand, once, from the +`script src` set of the prerendered route on disk, and the `+50%` stop condition +[#4616](https://github.com/objectstack-ai/objectui/issues/4616) set had no gauge behind it +([#6316](https://github.com/objectstack-ai/objectui/issues/6316)). + +**Structural, not byte-level — ruled that way on purpose.** A second byte budget would need a +556-page docs build in CI. This gate instead walks the route's **static** module graph from source — +the route entries, plus every compiled `content/docs/**` MDX module, which the route pulls in through +the generated `.source/server.ts` — and sorts every package the registrar names into one of three +buckets: + +| Bucket | Meaning | +|---|---| +| **Recorded** | listed in the gate's `MEASURED_PAYLOAD` — its eager cost was argued for and written down when it landed | +| **Free** | already reachable without this file naming it, so the import adds a *declaration* and no payload | +| **New graph** | neither, so the import pulls a graph this route has never carried — **fails** | + +The third bucket is the whole point: it turns an unmeasured hazard into a review event, which is +what a cheap instrument can honestly do. `MEASURED_PAYLOAD` is a ledger and **not a ceiling** — it +carries no bytes and no threshold, and every entry is re-measured on each run, so an entry the +registrar stopped naming, or one that became reachable some other way, fails and has to shrink. + +**Exit 1 and exit 2 mean different things, and must not be read as one.** Exit **1** is a verdict +about the registrar: a new graph, or a ledger that has drifted. Exit **2** says the **gauge** is not +trustworthy — a specifier the walk must resolve did not, a route entry moved, the registrar is no +longer reachable from the route at all, or every workspace package now reads as reachable (a +traversal that reaches everything cannot tell a new graph from a free one). A reader who sees exit 2 +must not conclude the registrar is wrong; nothing was validly measured. All three verdicts print +before any of them decides the code, the way `check-eager-closure-budget.mjs` prints its four. + +**A structural gate that cannot fail is worse than none**, because it converts an unmeasured hazard +into a false assurance — so the failing direction is verified rather than assumed. +`scripts/__tests__/check-docs-route-eager-closure.test.ts` drives the real analysis over fixture +trees for each way the walk could silently answer "everything is reachable": a fenced MDX code block +counted as an import, an erased `import type`, a lazy `import()` (the distinction the gate exists to +police — `PluginLoader` is built on it so those graphs stay *off* this route), a package named only +in the registrar's own prose, an unresolved specifier, and the registrar falling off the route. + +**If it fails:** the message names the package, the line that declares it, and the two ways out — +reach the code through a package the route already carries, or argue for the payload in review and +record it in `MEASURED_PAYLOAD` with what it is for. Run it locally with +`pnpm check:docs-route-closure`; a green run prints the full classification, including which file +each *free* package is already imported by. + ## Link Checking (`check-links.yml`) **Trigger:** Weekly cron (`17 4 * * 0` — Sundays, off the top of the hour, when the scheduled-run