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

# Generated-skills gate: skills/ is generated from the spec by
# `gen:skill-refs` + `gen:react-blocks` and committed, and nothing checked
# it — both had a `gen:` script but no `check:` sibling, so `check:docs`
# above (which only covers content/docs/references/) and `check:skill-docs`
# (only SKILL.md frontmatter → README + skills-reference.mdx) both miss it.
# It drifted: 8 files were stale on main, and skills/objectstack-{data,
# platform}/references/_index.md still pointed agents at
# data/dataset.zod.ts — renamed to data/seed.zod.ts back in #1620.
#
# This bites harder than the docs case: skills/ is PUBLISHED to third
# parties via `npx skills add objectstack-ai/framework --all`, so stale
# output ships to users. Same job as check:docs and for the same reason —
# it is a required status check with no paths filter, so the gate cannot go
# dormant. Both read src/ via tsx and need no build, so they fail in ~2s.
- name: Check generated skill references are in sync with the spec
run: pnpm --filter @objectstack/spec check:skill-refs

- name: Check generated 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
46 changes: 39 additions & 7 deletions packages/spec/scripts/build-react-blocks-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,28 @@
// - 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
// `skills/` is published to third parties (`npx skills add … --all`), so stale
// output here ships to users — `--check` gates it in CI.
//
// Run:
// pnpm --filter @objectstack/spec gen:react-blocks # write
// pnpm --filter @objectstack/spec check:react-blocks # verify in sync (CI); exit 1 on drift

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 { createGeneratedOutput } from './lib/generated-output';

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

const CHECK = process.argv.includes('--check');
const out = createGeneratedOutput({ repoRoot: REPO, check: CHECK });

// ---- 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 @@ -93,14 +102,28 @@ const blocks = REACT_BLOCKS.map((b) => {
return { tag: b.tag, schemaType: b.schemaType, summary: b.summary, specSchema: b.schema ? true : false, props };
});

// No blocks means the overlay failed to load, not that the contract is empty.
// Writing that would wipe the published contract; checking it would compare
// nothing and pass. Fail loudly in both modes instead.
if (blocks.length === 0) {
console.error(
`\n✗ No React blocks found in packages/spec/src/ui/react-blocks.ts — nothing to ${CHECK ? 'check' : 'write'}.\n` +
` REACT_BLOCKS is empty; the contract would be generated with zero blocks.\n`,
);
process.exit(1);
}

