Skip to content

Commit 6cc73e3

Browse files
os-zhuangclaude
andauthored
fix(spec): regenerate drifted skill references + react-blocks contract, and gate them in CI (#3138)
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. Gate mechanics follow build-docs.ts (#3134): every write goes through emit(), every wholesale-regenerated folder through manageDir(), 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. 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" (no paths filter + required), not ci.yml's check-generated: that job's filter lists specific spec paths but no schema dirs, so a PR touching src/data/** or src/ui/** never triggers it, and it is not required either. Both gates need no build, 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. Follow-up: #3139 (import regex is blind to multi-line imports). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3b6ef8a commit 6cc73e3

13 files changed

Lines changed: 309 additions & 60 deletions

File tree

.github/workflows/lint.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,29 @@ jobs:
144144
- name: Check generated reference docs are in sync with the spec
145145
run: pnpm --filter @objectstack/spec check:docs
146146

147+
# Same class, same reasoning, different surface: skills/*/references/_index.md
148+
# and the objectstack-ui react-blocks contract are generated from
149+
# packages/spec/src and committed, and nothing regenerated them either. These
150+
# ship to third parties via `npx skills add objectstack-ai/framework`, so the
151+
# drift is served straight to consumers' agents — 6 of 113 schema pointers named
152+
# files the spec had already deleted or renamed.
153+
#
154+
# Not in ci.yml's `check-generated` job for the reason above: its `generated`
155+
# filter lists specific spec paths (migrations/, conversions/, protocol-version)
156+
# but no schema dirs, so a PR touching src/data/** or src/ui/** — exactly what
157+
# drives these two artifacts — never triggers it. It is not required, either.
158+
#
159+
# Both read packages/spec/src via tsx and need no build (verified with every
160+
# workspace dist/ removed), so they run before the workspace build. check:skill-refs
161+
# additionally fails on a SKILL_MAP entry naming a file the spec no longer has:
162+
# that silent skip is what let the map keep pointing at data/dataset.zod.ts for a
163+
# year after #1620 renamed it to data/seed.zod.ts.
164+
- name: Check generated skill references are in sync with the spec
165+
run: pnpm --filter @objectstack/spec check:skill-refs
166+
167+
- name: Check the react-blocks contract is in sync with the spec
168+
run: pnpm --filter @objectstack/spec check:react-blocks
169+
147170
# Example apps are AI-authoring reference templates; a red typecheck is a
148171
# bad signal to copy from. tsup transpiles them without a full typecheck,
149172
# 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
@@ -190,6 +190,7 @@
190190
"gen:docs": "tsx scripts/build-docs.ts",
191191
"check:docs": "pnpm gen:schema && tsx scripts/build-docs.ts --check",
192192
"gen:skill-refs": "tsx scripts/build-skill-references.ts",
193+
"check:skill-refs": "tsx scripts/build-skill-references.ts --check",
193194
"gen:skill-docs": "tsx scripts/build-skill-docs.ts",
194195
"check:skill-docs": "tsx scripts/build-skill-docs.ts --check",
195196
"analyze": "tsx scripts/analyze-bundle-size.ts",
@@ -205,6 +206,7 @@
205206
"test:coverage": "vitest run --coverage",
206207
"check:liveness": "tsx scripts/liveness/check-liveness.mts",
207208
"gen:react-blocks": "tsx scripts/build-react-blocks-contract.ts",
209+
"check:react-blocks": "tsx scripts/build-react-blocks-contract.ts --check",
208210
"check:react-conformance": "tsx scripts/check-react-blocks-conformance.ts"
209211
},
210212
"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)