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
96 changes: 96 additions & 0 deletions .github/workflows/doc-snippet-types.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
name: Doc Snippet Types

# Compiles every fenced `ts` / `tsx` snippet in the documents this gate covers,
# `--strict`, against the packages' BUILT `dist/*.d.ts`. The gate itself, the
# fragment rule, the three self-controls and the coverage ledger are documented
# at length in `scripts/check-doc-snippet-types.mjs`.
#
# ── Why this is its own workflow ────────────────────────────────────────────
#
# Same reason `doc-component-types.yml`, `docs-links.yml` and `control-bytes.yml`
# are theirs, and it is worth restating because it is the whole point: the change
# that breaks a documentation snippet is a DOCS-ONLY change, and that is exactly
# the shape `ci.yml`'s expensive jobs short-circuit (their `git diff` excludes
# `content/**` and `'**/*.md'`). A snippet gate wired in there would be blind to
# every pull request most likely to introduce a defect. Hence: no `paths` and no
# `paths-ignore` here, deliberately, and
# `scripts/__tests__/check-doc-snippet-types.test.ts` fails if either is added,
# or if a second workflow starts running the same script.
#
# ── Why it builds, and why that is NOT the build a ruling rejected ──────────
#
# Unlike its install-free sibling `doc-component-types.yml`, this gate cannot
# read the checkout alone: its whole criterion is the PUBLISHED type surface, so
# the packages the covered snippets import have to exist as `dist/*.d.ts` first.
# Resolving against `src/` instead would be a different and weaker check — the
# root `tsconfig.json` maps the workspace to source, so that mistake is one
# inherited config away, and the script's RESOLUTION control fails the run rather
# than letting it pass quietly.
#
# The 2026-08-16 ruling on objectui#4846 (recorded in
# `.github/workflows/published-dist-gate.yml`) rejected a per-PR FULL-REPO build
# — all 39 published packages, on every pull request. This is not that, and the
# difference is mechanical rather than a matter of opinion: the build is filtered
# to the packages the COVERED documents actually import, and that list is emitted
# by the gate itself (`--build-filter`) rather than hand-maintained here. Today
# it is a minority of the workspace. It grows only when coverage grows, and when
# it does, the growth is visible in this job's log rather than hidden in a
# workflow edit.
#
# ⛔ Do not replace the filtered build with `pnpm build`. The filter is the reason
# this job is allowed to run on every pull request at all.

on:
pull_request:
branches: [main, develop]
push:
branches: [main, develop]
# Merge queue (objectui#3523 — `ci.yml`'s trigger block carries the full note).
# A required check that does not report on a queue build stalls the queue until
# the ruleset times it out, so an unfiltered gate subscribes from the start.
merge_group:
types: [checks_requested]
workflow_dispatch:

concurrency:
group: doc-snippet-types-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
doc-snippet-types:
name: Doc Snippet Type Check
runs-on: ubuntu-latest
timeout-minutes: 25

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'
cache: 'pnpm'

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

# The filter comes from the gate, so it can never drift from what the
# covered documents import. A package the snippets need but nothing built
# is reported by the gate as `unbuilt-package` — its own failure reason,
# never as a page full of broken imports.
- name: Derive the packages the covered snippets import
id: filter
run: echo "args=$(node scripts/check-doc-snippet-types.mjs --build-filter)" >> "$GITHUB_OUTPUT"

- name: Build those packages
run: pnpm exec turbo run build ${{ steps.filter.outputs.args }} --concurrency=2