const contract = {
version: 2,
adr: 'ADR-0081',
source: 'GENERATED from packages/spec/src/ui/react-blocks.ts — data props from the spec zod schemas, binding/controlled/callback from the React overlay.',
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');
// contracts/ is wholly owned by this generator, so a file in there we didn't
// emit is one a real run deletes — e.g. a contract left behind by a rename.
out.manageDir(CONTRACTS_DIR);
out.emit(OUT_JSON, JSON.stringify(contract, null, 2) + '\n');

// markdown
const esc = (s: string) => String(s).replace(/\|/g, '\\|');
Expand Down Expand Up @@ -134,7 +157,16 @@ 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'));
out.emit(OUT_MD, L.join('\n'));

// Disposition: write the emitted tree, or report drift against it.
out.flush({
what: 'skills/objectstack-ui react-blocks contract',
noun: 'react-blocks contract files',
fix: ['pnpm --filter @objectstack/spec gen:react-blocks', 'git add skills'],
});

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`);
if (!CHECK) {
console.log(`\n✅ react-blocks contract: ${blocks.length} blocks`);
for (const b of blocks) console.log(` <${b.tag}> ${b.props.length} props`);
}
112 changes: 69 additions & 43 deletions packages/spec/scripts/build-skill-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,17 @@
* 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
* `skills/` is published to third parties (`npx skills add … --all`), so stale
* output here ships to users — `--check` gates it in CI.
*
* 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 { createGeneratedOutput } from './lib/generated-output';

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

Expand All @@ -31,6 +37,8 @@ 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');

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

Expand All @@ -41,7 +49,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 +102,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,18 +138,27 @@ function extractLocalImports(filePath: string): string[] {
return imports;
}

function resolveAll(entryFiles: string[]): string[] {
function resolveAll(skillName: string, entryFiles: string[]): string[] {
const visited = new Set<string>();
const queue = [...entryFiles];
while (queue.length > 0) {
const rel = queue.shift()!;
if (visited.has(rel)) continue;
const abs = path.resolve(SPEC_SRC, rel);
const relPath = queue.shift()!;
if (visited.has(relPath)) continue;
const abs = path.resolve(SPEC_SRC, relPath);
// Only SKILL_MAP entries can miss here — transitive deps are already
// existence-filtered in extractLocalImports — so this is always a stale
// hand-authored mapping. Fatal, not a warning: warn-and-skip silently
// dropped the seed schema from two published skills across the
// dataset.zod.ts → seed.zod.ts rename, and stayed green doing it.
if (!fs.existsSync(abs)) {
console.warn(` ⚠ File not found: ${rel} (skipped)`);
continue;
console.error(
`\n✗ SKILL_MAP[${skillName}] points at a file that does not exist: ${relPath}\n` +
` Expected ${rel(path.resolve(SPEC_SRC, relPath))} — it was probably renamed or removed.\n` +
` Update SKILL_MAP in ${rel(__filename)}.\n`,
);
process.exit(1);
}
visited.add(rel);
visited.add(relPath);
for (const dep of extractLocalImports(abs)) {
if (!visited.has(dep)) queue.push(dep);
}
Expand Down Expand Up @@ -226,57 +243,66 @@ function generateIndex(skillName: string, coreFiles: string[], allFiles: string[
return lines.join('\n');
}

// ── Cleanup helper ───────────────────────────────────────────────────────────
// ── Output sink ──────────────────────────────────────────────────────────────

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);
}
}
const out = createGeneratedOutput({ repoRoot: REPO_ROOT, check: CHECK });
const rel = (p: string) => path.relative(REPO_ROOT, p);

/**
* What a real run removes from a skill's `references/` folder. Hand-written
* markdown alongside `_index.md` is deliberately preserved — `data-hooks.md`,
* `plugin-hooks.md`, and `react-blocks.md` (which build-react-blocks-contract.ts
* owns) all live here — so this is a selective wipe, not a wholesale one.
*/
function refsDirDeletes(relPath: string): boolean {
// Nested → lives in a sub-folder a real run removes wholesale.
if (relPath.includes(path.sep)) return true;
return relPath === '_index.md' || relPath.endsWith('.zod.ts');
}

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

function main() {
console.log('🔗 Building skill schema reference indexes...\n');
let totalSkills = 0;
console.log(`🔗 ${CHECK ? 'Checking' : 'Building'} skill schema reference indexes...\n`);

for (const [skillName, coreFiles] of Object.entries(SKILL_MAP)) {
const skillDir = path.resolve(SKILLS_DIR, skillName);
// A mapped skill that isn't on disk means SKILL_MAP lies. Skipping it would
// silently ship a skill with no schema index (and leave --check green), so
// fail instead — same reasoning as the missing-file check in resolveAll.
if (!fs.existsSync(skillDir)) {
console.warn(`⚠ Skill directory not found: ${skillName}, skipping`);
continue;
console.error(
`\n✗ SKILL_MAP names a skill with no directory: ${skillName}\n` +
` Expected ${rel(skillDir)}. Update SKILL_MAP in ${rel(__filename)}.\n`,
);
process.exit(1);
}

console.log(`📦 ${skillName}`);
const allFiles = resolveAll(coreFiles);
console.log(` ${coreFiles.length} core + ${allFiles.length - coreFiles.length} deps`);
if (!CHECK) console.log(`📦 ${skillName}`);
const allFiles = resolveAll(skillName, coreFiles);
if (!CHECK) console.log(` ${coreFiles.length} core + ${allFiles.length - coreFiles.length} deps`);

const refsDir = path.resolve(skillDir, 'references');
cleanReferencesDir(refsDir);
out.manageDir(refsDir, refsDirDeletes);
out.emit(path.resolve(refsDir, '_index.md'), generateIndex(skillName, coreFiles, allFiles));
}

fs.writeFileSync(
path.resolve(refsDir, '_index.md'),
generateIndex(skillName, coreFiles, allFiles),
// A run that mapped no skills emits nothing, and "nothing differs" would read
// as success — the gate would pass while checking no skills at all. Fail
// loudly instead of greenly.
if (out.size === 0) {
console.error(
`\n✗ No skill reference indexes generated — nothing to ${CHECK ? 'check' : 'write'}.\n` +
` SKILL_MAP is empty, or ${rel(SKILLS_DIR)} is missing.\n`,
);
totalSkills += 1;
process.exit(1);
}

console.log(`\n✅ Done — ${totalSkills} skill index files written`);
out.flush({
what: 'skills/*/references/_index.md',
noun: 'skill reference indexes',
fix: [`pnpm --filter ${SPEC_PKG} gen:skill-refs`, 'git add skills'],
});
}

main();
Loading