Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions .github/workflows/readme-exports.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
name: README Exports

# Why this is its own workflow, and not a step in `ci.yml`.
#
# The two changes that introduce this drift are (1) a README edit and (2) a
# source edit that renames or drops an export. `ci.yml` cannot see the first at
# all: every one of its jobs opens with the `id: relevant` short-circuit whose
# diff excludes `**/*.md`, so on a README-only pull request its expensive steps
# are skipped by design (objectui#3523 moved the path filter into the jobs on
# purpose, and `merge-queue-reporting.test.ts` holds it there). A gate against
# fabricated README imports, living behind a switch that skips README-only pull
# requests, would rebuild the hole it exists to close — the conclusion
# `vi-mock-specifiers.yml`, `docs-links.yml`, `control-bytes.yml`,
# `skills-paths.yml` and `changeset-presence.yml` each record in their own
# headers. One gate, one home.
#
# Hence: no `paths` and no `paths-ignore` here, deliberately.
# `scripts/__tests__/check-readme-exports.test.ts` fails if either is added.
#
# ## Why this one pays for an install and a build, when its neighbours do not
#
# The cheap-tier gates above run on a checkout plus one `node` call. This one
# cannot: the export set is read SYMBOL-LEVEL out of each package's declared
# type entry, which for 36 of the 39 packages is a built `dist/index.d.ts`. The
# card (objectui#5043) measured why an approximation is not an option — a bare
# `grep` for `GanttSchema` in `packages/types/src` has six hits, every one of
# them a substring of `ObjectGanttSchema`, so a text-level export set calls a
# fabricated name real.
#
# Measured cost of the extra steps on the tree this landed on: `turbo run build`
# over all 39 packages, cold cache, concurrency 2, on a CONTENDED container:
# 2m42s. That is well inside the same order as `ci.yml`'s own `Type Check` job
# and an order below `Build & E2E`, which is what makes an unfiltered per-PR run
# affordable here where it would not be for a full E2E.
#
# `scripts/dependabot-merge-gate.mjs` classifies `README Export Check` as a
# required context — an unclassified blocking check is one a Dependabot merge
# would be let past (objectui#6135), and since objectui#6160 the `merge_group`
# floor below DERIVES from that same list.

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: readme-exports-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
readme-exports:
name: README Export Check
runs-on: ubuntu-latest
timeout-minutes: 25

steps:
- name: Checkout code
uses: actions/checkout@v7
with:
submodules: true

- name: Enable Corepack
run: corepack enable

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22.x'
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile

# The gate reads each package's DECLARED type entry. Without this step
# those files do not exist, and the gate FAILS with `unbuilt` rather than
# reporting every README import as fabricated or quietly judging nothing —
# see the "a package whose types are not on disk" section in the script.
- name: Build every package, so the declared type entries exist
run: pnpm exec turbo run build --filter='./packages/*'

# A README in `packages/<pkg>/` teaching `import { X } from
# '@object-ui/<pkg>'` for an `X` the package does not export ships in the
# npm tarball and gives the reader TS2305 or a TypeError. One manual sweep
# (objectui#5043) found it in seven packages and recorded that as a LOWER
# bound, because the method could only see single-line imports.
#
# GREEN AT REST — zero drift in the tree when this landed and there should
# stay zero — so it prints its census rather than a bare "OK", and FAILS if
# the population collapses to nothing.
- name: Check every README self-import against the real export surface
run: pnpm check:readme-exports
65 changes: 65 additions & 0 deletions content/docs/guide/ci-cd-pipeline.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,7 @@ one has its own section below.
| `pre-install-import-graph.yml` | Pre-Install Import Graph Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a gate a workflow runs *before* `pnpm install` reaches a package anywhere in its import graph |
| `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 |
| `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 |
Expand DownExpand Up@@ -894,6 +895,70 @@ those is reported for a human to fix in its own change, not folded into an unrel
finding under `content/docs/**` is an ordinary docs fix. Run it locally with
`pnpm check:shell-escape-residue`, or `node scripts/check-shell-escape-residue.mjs --list` to see the
per-root census. It needs no install and no build.
## README Exports (`readme-exports.yml`)

**Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no
path filter at all**. It appears in the checks list as **README Export Check**.

