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
39 changes: 39 additions & 0 deletions .changeset/check-docs-no-generator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
"@objectstack/spec": patch
---

`check:docs` 不再以 `gen:schema` 开头 —— 一条名字叫 check 的脚本不该改工作区

`check:docs` 原本是 `pnpm gen:schema && tsx scripts/build-docs.ts --check`。前半截是**生成器**:
`json-schema.manifest.json` 与 `authorable-surface.json` 陈旧时它会把这两个 **tracked** 文件写掉。
于是跑一次「检查」就改了跑它的人的工作区,而且陈旧本身**从未被报告**。这正是 #4711 从 `--check`
里摘掉的缺陷,只是换了个入口(#4723)。

在 `check:generated` 里最难解释:`check:authorable-surface` 排在 `check:docs` 前面,且前者失败**不会**
中止后者。所以 manifest 陈旧时跑一次聚合门禁的结果是 —— 一份红色报告,配一个已经被悄悄修好的文件。

修法与 #4711 同形:**检查只检查,生成交给调用方。**

- `check:docs` = `tsx scripts/build-docs.ts --check`,不再生成任何东西。
- 调用方本来就在生成:CI 的 `check:authorable-surface` 步骤、`check:generated` 的门禁顺序、
`pnpm build`、`apps/docs` 的 build。它们跑的是 `build-schemas.ts --check`,该模式写 gitignored 的
`json-schema/`、拒绝碰任何 tracked 文件(#4711),正是这里唯一合格的「显式先跑一步」。
- 顺带省掉一次重复生成:`check:generated` 与 lint.yml 的 typecheck job 原本各跑两遍 ~1600 个 schema。
实测 `check:docs` 从 8.97s 降到 2.05s。

原来的第一步还**顺手保证了新鲜度**,所以这一半必须补上,否则只是把「改工作区」换成更糟的
「假绿」——对着编辑前生成的树报告「文档已同步」。因此:

- `build-docs.ts` 在**所有模式**下先断言 `packages/spec/json-schema/` 存在且不旧于 `src/`,否则红着退出
并给出 `gen:schema` 命令。写模式尤其要拒绝:陈旧树上的 `gen:docs` 不会失败,它会**写出**陈旧页面,
即 `readsDist` 那个坑挪一个产物(AGENTS.md 记着它的代价)。
- 新鲜度规则 `schemaTreeIsStale()` 与 `distIsStale()` 同住 `scripts/check-regen-pending.mjs`:同一个问题、
三个消费方(生成器、pre-commit 钩子、merge driver 的提示),两份拷贝会朝「拿没人重建过的树渲染出
一个自信页面」的方向漂移(#4675)。与 `distIsStale` 唯一的有意差别是排除 `.test.ts` —— 测试文件不是
`build-schemas.ts` 的输入,算进去会让每个纯测试 PR 都被要求跑一次没有意义的 `gen:schema`。
- `check:generated` 的 GATED 表把这条依赖**声明**出来(`readsSchemaTree`),reconciliation 在生产者缺失或
排在消费者后面时失败 —— 数组字面量的顺序是一条真实依赖,不该靠巧合表达。

对使用者的影响:**`check:docs` 不再自足**。先跑 `pnpm --filter @objectstack/spec build`
(AGENTS.md 里本来就因 `dist` 那条要求先跑),或让 `check:generated` 按顺序跑。忘了也不会得到错的结论 ——
`build-docs.ts` 会指名道姓地拒绝。
32 changes: 27 additions & 5 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -555,11 +555,20 @@ jobs:
# this job one at a time, each with a comment saying the filter had failed
# it; these four finish the migration and retire the duplicate ledger.
#
# Affordable because the work was already being done here: `check:docs`
# below runs `gen:schema` — the same scripts/build-schemas.ts that backs
# `check:authorable-surface` — and that whole step measures 4s in CI. All
# four read source via tsx and need no build, so they run before the
# workspace build, as the gates below already do.
# Affordable because the work was already being done here: one run of
# scripts/build-schemas.ts serves both `check:authorable-surface` and the
# `check:docs` gate below, and that whole step measures 4s in CI. All four
# read source via tsx and need no build, so they run before the workspace
# build, as the gates below already do.
#
# It used to be `check:docs` that ran `gen:schema`, and that is the half
# #4723 removed: `gen:schema` is a generator, so a step named `check:` was
# rewriting two TRACKED files (json-schema.manifest.json,
# authorable-surface.json) whenever they were behind — silently repairing
# locally what it was supposed to report, and running the same ~1600-schema
# generation twice per job. The generation now happens once, in the
# `check:authorable-surface` step below, whose `--check` mode writes the
# gitignored json-schema/ tree and refuses to touch a tracked file (#4711).
- name: Check skill docs are generated from SKILL.md frontmatter
run: pnpm --filter @objectstack/spec check:skill-docs

Expand All @@ -576,6 +585,14 @@ jobs:
# key added to or removed from a schema. #3883 removed three authorable keys
# with every witness green; #3733 did it by accident. ADR-0059 §5 deferred
# this gate until a narrowing actually slipped both — it has.
#
# ⚠ ORDER: this step must stay ABOVE the `check:docs` step below. Its
# `--check` run of scripts/build-schemas.ts writes the gitignored
# packages/spec/json-schema/ tree that `check:docs` renders from — the
# explicit generation that replaced the `gen:schema` `check:docs` used to run
# itself (#4723). Nothing here silently depends on that: `build-docs.ts`
# refuses on a missing or stale tree, so a reordering fails loudly with the
# prescription rather than reporting on last run's schemas.
- name: Check the authorable key surface is recorded and nothing vanished
run: pnpm --filter @objectstack/spec check:authorable-surface

Expand All @@ -591,6 +608,11 @@ jobs:
# job has no paths filter and is a required status check, so the gate cannot go
# dormant. It reads src/ + json-schema/ via tsx and needs no build, so it runs
# before the workspace build and fails in ~2s.
#
# It CONSUMES the json-schema/ tree the `check:authorable-surface` step above
# generated; it no longer generates one itself (#4723). Two things came off
# this step with that: the duplicate ~1600-schema generation, and the tracked
# files a `check:` script had no business writing.
- name: Check generated reference docs are in sync with the spec
run: pnpm --filter @objectstack/spec check:docs

Expand Down
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,20 @@ signal: it rewrites artifacts whose staleness you never saw, so a real semantic
lands silently inside a mechanical diff. Let the check tell you which are stale, then
regenerate those.

**No `check:` script regenerates anything — that is the point of the split, not an
oversight.** `check:docs` used to begin with `pnpm gen:schema`, which rewrites two
*tracked* files (`json-schema.manifest.json`, `authorable-surface.json`) whenever they
are behind: running the gate edited your working tree and reported nothing, so a
`check:generated` run on a stale manifest printed a red `check:authorable-surface`
over a file the gate two lines below had already quietly fixed (#4711, #4723). The
generation belongs to the **caller** now — `pnpm build`, or the
`check:authorable-surface` gate that runs before `check:docs` in both CI and
`check:generated`, whose `--check` mode writes the gitignored `json-schema/` tree and
refuses to touch a tracked one. Consequence for you: **`check:docs` is not
self-sufficient**. Run the `build` line above first (it is already required for the
`dist` caveat below) — `build-docs.ts` refuses on a missing or stale tree and names
the command, so the failure is loud, never a wrong verdict.

The script carries its own ledger of gate → generator and **reconciles it against
`package.json` on every run**, in both directions. A new `check:`/`gen:` script that
nobody classified fails the run rather than quietly dropping out of coverage — the
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@
"gen:authorable-surface-base": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts --update-base",
"gen:openapi": "tsx scripts/build-openapi.ts",
"gen:docs": "tsx scripts/build-docs.ts",
"check:docs": "pnpm gen:schema && tsx scripts/build-docs.ts --check",
"check:docs": "tsx scripts/build-docs.ts --check",
"check:generated": "tsx scripts/check-generated.ts",
"gen:skill-refs": "tsx scripts/build-skill-references.ts",
"check:skill-refs": "tsx scripts/build-skill-references.ts --check",
Expand Down
71 changes: 66 additions & 5 deletions packages/spec/scripts/build-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
import fs from 'fs';
import path from 'path';

// One staleness rule, shared with the merge driver's pre-commit half and with
// `check:generated`'s `--fix` refusal — a second copy of "is this artifact older
// than src" would drift, and the direction it drifts in is the one that renders
// a confident page from a tree nobody rebuilt (#4675, #4723).
import { schemaTreeIsStale } from '../../../scripts/check-regen-pending.mjs';

import {
evaluateBaseline,
loadEntrySurfaces,
Expand Down Expand Up @@ -57,6 +63,50 @@ const IMPORT_BASELINE_PATH = path.resolve(__dirname, '../docs-import-surface.bas
const CHECK = process.argv.includes('--check');
const UPDATE_IMPORT_BASELINE = process.argv.includes('--update-import-baseline');

// ── The input tree is a BUILD ARTIFACT, and it must be current (#4723) ────────
//
// Every mode below renders from `SCHEMA_DIR` — `packages/spec/json-schema/`,
// which is gitignored, so no checkout carries it and nothing in git can tell you
// how old it is.
//
// Until #4723 the question could not come up: `check:docs` was
// `pnpm gen:schema && tsx scripts/build-docs.ts --check`, so the tree was rebuilt
// on every run. That first step is what made a script called `check:` WRITE two
// TRACKED files — `json-schema.manifest.json` and `authorable-surface.json` are
// projections `gen:schema` repairs whenever they are behind — so running the gate
// silently edited the tree of whoever ran it and left the staleness unreported.
// #4711 removed exactly that from `--check`; this was the same defect at a
// different entry, and the fix is the same shape: the check checks, and the
// CALLER generates (lint.yml's `check:authorable-surface` step, `check:generated`'s
// declared gate order, `pnpm build`, `apps/docs`' build).
//
// What the old first step also provided, silently, was FRESHNESS. Dropping it
// without asserting freshness would trade a tracked-file write for something
// worse: a green `check:docs` computed against a tree that predates the edit
// under test — a false green on precisely the change (`.describe()` added, a key
// renamed) this gate exists to catch. So the prerequisite is stated, in every
// mode, and it is fatal rather than a warning: `gen:docs` on a stale tree does
// not fail, it WRITES stale pages, which is the `readsDist` trap one artifact
// over (AGENTS.md records what that one cost).
if (schemaTreeIsStale(path.resolve(__dirname, '..'))) {
const missing = !fs.existsSync(SCHEMA_DIR);
console.error(
`\n❌ ${path.relative(REPO_ROOT, SCHEMA_DIR)} is ${missing ? 'missing' : 'older than packages/spec/src'}.\n\n` +
` The reference docs are rendered from that tree, and it is a gitignored build\n` +
` artifact — nothing in a checkout carries it, and a merge never brings it along.\n` +
` Rendering ${CHECK ? 'a verdict' : 'pages'} from a stale tree would ${
CHECK ? 'report the docs in sync with sources this run never read' : 'WRITE pages describing sources this run never read'
}.\n\n` +
` Generate it first:\n\n` +
` pnpm --filter @objectstack/spec gen:schema\n\n` +
` (\`pnpm --filter @objectstack/spec build\` does this as its first step, and so does\n` +
` \`check:authorable-surface\`, which runs before this gate in CI and in check:generated.\n` +
` This script no longer runs it for you: a check that regenerates is a check that\n` +
` repairs the two tracked projections instead of reporting them — #4711, #4723.)`,
);
process.exit(1);
}

// ── Output sink ──────────────────────────────────────────────────────────────
// Shared with the spec's other generators — see lib/generated-output.ts for why
// the write and --check paths must be the same code.
Expand Down Expand Up @@ -661,7 +711,12 @@ Object.keys(CATEGORIES).forEach(category => {
&& fs.readdirSync(schemaDir).some(f => f.endsWith('.json'));
if (!hasSchemas) {
if (fs.existsSync(dir)) {
console.warn(`⚠ Skipping clean of ${category}/ — no JSON schemas found in ${schemaDir}. Run \`pnpm gen:schema\` first.`);
// NOT "run gen:schema first" any more: the freshness guard at the top of
// this file has already proved the tree is newer than src, so this is the
// steady state for a category whose schemas are all unrepresentable in JSON
// Schema (`contracts/` is the standing example) — the old line sent readers
// after a regeneration that would change nothing (#4723).
console.warn(`⚠ Skipping clean of ${category}/ — this build published no JSON Schema under ${schemaDir}; leaving its pages as they are.`);
}
return;
}
Expand Down Expand Up @@ -938,13 +993,19 @@ flush({
regenerate:
' pnpm --filter @objectstack/spec gen:schema && pnpm --filter @objectstack/spec gen:docs\n' +
' git add content/docs/references',
// json-schema/ is gitignored, so a fresh checkout that forgot gen:schema has no
// input at all: every category is skipped, nothing is managed, and "nothing
// differs" would read as success — green while checking no pages. Fail loudly.
// Backstop to the freshness guard at the top of this file. That one catches the
// common shape — an absent or stale tree — before a single page is rendered.
// This one catches what mtimes cannot see: a tree that is NEWER than src and
// still has no category with schemas in it (a truncated or half-written
// generation). Either way "nothing differs" must never read as success — green
// while checking no pages is the silent shape this whole file guards against.
// `check:docs` no longer regenerates for you, deliberately: that first step is
// what made a check repair two tracked projections (#4711, #4723).
guard: () =>
managedCount === 0
? `No JSON schemas found under ${path.relative(REPO_ROOT, SCHEMA_DIR)} — nothing to check against.\n` +
' Run `pnpm --filter @objectstack/spec gen:schema` first (`check:docs` does this for you).'
' The tree is newer than packages/spec/src but published no category, which means a\n' +
' partial generation. Run `pnpm --filter @objectstack/spec gen:schema` again.'
: null,
});

Expand Down
77 changes: 77 additions & 0 deletions packages/spec/scripts/check-generated-ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,81 @@ describe('check:generated --reconcile-only', () => {
expect(scripts['gen:test-typecheck-debt']).toBeDefined();
expect(runReconcile().status).toBe(0);
});

// ───────────────────────────────────────────────────────────────────────────
// #4723 — no `check:` script may run a `gen:` script.
//
// The defect this pins is a COMPOSITION, one line of package.json, and that is
// why it is pinned here rather than by an end-to-end run: nothing has to be
// executed to see it, and a source-level assertion cannot go quiet the way a
// spawned run can when its gitignored input is absent (which is the state
// `turbo run test` leaves this package in — see root-index.test.ts).
//
// `check:docs` was `pnpm gen:schema && tsx scripts/build-docs.ts --check`. The
// first half is a GENERATOR: on a stale tree it rewrites `json-schema.manifest.json`
// and `authorable-surface.json`, both TRACKED. So running the gate edited the
// working tree of whoever ran it, and — because `check:generated` runs
// `check:authorable-surface` first and does not stop on failure — a single
// aggregate run produced a red report about a manifest that the gate two lines
// below had already quietly fixed. #4711 removed exactly this from `--check`;
// this was the same defect at a different entry.
//
// Stated as the CLASS rather than the one instance, because the class is what
// came back: a check that repairs what it detects can never report it.
describe('no check: script composes a gen: script (#4711, #4723)', () => {
const generatorNames = gens;

it('is true of every check: script in this package', () => {
const offenders = checks
.map((name) => ({
name,
runs: generatorNames.filter((g) =>
// The composition spellings pnpm accepts. Matched with the boundary
// included so `gen:schema` does not also match `gen:schema-foo`.
new RegExp(`pnpm(?: run)? ${g.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w:-])`).test(scripts[name]),
),
}))
.filter((o) => o.runs.length > 0);

expect(
offenders,
offenders
.map(
(o) =>
`\`${o.name}\` runs the generator(s) ${o.runs.map((r) => `\`${r}\``).join(', ')}:\n` +
` ${scripts[o.name]}\n` +
' A gate that regenerates repairs the tracked artifact it is supposed to report,\n' +
" and silently edits the tree of whoever ran it. Move the generation to the CALLER\n" +
' (the CI step / the check:generated gate order), or make the gate read the build\n' +
' artifact and refuse when it is stale, as build-docs.ts does (#4711, #4723).',
)
.join('\n'),
).toEqual([]);
});

it('leaves check:docs as the read-only half it is named for', () => {
// The specimen, named so a re-composition has to come back through here
// even if the regex above is ever loosened.
expect(scripts['check:docs']).toBe('tsx scripts/build-docs.ts --check');
});
});

it('declares which gate generates the tree check:docs renders from (#4723)', () => {
// With the generation gone from the composition, `check:docs` depends on a
// gitignored build artifact somebody else produced. Inside this aggregate that
// somebody is the gate ORDER — a dependency an array literal expresses by
// accident, so the ledger declares it and the reconciliation enforces it. The
// narration is asserted because an ordering nobody can see is one the next
// tidy-up breaks silently.
const { status, output } = runReconcile();
expect(status, output).toBe(0);
expect(output).toContain(
'check:docs renders from json-schema/, generated by check:authorable-surface above it.',
);
// And the enforcement itself is real, not just printed: the two failure
// sentences exist in the script that would emit them.
const ledger = fs.readFileSync(path.join(HERE, 'check-generated.ts'), 'utf8');
expect(ledger).toContain('BEFORE its declared producer');
expect(ledger).toContain('which this ledger does not run');
});
});
Loading
Loading