Skip to content

Commit 0262462

Browse files
os-zhuangclaude
andcommitted
fix(spec): regenerate drifted skill references + react-blocks contract, and gate them in CI
skills/*/references/_index.md and the objectstack-ui react-blocks contract are generated from packages/spec/src and committed, but nothing regenerated them in CI — no workflow referenced either generator, the spec `build` script does not run them, and neither had a `check:` mode. They drifted. Unlike the reference docs (#3134), these ship to third parties via `npx skills add`, so the drift is served straight to consumers' agents. An independent oracle over the committed indexes: 6 of 113 schema pointers named files that no longer exist — `automation/workflow.zod.ts` (deleted by #1398), `system/masking.zod.ts` (deleted), `data/dataset.zod.ts` (renamed to `data/seed.zod.ts` by #1620). After this change: 126 pointers, 0 broken. Regenerating alone was not enough. SKILL_MAP still named `data/dataset.zod.ts`, and the generator warned-and-skipped on the missing file, so a plain regen would have quietly dropped seed coverage from objectstack-data — whose own SKILL.md advertises `defineSeed()` authoring — and the new gate would then have locked that in as correct. Point the map at `data/seed.zod.ts` and make a dangling entry a hard failure in both modes: the map is authored config, so a dangling entry is always a bug in it, never a spec change to absorb. Gate mechanics follow build-docs.ts (#3134): every write goes through `emit()`, every wholesale-regenerated folder through `manageDir()`, and nothing touches disk until `flush()` — so write and --check share all generation logic and differ only in disposition. Extracted to scripts/lib/generated-output.ts rather than copy-pasted. `manageDir()` takes an ownership predicate because a skill's references/ folder is only partially generated: `_index.md` is ours, but `react-blocks.md` (sibling generator) and hand-written notes must survive. Placed in lint.yml's "TypeScript Type Check", not ci.yml's check-generated job. That job is paths-filtered on skills/** and build-skill-docs.ts but NOT on packages/spec/src/**, so it would skip exactly the spec-only PRs that cause this drift — and it is not a required check, so it could be merged past anyway. The type-check job has no paths filter and IS required. Both gates read src/ via tsx and need no build (verified with every workspace dist/ removed), so they run before the workspace build and fail in seconds. Each gate was proven to go red before being trusted (13/13): stale content, missing file, stale untracked leftover, dangling SKILL_MAP entry, missing skill dir, and a spec change the contract had not absorbed. The stale-leftover case is why manageDir exists — `git diff --exit-code` returns 0 on it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ce468c8 commit 0262462

13 files changed

Lines changed: 310 additions & 60 deletions

File tree

.github/workflows/lint.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,30 @@ jobs:
119119
- name: Type check (@objectstack/spec)
120120
run: pnpm --filter @objectstack/spec exec tsc --noEmit
121121

122+
# Generated-skill gate: skills/*/references/_index.md and the objectstack-ui
123+
# react-blocks contract are generated from packages/spec/src and committed.
124+
# Nothing regenerated them in CI, so they drifted — and unlike the reference
125+
# docs these ship to third parties via `npx skills add objectstack-ai/framework`,
126+
# so the drift is served straight to consumers' agents.
127+
#
128+
# Deliberately here rather than in ci.yml's `check-generated` job: that job is
129+
# paths-filtered on skills/** and the build-skill-docs.ts script, NOT on
130+
# packages/spec/src/**, so it skips exactly the spec-only PRs that cause this
131+
# drift — and it is not a required check either, so it could be merged past
132+
# even when it does run. This job has no paths filter and IS required
133+
# ("TypeScript Type Check"), so the gate can neither go dormant nor be bypassed.
134+
#
135+
# Both read packages/spec/src directly via tsx and need no build (verified with
136+
# every workspace dist/ removed), so they run before the workspace build and
137+
# fail in seconds. `check:skill-refs` also fails on a SKILL_MAP entry pointing
138+
# at a file the spec no longer has — the silent skip that let the map keep
139+
# naming data/dataset.zod.ts for a year after #1620 renamed it to data/seed.zod.ts.
140+
- name: Check generated skill references are in sync with the spec
141+
run: pnpm --filter @objectstack/spec check:skill-refs
142+
143+
- name: Check the react-blocks contract is in sync with the spec
144+
run: pnpm --filter @objectstack/spec check:react-blocks
145+
122146
# Example apps are AI-authoring reference templates; a red typecheck is a
123147
# bad signal to copy from. tsup transpiles them without a full typecheck,
124148
# so build alone will not catch type drift — typecheck them explicitly.

