Skip to content

Commit d72fb21

Browse files
committed
fix(spec): check:docs 不再以 gen:schema 开头,生成交给调用方 (#4723)
`check:docs` 曾是 `pnpm gen:schema && tsx scripts/build-docs.ts --check`。 前半截是生成器:manifest / authorable-surface 陈旧时它会写掉这两个 tracked 文件。于是跑一次「检查」就改了工作区,而陈旧本身从未被报告 —— 这是 #4711 从 `--check` 里摘掉的同一个缺陷,只是换了个入口。 在 check:generated 里最难解释:check:authorable-surface 排在前面且失败不中止 后续,所以一次聚合运行的结果是一份红色报告配一个已被悄悄修好的文件。 - check:docs 变为纯 `tsx scripts/build-docs.ts --check` - 生成由调用方承担(CI 的 check:authorable-surface 步骤 / check:generated 的 门禁顺序 / pnpm build / apps/docs build);它们跑的 `--check` 只写 gitignored 的 json-schema/,拒绝碰 tracked 文件 - 原第一步顺带保证的「新鲜度」改为显式断言:build-docs.ts 在所有模式下先检查 json-schema/ 存在且不旧于 src,否则红着退出并给出命令 —— 否则只是把「改工作区」 换成更糟的假绿 - 新鲜度规则 schemaTreeIsStale() 与 distIsStale() 同住 check-regen-pending.mjs - check:generated 的 GATED 表声明 readsSchemaTree,reconciliation 强制生产者 必须排在消费者之前 实测:check:docs 8.97s → 2.05s(少跑一次 ~1600 schema 的生成)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01559M8FVm6W6vDLABL3jvdW
1 parent 2381c19 commit d72fb21

11 files changed

Lines changed: 498 additions & 18 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
`check:docs` 不再以 `gen:schema` 开头 —— 一条名字叫 check 的脚本不该改工作区
6+
7+
`check:docs` 原本是 `pnpm gen:schema && tsx scripts/build-docs.ts --check`。前半截是**生成器**:
8+
`json-schema.manifest.json``authorable-surface.json` 陈旧时它会把这两个 **tracked** 文件写掉。
9+
于是跑一次「检查」就改了跑它的人的工作区,而且陈旧本身**从未被报告**。这正是 #4711`--check`
10+
里摘掉的缺陷,只是换了个入口(#4723)。
11+
12+
`check:generated` 里最难解释:`check:authorable-surface` 排在 `check:docs` 前面,且前者失败**不会**
13+
中止后者。所以 manifest 陈旧时跑一次聚合门禁的结果是 —— 一份红色报告,配一个已经被悄悄修好的文件。
14+
15+
修法与 #4711 同形:**检查只检查,生成交给调用方。**
16+
17+
- `check:docs` = `tsx scripts/build-docs.ts --check`,不再生成任何东西。
18+
- 调用方本来就在生成:CI`check:authorable-surface` 步骤、`check:generated` 的门禁顺序、
19+
`pnpm build``apps/docs` 的 build。它们跑的是 `build-schemas.ts --check`,该模式写 gitignored 的
20+
`json-schema/`、拒绝碰任何 tracked 文件(#4711),正是这里唯一合格的「显式先跑一步」。
21+
- 顺带省掉一次重复生成:`check:generated` 与 lint.yml 的 typecheck job 原本各跑两遍 ~1600 个 schema。
22+
实测 `check:docs` 从 8.97s 降到 2.05s。
23+
24+
原来的第一步还**顺手保证了新鲜度**,所以这一半必须补上,否则只是把「改工作区」换成更糟的
25+
「假绿」——对着编辑前生成的树报告「文档已同步」。因此:
26+
27+
- `build-docs.ts`**所有模式**下先断言 `packages/spec/json-schema/` 存在且不旧于 `src/`,否则红着退出
28+
并给出 `gen:schema` 命令。写模式尤其要拒绝:陈旧树上的 `gen:docs` 不会失败,它会**写出**陈旧页面,
29+
`readsDist` 那个坑挪一个产物(AGENTS.md 记着它的代价)。
30+
- 新鲜度规则 `schemaTreeIsStale()``distIsStale()` 同住 `scripts/check-regen-pending.mjs`:同一个问题、
31+
三个消费方(生成器、pre-commit 钩子、merge driver 的提示),两份拷贝会朝「拿没人重建过的树渲染出
32+
一个自信页面」的方向漂移(#4675)。与 `distIsStale` 唯一的有意差别是排除 `.test.ts` —— 测试文件不是
33+
`build-schemas.ts` 的输入,算进去会让每个纯测试 PR 都被要求跑一次没有意义的 `gen:schema`
34+
- `check:generated` 的 GATED 表把这条依赖**声明**出来(`readsSchemaTree`),reconciliation 在生产者缺失或
35+
排在消费者后面时失败 —— 数组字面量的顺序是一条真实依赖,不该靠巧合表达。
36+
37+
对使用者的影响:**`check:docs` 不再自足**。先跑 `pnpm --filter @objectstack/spec build`
38+
(AGENTS.md 里本来就因 `dist` 那条要求先跑),或让 `check:generated` 按顺序跑。忘了也不会得到错的结论 ——
39+
`build-docs.ts` 会指名道姓地拒绝。

.github/workflows/lint.yml

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -555,11 +555,20 @@ jobs:
555555
# this job one at a time, each with a comment saying the filter had failed
556556
# it; these four finish the migration and retire the duplicate ledger.
557557
#
558-
# Affordable because the work was already being done here: `check:docs`
559-
# below runs `gen:schema` — the same scripts/build-schemas.ts that backs
560-
# `check:authorable-surface` — and that whole step measures 4s in CI. All
561-
# four read source via tsx and need no build, so they run before the
562-
# workspace build, as the gates below already do.
558+
# Affordable because the work was already being done here: one run of
559+
# scripts/build-schemas.ts serves both `check:authorable-surface` and the
560+
# `check:docs` gate below, and that whole step measures 4s in CI. All four
561+
# read source via tsx and need no build, so they run before the workspace
562+
# build, as the gates below already do.
563+
#
564+
# It used to be `check:docs` that ran `gen:schema`, and that is the half
565+
# #4723 removed: `gen:schema` is a generator, so a step named `check:` was
566+
# rewriting two TRACKED files (json-schema.manifest.json,
567+
# authorable-surface.json) whenever they were behind — silently repairing
568+
# locally what it was supposed to report, and running the same ~1600-schema
569+
# generation twice per job. The generation now happens once, in the
570+
# `check:authorable-surface` step below, whose `--check` mode writes the
571+
# gitignored json-schema/ tree and refuses to touch a tracked file (#4711).
563572
- name: Check skill docs are generated from SKILL.md frontmatter
564573
run: pnpm --filter @objectstack/spec check:skill-docs
565574

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

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

AGENTS.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,20 @@ signal: it rewrites artifacts whose staleness you never saw, so a real semantic
399399
lands silently inside a mechanical diff. Let the check tell you which are stale, then
400400
regenerate those.
401401

402+
**No `check:` script regenerates anything — that is the point of the split, not an
403+
oversight.** `check:docs` used to begin with `pnpm gen:schema`, which rewrites two
404+
*tracked* files (`json-schema.manifest.json`, `authorable-surface.json`) whenever they
405+
are behind: running the gate edited your working tree and reported nothing, so a
406+
`check:generated` run on a stale manifest printed a red `check:authorable-surface`
407+
over a file the gate two lines below had already quietly fixed (#4711, #4723). The
408+
generation belongs to the **caller** now — `pnpm build`, or the
409+
`check:authorable-surface` gate that runs before `check:docs` in both CI and
410+
`check:generated`, whose `--check` mode writes the gitignored `json-schema/` tree and
411+
refuses to touch a tracked one. Consequence for you: **`check:docs` is not
412+
self-sufficient**. Run the `build` line above first (it is already required for the
413+
`dist` caveat below) — `build-docs.ts` refuses on a missing or stale tree and names
414+
the command, so the failure is loud, never a wrong verdict.
415+
402416
The script carries its own ledger of gate → generator and **reconciles it against
403417
`package.json` on every run**, in both directions. A new `check:`/`gen:` script that
404418
nobody classified fails the run rather than quietly dropping out of coverage — the

packages/spec/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@
189189
"gen:authorable-surface-base": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts --update-base",
190190
"gen:openapi": "tsx scripts/build-openapi.ts",
191191
"gen:docs": "tsx scripts/build-docs.ts",
192-
"check:docs": "pnpm gen:schema && tsx scripts/build-docs.ts --check",
192+
"check:docs": "tsx scripts/build-docs.ts --check",
193193
"check:generated": "tsx scripts/check-generated.ts",
194194
"gen:skill-refs": "tsx scripts/build-skill-references.ts",
195195
"check:skill-refs": "tsx scripts/build-skill-references.ts --check",

packages/spec/scripts/build-docs.ts

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020
import fs from 'fs';
2121
import path from 'path';
2222

23+
// One staleness rule, shared with the merge driver's pre-commit half and with
24+
// `check:generated`'s `--fix` refusal — a second copy of "is this artifact older
25+
// than src" would drift, and the direction it drifts in is the one that renders
26+
// a confident page from a tree nobody rebuilt (#4675, #4723).
27+
import { schemaTreeIsStale } from '../../../scripts/check-regen-pending.mjs';
28+
2329
import {
2430
evaluateBaseline,
2531
loadEntrySurfaces,
@@ -57,6 +63,50 @@ const IMPORT_BASELINE_PATH = path.resolve(__dirname, '../docs-import-surface.bas
5763
const CHECK = process.argv.includes('--check');
5864
const UPDATE_IMPORT_BASELINE = process.argv.includes('--update-import-baseline');
5965

66+
// ── The input tree is a BUILD ARTIFACT, and it must be current (#4723) ────────
67+
//
68+
// Every mode below renders from `SCHEMA_DIR` — `packages/spec/json-schema/`,
69+
// which is gitignored, so no checkout carries it and nothing in git can tell you
70+
// how old it is.
71+
//
72+
// Until #4723 the question could not come up: `check:docs` was
73+
// `pnpm gen:schema && tsx scripts/build-docs.ts --check`, so the tree was rebuilt
74+
// on every run. That first step is what made a script called `check:` WRITE two
75+
// TRACKED files — `json-schema.manifest.json` and `authorable-surface.json` are
76+
// projections `gen:schema` repairs whenever they are behind — so running the gate
77+
// silently edited the tree of whoever ran it and left the staleness unreported.
78+
// #4711 removed exactly that from `--check`; this was the same defect at a
79+
// different entry, and the fix is the same shape: the check checks, and the
80+
// CALLER generates (lint.yml's `check:authorable-surface` step, `check:generated`'s
81+
// declared gate order, `pnpm build`, `apps/docs`' build).
82+
//
83+
// What the old first step also provided, silently, was FRESHNESS. Dropping it
84+
// without asserting freshness would trade a tracked-file write for something
85+
// worse: a green `check:docs` computed against a tree that predates the edit
86+
// under test — a false green on precisely the change (`.describe()` added, a key
87+
// renamed) this gate exists to catch. So the prerequisite is stated, in every
88+
// mode, and it is fatal rather than a warning: `gen:docs` on a stale tree does
89+
// not fail, it WRITES stale pages, which is the `readsDist` trap one artifact
90+
// over (AGENTS.md records what that one cost).
91+
if (schemaTreeIsStale(path.resolve(__dirname, '..'))) {
92+
const missing = !fs.existsSync(SCHEMA_DIR);
93+
console.error(
94+
`\n❌ ${path.relative(REPO_ROOT, SCHEMA_DIR)} is ${missing ? 'missing' : 'older than packages/spec/src'}.\n\n` +
95+
` The reference docs are rendered from that tree, and it is a gitignored build\n` +
96+
` artifact — nothing in a checkout carries it, and a merge never brings it along.\n` +
97+
` Rendering ${CHECK ? 'a verdict' : 'pages'} from a stale tree would ${
98+
CHECK ? 'report the docs in sync with sources this run never read' : 'WRITE pages describing sources this run never read'
99+
}.\n\n` +
100+
` Generate it first:\n\n` +
101+
` pnpm --filter @objectstack/spec gen:schema\n\n` +
102+
` (\`pnpm --filter @objectstack/spec build\` does this as its first step, and so does\n` +
103+
` \`check:authorable-surface\`, which runs before this gate in CI and in check:generated.\n` +
104+
` This script no longer runs it for you: a check that regenerates is a check that\n` +
105+
` repairs the two tracked projections instead of reporting them — #4711, #4723.)`,
106+
);
107+
process.exit(1);
108+
}
109+
60110
// ── Output sink ──────────────────────────────────────────────────────────────
61111
// Shared with the spec's other generators — see lib/generated-output.ts for why
62112
// the write and --check paths must be the same code.
@@ -661,7 +711,12 @@ Object.keys(CATEGORIES).forEach(category => {
661711
&& fs.readdirSync(schemaDir).some(f => f.endsWith('.json'));
662712
if (!hasSchemas) {
663713
if (fs.existsSync(dir)) {
664-
console.warn(`⚠ Skipping clean of ${category}/ — no JSON schemas found in ${schemaDir}. Run \`pnpm gen:schema\` first.`);
714+
// NOT "run gen:schema first" any more: the freshness guard at the top of
715+
// this file has already proved the tree is newer than src, so this is the
716+
// steady state for a category whose schemas are all unrepresentable in JSON
717+
// Schema (`contracts/` is the standing example) — the old line sent readers
718+
// after a regeneration that would change nothing (#4723).
719+
console.warn(`⚠ Skipping clean of ${category}/ — this build published no JSON Schema under ${schemaDir}; leaving its pages as they are.`);
665720
}
666721
return;
667722
}
@@ -938,13 +993,19 @@ flush({
938993
regenerate:
939994
' pnpm --filter @objectstack/spec gen:schema && pnpm --filter @objectstack/spec gen:docs\n' +
940995
' git add content/docs/references',
941-
// json-schema/ is gitignored, so a fresh checkout that forgot gen:schema has no
942-
// input at all: every category is skipped, nothing is managed, and "nothing
943-
// differs" would read as success — green while checking no pages. Fail loudly.
996+
// Backstop to the freshness guard at the top of this file. That one catches the
997+
// common shape — an absent or stale tree — before a single page is rendered.
998+
// This one catches what mtimes cannot see: a tree that is NEWER than src and
999+
// still has no category with schemas in it (a truncated or half-written
1000+
// generation). Either way "nothing differs" must never read as success — green
1001+
// while checking no pages is the silent shape this whole file guards against.
1002+
// `check:docs` no longer regenerates for you, deliberately: that first step is
1003+
// what made a check repair two tracked projections (#4711, #4723).
9441004
guard: () =>
9451005
managedCount === 0
9461006
? `No JSON schemas found under ${path.relative(REPO_ROOT, SCHEMA_DIR)} — nothing to check against.\n` +
947-
' Run `pnpm --filter @objectstack/spec gen:schema` first (`check:docs` does this for you).'
1007+
' The tree is newer than packages/spec/src but published no category, which means a\n' +
1008+
' partial generation. Run `pnpm --filter @objectstack/spec gen:schema` again.'
9481009
: null,
9491010
});
9501011

packages/spec/scripts/check-generated-ledger.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,81 @@ describe('check:generated --reconcile-only', () => {
112112
expect(scripts['gen:test-typecheck-debt']).toBeDefined();
113113
expect(runReconcile().status).toBe(0);
114114
});
115+
116+
// ───────────────────────────────────────────────────────────────────────────
117+
// #4723 — no `check:` script may run a `gen:` script.
118+
//
119+
// The defect this pins is a COMPOSITION, one line of package.json, and that is
120+
// why it is pinned here rather than by an end-to-end run: nothing has to be
121+
// executed to see it, and a source-level assertion cannot go quiet the way a
122+
// spawned run can when its gitignored input is absent (which is the state
123+
// `turbo run test` leaves this package in — see root-index.test.ts).
124+
//
125+
// `check:docs` was `pnpm gen:schema && tsx scripts/build-docs.ts --check`. The
126+
// first half is a GENERATOR: on a stale tree it rewrites `json-schema.manifest.json`
127+
// and `authorable-surface.json`, both TRACKED. So running the gate edited the
128+
// working tree of whoever ran it, and — because `check:generated` runs
129+
// `check:authorable-surface` first and does not stop on failure — a single
130+
// aggregate run produced a red report about a manifest that the gate two lines
131+
// below had already quietly fixed. #4711 removed exactly this from `--check`;
132+
// this was the same defect at a different entry.
133+
//
134+
// Stated as the CLASS rather than the one instance, because the class is what
135+
// came back: a check that repairs what it detects can never report it.
136+
describe('no check: script composes a gen: script (#4711, #4723)', () => {
137+
const generatorNames = gens;
138+
139+
it('is true of every check: script in this package', () => {
140+
const offenders = checks
141+
.map((name) => ({
142+
name,
143+
runs: generatorNames.filter((g) =>
144+
// The composition spellings pnpm accepts. Matched with the boundary
145+
// included so `gen:schema` does not also match `gen:schema-foo`.
146+
new RegExp(`pnpm(?: run)? ${g.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w:-])`).test(scripts[name]),
147+
),
148+
}))
149+
.filter((o) => o.runs.length > 0);
150+
151+
expect(
152+
offenders,
153+
offenders
154+
.map(
155+
(o) =>
156+
`\`${o.name}\` runs the generator(s) ${o.runs.map((r) => `\`${r}\``).join(', ')}:\n` +
157+
` ${scripts[o.name]}\n` +
158+
' A gate that regenerates repairs the tracked artifact it is supposed to report,\n' +
159+
" and silently edits the tree of whoever ran it. Move the generation to the CALLER\n" +
160+
' (the CI step / the check:generated gate order), or make the gate read the build\n' +
161+
' artifact and refuse when it is stale, as build-docs.ts does (#4711, #4723).',
162+
)
163+
.join('\n'),
164+
).toEqual([]);
165+
});
166+
167+
it('leaves check:docs as the read-only half it is named for', () => {
168+
// The specimen, named so a re-composition has to come back through here
169+
// even if the regex above is ever loosened.
170+
expect(scripts['check:docs']).toBe('tsx scripts/build-docs.ts --check');
171+
});
172+
});
173+
174+
it('declares which gate generates the tree check:docs renders from (#4723)', () => {
175+
// With the generation gone from the composition, `check:docs` depends on a
176+
// gitignored build artifact somebody else produced. Inside this aggregate that
177+
// somebody is the gate ORDER — a dependency an array literal expresses by
178+
// accident, so the ledger declares it and the reconciliation enforces it. The
179+
// narration is asserted because an ordering nobody can see is one the next
180+
// tidy-up breaks silently.
181+
const { status, output } = runReconcile();
182+
expect(status, output).toBe(0);
183+
expect(output).toContain(
184+
'check:docs renders from json-schema/, generated by check:authorable-surface above it.',
185+
);
186+
// And the enforcement itself is real, not just printed: the two failure
187+
// sentences exist in the script that would emit them.
188+
const ledger = fs.readFileSync(path.join(HERE, 'check-generated.ts'), 'utf8');
189+
expect(ledger).toContain('BEFORE its declared producer');
190+
expect(ledger).toContain('which this ledger does not run');
191+
});
115192
});

0 commit comments

Comments
 (0)