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
23 changes: 23 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,29 @@ jobs:
- name: Check generated reference docs are in sync with the spec
run: pnpm --filter @objectstack/spec check:docs

# Same class, same reasoning, different surface: skills/*/references/_index.md
# and the objectstack-ui react-blocks contract are generated from
# packages/spec/src and committed, and nothing regenerated them either. These
# ship to third parties via `npx skills add objectstack-ai/framework`, so the
# drift is served straight to consumers' agents — 6 of 113 schema pointers named
# files the spec had already deleted or renamed.
#
# Not in ci.yml's `check-generated` job for the reason above: its `generated`
# filter lists specific spec paths (migrations/, conversions/, protocol-version)
# but no schema dirs, so a PR touching src/data/** or src/ui/** — exactly what
# drives these two artifacts — never triggers it. It is not required, either.
#
# Both read packages/spec/src via tsx and need no build (verified with every
# workspace dist/ removed), so they run before the workspace build. check:skill-refs
# additionally fails on a SKILL_MAP entry naming a file the spec no longer has:
# that silent skip is what let the map keep pointing at data/dataset.zod.ts for a
# year after #1620 renamed it to data/seed.zod.ts.
- name: Check generated skill references are in sync with the spec
run: pnpm --filter @objectstack/spec check:skill-refs

- name: Check the react-blocks contract is in sync with the spec
run: pnpm --filter @objectstack/spec check:react-blocks