- name: Compile documentation snippets against the built types
run: node scripts/check-doc-snippet-types.mjs
64 changes: 64 additions & 0 deletions content/docs/guide/ci-cd-pipeline.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,7 @@ one has its own section below.
| `docs-links.yml` | Internal Docs Link Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** |
| `skills-paths.yml` | Skill Guide Path Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a path stated in a `skills/` guide does not exist |
| `doc-component-types.yml` | Doc Component Type Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `content/docs/**.mdx` snippet teaches a `type` nothing registers |
| `doc-snippet-types.yml` | Doc Snippet Type Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a covered documentation snippet no longer compiles against the packages' built types |
| `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@@ -590,6 +591,69 @@ spell the registered key (`grep -rn "ComponentRegistry.register(" packages/` for
if the value belongs to another vocabulary — add the declaration with its reason. Run it locally with
`pnpm check:doc-types`.

## Documented Snippet Types (`doc-snippet-types.yml`)

**Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no
path filter at all**, for the same reason as the section above: the change that breaks a
documentation snippet is a docs-only change, and that is exactly the shape `ci.yml`'s expensive jobs
short-circuit. It appears in the checks list as **Doc Snippet Type Check**.

Runs `scripts/check-doc-snippet-types.mjs`, which extracts every fenced `ts` / `tsx` block from the
documents it covers and compiles them `--strict` against each package's **built** `dist/*.d.ts` — the
surface a reader who copies the snippet actually imports.

**The second dimension, and why it is separate from the first.**
`doc-component-types.yml` answers whether a `type` literal names a registered component. It says so
in its own header, and [#5138](https://github.com/objectstack-ai/objectui/issues/5138) measured what
the gap beside it allowed: both plugin-report documents taught the pre-9.0 report form for the whole
interval after the ADR-0021 cutover, and every gate was green on that prose — because the `type`
literals (`summary`, `matrix`, `joined`) were the one thing that was correct, while `objectName`,
`groupingsDown`, an object-shaped `columns` and an import of a type the spec does not export sat
beside them. The harness that catches those had by then been hand-rolled three times, privately, in
[#5053](https://github.com/objectstack-ai/objectui/issues/5053),
[#5060](https://github.com/objectstack-ai/objectui/issues/5060) and
[#5047](https://github.com/objectstack-ai/objectui/issues/5047) — which is what made it consolidation
rather than new capability.

**Why this one builds.** Its criterion is the *published* type surface, so the packages the covered
snippets import must exist as `dist/*.d.ts` first. The build is filtered to exactly those packages,
and the filter is emitted by the gate itself (`node scripts/check-doc-snippet-types.mjs
--build-filter`) rather than hand-maintained in the workflow — so it can never drift from what the
documents import, and the cost grows only when coverage grows. This is deliberately **not** the
per-PR full-repo build the 2026-08-16 ruling on
[#4846](https://github.com/objectstack-ai/objectui/issues/4846) rejected; see *Published Dist Gate*
below.

**Fragments are declared, never guessed.** Documentation legitimately carries partial snippets, so a
block that is not meant to compile carries a marker line immediately above its fence with a written
reason — `{/* doc-snippet: fragment - why */}` in `.mdx`, the HTML-comment form in `.md`. A block
that merely fails to parse is **reported**, never skipped: a skip-on-failure rule turns every real
defect into silence, and degrades exactly as the docs get worse.

**Syntax and semantics are reported apart.** `tsc` reports syntactic diagnostics and, if there are
any, never reports semantic ones — program-wide. #5047 measured a run that printed five parse errors,
zero semantic diagnostics, and read as a meaningful red while proving nothing. So this gate parses
blocks one at a time first, keeps unparseable ones out of the semantic program, tags every failure
`[syntax]` or `[semantic]`, and always prints how many blocks the semantic phase actually judged.

**It proves itself before it judges the docs.** Every run prints three controls: the resolved path
for `@object-ui/types` (which must land in a `dist/*.d.ts` — the root `tsconfig.json` maps the
workspace to *source*, so that substitution is one inherited config away), a planted
`ThisNameIsDefinitelyNotExported` import that must produce TS2305 (a program silently resolving to
`any` reports green forever), and a real import that must be clean (so a broken harness cannot read
as "the docs are full of defects"). A failed control fails the run and says no verdict about the
documents can be read from it.

**Coverage is declared.** A document is covered unless the script's `UNGATED_DOCS` ledger names it
with a reason, so a new page is gated from the day it lands and opting one out is a visible edit.
The ledger is debt with names: those documents are **not** compiled and **not** counted, which the
script's header states plainly rather than letting a green run imply otherwise.

**If it fails:** each line is `file:line TS<code>: <message>`, addressed at the document rather than
at the harness. Either fix what the snippet teaches, or — if the block is genuinely partial — declare
it with a reason. Run it locally with `pnpm check:doc-snippets` (after building the packages it
names: `pnpm exec turbo run build $(node scripts/check-doc-snippet-types.mjs --build-filter)`).

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

**Trigger:** Weekly cron (`17 4 * * 0` — Sundays, off the top of the hour, when the scheduled-run
Expand Down
2 changes: 2 additions & 0 deletions content/docs/plugins/plugin-charts.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -233,6 +233,7 @@ Without lazy loading, this would add 541 KB to your main bundle. With lazy loadi

### Custom Colors

{/* doc-snippet: fragment — shape excerpt; `salesData` is the reader's own rows */}
```tsx
const schema = {
type: 'bar-chart',
Expand All@@ -244,6 +245,7 @@ const schema = {

### Responsive Height

{/* doc-snippet: fragment — shape excerpt; `metricsData` is the reader's own rows */}
```tsx
const schema = {
type: 'chart',
Expand Down
2 changes: 2 additions & 0 deletions content/docs/plugins/plugin-report.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -79,6 +79,7 @@ export const OpportunitiesByStage = defineReport({
defines; `stage` is one of its dimensions. The report picks from that
vocabulary and adds nothing to it.

{/* doc-snippet: fragment — renders the report defined in the block above, with the host's data source */}
```tsx
import { ReportRenderer } from '@object-ui/plugin-report';

Expand DownExpand Up@@ -203,6 +204,7 @@ every aggregated row and matrix cell becomes clickable; supply nothing and
nothing is clickable. The report emits *what was clicked* and the host decides
where that goes, because the renderer only knows dimension names:

{/* doc-snippet: fragment — renders the report defined above and calls the host's own router */}
```tsx
import { ReportRenderer, type DatasetDrillArgs } from '@object-ui/plugin-report';

Expand Down
1 change: 1 addition & 0 deletions content/docs/plugins/plugin-view.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -252,6 +252,7 @@ row — there is no `recordId` to author, because the click chooses the record.
`navigation.mode` decides how it opens, and `onNavigate` is what hands a
page-mode record off to your router:

{/* doc-snippet: fragment — the navigation callback hands off to the host's own router */}
```typescript
import type { ObjectViewSchema } from '@object-ui/types';

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,7 @@
"check:i18n-dead-keys": "node scripts/check-i18n-dead-keys.mjs",
"check:skills-paths": "node scripts/check-skills-paths.mjs",
"check:doc-types": "node scripts/check-doc-component-types.mjs",
"check:doc-snippets": "node scripts/check-doc-snippet-types.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