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
85 changes: 85 additions & 0 deletions .github/workflows/doc-fence-languages.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
name: Doc Fence Languages

# Why this is its own workflow rather than a step in `ci.yml` or `lint.yml`: the
# defect it exists for arrives in a DOCS-ONLY pull request, and that is exactly
# the shape both of those workflows skip — their "does this change need a full
# run" diff excludes `content/**` and `'**/*.md'`, which is the entire surface
# this gate reads. `doc-component-types.yml`'s header records the reasoning in
# full; this is the sixth instance of the same shape, and `control-bytes.yml`'s
# header names the consequence of getting it wrong: a gate that cannot see a
# markdown-only change "rebuilds the hole it exists to close".
#
# Hence: no `paths` and no `paths-ignore` here, deliberately.
# `scripts/__tests__/check-doc-fence-languages.test.ts` fails if either is ever
# added, and fails too if a second workflow starts running the same script — one
# gate, one home.
#
# It needs no install and no build. The script reads the checkout with `node:fs`
# only — the same 222 documents `check-doc-snippet-types` covers — and it
# deliberately re-implements that gate's document walk rather than importing it,
# because that gate imports `typescript` and an install-gated docs check is one
# a docs-only pull request skips. The copy is held to the original by the pin
# test above, which imports BOTH walks and compares them. Keep this job
# install-free: the moment it needs `pnpm install` it stops being cheap enough
# to run unfiltered, and the filter is the hole.

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 could 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: doc-fence-languages-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
doc-fence-languages:
name: Doc Fence Language Check
runs-on: ubuntu-latest
timeout-minutes: 5

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

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

# `check-doc-snippet-types` compiles `ts` / `tsx` / `typescript` fences and
# nothing else, so a TypeScript block fenced any other way is invisible to
# it — objectui#5867, whose remediation lane collected its population from
# ```plaintext fences only. `plaintext` is not the only spelling of an
# unhighlighted fence: objectui#6135 measured a ```text block opening
# `interface FileUploadSchema {` sitting outside the gate AND outside the
# lane that exists to close it, for no reason but how its fence is spelled.
#
# This gate reads block BODIES with objectui#5867's own triage classifier,
# so no list of languages is on the enforcement path and `txt`, `console`,
# `raw` or a bare fence with no info string at all cannot reopen the gap.
# Today's residue is declared, SHRINK-ONLY, and is that lane's remaining
# population per file.
#
# `--self-test` runs FIRST and is the half that stops the gate rotting into
# decoration: it drives the real scanner over fixture sources, including
# the three spellings named on objectui#6135, and pins the shrink-only
# baseline in every direction it can move. A scanner whose recogniser is
# broken reports a clean tree.
- name: Check that no fence spelling hides a TypeScript block
run: |
node scripts/check-doc-fence-languages.mjs --self-test
node scripts/check-doc-fence-languages.mjs
49 changes: 49 additions & 0 deletions content/docs/guide/ci-cd-pipeline.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@ one has its own section below.
| `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 |
| `doc-fence-languages.yml` | Doc Fence Language Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a TypeScript block sits under a fence the snippet gate does not read |
| `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@@ -673,6 +674,54 @@ at the harness. Either fix what the snippet teaches, or — if the block is genu
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)`).

## Fence Languages (`doc-fence-languages.yml`)

**Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — **no path
filter**, for the same reason as the two sections above. It appears in the checks list as **Doc Fence
Language Check**.

Runs `scripts/check-doc-fence-languages.mjs`. It answers the question the gate above cannot ask about
itself: *is every TypeScript block in the documentation actually fenced as TypeScript?*
`check-doc-snippet-types` reads `ts` / `tsx` / `typescript` fences and nothing else, so a TypeScript
block fenced any other way is invisible to it —
[#5867](https://github.com/objectstack-ai/objectui/issues/5867), whose remediation lane collected its
population from ```plaintext fences only.

**`plaintext` is not the only spelling of an unhighlighted fence.**
[#6135](https://github.com/objectstack-ai/objectui/issues/6135) measured a ```text block opening
`interface FileUploadSchema {` sitting outside the gate *and* outside the lane that exists to close
it, for no reason but how its fence is spelled. Widening the lane's derivation once would fix that
block; it would not stop a sixth spelling reopening the identical gap.

**So it reads bodies, not a list of languages.** No enumeration of allowed fence languages is on the
enforcement path — an enumeration is the thing that rots, and it rots silently. Every fence's body is
put to #5867's own binding triage classifier (*a block whose first line starts with `import` /
`export` / `interface` / `type X =` / `const x: T` is code*), quoted rather than extended. `txt`,
`console`, `raw`, or a bare fence with no info string at all therefore cannot hide a block.