# Example apps are AI-authoring reference templates; a red typecheck is a
# bad signal to copy from. tsup transpiles them without a full typecheck,
# so build alone will not catch type drift — typecheck them explicitly.
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@
"gen:docs": "tsx scripts/build-docs.ts",
"check:docs": "pnpm gen:schema && tsx scripts/build-docs.ts --check",
"gen:skill-refs": "tsx scripts/build-skill-references.ts",
"check:skill-refs": "tsx scripts/build-skill-references.ts --check",
"gen:skill-docs": "tsx scripts/build-skill-docs.ts",
"check:skill-docs": "tsx scripts/build-skill-docs.ts --check",
"analyze": "tsx scripts/analyze-bundle-size.ts",
Expand All @@ -205,6 +206,7 @@
"test:coverage": "vitest run --coverage",
"check:liveness": "tsx scripts/liveness/check-liveness.mts",
"gen:react-blocks": "tsx scripts/build-react-blocks-contract.ts",
"check:react-blocks": "tsx scripts/build-react-blocks-contract.ts --check",
"check:react-conformance": "tsx scripts/check-react-blocks-conformance.ts"
},
"keywords": [
Expand Down
38 changes: 33 additions & 5 deletions packages/spec/scripts/build-react-blocks-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,24 @@
// - skills/objectstack-ui/contracts/react-blocks.contract.json (machine)
// - skills/objectstack-ui/references/react-blocks.md (AI-facing)
//
// Run: pnpm --filter @objectstack/spec gen:react-blocks
// Run:
// pnpm --filter @objectstack/spec gen:react-blocks # write
// pnpm --filter @objectstack/spec check:react-blocks # verify in sync (CI)

process.env.OS_EAGER_SCHEMAS = '1';

import fs from 'fs';
import path from 'path';
import { z } from 'zod';
import { REACT_BLOCKS, type ReactInteractionProp } from '../src/ui/react-blocks';
import { createSink } from './lib/generated-output';

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

const CHECK = process.argv.includes('--check');
const { emit, flush } = createSink({ check: CHECK, repoRoot: REPO });

// ---- JSON-schema prop extraction -----------------------------------------
function resolveRoot(js: any): any {
// zod v4 may wrap the root in { $ref: '#/$defs/X', $defs: { X: {...} } }.
Expand Down Expand Up @@ -100,7 +105,7 @@ const contract = {
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.",
blocks,
};
fs.writeFileSync(OUT_JSON, JSON.stringify(contract, null, 2) + '\n');
emit(OUT_JSON, JSON.stringify(contract, null, 2) + '\n');

// markdown
const esc = (s: string) => String(s).replace(/\|/g, '\\|');
Expand Down Expand Up @@ -134,7 +139,30 @@ L.push('## Injected scope (closure variables, reference directly — not props)'
L.push('');
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" …/>`.');
L.push('');
fs.writeFileSync(OUT_MD, L.join('\n'));
emit(OUT_MD, L.join('\n'));

console.log(`react-blocks contract: ${blocks.length} blocks → ${path.relative(REPO, OUT_JSON)} + ${path.relative(REPO, OUT_MD)}`);
console.log(`react-blocks contract: ${blocks.length} blocks → ${path.relative(REPO, OUT_JSON)} + ${path.relative(REPO, OUT_MD)}`);
for (const b of blocks) console.log(` <${b.tag}> ${b.props.length} props`);

flush({
surface: 'skills/objectstack-ui/ react-blocks contract',
regenerate: ' pnpm --filter @objectstack/spec gen:react-blocks\n git add skills/objectstack-ui',
guard: () => {
// Guard the two ways this generator can emit a plausible-but-empty contract
// and have --check compare it against nothing meaningful.
if (blocks.length === 0) return 'REACT_BLOCKS is empty — nothing to generate from packages/spec/src/ui/react-blocks.ts.';
// `dataProps()` swallows a z.toJSONSchema failure and returns [], so schemas
// that fail to load degrade to a contract carrying only the hand-authored
// overlay props rather than crashing. Checking `props.length` would miss it
// (the overlay is still there) — the data props are what vanish.
const specBacked = blocks.filter((b) => b.specSchema);
const withData = specBacked.filter((b) => b.props.some((p) => p.kind === 'data'));
if (specBacked.length > 0 && withData.length === 0) {
return (
`All ${specBacked.length} spec-backed blocks resolved 0 data props — the spec schemas failed to load\n` +
` (z.toJSONSchema errors are swallowed in dataProps()), so this is the overlay alone, not a real contract.`
);
}
return null;
},
});
103 changes: 68 additions & 35 deletions packages/spec/scripts/build-skill-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@
* 3. Writes `skills/{name}/references/_index.md` with pointers + one-line
* descriptions extracted from each file's leading JSDoc comment
*
* Usage: tsx scripts/build-skill-references.ts
* Usage:
* tsx scripts/build-skill-references.ts # write
* tsx scripts/build-skill-references.ts --check # verify in sync (CI); exit 1 on drift
*/

import fs from 'fs';
import path from 'path';
import { createSink, type Owns } from './lib/generated-output';

// ── Paths ────────────────────────────────────────────────────────────────────

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

const CHECK = process.argv.includes('--check');
const { emit, manageDir, flush } = createSink({ check: CHECK, repoRoot: REPO_ROOT });

// ── Skill → Zod file mapping ────────────────────────────────────────────────
// Paths are relative to packages/spec/src/ (category/file.zod.ts)

Expand All @@ -41,7 +47,7 @@ const SKILL_MAP: Record<string, string[]> = {
'data/validation.zod.ts',
'data/hook.zod.ts',
'data/datasource.zod.ts',
'data/dataset.zod.ts',
'data/seed.zod.ts',
'security/permission.zod.ts',
],
'objectstack-query': [
Expand Down Expand Up @@ -94,7 +100,7 @@ const SKILL_MAP: Record<string, string[]> = {
// project setup (was objectstack-quickstart)
'kernel/manifest.zod.ts',
'data/datasource.zod.ts',
'data/dataset.zod.ts',
'data/seed.zod.ts',
// plugin development (was objectstack-plugin)
'kernel/plugin.zod.ts',
'kernel/context.zod.ts',
Expand Down Expand Up @@ -130,23 +136,34 @@ function extractLocalImports(filePath: string): string[] {
return imports;
}

function resolveAll(entryFiles: string[]): string[] {
/**
* Transitive closure of a skill's core files.
*
* Only SKILL_MAP entries can be `missing` — `extractLocalImports` resolves
* against disk, so a dep that doesn't exist is never queued. A dangling entry
* is therefore an authoring bug in SKILL_MAP, not a spec change to absorb, and
* the caller fails on it: silently dropping the pointer is how the skill ends
* up advertising a schema it no longer references (`data/dataset.zod.ts`
* lingered here for a year after #1620 renamed it to `data/seed.zod.ts`).
*/
function resolveAll(entryFiles: string[]): { files: string[]; missing: string[] } {
const visited = new Set<string>();
const missing: string[] = [];
const queue = [...entryFiles];
while (queue.length > 0) {
const rel = queue.shift()!;
if (visited.has(rel)) continue;
const abs = path.resolve(SPEC_SRC, rel);
if (!fs.existsSync(abs)) {
console.warn(` ⚠ File not found: ${rel} (skipped)`);
missing.push(rel);
continue;
}
visited.add(rel);
for (const dep of extractLocalImports(abs)) {
if (!visited.has(dep)) queue.push(dep);
}
}
return [...visited].sort();
return { files: [...visited].sort(), missing };
}

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

// ── Cleanup helper ───────────────────────────────────────────────────────────
// ── Managed scope ────────────────────────────────────────────────────────────

function cleanReferencesDir(refsDir: string) {
if (!fs.existsSync(refsDir)) {
fs.mkdirSync(refsDir, { recursive: true });
return;
}
for (const entry of fs.readdirSync(refsDir)) {
// Keep hand-written markdown other than _index.md untouched.
if (entry === '_index.md') {
fs.rmSync(path.resolve(refsDir, entry));
continue;
}
const entryPath = path.resolve(refsDir, entry);
const stat = fs.statSync(entryPath);
if (stat.isDirectory()) {
fs.rmSync(entryPath, { recursive: true });
} else if (entry.endsWith('.zod.ts')) {
fs.rmSync(entryPath);
}
}
/**
* Which paths under a skill's `references/` folder this generator owns — i.e.
* would delete and rewrite on a real run, and must therefore flag as stale
* under `--check`.
*
* The folder is only *partially* ours: `react-blocks.md` is written by
* build-react-blocks-contract.ts and hand-written notes are allowed, so both
* are left alone. Subfolders and loose `*.zod.ts` are leftovers from the
* retired bundled-schema layout (skills used to carry copies of the schemas);
* we still sweep them so an old checkout converges.
*/
function ownsReferenceEntry(refsDir: string): Owns {
return (abs) => {
const rel = path.relative(refsDir, abs);
if (!rel || rel.startsWith('..')) return false;
if (rel.includes(path.sep)) return true; // inside a subfolder → wiped wholesale
return rel === '_index.md' || rel.endsWith('.zod.ts');
};
}

// ── Main ─────────────────────────────────────────────────────────────────────

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

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

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

const refsDir = path.resolve(skillDir, 'references');
cleanReferencesDir(refsDir);

fs.writeFileSync(
path.resolve(refsDir, '_index.md'),
generateIndex(skillName, coreFiles, allFiles),
);
manageDir(refsDir, ownsReferenceEntry(refsDir));
emit(path.resolve(refsDir, '_index.md'), generateIndex(skillName, coreFiles, allFiles));
totalSkills += 1;
}

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

main();
Loading