The absent filter is the point. The two edits that introduce this drift are a README change and a
source change that renames or drops an export, and `ci.yml` structurally cannot see the first: every
one of its jobs opens with the `id: relevant` short-circuit whose diff excludes `**/*.md`, so on a
README-only pull request its expensive steps are skipped by design. A gate against fabricated README
imports living behind that switch would rebuild the hole it exists to close.

Runs `scripts/check-readme-exports.mjs` (`pnpm check:readme-exports`). For every `README.md` under
`packages/`, it extracts the fenced code blocks, parses each one with the TypeScript parser, walks the
`ImportDeclaration` nodes, and for every binding that names the README's **own** package checks the
name against that package's real export surface.

**Why it needed a gate.** A README teaching `import { X } from '@object-ui/<pkg>'` for an `X` the
package does not export gave the reader a `TypeError` at runtime or a TS2305/TS2724 at build time, and
these READMEs are listed in each package's `files`, so they ship in the npm tarball. Nothing checked
them: `check-doc-links.mjs` parses links and never looks inside a code block, and
`check-doc-component-types.mjs` scans `content/docs` and never enters `packages/`. One manual sweep
([#5043](https://github.com/objectstack-ai/objectui/issues/5043)) found drift in **seven** packages
(#5010–#5016) and recorded that number as a *lower bound*, because the method it used could only see
single-line import statements.

**It parses, it does not match.** The card's first sketch was a cross-line regex; measured on
`plugin-gantt` it reported five words of prose as fabricated import names and missed both real
fabrications, because a **side-effect import** (`import '@object-ui/plugin-gantt';`, no `from`) lets a
lazy quantifier run on to the next `from` twenty lines later. Parsing makes that unrepresentable: a
multi-line block is one node, a trailing `//` comment is trivia that can never contribute a name, and
`A as B` exposes the export name separately from the local alias — the gate judges **`A`**.

**The export set is symbols, never a grep.** It comes from the TypeScript checker's
`getExportsOfModule` over each package's *declared* type entry, with aliases resolved before the
value/type flags are read. A text-level set is measurably wrong here: `GanttSchema` grepped in
`packages/types/src` has six hits, every one of them a substring of `ObjectGanttSchema`.

**Three verdicts, because two of them have different fixes.** `real`; `fabricated` (no package exports
it — delete or rename); and `wrong-path` (the name is real but belongs to another package, so the
*path* is what to change). #5010's `CalendarViewSchema` was the third kind, and the first run of this
gate found one more: `packages/core/src/adapters/README.md` imported `ObjectStackAdapter` and
`createObjectStackAdapter` from `@object-ui/core` when both live in `@object-ui/data-objectstack`.

**It builds first, and refuses to guess when it cannot.** The declared type entry is a built
`dist/index.d.ts` for almost every package, so the workflow installs and runs `turbo run build` before
the check (measured cold, concurrency 2, on a contended container: 2m42s for all 39 packages). If a
package's type entry is missing anyway, that is a **failure**, never a skip: counting it as "exports
nothing" would mark every import in its README fabricated, and skipping it would shrink the judged
population with nothing in the output to say so.

**It is green at rest, so its census is part of the verdict** — READMEs scanned, blocks parsed,
bindings judged, packages whose exports were read — and the scan **fails when that population
collapses**. The evidence that it can fail lives in `scripts/__tests__/check-readme-exports.test.ts`,
which plants four mutations on a fixture tree: a fabricated name in a multi-line block, one in a
trailing comment (which must **not** be reported), an `X as Y` with `X` fabricated, and one mid-block
in a type import.

**Out of scope, deliberately:** compiling the extracted blocks (a separate card — it has pre-existing
reds that need a baseline decision first), and authorable-JSON *key* surfaces, which no type check can
reject while `BaseSchema` carries an index signature and its Zod mirror is `.passthrough()`.

**If it fails:** it names the README, the line of the offending specifier, and which package really
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.

## Link Checking (`check-links.yml`)

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,7 @@
"check:pre-install-import-graph": "node scripts/check-pre-install-import-graph.mjs",
"check:vi-mock-specifiers": "node scripts/check-vi-mock-specifiers.mjs",
"check:shell-escape-residue": "node scripts/check-shell-escape-residue.mjs",
"check:readme-exports": "node scripts/check-readme-exports.mjs",
"cli": "node packages/cli/dist/cli.js",
"objectui": "node packages/cli/dist/cli.js",
"create-plugin": "node packages/create-plugin/dist/index.js",
Expand Down
Loading
Loading