|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Build Skill References |
| 5 | + * |
| 6 | + * Copies Zod schema source files into each skill's `references/zod/` folder |
| 7 | + * so that AI agents can read the precise type definitions directly — without |
| 8 | + * needing access to the monorepo source tree. |
| 9 | + * |
| 10 | + * Usage: tsx scripts/build-skill-references.ts |
| 11 | + * |
| 12 | + * The script: |
| 13 | + * 1. Reads a declarative mapping of { skill → core zod files } |
| 14 | + * 2. Recursively resolves local `import … from` dependencies |
| 15 | + * 3. Copies all resolved files into `skills/{name}/references/zod/` |
| 16 | + * preserving the category-based directory structure |
| 17 | + * 4. Generates an `_index.md` per skill listing all bundled schemas |
| 18 | + */ |
| 19 | + |
| 20 | +import fs from 'fs'; |
| 21 | +import path from 'path'; |
| 22 | + |
| 23 | +// ── Paths ──────────────────────────────────────────────────────────────────── |
| 24 | + |
| 25 | +const REPO_ROOT = path.resolve(__dirname, '../../..'); |
| 26 | +const SPEC_SRC = path.resolve(__dirname, '../src'); |
| 27 | +const SKILLS_DIR = path.resolve(REPO_ROOT, 'skills'); |
| 28 | + |
| 29 | +// ── Skill → Zod file mapping ──────────────────────────────────────────────── |
| 30 | +// Paths are relative to packages/spec/src/ (category/file.zod.ts) |
| 31 | + |
| 32 | +const SKILL_MAP: Record<string, string[]> = { |
| 33 | + 'objectstack-schema': [ |
| 34 | + 'data/field.zod.ts', |
| 35 | + 'data/object.zod.ts', |
| 36 | + 'data/validation.zod.ts', |
| 37 | + ], |
| 38 | + 'objectstack-ai': [ |
| 39 | + 'ai/agent.zod.ts', |
| 40 | + 'ai/tool.zod.ts', |
| 41 | + 'ai/skill.zod.ts', |
| 42 | + 'ai/rag-pipeline.zod.ts', |
| 43 | + 'ai/model-registry.zod.ts', |
| 44 | + ], |
| 45 | + 'objectstack-api': [ |
| 46 | + 'api/endpoint.zod.ts', |
| 47 | + 'api/auth.zod.ts', |
| 48 | + 'api/realtime.zod.ts', |
| 49 | + 'api/rest-server.zod.ts', |
| 50 | + ], |
| 51 | + 'objectstack-automation': [ |
| 52 | + 'automation/flow.zod.ts', |
| 53 | + 'automation/workflow.zod.ts', |
| 54 | + 'automation/trigger-registry.zod.ts', |
| 55 | + 'automation/approval.zod.ts', |
| 56 | + 'automation/state-machine.zod.ts', |
| 57 | + ], |
| 58 | + 'objectstack-ui': [ |
| 59 | + 'ui/view.zod.ts', |
| 60 | + 'ui/app.zod.ts', |
| 61 | + 'ui/dashboard.zod.ts', |
| 62 | + 'ui/chart.zod.ts', |
| 63 | + 'ui/action.zod.ts', |
| 64 | + ], |
| 65 | +}; |
| 66 | + |
| 67 | +// ── Import resolver ────────────────────────────────────────────────────────── |
| 68 | + |
| 69 | +/** |
| 70 | + * Extract local imports from a .zod.ts file. |
| 71 | + * Returns paths relative to SPEC_SRC (e.g. "shared/identifiers.zod.ts"). |
| 72 | + * Ignores external imports (zod, node modules). |
| 73 | + */ |
| 74 | +function extractLocalImports(filePath: string): string[] { |
| 75 | + const content = fs.readFileSync(filePath, 'utf-8'); |
| 76 | + const imports: string[] = []; |
| 77 | + // Match: import { ... } from './foo.zod' or '../shared/bar.zod' |
| 78 | + const re = /^import\s+.*\s+from\s+['"](\.[^'"]+)['"]/gm; |
| 79 | + let match: RegExpExecArray | null; |
| 80 | + while ((match = re.exec(content)) !== null) { |
| 81 | + const importSpec = match[1]; // e.g. '../shared/identifiers.zod' |
| 82 | + const dir = path.dirname(filePath); |
| 83 | + let resolved = path.resolve(dir, importSpec); |
| 84 | + // Append .ts if needed |
| 85 | + if (!resolved.endsWith('.ts')) { |
| 86 | + resolved += '.ts'; |
| 87 | + } |
| 88 | + if (fs.existsSync(resolved)) { |
| 89 | + // Convert back to relative from SPEC_SRC |
| 90 | + const rel = path.relative(SPEC_SRC, resolved); |
| 91 | + imports.push(rel); |
| 92 | + } |
| 93 | + } |
| 94 | + return imports; |
| 95 | +} |
| 96 | + |
| 97 | +/** |
| 98 | + * Recursively resolve all dependencies for a set of entry files. |
| 99 | + * Returns deduplicated set of all files (entries + transitive deps), |
| 100 | + * all relative to SPEC_SRC. |
| 101 | + */ |
| 102 | +function resolveAll(entryFiles: string[]): string[] { |
| 103 | + const visited = new Set<string>(); |
| 104 | + const queue = [...entryFiles]; |
| 105 | + |
| 106 | + while (queue.length > 0) { |
| 107 | + const rel = queue.shift()!; |
| 108 | + if (visited.has(rel)) continue; |
| 109 | + visited.add(rel); |
| 110 | + |
| 111 | + const abs = path.resolve(SPEC_SRC, rel); |
| 112 | + if (!fs.existsSync(abs)) { |
| 113 | + console.warn(` ⚠ File not found: ${rel}`); |
| 114 | + continue; |
| 115 | + } |
| 116 | + const deps = extractLocalImports(abs); |
| 117 | + for (const dep of deps) { |
| 118 | + if (!visited.has(dep)) { |
| 119 | + queue.push(dep); |
| 120 | + } |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + return [...visited].sort(); |
| 125 | +} |
| 126 | + |
| 127 | +// ── JSDoc description extractor ────────────────────────────────────────────── |
| 128 | + |
| 129 | +/** |
| 130 | + * Extract a short description from a file's first JSDoc comment. |
| 131 | + * Takes only the first sentence/line. Falls back to exported schema names. |
| 132 | + */ |
| 133 | +function extractDescription(filePath: string): string { |
| 134 | + const content = fs.readFileSync(filePath, 'utf-8'); |
| 135 | + // Try to grab the first top-level JSDoc comment |
| 136 | + const jsdocRe = /\/\*\*\s*\n([\s\S]*?)\*\//; |
| 137 | + const jsdocMatch = content.match(jsdocRe); |
| 138 | + if (jsdocMatch) { |
| 139 | + const lines = jsdocMatch[1] |
| 140 | + .split('\n') |
| 141 | + .map((line) => line.replace(/^\s*\*\s?/, '').trim()) |
| 142 | + .filter((line) => line && !line.startsWith('@') && !line.startsWith('```')); |
| 143 | + // Take only the first non-empty line as a short description |
| 144 | + const firstLine = lines[0]; |
| 145 | + if (firstLine && firstLine.length > 5) { |
| 146 | + // Strip leading markdown heading markers |
| 147 | + const clean = firstLine.replace(/^#+\s*/, ''); |
| 148 | + // Truncate to first sentence or 120 chars |
| 149 | + const sentence = clean.split(/\.\s/)[0]; |
| 150 | + return sentence.length > 120 ? sentence.slice(0, 117) + '...' : sentence; |
| 151 | + } |
| 152 | + } |
| 153 | + // Fallback: list exported schema names |
| 154 | + const exports: string[] = []; |
| 155 | + const exportRe = /export\s+const\s+(\w+Schema|\w+)\s*(?:[:=])/g; |
| 156 | + let m: RegExpExecArray | null; |
| 157 | + while ((m = exportRe.exec(content)) !== null) { |
| 158 | + exports.push(m[1]); |
| 159 | + } |
| 160 | + if (exports.length > 0) return `Exports: ${exports.slice(0, 5).join(', ')}`; |
| 161 | + return ''; |
| 162 | +} |
| 163 | + |
| 164 | +// ── Index generator ────────────────────────────────────────────────────────── |
| 165 | + |
| 166 | +function generateIndex(skillName: string, coreFiles: string[], allFiles: string[]): string { |
| 167 | + const coreSet = new Set(coreFiles); |
| 168 | + const lines: string[] = [ |
| 169 | + `# ${skillName} — Zod Schema Reference`, |
| 170 | + '', |
| 171 | + '> **Auto-generated** by `build-skill-references.ts`.', |
| 172 | + '> These files are copied from `packages/spec/src/` for AI agent consumption.', |
| 173 | + '> Do not edit — re-run `pnpm --filter @objectstack/spec run gen:skill-refs` to update.', |
| 174 | + '', |
| 175 | + '## Core Schemas', |
| 176 | + '', |
| 177 | + ]; |
| 178 | + |
| 179 | + for (const f of allFiles.filter((f) => coreSet.has(f))) { |
| 180 | + const desc = extractDescription(path.resolve(SPEC_SRC, f)); |
| 181 | + lines.push(`- [\`${f}\`](./${f})${desc ? ` — ${desc}` : ''}`); |
| 182 | + } |
| 183 | + |
| 184 | + const deps = allFiles.filter((f) => !coreSet.has(f)); |
| 185 | + if (deps.length > 0) { |
| 186 | + lines.push('', '## Dependencies (auto-resolved)', ''); |
| 187 | + for (const f of deps) { |
| 188 | + const desc = extractDescription(path.resolve(SPEC_SRC, f)); |
| 189 | + lines.push(`- [\`${f}\`](./${f})${desc ? ` — ${desc}` : ''}`); |
| 190 | + } |
| 191 | + } |
| 192 | + |
| 193 | + lines.push(''); |
| 194 | + return lines.join('\n'); |
| 195 | +} |
| 196 | + |
| 197 | +// ── Main ───────────────────────────────────────────────────────────────────── |
| 198 | + |
| 199 | +function main() { |
| 200 | + console.log('🔗 Building skill Zod references...\n'); |
| 201 | + |
| 202 | + let totalFiles = 0; |
| 203 | + |
| 204 | + for (const [skillName, coreFiles] of Object.entries(SKILL_MAP)) { |
| 205 | + const skillDir = path.resolve(SKILLS_DIR, skillName); |
| 206 | + if (!fs.existsSync(skillDir)) { |
| 207 | + console.warn(`⚠ Skill directory not found: ${skillName}, skipping`); |
| 208 | + continue; |
| 209 | + } |
| 210 | + |
| 211 | + console.log(`📦 ${skillName}`); |
| 212 | + |
| 213 | + // Resolve full dependency tree |
| 214 | + const allFiles = resolveAll(coreFiles); |
| 215 | + console.log(` ${coreFiles.length} core + ${allFiles.length - coreFiles.length} deps = ${allFiles.length} files`); |
| 216 | + |
| 217 | + // Target directory |
| 218 | + const zodDir = path.resolve(skillDir, 'references/zod'); |
| 219 | + |
| 220 | + // Clean previous output |
| 221 | + if (fs.existsSync(zodDir)) { |
| 222 | + fs.rmSync(zodDir, { recursive: true }); |
| 223 | + } |
| 224 | + |
| 225 | + // Copy files preserving directory structure |
| 226 | + for (const rel of allFiles) { |
| 227 | + const src = path.resolve(SPEC_SRC, rel); |
| 228 | + const dest = path.resolve(zodDir, rel); |
| 229 | + fs.mkdirSync(path.dirname(dest), { recursive: true }); |
| 230 | + fs.copyFileSync(src, dest); |
| 231 | + } |
| 232 | + |
| 233 | + // Generate _index.md |
| 234 | + const indexContent = generateIndex(skillName, coreFiles, allFiles); |
| 235 | + fs.writeFileSync(path.resolve(zodDir, '_index.md'), indexContent); |
| 236 | + |
| 237 | + totalFiles += allFiles.length; |
| 238 | + } |
| 239 | + |
| 240 | + console.log(`\n✅ Done — ${totalFiles} files copied across ${Object.keys(SKILL_MAP).length} skills`); |
| 241 | +} |
| 242 | + |
| 243 | +main(); |
0 commit comments