Skip to content

Commit 9415fa9

Browse files
waleedlatif1claude
andcommitted
refactor(scripts): consolidate block registry CI checks into one script
Folds the canonical-id contract audit into the existing subblock ID stability script and renames it to `check-block-registry.ts`. Both checks share the same `getAllBlocks()` import and registry-invariant purpose, so a single CI gate now catches both regression classes. The early-exit path on the stability check no longer short-circuits the canonical-id check. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f608761 commit 9415fa9

6 files changed

Lines changed: 253 additions & 248 deletions

File tree

.github/workflows/test-build.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,15 @@ jobs:
9090
9191
echo "✅ All feature flags are properly configured"
9292
93-
- name: Check subblock ID stability
93+
- name: Check block registry invariants
9494
run: |
9595
if [ "${{ github.event_name }}" = "pull_request" ]; then
9696
BASE_REF="origin/${{ github.base_ref }}"
9797
git fetch --depth=1 origin "${{ github.base_ref }}" 2>/dev/null || true
9898
else
9999
BASE_REF="HEAD~1"
100100
fi
101-
bun run apps/sim/scripts/check-subblock-id-stability.ts "$BASE_REF"
101+
bun run apps/sim/scripts/check-block-registry.ts "$BASE_REF"
102102
103103
- name: Lint code
104104
run: bun run lint:check

apps/sim/scripts/check-block-canonical.ts

Lines changed: 0 additions & 74 deletions
This file was deleted.
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
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

Comments
 (0)