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 000000000..d39e34ce1 --- /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 000000000..6eb46712e --- /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 6aa4a78d9..595a019c5 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/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 708913b39..7d62414c7 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 diff --git a/package.json b/package.json index 3ba40db41..988c49310 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 000000000..32048b9c5 --- /dev/null +++ b/scripts/__tests__/check-docs-route-eager-closure.test.ts @@ -0,0 +1,398 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Plain-JS CI helper; its types are INFERRED from the .mjs source by +// `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here. +import { + analyse, + evaluateDeclared, + evaluateGauge, + evaluateLedger, + extractImports, + maskFences, + MEASURED_PAYLOAD, + REGISTRAR, +} from '../check-docs-route-eager-closure.mjs'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +/** + * objectui#6316. `check:eager-closure` weighs `apps/console` and nothing weighs + * the docs route, so the budget objectui#4616 set (+50% over a hand-taken + * `7738.7 kB / 29 chunks`) has never had an instrument. The ruled shape is + * structural rather than byte-level, and its ONE failure mode is vacuity: a + * reachability computation that answers "reachable" for every input passes on + * the current tree, passes on a genuinely new graph, and reads exactly like a + * working gate. + * + * So the cases below are weighted toward the RED direction. Every one of them + * builds a fixture tree mirroring the real layout (`apps/site/app/...`, + * `content/docs/...`) and drives the real `analyse()` over it, because a model + * of the traversal would agree with a broken traversal. + */ + +/** + * A fixture tree, path -> contents. `null` means "this file is absent", applied + * after the base tree so a case can take a file away. + * + * `null` and not a string sentinel: the first spelling of this used an + * impossible-character sentinel, and the two U+0000 bytes it needed reached + * disk as raw bytes -- caught by `pnpm check:control-bytes`, which is exactly + * what that gate is for. A type that cannot be a filename needs no impossible + * character at all. + */ +type Files = Record; + +/** A minimal tree with the same SHAPE as this repository's docs route. */ +function baseTree(): Record { + return { + 'pnpm-workspace.yaml': "packages:\n - 'packages/*'\n - 'apps/*'\n", + + 'apps/site/package.json': JSON.stringify({ name: '@fx/site', private: true }), + 'apps/site/app/layout.tsx': 'export default function RootLayout() { return null; }\n', + 'apps/site/app/docs/layout.tsx': 'export default function DocsLayout() { return null; }\n', + 'apps/site/app/docs/[[...slug]]/page.tsx': + "import { getMDXComponents } from '@/mdx-components';\nexport default function Page() { return getMDXComponents(); }\n", + 'apps/site/mdx-components.tsx': 'export function getMDXComponents() { return {}; }\n', + + // The real chain is schema-catalog.mdx -> SchemaCatalogIndex -> + // SchemaThumbnail -> the registrar. One hop is dropped; the property that + // matters (the registrar is on the route ONLY through MDX) is kept. + 'apps/site/app/components/SchemaCatalogIndex.tsx': + "import './registerCatalogBlocks';\nexport function SchemaCatalogIndex() { return null; }\n", + [REGISTRAR]: "import '@fx/hub';\nimport '@fx/free';\n", + 'content/docs/guide/catalog.mdx': + "---\ntitle: Catalog\n---\n\nimport { SchemaCatalogIndex } from '@/app/components/SchemaCatalogIndex';\n\n\n", + + ...workspacePackage('hub', "import '@fx/free';\nexport const hub = 1;\n"), + ...workspacePackage('free', 'export const free = 1;\n'), + ...workspacePackage('orphan', 'export const orphan = 1;\n'), + }; +} + +function workspacePackage(name: string, source: string): Record { + return { + [`packages/${name}/package.json`]: JSON.stringify({ + name: `@fx/${name}`, + exports: { '.': { types: './dist/index.d.ts', import: './dist/index.js' } }, + }), + [`packages/${name}/src/index.ts`]: source, + }; +} + +const LEDGER = Object.freeze({ '@fx/hub': 'fixture -- the payload this tree argued for when it landed.' }); + +function withTree(overrides: Files, run: (root: string) => T): T { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-route-closure-')); + try { + for (const [rel, contents] of Object.entries({ ...baseTree(), ...overrides })) { + const full = path.join(root, rel); + if (contents === null) { + fs.rmSync(full, { force: true }); + continue; + } + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, contents); + } + return run(root); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +const verdicts = (root: string) => { + const model = analyse({ root, ledger: LEDGER }); + return { + model, + declared: evaluateDeclared(model), + ledger: evaluateLedger(model), + gauge: evaluateGauge(model), + }; +}; + +describe('the three buckets a declared package falls into', () => { + it('passes when every declaration is recorded payload or already reachable', () => { + withTree({}, (root) => { + const { declared, ledger, gauge } = verdicts(root); + expect(gauge.status).toBe('pass'); + expect(ledger.status).toBe('pass'); + expect(declared.status).toBe('pass'); + expect(declared.recorded.map((e: { spec: string }) => e.spec)).toEqual(['@fx/hub']); + // FREE is a MEASUREMENT, and this is the shape of the objectui#6314 case: + // the package is reachable through another package's module-scope import, + // so the declaration adds no payload -- and the gate can say through what. + expect(declared.free.map((e: { spec: string }) => e.spec)).toEqual(['@fx/free']); + expect(declared.free[0].via).toContain(path.join('packages', 'hub', 'src', 'index.ts')); + }); + }); + + it('FAILS on a package nothing on the route reaches -- the case that decides this gate is real', () => { + withTree({ [REGISTRAR]: "import '@fx/hub';\nimport '@fx/free';\nimport '@fx/orphan';\n" }, (root) => { + const { declared, gauge } = verdicts(root); + // The gauge must still be sound, or the red below would be about the + // instrument rather than about the tree. + expect(gauge.status).toBe('pass'); + expect(declared.status).toBe('fail'); + expect(declared.newGraph.map((e: { spec: string }) => e.spec)).toEqual(['@fx/orphan']); + expect(declared.message).toContain('@fx/orphan'); + expect(declared.message).toContain('NEW graph'); + expect(declared.message).toContain('MEASURED_PAYLOAD'); + }); + }); + + it('fails on a non-workspace package too -- a graph it cannot weigh is not a graph it may pass', () => { + withTree({ [REGISTRAR]: "import '@fx/hub';\nimport 'some-npm-widget';\n" }, (root) => { + const { declared } = verdicts(root); + expect(declared.status).toBe('fail'); + expect(declared.newGraph[0].spec).toBe('some-npm-widget'); + }); + }); +}); + +describe('the ways a traversal could silently answer "everything is reachable"', () => { + it('does not count a fenced MDX code block as an import', () => { + // 87 files under content/docs open a line with `import`, and the ones + // naming plugin packages are all inside fences. Counting them would make a + // package read FREE for a reason that does not exist. + withTree( + { + 'content/docs/guide/fenced.mdx': [ + '---', + 'title: Fenced', + '---', + '', + '```ts', + "import '@fx/orphan';", + '```', + '', + ].join('\n'), + [REGISTRAR]: "import '@fx/hub';\nimport '@fx/orphan';\n", + }, + (root) => { + const { declared } = verdicts(root); + expect(declared.status).toBe('fail'); + expect(declared.newGraph[0].spec).toBe('@fx/orphan'); + }, + ); + }); + + it('does not count a type-only import as payload', () => { + withTree( + { + 'apps/site/app/docs/layout.tsx': + "import type { Orphan } from '@fx/orphan';\nexport default function DocsLayout(_: Orphan) { return null; }\n", + [REGISTRAR]: "import '@fx/hub';\nimport '@fx/orphan';\n", + }, + (root) => { + expect(verdicts(root).declared.status).toBe('fail'); + }, + ); + }); + + it('does not count a dynamic import as eager -- that distinction IS the gate', () => { + // `PluginLoader` is built on `import()` precisely so those graphs stay off + // this route. A traversal that followed them would report every lazily + // loaded plugin as already reachable. + withTree( + { + 'apps/site/app/docs/layout.tsx': + "export default async function DocsLayout() { const m = await import('@fx/orphan'); return m; }\n", + [REGISTRAR]: "import '@fx/hub';\nimport '@fx/orphan';\n", + }, + (root) => { + expect(verdicts(root).declared.status).toBe('fail'); + }, + ); + }); + + it('does not read a package named in the registrar OWN PROSE as declared', () => { + // Not hypothetical: the real file's header quotes + // `import { ObjectForm } from '@object-ui/plugin-form'` in a comment. + withTree( + { + [REGISTRAR]: + "/**\n * Once upon a time this file did `import '@fx/orphan';`.\n */\nimport '@fx/hub';\nimport '@fx/free';\n", + }, + (root) => { + const { declared } = verdicts(root); + expect(declared.status).toBe('pass'); + expect([...declared.recorded, ...declared.free].map((e: { spec: string }) => e.spec)).toEqual([ + '@fx/hub', + '@fx/free', + ]); + }, + ); + }); + + it('ERRORS when every workspace package reads as reachable', () => { + withTree( + { + 'apps/site/app/docs/layout.tsx': + "import '@fx/orphan';\nexport default function DocsLayout() { return null; }\n", + }, + (root) => { + const { gauge } = verdicts(root); + expect(gauge.status).toBe('error'); + expect(gauge.message).toContain('reads as reachable'); + }, + ); + }); +}); + +describe('the gauge refuses to judge what it cannot see', () => { + it('ERRORS when the registrar is not on the route at all', () => { + withTree({ 'content/docs/guide/catalog.mdx': null }, (root) => { + const { gauge } = verdicts(root); + expect(gauge.status).toBe('error'); + expect(gauge.message).toContain('NOT reachable'); + }); + }); + + it('ERRORS on a specifier it must resolve and could not', () => { + withTree( + { + 'apps/site/app/docs/layout.tsx': + "import './not-a-file.js';\nexport default function DocsLayout() { return null; }\n", + }, + (root) => { + const { gauge } = verdicts(root); + expect(gauge.status).toBe('error'); + expect(gauge.message).toContain('not-a-file.js'); + }, + ); + }); + + it('ERRORS when a route entry has moved out from under it', () => { + withTree({ 'apps/site/app/docs/layout.tsx': null }, (root) => { + const { gauge } = verdicts(root); + expect(gauge.status).toBe('error'); + expect(gauge.message).toContain('do not exist'); + }); + }); + + it('treats an unresolvable EXTERNAL package as a leaf, not a failure', () => { + withTree( + { + 'apps/site/app/docs/layout.tsx': + "import { notFound } from 'next/navigation';\nimport 'fumadocs-mdx:collections/server';\nexport default function DocsLayout() { return notFound; }\n", + }, + (root) => { + expect(verdicts(root).gauge.status).toBe('pass'); + }, + ); + }); +}); + +describe('the ledger stays honest or the gate says so', () => { + it('fails on an entry the registrar no longer names', () => { + withTree({ [REGISTRAR]: "import '@fx/free';\n" }, (root) => { + const model = analyse({ root, ledger: LEDGER }); + const ledger = evaluateLedger(model); + expect(ledger.status).toBe('fail'); + expect(ledger.message).toContain('@fx/hub'); + expect(ledger.message).toContain('no longer named'); + }); + }); + + it('fails on an entry the route now reaches on its own -- the ledger would overstate', () => { + withTree( + { + 'apps/site/app/docs/layout.tsx': + "import '@fx/hub';\nexport default function DocsLayout() { return null; }\n", + }, + (root) => { + const { ledger } = verdicts(root); + expect(ledger.status).toBe('fail'); + expect(ledger.message).toContain('no longer payload'); + }, + ); + }); +}); + +describe('the extraction primitives', () => { + it('keeps line numbers stable across a masked fence', () => { + const masked = maskFences(['a', '```ts', "import 'x';", '```', 'b', ''].join('\n')); + expect(masked.split('\n')).toHaveLength(6); + expect(masked).not.toContain('import'); + }); + + it('reads every static form and no lazy one', () => { + const specs = extractImports( + [ + "import 'side-effect';", + "import def from 'default';", + "import { a, b } from 'named';", + "import * as ns from 'namespace';", + "export { c } from 'reexport';", + "export * from 'star';", + "import type { T } from 'types-only';", + "export type { U } from 'types-only-too';", + "const lazy = () => import('dynamic');", + " const indented = await import('also-dynamic');", + ].join('\n'), + ).map((s: { spec: string }) => s.spec); + + expect(specs).toEqual(['side-effect', 'default', 'named', 'namespace', 'reexport', 'star']); + }); +}); + +describe('the real tree -- the properties that keep this gate from being decorative', () => { + const model = analyse({}); + + it('walks a route the registrar is actually on', () => { + // If this ever fails, the gate is judging a file the docs route does not + // load and must be re-derived rather than relaxed. + expect(model.full.files.has(path.join(repoRoot, REGISTRAR))).toBe(true); + expect(model.mdxCount).toBeGreaterThan(100); + }); + + it('still discriminates: real packages sit outside the closure', () => { + const gauge = evaluateGauge(model); + expect(gauge.status).toBe('pass'); + expect(gauge.outside.length).toBeGreaterThan(0); + }); + + it('states a reason for every ledger entry, and lists only workspace packages', () => { + for (const [name, reason] of Object.entries(MEASURED_PAYLOAD)) { + expect(model.packages.has(name), `${name} is not a workspace package`).toBe(true); + expect((reason as string).length, `${name} needs a reason, not just an entry`).toBeGreaterThan(40); + } + }); + + // Deliberately NOT asserted here: that `evaluateDeclared` passes. The gate + // itself runs in CI and is the one place that verdict belongs; asserting it + // here too would turn one legitimate red into two, in two different jobs, and + // teach readers to fix the test rather than the import. +}); + +describe('wiring -- the gate is actually reachable and actually runs', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + const workflowPath = path.join(repoRoot, '.github/workflows/docs-route-eager-closure.yml'); + + it('is exposed as a root package script', () => { + expect(pkg.scripts['check:docs-route-closure']).toBe( + 'node scripts/check-docs-route-eager-closure.mjs', + ); + }); + + it('has a workflow that gates pull requests and reports on a queue build', () => { + expect(fs.existsSync(workflowPath), 'a check nothing runs is not a gate').toBe(true); + const workflow = fs.readFileSync(workflowPath, 'utf8'); + expect(workflow).toMatch(/pull_request:/); + expect(workflow).toMatch(/merge_group:/); + expect(workflow).toContain('pnpm check:docs-route-closure'); + }); + + it('does NOT filter that workflow by path', () => { + // Its inputs are the whole route graph -- `packages/**`, `apps/site/**`, + // `content/docs/**` -- plus its own closure in `scripts/`. A filter listing + // all of that is indistinguishable from no filter, and a filter that misses + // one of them cannot be exercised by the PR that changes it (objectui#6321). + const workflow = fs.readFileSync(workflowPath, 'utf8'); + expect(workflow).not.toMatch(/paths-ignore:/); + expect(workflow).not.toMatch(/^\s+paths:/m); + }); +}); diff --git a/scripts/check-docs-route-eager-closure.mjs b/scripts/check-docs-route-eager-closure.mjs new file mode 100644 index 000000000..bd8e22015 --- /dev/null +++ b/scripts/check-docs-route-eager-closure.mjs @@ -0,0 +1,827 @@ +#!/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. + * + * 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); + 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 5de2d9e1e..706f15021 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', ]); /**