|
| 1 | +#!/usr/bin/env bun |
| 2 | + |
| 3 | +/** |
| 4 | + * CI check: enforces block-registry invariants that protect the runtime. |
| 5 | + * |
| 6 | + * Two checks run in sequence: |
| 7 | + * |
| 8 | + * 1. **Subblock ID stability** — diffs the current registry against a base ref |
| 9 | + * and fails if any subblock ID was removed without a corresponding entry in |
| 10 | + * `SUBBLOCK_ID_MIGRATIONS`. Removing IDs without a migration breaks |
| 11 | + * deployed workflows. |
| 12 | + * |
| 13 | + * 2. **Canonical-id contract** — for every (block, tool) pair where the tool |
| 14 | + * param is `required: true` and `visibility: 'user-only'`, the block must |
| 15 | + * expose a subBlock whose `id` or `canonicalParamId` equals the tool param |
| 16 | + * id. The serializer's pre-execution validator depends on this contract to |
| 17 | + * resolve values via direct lookup; mismatches false-flag fields as missing |
| 18 | + * at submit time. |
| 19 | + * |
| 20 | + * Usage: |
| 21 | + * bun run apps/sim/scripts/check-block-registry.ts [base-ref] |
| 22 | + * |
| 23 | + * `base-ref` defaults to `HEAD~1`. In a PR CI pipeline, pass the merge base: |
| 24 | + * bun run apps/sim/scripts/check-block-registry.ts origin/main |
| 25 | + */ |
| 26 | + |
| 27 | +import { execSync } from 'child_process' |
| 28 | +import { SUBBLOCK_ID_MIGRATIONS } from '@/lib/workflows/migrations/subblock-migrations' |
| 29 | +import { getAllBlocks } from '@/blocks/registry' |
| 30 | +import { tools as toolRegistry } from '@/tools/registry' |
| 31 | + |
| 32 | +const baseRef = process.argv[2] || 'HEAD~1' |
| 33 | + |
| 34 | +const gitRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim() |
| 35 | +const gitOpts = { encoding: 'utf-8' as const, cwd: gitRoot } |
| 36 | + |
| 37 | +type IdMap = Record<string, Set<string>> |
| 38 | + |
| 39 | +/** |
| 40 | + * Extracts subblock IDs from the `subBlocks: [ ... ]` section of a block |
| 41 | + * definition. Only grabs the top-level `id:` of each subblock object — |
| 42 | + * ignores nested IDs inside `options`, `columns`, etc. |
| 43 | + */ |
| 44 | +function extractSubBlockIds(source: string): string[] { |
| 45 | + const startIdx = source.indexOf('subBlocks:') |
| 46 | + if (startIdx === -1) return [] |
| 47 | + |
| 48 | + const bracketStart = source.indexOf('[', startIdx) |
| 49 | + if (bracketStart === -1) return [] |
| 50 | + |
| 51 | + const ids: string[] = [] |
| 52 | + let braceDepth = 0 |
| 53 | + let bracketDepth = 0 |
| 54 | + let i = bracketStart + 1 |
| 55 | + bracketDepth = 1 |
| 56 | + |
| 57 | + while (i < source.length && bracketDepth > 0) { |
| 58 | + const ch = source[i] |
| 59 | + |
| 60 | + if (ch === '[') bracketDepth++ |
| 61 | + else if (ch === ']') { |
| 62 | + bracketDepth-- |
| 63 | + if (bracketDepth === 0) break |
| 64 | + } else if (ch === '{') { |
| 65 | + braceDepth++ |
| 66 | + if (braceDepth === 1) { |
| 67 | + const ahead = source.slice(i, i + 200) |
| 68 | + const idMatch = ahead.match(/{\s*(?:\/\/[^\n]*\n\s*)*id:\s*['"]([^'"]+)['"]/) |
| 69 | + if (idMatch) { |
| 70 | + ids.push(idMatch[1]) |
| 71 | + } |
| 72 | + } |
| 73 | + } else if (ch === '}') { |
| 74 | + braceDepth-- |
| 75 | + } |
| 76 | + |
| 77 | + i++ |
| 78 | + } |
| 79 | + |
| 80 | + return ids |
| 81 | +} |
| 82 | + |
| 83 | +function getCurrentIds(): IdMap { |
| 84 | + const map: IdMap = {} |
| 85 | + for (const block of getAllBlocks()) { |
| 86 | + map[block.type] = new Set(block.subBlocks.map((sb) => sb.id)) |
| 87 | + } |
| 88 | + return map |
| 89 | +} |
| 90 | + |
| 91 | +type PreviousIdsResult = |
| 92 | + | { kind: 'skip'; reason: string } |
| 93 | + | { kind: 'noop' } |
| 94 | + | { kind: 'ok'; map: IdMap } |
| 95 | + |
| 96 | +function getPreviousIds(): PreviousIdsResult { |
| 97 | + const registryPath = 'apps/sim/blocks/registry.ts' |
| 98 | + const blocksDir = 'apps/sim/blocks/blocks' |
| 99 | + |
| 100 | + let hasChanges = false |
| 101 | + try { |
| 102 | + const diff = execSync( |
| 103 | + `git diff --name-only ${baseRef} HEAD -- ${registryPath} ${blocksDir}`, |
| 104 | + gitOpts |
| 105 | + ).trim() |
| 106 | + hasChanges = diff.length > 0 |
| 107 | + } catch { |
| 108 | + return { kind: 'skip', reason: 'Could not diff against base ref' } |
| 109 | + } |
| 110 | + |
| 111 | + if (!hasChanges) { |
| 112 | + return { kind: 'noop' } |
| 113 | + } |
| 114 | + |
| 115 | + const map: IdMap = {} |
| 116 | + |
| 117 | + try { |
| 118 | + const blockFiles = execSync(`git ls-tree -r --name-only ${baseRef} -- ${blocksDir}`, gitOpts) |
| 119 | + .trim() |
| 120 | + .split('\n') |
| 121 | + .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts')) |
| 122 | + |
| 123 | + for (const filePath of blockFiles) { |
| 124 | + let content: string |
| 125 | + try { |
| 126 | + content = execSync(`git show ${baseRef}:${filePath}`, gitOpts) |
| 127 | + } catch { |
| 128 | + continue |
| 129 | + } |
| 130 | + |
| 131 | + const typeMatch = content.match(/BlockConfig\s*=\s*\{[\s\S]*?type:\s*['"]([^'"]+)['"]/) |
| 132 | + if (!typeMatch) continue |
| 133 | + const blockType = typeMatch[1] |
| 134 | + |
| 135 | + const ids = extractSubBlockIds(content) |
| 136 | + if (ids.length === 0) continue |
| 137 | + |
| 138 | + map[blockType] = new Set(ids) |
| 139 | + } |
| 140 | + } catch (err) { |
| 141 | + return { kind: 'skip', reason: `Could not read previous block files from ${baseRef}: ${err}` } |
| 142 | + } |
| 143 | + |
| 144 | + return { kind: 'ok', map } |
| 145 | +} |
| 146 | + |
| 147 | +function checkSubblockIdStability(): string[] { |
| 148 | + const previous = getPreviousIds() |
| 149 | + |
| 150 | + if (previous.kind === 'skip') { |
| 151 | + console.log(`⚠ ${previous.reason} — skipping subblock ID stability check`) |
| 152 | + return [] |
| 153 | + } |
| 154 | + if (previous.kind === 'noop') { |
| 155 | + console.log('✓ No block definition changes detected — skipping subblock ID stability check') |
| 156 | + return [] |
| 157 | + } |
| 158 | + |
| 159 | + const current = getCurrentIds() |
| 160 | + const errors: string[] = [] |
| 161 | + |
| 162 | + for (const [blockType, prevIds] of Object.entries(previous.map)) { |
| 163 | + const currIds = current[blockType] |
| 164 | + if (!currIds) continue |
| 165 | + |
| 166 | + const migrations = SUBBLOCK_ID_MIGRATIONS[blockType] ?? {} |
| 167 | + |
| 168 | + for (const oldId of prevIds) { |
| 169 | + if (currIds.has(oldId)) continue |
| 170 | + if (oldId in migrations) continue |
| 171 | + |
| 172 | + errors.push( |
| 173 | + `Block "${blockType}": subblock ID "${oldId}" was removed.\n` + |
| 174 | + ` → Add a migration in SUBBLOCK_ID_MIGRATIONS (lib/workflows/migrations/subblock-migrations.ts)\n` + |
| 175 | + ` mapping "${oldId}" to its replacement ID.` |
| 176 | + ) |
| 177 | + } |
| 178 | + } |
| 179 | + |
| 180 | + return errors |
| 181 | +} |
| 182 | + |
| 183 | +function checkCanonicalIdContract(): string[] { |
| 184 | + const errors: string[] = [] |
| 185 | + |
| 186 | + for (const block of getAllBlocks()) { |
| 187 | + const access: string[] = block.tools?.access ?? [] |
| 188 | + if (access.length === 0) continue |
| 189 | + |
| 190 | + const subBlockKeys = new Set<string>() |
| 191 | + for (const sb of block.subBlocks ?? []) { |
| 192 | + if (sb.id) subBlockKeys.add(sb.id) |
| 193 | + const canonical = (sb as { canonicalParamId?: string }).canonicalParamId |
| 194 | + if (canonical) subBlockKeys.add(canonical) |
| 195 | + } |
| 196 | + |
| 197 | + for (const toolId of access) { |
| 198 | + const tool = toolRegistry[toolId] |
| 199 | + if (!tool) continue |
| 200 | + |
| 201 | + for (const [paramId, paramConfig] of Object.entries(tool.params ?? {})) { |
| 202 | + if (!paramConfig || typeof paramConfig !== 'object') continue |
| 203 | + const required = (paramConfig as { required?: boolean }).required === true |
| 204 | + const userOnly = (paramConfig as { visibility?: string }).visibility === 'user-only' |
| 205 | + if (!required || !userOnly) continue |
| 206 | + |
| 207 | + if (!subBlockKeys.has(paramId)) { |
| 208 | + errors.push( |
| 209 | + `Block "${block.type}" → tool "${toolId}": required user-only param "${paramId}" has no subBlock with id or canonicalParamId === "${paramId}".\n` + |
| 210 | + ' → Rename a subBlock id or canonicalParamId to match the tool param id,\n' + |
| 211 | + " and update the block's inputs + tools.config.params mapper to read from that key." |
| 212 | + ) |
| 213 | + } |
| 214 | + } |
| 215 | + } |
| 216 | + } |
| 217 | + |
| 218 | + return errors |
| 219 | +} |
| 220 | + |
| 221 | +const stabilityErrors = checkSubblockIdStability() |
| 222 | +const canonicalErrors = checkCanonicalIdContract() |
| 223 | + |
| 224 | +if (stabilityErrors.length > 0) { |
| 225 | + console.error('\n✗ Subblock ID stability check FAILED\n') |
| 226 | + console.error( |
| 227 | + 'Removing subblock IDs breaks deployed workflows.\n' + |
| 228 | + 'Either revert the rename or add a migration entry.\n' |
| 229 | + ) |
| 230 | + for (const err of stabilityErrors) { |
| 231 | + console.error(` ${err}\n`) |
| 232 | + } |
| 233 | +} else { |
| 234 | + console.log('✓ Subblock ID stability check passed') |
| 235 | +} |
| 236 | + |
| 237 | +if (canonicalErrors.length > 0) { |
| 238 | + console.error('\n✗ Canonical-id contract check FAILED\n') |
| 239 | + for (const err of canonicalErrors) { |
| 240 | + console.error(` ${err}\n`) |
| 241 | + } |
| 242 | +} else { |
| 243 | + console.log('✓ Canonical-id contract check passed') |
| 244 | +} |
| 245 | + |
| 246 | +if (stabilityErrors.length > 0 || canonicalErrors.length > 0) { |
| 247 | + process.exit(1) |
| 248 | +} |
| 249 | + |
| 250 | +process.exit(0) |
0 commit comments