Repository navigation
Expand file tree
/
Copy pathgenerate-block-successors.ts
More file actions
190 lines (173 loc) · 7.27 KB
/
Copy pathgenerate-block-successors.ts
File metadata and controls
190 lines (173 loc) · 7.27 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
#!/usr/bin/env bun
/**
* Generates access-control successors and display names from the block registry.
*
* The map answers one question — "which block type is an allowlist decision
* about this id really made against?" — and it has to be answerable from
* `lib/permission-groups/`, which `scripts/check-application-graph.ts` forbids
* from importing `blocks/`: the authorization funnel would pull every block
* definition into every surface that authorizes anything. Before this file the
* answer was reachable only through `getBlock`, so the env allowlist was
* intersected with the group allowlist *textually*, and a deployment naming
* `slack` against a group naming `slack_v2` intersected to nothing — refusing an
* integration both policies allow.
*
* Entries are flattened to the terminal successor; see {@link flattenSuccessors}
* for the walk and its stopping rules.
*
* Usage:
* bun run scripts/generate-block-successors.ts
* bun run scripts/generate-block-successors.ts --check
*/
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { formatGeneratedSource } from './format-generated-source'
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(SCRIPT_DIR, '..')
const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/permission-groups/block-successors.generated.ts')
const NAMES_OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/block-metadata/names.generated.ts')
const CHECK_MODE = process.argv.includes('--check')
interface SunsetBlock {
name: string
sunset?: { status: string; replacedBy?: string }
}
/**
* The block registry, loaded lazily.
*
* A static import would run on every import of this module, including the unit
* test for {@link flattenSuccessors}, which needs no registry and cannot
* resolve the `@/` specifiers every block file uses from the repo-root vitest
* project.
*/
async function loadRegistry(): Promise<Record<string, SunsetBlock>> {
const { BLOCK_REGISTRY } = await import('../apps/sim/blocks/registry-maps')
return BLOCK_REGISTRY
}
/**
* Flattens one-hop `replacedBy` edges to terminal successors.
*
* Reproduces the walk the runtime used to perform against the registry, with
* both of its stopping rules: a cycle stops at the last id visited rather than
* spinning, and an edge naming an unregistered block is not followed, leaving
* the id as its own answer. Only ids whose answer differs from themselves are
* returned, so a lookup that misses is a block with no successor.
*/
export function flattenSuccessors(
directSuccessors: Readonly<Record<string, string>>,
isRegistered: (blockType: string) => boolean
): ReadonlyMap<string, string> {
const terminal = (blockType: string): string => {
const seen = new Set<string>([blockType])
let current = blockType
while (true) {
const successor = directSuccessors[current]
if (!successor || seen.has(successor) || !isRegistered(successor)) return current
seen.add(successor)
current = successor
}
}
const successors = new Map<string, string>()
for (const blockType of Object.keys(directSuccessors).sort()) {
const resolved = terminal(blockType)
if (resolved !== blockType) successors.set(blockType, resolved)
}
return successors
}
export async function buildBlockSuccessors(): Promise<ReadonlyMap<string, string>> {
const registry = await loadRegistry()
/** `getBlock`'s own-key lookup, with its dash-to-underscore normalization. */
const lookup = (type: string): SunsetBlock | undefined => {
if (Object.hasOwn(registry, type)) return registry[type]
const normalized = type.replace(/-/g, '_')
return Object.hasOwn(registry, normalized) ? registry[normalized] : undefined
}
const directSuccessors: Record<string, string> = {}
for (const blockType of Object.keys(registry)) {
const successor = registry[blockType]?.sunset?.replacedBy
if (successor) directSuccessors[blockType] = successor
}
return flattenSuccessors(directSuccessors, (blockType) => lookup(blockType) !== undefined)
}
function render(successors: ReadonlyMap<string, string>): string {
const quote = (value: string) => `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
const entries = [...successors]
.map(
([blockType, successor]) =>
` ${/^[A-Za-z_$][\w$]*$/.test(blockType) ? blockType : quote(blockType)}: ${quote(successor)},`
)
.join('\n')
return `/**
* Generated by \`bun run generate:block-successors\` from the block registry.
* Do not edit this file directly.
*
* Maps a retired block type to the *terminal* type an access-control decision
* about it is made against — \`sunset.replacedBy\`, followed transitively. It
* exists as a generated projection because \`lib/permission-groups/\` may not
* import \`blocks/\`; see \`scripts/generate-block-successors.ts\`.
*/
export const BLOCK_ACCESS_SUCCESSORS: Record<string, string> = {
${entries}
}
`
}
/** Display labels are looked up after successor resolution; retired aliases need no second row. */
export function buildBlockNames(
registry: Readonly<Record<string, Pick<SunsetBlock, 'name'>>>,
successors: ReadonlyMap<string, string>
): ReadonlyMap<string, string> {
const names: Array<[string, string]> = [
['loop', 'Loop'],
['parallel', 'Parallel'],
...Object.entries(registry)
.filter(([type]) => !successors.has(type))
.map(([type, block]): [string, string] => [type, block.name]),
]
names.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
return new Map(names)
}
async function main(): Promise<void> {
const successors = await buildBlockSuccessors()
/**
* The map must be closed: no key may also be a value, or a lookup would need
* a second hop and the runtime does exactly one. Flattening guarantees it, so
* a violation means the flattening itself regressed.
*/
for (const successor of successors.values()) {
if (successors.has(successor)) {
throw new Error(
`Block successor map is not flattened: '${successor}' is both a successor and a retired id.`
)
}
}
const registry = await loadRegistry()
const names = buildBlockNames(registry, successors)
const artifacts = [
{ path: OUTPUT_PATH, source: render(successors) },
{
path: NAMES_OUTPUT_PATH,
source: `/**
* Generated by \`bun run generate:block-successors\` from the block registry.
* Display-only metadata; keeps block implementations out of permission previews.
*/
export const BLOCK_NAMES: Readonly<Record<string, string>> = ${JSON.stringify(Object.fromEntries(names), null, 2)}\n`,
},
]
for (const artifact of artifacts) {
const generated = formatGeneratedSource(artifact.source, artifact.path, ROOT)
if (CHECK_MODE) {
const current = await readFile(artifact.path, 'utf8').catch(() => '')
if (current !== generated) {
throw new Error(
`${artifact.path} is stale. Run \`bun run generate:block-successors\` and commit the result.`
)
}
} else {
await mkdir(dirname(artifact.path), { recursive: true })
await writeFile(artifact.path, generated)
process.stdout.write(`Generated ${artifact.path}\n`)
}
}
if (CHECK_MODE) process.stdout.write('Block access metadata is current.\n')
}
if (import.meta.main) await main()