**Two failure modes, because only one can be auto-classified.** A *known* spelling of an
unhighlighted fence (`plaintext`, `text`, `plain`, `txt`, no info string) is #5867's population and
its remedy is mechanical, so it is the only mode the baseline describes. Any *other* spelling might
be a sixth synonym or a real highlighter language — that is a human's call, so it is reported
separately and can **never** be baselined.

**The baseline is #5867's remaining population.** `KNOWN_UNHIGHLIGHTED_TS_FENCES` maps a path to the
number of hidden blocks it carries, ⛔ **shrink-only** in the shape
[#6133](https://github.com/objectstack-ai/objectui/issues/6133) landed for
`KNOWN_HAND_TYPED_GUARDS`: a file not in the map that carries one fails, a file carrying more than
its number fails, and a file carrying fewer fails as *stale* and names itself. Every #5867 batch now
lowers these numbers in the same pull request that re-fences the blocks, so the lane's arithmetic
lives in the repository instead of being re-derived by hand in each handback.

**`--self-test` runs first.** It drives the real scanner over fixture sources — including a
`text`-fenced, a `txt`-fenced and an info-string-less TypeScript block — and pins the shrink-only
baseline in every direction it can move. A scanner whose recogniser is broken reports a clean tree,
which is why the probe runs before the verdict.

**If it fails:** each line is `file:line ```<language> — <first line of the block>`. Re-fence the
block ```ts (or ```tsx) and fix whatever `check-doc-snippets` then reports, then lower the file's
number. Run it locally with `pnpm check:doc-fences`; it needs no install and no build.

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

**Trigger:** Weekly cron (`17 4 * * 0` — Sundays, off the top of the hour, when the scheduled-run
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,6 +54,7 @@
"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",
"check:doc-fences": "node scripts/check-doc-fence-languages.mjs",
"check:eager-closure": "node scripts/check-eager-closure-budget.mjs",
"check:entry-guard": "node scripts/check-entry-guard.mjs",
"cli": "node packages/cli/dist/cli.js",
Expand Down
141 changes: 141 additions & 0 deletions scripts/__tests__/check-doc-fence-languages.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse as parseYaml } from 'yaml';

import { census, listDocuments as fenceDocuments, TS_FENCE_LANGUAGES as GUARD_TS_FENCES } from '../check-doc-fence-languages.mjs';
import { listDocuments as snippetDocuments, TS_FENCE_LANGUAGES as GATE_TS_FENCES } from '../check-doc-snippet-types.mjs';

const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..');
const GUARD = 'scripts/check-doc-fence-languages.mjs';
const WORKFLOW = 'doc-fence-languages.yml';

/**
* objectui#6135. `check-doc-snippet-types` reads `ts` / `tsx` / `typescript`
* fences, so a TypeScript block fenced any other way is invisible to it, and
* objectui#5867's remediation lane collected its population from `plaintext`
* fences only — one spelling of an unhighlighted fence out of several. A
* ```text block opening `interface FileUploadSchema {` was outside both.
*
* `check-doc-fence-languages.mjs` closes that by reading BODIES rather than
* pinning a list of languages. This file pins the two things its correctness
* rests on and cannot check about itself:
*
* 1. **The scan surface really is the gate's.** The guard re-implements
* `listDocuments` so it needs no `pnpm install` — the gate imports
* `typescript`, and an install-gated docs check is one a docs-only pull
* request skips, which is the hole `doc-component-types.yml`'s header
* records. A copy that is never compared is a copy free to drift, and the
* drift direction is silent: a document the guard stops walking is a
* document nothing reports on. So both walks are imported and compared.
* 2. **The wiring.** A gate nobody runs is indistinguishable from a gate that
* passes (`entry-guard-wiring.test.ts` records that lesson for its own
* subject). The alias, the workflow, the self-test leg and the
* unfiltered triggers are asserted here.
*
* Deliberately NOT asserted: the size of the baseline or the number of blocks it
* carries. Those move with every objectui#5867 batch, and a hand-copied
* enumeration in a test drifts by construction — the lesson `lint-workflow.test.ts`
* records for this repository at length. The guard's own output is the honest
* place for those numbers.
*/
describe('check-doc-fence-languages: the scan surface is check-doc-snippet-types’s', () => {
it('walks exactly the documents the snippet gate walks', () => {
expect(fenceDocuments(ROOT)).toEqual(snippetDocuments(ROOT));
});

it('…and that is a non-empty set, so the comparison is not vacuous', () => {
expect(fenceDocuments(ROOT).length).toBeGreaterThan(100);
});

it('treats exactly the snippet gate’s fence languages as already-covered', () => {
expect([...GUARD_TS_FENCES].sort()).toEqual([...GATE_TS_FENCES].sort());
});
});

describe('check-doc-fence-languages: non-vacuity, through the shipped module', () => {
const TS_BODY = 'interface FileUploadSchema {\n accept?: string;\n}';
const doc = (info: string, body: string = TS_BODY) => [
{ rel: 'probe.mdx', source: ['```' + info, body, '```'].join('\n') },
];
const modes = (info: string, body?: string) =>
census(doc(info, body)).sites.map((s: { mode: string }) => s.mode);

// The three spellings the 2026-08-24 ruling on objectui#6135 named by hand.
it.each(['text', 'txt', ''])('a TypeScript block fenced %o is found', (info) => {
expect(modes(info)).toEqual(['synonym']);
});

it('names the file and the fence line, rather than only counting', () => {
const [site] = census(doc('text')).sites;
expect(site).toMatchObject({ rel: 'probe.mdx', line: 1, language: 'text', mode: 'synonym' });
});

it.each(['ts', 'tsx', 'typescript'])('the same block fenced %o is not a finding', (info) => {
expect(modes(info)).toEqual([]);
});

it('a spelling nobody has thought of is the OTHER failure mode', () => {
expect(modes('console')).toEqual(['unknown']);
});

it('prose under an unhighlighted fence is not a finding — the classifier is quoted, not widened', () => {
expect(modes('plaintext', 'Upload a file, then press Save.')).toEqual([]);
});
});

describe('check-doc-fence-languages is wired, not merely present', () => {
const workflow = parseYaml(fs.readFileSync(path.join(ROOT, '.github/workflows', WORKFLOW), 'utf8'));
const steps: Array<Record<string, unknown>> = workflow.jobs['doc-fence-languages'].steps;
const gateSteps = steps.filter((s) => typeof s.run === 'string' && (s.run as string).includes(GUARD));

it('the guard script exists', () => {
expect(fs.existsSync(path.join(ROOT, GUARD))).toBe(true);
});

it('package.json aliases it, and the alias points at the script that exists', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
expect(pkg.scripts['check:doc-fences']).toContain(GUARD);
});

it('the workflow runs it — one step, both legs', () => {
expect(gateSteps).toHaveLength(1);
const run = gateSteps[0].run as string;
expect(run).toContain(`node ${GUARD} --self-test`);
expect(run.split('\n').some((l) => l.trim() === `node ${GUARD}`)).toBe(true);
});

it('one gate, one home — no other workflow runs the same script', () => {
const dir = path.join(ROOT, '.github/workflows');
const others = fs
.readdirSync(dir)
.filter((f) => f.endsWith('.yml') && f !== WORKFLOW)
.filter((f) => fs.readFileSync(path.join(dir, f), 'utf8').includes(GUARD));
expect(others).toEqual([]);
});

it('has no paths filter — the defect it catches arrives in a docs-only pull request', () => {
// `on` parses as the boolean `true` in YAML 1.1; `yaml` gives back `on`.
const on = workflow.on ?? workflow[true as unknown as string];
expect(Object.keys(on)).toContain('pull_request');
expect(on.pull_request).not.toHaveProperty('paths');
expect(on.pull_request).not.toHaveProperty('paths-ignore');
});

it('subscribes to the merge queue, so it cannot stall one if it becomes required', () => {
const on = workflow.on ?? workflow[true as unknown as string];
expect(Object.keys(on)).toContain('merge_group');
});

it('needs no install — nothing in the job runs pnpm', () => {
const runs = steps.map((s) => (typeof s.run === 'string' ? s.run : '')).join('\n');
expect(runs).not.toContain('pnpm');
});

it('its self-test passes — the half that makes a green scan mean something', () => {
const out = execFileSync('node', [GUARD, '--self-test'], { cwd: ROOT, encoding: 'utf8' });
expect(out).toMatch(/check-doc-fence-languages self-test: \d+ cases pass/);
});
});
Loading
Loading