Skip to content

Commit 22d9d7a

Browse files
fix(audit): compare effective block fields from base snapshots
1 parent 2081acc commit 22d9d7a

5 files changed

Lines changed: 283 additions & 145 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/** @vitest-environment node */
2+
import { execFileSync } from 'node:child_process'
3+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
4+
import { tmpdir } from 'node:os'
5+
import { dirname, join } from 'node:path'
6+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
7+
import { readBlockRegistryAtRef } from '@/scripts/block-registry-snapshot'
8+
9+
let root: string
10+
11+
function write(path: string, content: string) {
12+
const target = join(root, path)
13+
mkdirSync(dirname(target), { recursive: true })
14+
writeFileSync(target, content)
15+
}
16+
17+
function git(...args: string[]) {
18+
return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: 'pipe' }).trim()
19+
}
20+
21+
function commit() {
22+
git('add', '.')
23+
git(
24+
'-c',
25+
'user.name=Test',
26+
'-c',
27+
'commit.gpgsign=false',
28+
'-c',
29+
'core.hooksPath=/dev/null',
30+
'-c',
31+
'user.email=test@example.test',
32+
'commit',
33+
'-m',
34+
'Baseline fixture'
35+
)
36+
return git('rev-parse', 'HEAD')
37+
}
38+
39+
beforeEach(() => {
40+
root = mkdtempSync(join(tmpdir(), 'registry-snapshot-test-'))
41+
git('init', '--quiet')
42+
write('.gitignore', 'node_modules\n')
43+
write('apps/sim/package.json', JSON.stringify({ name: '@sim/app', type: 'module' }))
44+
write(
45+
'apps/sim/tsconfig.json',
46+
JSON.stringify({ compilerOptions: { paths: { '@/*': ['./*'] } } })
47+
)
48+
})
49+
50+
afterEach(() => rmSync(root, { recursive: true, force: true }))
51+
52+
describe('readBlockRegistryAtRef', () => {
53+
it('reads effective IDs from spreads, local arrays, helpers, and derived blocks at the base revision', () => {
54+
write(
55+
'apps/sim/blocks/registry.ts',
56+
`
57+
import { sharedFields } from '@sim/fields'
58+
import { triggerFields } from '@/triggers/fields'
59+
const localFields = [{ id: 'operation', options: [{ id: 'nested-option' }] }, { id: 'encoding' }]
60+
const LegacyBlock = { type: 'legacy', subBlocks: localFields } satisfies { type: string; subBlocks: { id: string }[] }
61+
const CurrentBlock = { ...LegacyBlock, type: 'current', subBlocks: LegacyBlock.subBlocks.filter(field => field.id !== 'encoding') }
62+
const makeFields = () => [...sharedFields, ...triggerFields]
63+
const SpreadBlock = { type: 'spread', subBlocks: [...localFields, ...makeFields()] }
64+
export const getBlockRegistry = () => ({ legacy: LegacyBlock, current: CurrentBlock, spread: SpreadBlock })
65+
`
66+
)
67+
write('apps/sim/triggers/fields.ts', "export const triggerFields = [{ id: 'trigger' }]\n")
68+
write(
69+
'packages/fields/package.json',
70+
JSON.stringify({ name: '@sim/fields', type: 'module', exports: './index.ts' })
71+
)
72+
write('packages/fields/index.ts', "export const sharedFields = [{ id: 'shared' }]\n")
73+
const base = commit()
74+
mkdirSync(join(root, 'node_modules/@sim'), { recursive: true })
75+
symlinkSync(join(root, 'packages/fields'), join(root, 'node_modules/@sim/fields'), 'dir')
76+
write(
77+
'packages/fields/index.ts',
78+
"export const sharedFields = [{ id: 'changed-after-base' }]\n"
79+
)
80+
write('apps/sim/triggers/fields.ts', 'export const triggerFields = []\n')
81+
const statusBefore = git('status', '--porcelain')
82+
83+
expect(readBlockRegistryAtRef(root, base)).toEqual({
84+
legacy: ['operation', 'encoding'],
85+
current: ['operation'],
86+
spread: ['operation', 'encoding', 'shared', 'trigger'],
87+
})
88+
expect(git('status', '--porcelain')).toBe(statusBefore)
89+
expect(readFileSync(join(root, 'packages/fields/index.ts'), 'utf8')).toContain(
90+
'changed-after-base'
91+
)
92+
})
93+
94+
it('keeps installed third-party dependencies available without treating their output as registry JSON', () => {
95+
write(
96+
'apps/sim/blocks/registry.ts',
97+
`
98+
import { field } from 'fixture-provider'
99+
console.log('Registry initialization diagnostic')
100+
export const getBlockRegistry = () => ({ block: { type: 'block', subBlocks: [field] } })
101+
`
102+
)
103+
const base = commit()
104+
write(
105+
'node_modules/fixture-provider/package.json',
106+
JSON.stringify({ name: 'fixture-provider', type: 'module', exports: './index.js' })
107+
)
108+
write(
109+
'node_modules/fixture-provider/index.js',
110+
"export const field = { id: 'installed-field' }\n"
111+
)
112+
113+
expect(readBlockRegistryAtRef(root, base)).toEqual({ block: ['installed-field'] })
114+
})
115+
116+
it('fails instead of returning partial IDs when a derived definition cannot load', () => {
117+
write(
118+
'apps/sim/blocks/registry.ts',
119+
`
120+
import { missingFields } from './missing'
121+
export const getBlockRegistry = () => ({ block: { type: 'block', subBlocks: missingFields } })
122+
`
123+
)
124+
const base = commit()
125+
expect(() => readBlockRegistryAtRef(root, base)).toThrow()
126+
})
127+
128+
it('fails when the requested base revision is unavailable', () => {
129+
write('apps/sim/blocks/registry.ts', 'export const getBlockRegistry = () => ({})\n')
130+
commit()
131+
expect(() => readBlockRegistryAtRef(root, 'missing-base')).toThrow()
132+
})
133+
})
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { execFileSync } from 'node:child_process'
2+
import {
3+
existsSync,
4+
mkdirSync,
5+
mkdtempSync,
6+
readdirSync,
7+
readFileSync,
8+
realpathSync,
9+
rmSync,
10+
symlinkSync,
11+
writeFileSync,
12+
} from 'node:fs'
13+
import { tmpdir } from 'node:os'
14+
import { dirname, join, resolve, sep } from 'node:path'
15+
import { z } from 'zod'
16+
17+
const registryIdsSchema = z.record(z.string().min(1), z.array(z.string().min(1)))
18+
19+
interface WorkspacePackage {
20+
name: string
21+
path: string
22+
relativePath: string
23+
}
24+
25+
function readWorkspacePackages(snapshot: string): WorkspacePackage[] {
26+
const workspaces: WorkspacePackage[] = []
27+
for (const group of ['apps', 'packages']) {
28+
const directory = join(snapshot, group)
29+
if (!existsSync(directory)) continue
30+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
31+
if (!entry.isDirectory()) continue
32+
const relativePath = join(group, entry.name)
33+
const path = join(snapshot, relativePath)
34+
const manifestPath = join(path, 'package.json')
35+
if (!existsSync(manifestPath)) continue
36+
const { name } = z
37+
.object({ name: z.string().min(1) })
38+
.parse(JSON.parse(readFileSync(manifestPath, 'utf8')))
39+
workspaces.push({ name, path, relativePath })
40+
}
41+
}
42+
return workspaces
43+
}
44+
45+
function linkInstalledDependencies(
46+
source: string,
47+
target: string,
48+
root: string,
49+
workspaceNames: Set<string>,
50+
scope = ''
51+
) {
52+
if (!existsSync(source)) return
53+
mkdirSync(target, { recursive: true })
54+
for (const entry of readdirSync(source, { withFileTypes: true })) {
55+
const sourcePath = join(source, entry.name)
56+
const targetPath = join(target, entry.name)
57+
if (entry.name.startsWith('@')) {
58+
linkInstalledDependencies(sourcePath, targetPath, root, workspaceNames, `${entry.name}/`)
59+
continue
60+
}
61+
if (workspaceNames.has(scope + entry.name)) continue
62+
const resolved = realpathSync(sourcePath)
63+
if (['apps', 'packages'].some((group) => resolved.startsWith(join(root, group) + sep))) continue
64+
symlinkSync(sourcePath, targetPath, 'dir')
65+
}
66+
}
67+
68+
function linkWorkspaceDependencies(root: string, snapshot: string) {
69+
const workspaces = readWorkspacePackages(snapshot)
70+
const workspaceNames = new Set(workspaces.map(({ name }) => name))
71+
const modules = join(snapshot, 'node_modules')
72+
linkInstalledDependencies(join(root, 'node_modules'), modules, root, workspaceNames)
73+
for (const workspace of workspaces) {
74+
const packageLink = resolve(modules, workspace.name)
75+
if (!packageLink.startsWith(modules + sep)) {
76+
throw new Error(`Invalid workspace package name: ${workspace.name}`)
77+
}
78+
mkdirSync(dirname(packageLink), { recursive: true })
79+
symlinkSync(workspace.path, packageLink, 'dir')
80+
linkInstalledDependencies(
81+
join(root, workspace.relativePath, 'node_modules'),
82+
join(workspace.path, 'node_modules'),
83+
root,
84+
workspaceNames
85+
)
86+
}
87+
}
88+
89+
/**
90+
* Reads effective subblock IDs from the base revision's complete source tree.
91+
* Workspace packages resolve inside the snapshot; only installed third-party
92+
* dependencies are shared. A failed import or missing revision fails the audit.
93+
*/
94+
export function readBlockRegistryAtRef(root: string, ref: string): Record<string, string[]> {
95+
root = realpathSync(root)
96+
const gitOptions = { cwd: root, encoding: 'utf8' as const, stdio: 'pipe' as const }
97+
const commit = execFileSync(
98+
'git',
99+
['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`],
100+
gitOptions
101+
).trim()
102+
const temporary = mkdtempSync(join(tmpdir(), 'sim-block-registry-'))
103+
try {
104+
const archive = join(temporary, 'source.tar')
105+
const snapshot = join(temporary, 'source')
106+
mkdirSync(snapshot)
107+
execFileSync('git', ['archive', '--format=tar', `--output=${archive}`, commit], gitOptions)
108+
execFileSync('tar', ['-xf', archive, '-C', snapshot])
109+
linkWorkspaceDependencies(root, snapshot)
110+
111+
const script = join(snapshot, 'apps/sim/.block-registry-snapshot.ts')
112+
const output = join(temporary, 'ids.json')
113+
writeFileSync(
114+
script,
115+
`
116+
import { writeFileSync } from 'node:fs'
117+
import { getBlockRegistry } from '@/blocks/registry'
118+
119+
const entries = Object.values(getBlockRegistry()).map(block => [block.type, block.subBlocks.map(field => field.id)])
120+
writeFileSync(process.argv[2], JSON.stringify(Object.fromEntries(entries)))
121+
`
122+
)
123+
execFileSync('bun', ['--no-env-file', 'run', script, output], {
124+
cwd: join(snapshot, 'apps/sim'),
125+
encoding: 'utf8',
126+
stdio: 'pipe',
127+
timeout: 60_000,
128+
maxBuffer: 4 * 1024 * 1024,
129+
})
130+
return registryIdsSchema.parse(JSON.parse(readFileSync(output, 'utf8')))
131+
} finally {
132+
rmSync(temporary, { recursive: true, force: true })
133+
}
134+
}

‎apps/sim/scripts/block-registry-source.test.ts‎

Lines changed: 0 additions & 36 deletions
This file was deleted.

‎apps/sim/scripts/block-registry-source.ts‎

Lines changed: 0 additions & 51 deletions
This file was deleted.

0 commit comments

Comments
 (0)