packages/spec/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@
189189
"gen:openapi": "tsx scripts/build-openapi.ts",
190190
"gen:docs": "tsx scripts/build-docs.ts",
191191
"gen:skill-refs": "tsx scripts/build-skill-references.ts",
192+
"check:skill-refs": "tsx scripts/build-skill-references.ts --check",
192193
"gen:skill-docs": "tsx scripts/build-skill-docs.ts",
193194
"check:skill-docs": "tsx scripts/build-skill-docs.ts --check",
194195
"analyze": "tsx scripts/analyze-bundle-size.ts",
@@ -204,6 +205,7 @@
204205
"test:coverage": "vitest run --coverage",
205206
"check:liveness": "tsx scripts/liveness/check-liveness.mts",
206207
"gen:react-blocks": "tsx scripts/build-react-blocks-contract.ts",
208+
"check:react-blocks": "tsx scripts/build-react-blocks-contract.ts --check",
207209
"check:react-conformance": "tsx scripts/check-react-blocks-conformance.ts"
208210
},
209211
"keywords": [

packages/spec/scripts/build-react-blocks-contract.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,24 @@
88
// - skills/objectstack-ui/contracts/react-blocks.contract.json (machine)
99
// - skills/objectstack-ui/references/react-blocks.md (AI-facing)
1010
//
11-
// Run: pnpm --filter @objectstack/spec gen:react-blocks
11+
// Run:
12+
// pnpm --filter @objectstack/spec gen:react-blocks # write
13+
// pnpm --filter @objectstack/spec check:react-blocks # verify in sync (CI)
1214

1315
process.env.OS_EAGER_SCHEMAS = '1';
1416

15-
import fs from 'fs';
1617
import path from 'path';
1718
import { z } from 'zod';
1819
import { REACT_BLOCKS, type ReactInteractionProp } from '../src/ui/react-blocks';
20+
import { createSink } from './lib/generated-output';
1921

2022
const REPO = path.resolve(__dirname, '../../..');
2123
const OUT_JSON = path.join(REPO, 'skills/objectstack-ui/contracts/react-blocks.contract.json');
2224
const OUT_MD = path.join(REPO, 'skills/objectstack-ui/references/react-blocks.md');
2325

26+
const CHECK = process.argv.includes('--check');
27+
const { emit, flush } = createSink({ check: CHECK, repoRoot: REPO });
28+
2429
// ---- JSON-schema prop extraction -----------------------------------------
2530
function resolveRoot(js: any): any {
2631
// zod v4 may wrap the root in { $ref: '#/$defs/X', $defs: { X: {...} } }.
@@ -100,7 +105,7 @@ const contract = {
100105
note: "Props each component accepts in kind:'react' page source. Reference blocks by their PascalCase tag. kind: data=declarative config (from the spec schema) · binding=connects to data · controlled=React state · callback=React function. These blocks are for DATA. Live data: const adapter = useAdapter(); adapter.find/findOne/create/update. STYLING (ADR-0065) — a page's source is runtime metadata, so the console's build-time Tailwind NEVER scans it: utility classNAMES silently produce no CSS. Do NOT use Tailwind className in page source. (a) Layout/chrome: inline style={} with hsl(var(--token)) theme colors — e.g. color:'hsl(var(--foreground))', background:'hsl(var(--card))', border:'1px solid hsl(var(--border))', and px/flex for layout. (b) Overlays: render <ObjectForm formType='drawer'|'modal' open onOpenChange> (a pre-styled Sheet/Dialog) — never hand-roll a fixed inset-0 backdrop.",
101106
blocks,
102107
};
103-
fs.writeFileSync(OUT_JSON, JSON.stringify(contract, null, 2) + '\n');
108+
emit(OUT_JSON, JSON.stringify(contract, null, 2) + '\n');
104109

105110
// markdown
106111
const esc = (s: string) => String(s).replace(/\|/g, '\\|');
@@ -134,7 +139,30 @@ L.push('## Injected scope (closure variables, reference directly — not props)'
134139
L.push('');
135140
L.push('`React` · `useAdapter` · `data` · `variables` · `page`. Kanban/calendar/gantt/timeline/map of an object = `<ListView navigation={…} />` with the matching visualization, or `<Block type="object-kanban" …/>`.');
136141
L.push('');
137-
fs.writeFileSync(OUT_MD, L.join('\n'));
142+
emit(OUT_MD, L.join('\n'));
138143

139-
console.log(`react-blocks contract: ${blocks.length} blocks → ${path.relative(REPO, OUT_JSON)} + ${path.relative(REPO, OUT_MD)}`);
144+
console.log(`react-blocks contract: ${blocks.length} blocks → ${path.relative(REPO, OUT_JSON)} + ${path.relative(REPO, OUT_MD)}`);
140145
for (const b of blocks) console.log(` <${b.tag}> ${b.props.length} props`);
146+
147+
flush({
148+
surface: 'skills/objectstack-ui/ react-blocks contract',
149+
regenerate: ' pnpm --filter @objectstack/spec gen:react-blocks\n git add skills/objectstack-ui',
150+
guard: () => {
151+
// Guard the two ways this generator can emit a plausible-but-empty contract
152+
// and have --check compare it against nothing meaningful.
153+
if (blocks.length === 0) return 'REACT_BLOCKS is empty — nothing to generate from packages/spec/src/ui/react-blocks.ts.';
154+
// `dataProps()` swallows a z.toJSONSchema failure and returns [], so schemas
155+
// that fail to load degrade to a contract carrying only the hand-authored
156+
// overlay props rather than crashing. Checking `props.length` would miss it
157+
// (the overlay is still there) — the data props are what vanish.
158+
const specBacked = blocks.filter((b) => b.specSchema);
159+
const withData = specBacked.filter((b) => b.props.some((p) => p.kind === 'data'));
160+
if (specBacked.length > 0 && withData.length === 0) {
161+
return (
162+
`All ${specBacked.length} spec-backed blocks resolved 0 data props — the spec schemas failed to load\n` +
163+
` (z.toJSONSchema errors are swallowed in dataProps()), so this is the overlay alone, not a real contract.`
164+
);
165+
}
166+
return null;
167+
},
168+
});

packages/spec/scripts/build-skill-references.ts

Lines changed: 68 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,14 @@
1818
* 3. Writes `skills/{name}/references/_index.md` with pointers + one-line
1919
* descriptions extracted from each file's leading JSDoc comment
2020
*
21-
* Usage: tsx scripts/build-skill-references.ts
21+
* Usage:
22+
* tsx scripts/build-skill-references.ts # write
23+
* tsx scripts/build-skill-references.ts --check # verify in sync (CI); exit 1 on drift
2224
*/
2325

2426
import fs from 'fs';
2527
import path from 'path';
28+
import { createSink, type Owns } from './lib/generated-output';
2629

2730
// ── Paths ────────────────────────────────────────────────────────────────────
2831

@@ -31,6 +34,9 @@ const SPEC_SRC = path.resolve(__dirname, '../src');
3134
const SKILLS_DIR = path.resolve(REPO_ROOT, 'skills');
3235
const SPEC_PKG = '@objectstack/spec';
3336

37+
const CHECK = process.argv.includes('--check');
38+
const { emit, manageDir, flush } = createSink({ check: CHECK, repoRoot: REPO_ROOT });
39+
3440
// ── Skill → Zod file mapping ────────────────────────────────────────────────
3541
// Paths are relative to packages/spec/src/ (category/file.zod.ts)
3642

@@ -41,7 +47,7 @@ const SKILL_MAP: Record<string, string[]> = {
4147
'data/validation.zod.ts',
4248
'data/hook.zod.ts',
4349
'data/datasource.zod.ts',
44-
'data/dataset.zod.ts',
50+
'data/seed.zod.ts',
4551
'security/permission.zod.ts',
4652
],
4753
'objectstack-query': [
@@ -94,7 +100,7 @@ const SKILL_MAP: Record<string, string[]> = {
94100
// project setup (was objectstack-quickstart)
95101
'kernel/manifest.zod.ts',
96102
'data/datasource.zod.ts',
97-
'data/dataset.zod.ts',
103+
'data/seed.zod.ts',
98104
// plugin development (was objectstack-plugin)
99105
'kernel/plugin.zod.ts',
100106
'kernel/context.zod.ts',
@@ -130,23 +136,34 @@ function extractLocalImports(filePath: string): string[] {
130136
return imports;
131137
}
132138

133-
function resolveAll(entryFiles: string[]): string[] {
139+
/**
140+
* Transitive closure of a skill's core files.
141+
*
142+
* Only SKILL_MAP entries can be `missing` — `extractLocalImports` resolves
143+
* against disk, so a dep that doesn't exist is never queued. A dangling entry
144+
* is therefore an authoring bug in SKILL_MAP, not a spec change to absorb, and
145+
* the caller fails on it: silently dropping the pointer is how the skill ends
146+
* up advertising a schema it no longer references (`data/dataset.zod.ts`
147+
* lingered here for a year after #1620 renamed it to `data/seed.zod.ts`).
148+
*/
149+
function resolveAll(entryFiles: string[]): { files: string[]; missing: string[] } {
134150
const visited = new Set<string>();
151+
const missing: string[] = [];
135152
const queue = [...entryFiles];
136153
while (queue.length > 0) {
137154
const rel = queue.shift()!;
138155
if (visited.has(rel)) continue;
139156
const abs = path.resolve(SPEC_SRC, rel);
140157
if (!fs.existsSync(abs)) {
141-
console.warn(` ⚠ File not found: ${rel} (skipped)`);
158+
missing.push(rel);
142159
continue;
143160
}
144161
visited.add(rel);
145162
for (const dep of extractLocalImports(abs)) {
146163
if (!visited.has(dep)) queue.push(dep);
147164
}
148165
}
149-
return [...visited].sort();
166+
return { files: [...visited].sort(), missing };
150167
}
151168

152169
// ── JSDoc description extractor ──────────────────────────────────────────────
@@ -226,57 +243,73 @@ function generateIndex(skillName: string, coreFiles: string[], allFiles: string[
226243
return lines.join('\n');
227244
}
228245

229-
// ── Cleanup helper ───────────────────────────────────────────────────────────
246+
// ── Managed scope ────────────────────────────────────────────────────────────
230247

231-
function cleanReferencesDir(refsDir: string) {
232-
if (!fs.existsSync(refsDir)) {
233-
fs.mkdirSync(refsDir, { recursive: true });
234-
return;
235-
}
236-
for (const entry of fs.readdirSync(refsDir)) {
237-
// Keep hand-written markdown other than _index.md untouched.
238-
if (entry === '_index.md') {
239-
fs.rmSync(path.resolve(refsDir, entry));
240-
continue;
241-
}
242-
const entryPath = path.resolve(refsDir, entry);
243-
const stat = fs.statSync(entryPath);
244-
if (stat.isDirectory()) {
245-
fs.rmSync(entryPath, { recursive: true });
246-
} else if (entry.endsWith('.zod.ts')) {
247-
fs.rmSync(entryPath);
248-
}
249-
}
248+
/**
249+
* Which paths under a skill's `references/` folder this generator owns — i.e.
250+
* would delete and rewrite on a real run, and must therefore flag as stale
251+
* under `--check`.
252+
*
253+
* The folder is only *partially* ours: `react-blocks.md` is written by
254+
* build-react-blocks-contract.ts and hand-written notes are allowed, so both
255+
* are left alone. Subfolders and loose `*.zod.ts` are leftovers from the
256+
* retired bundled-schema layout (skills used to carry copies of the schemas);
257+
* we still sweep them so an old checkout converges.
258+
*/
259+
function ownsReferenceEntry(refsDir: string): Owns {
260+
return (abs) => {
261+
const rel = path.relative(refsDir, abs);
262+
if (!rel || rel.startsWith('..')) return false;
263+
if (rel.includes(path.sep)) return true; // inside a subfolder → wiped wholesale
264+
return rel === '_index.md' || rel.endsWith('.zod.ts');
265+
};
250266
}
251267

252268
// ── Main ─────────────────────────────────────────────────────────────────────
253269

254270
function main() {
255271
console.log('🔗 Building skill schema reference indexes...\n');
272+
const problems: string[] = [];
256273
let totalSkills = 0;
257274

258275
for (const [skillName, coreFiles] of Object.entries(SKILL_MAP)) {
259276
const skillDir = path.resolve(SKILLS_DIR, skillName);
260277
if (!fs.existsSync(skillDir)) {
261-
console.warn(`⚠ Skill directory not found: ${skillName}, skipping`);
278+
problems.push(`${skillName} → no such skill directory under skills/`);
262279
continue;
263280
}
264281

265282
console.log(`📦 ${skillName}`);
266-
const allFiles = resolveAll(coreFiles);
283+
const { files: allFiles, missing } = resolveAll(coreFiles);
284+
for (const m of missing) problems.push(`${skillName}${m} (no such file under packages/spec/src)`);
267285
console.log(` ${coreFiles.length} core + ${allFiles.length - coreFiles.length} deps`);
268286

269287
const refsDir = path.resolve(skillDir, 'references');
270-
cleanReferencesDir(refsDir);
271-
272-
fs.writeFileSync(
273-
path.resolve(refsDir, '_index.md'),
274-
generateIndex(skillName, coreFiles, allFiles),
275-
);
288+
manageDir(refsDir, ownsReferenceEntry(refsDir));
289+
emit(path.resolve(refsDir, '_index.md'), generateIndex(skillName, coreFiles, allFiles));
276290
totalSkills += 1;
277291
}
278292

279-
console.log(`\n✅ Done — ${totalSkills} skill index files written`);
293+
flush({
294+
surface: 'skills/*/references/_index.md',
295+
regenerate: ' pnpm --filter @objectstack/spec gen:skill-refs\n git add skills',
296+
guard: () => {
297+
// A dangling SKILL_MAP entry drops a schema pointer from a shipped skill.
298+
// Dropping it quietly is what this generator used to do; fail instead, in
299+
// both modes — the map is authored config, so this is always a bug in it.
300+
if (problems.length) {
301+
return (
302+
`SKILL_MAP is out of sync with packages/spec/src:\n\n` +
303+
problems.map((p) => ` - ${p}`).join('\n') +
304+
`\n\n Fix the mapping in ${path.relative(REPO_ROOT, __filename)} — point it at the\n` +
305+
` schema's current path, or drop the entry if the concept is gone.`
306+
);
307+
}
308+
// Nothing emitted means nothing compared; "no drift" would read as green.
309+
if (totalSkills === 0) return `No skills found under ${path.relative(REPO_ROOT, SKILLS_DIR)} — nothing to generate.`;
310+
return null;
311+
},
312+
});
280313
}
281314

282315
main();

0 commit comments

Comments
 (0)