@fugood/bricks-ctor 2.25.0-beta.6 → 2.25.0-beta.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/compile/__tests__/config-diff.test.js +100 -0
  2. package/compile/__tests__/index.test.js +461 -0
  3. package/compile/__tests__/util.test.js +450 -0
  4. package/compile/action-name-map.ts +64 -0
  5. package/compile/config-diff.ts +155 -0
  6. package/compile/index.ts +668 -352
  7. package/compile/util.ts +134 -10
  8. package/package.json +7 -3
  9. package/skills/bricks-ctor/SKILL.md +23 -17
  10. package/skills/bricks-ctor/{rules → references}/animation.md +3 -2
  11. package/skills/bricks-ctor/{rules → references}/architecture-patterns.md +19 -0
  12. package/skills/bricks-ctor/{rules → references}/automations.md +11 -0
  13. package/skills/bricks-ctor/references/buttress.md +245 -0
  14. package/skills/bricks-ctor/references/data-calculation.md +252 -0
  15. package/skills/bricks-ctor/{rules → references}/media-flow.md +7 -0
  16. package/skills/bricks-ctor/references/simulator.md +132 -0
  17. package/skills/bricks-ctor/references/source-editing-tools.md +81 -0
  18. package/skills/bricks-ctor/references/verification-toolchain.md +200 -0
  19. package/skills/bricks-design/SKILL.md +150 -45
  20. package/skills/bricks-design/references/architecture-truths.md +132 -0
  21. package/skills/bricks-design/references/avoiding-complexity.md +91 -0
  22. package/skills/bricks-design/references/design-critique.md +195 -0
  23. package/skills/bricks-design/references/design-languages.md +265 -0
  24. package/skills/bricks-design/references/performance.md +116 -0
  25. package/skills/bricks-design/references/presentation-and-slideshow.md +137 -0
  26. package/skills/bricks-design/references/translating-inputs.md +152 -0
  27. package/skills/bricks-design/references/variations-and-tweaks.md +124 -0
  28. package/skills/bricks-design/references/when-the-brief-is-branded.md +284 -0
  29. package/skills/bricks-design/references/when-the-brief-is-vague.md +85 -0
  30. package/skills/bricks-design/references/workflow.md +134 -0
  31. package/skills/bricks-ux/SKILL.md +114 -0
  32. package/skills/bricks-ux/references/accessibility.md +162 -0
  33. package/skills/bricks-ux/references/flow-states.md +175 -0
  34. package/skills/bricks-ux/references/interaction-archetypes.md +189 -0
  35. package/skills/bricks-ux/references/monitoring-screens.md +153 -0
  36. package/skills/bricks-ux/references/pressable-composition.md +126 -0
  37. package/skills/bricks-ux/references/user-journey.md +168 -0
  38. package/skills/bricks-ux/references/ux-critique.md +256 -0
  39. package/tools/__tests__/_cli-error.test.ts +35 -0
  40. package/tools/__tests__/_mcp-config.test.ts +67 -0
  41. package/tools/__tests__/pull.test.ts +108 -0
  42. package/tools/_cli-error.ts +17 -0
  43. package/tools/_edits-log.ts +41 -0
  44. package/tools/_git-author.ts +10 -2
  45. package/tools/_last-pushed-commit.ts +28 -0
  46. package/tools/_mcp-config.ts +42 -0
  47. package/tools/_shell.ts +8 -1
  48. package/tools/deploy.ts +17 -6
  49. package/tools/mcp-env.ts +13 -0
  50. package/tools/mcp-server.ts +8 -0
  51. package/tools/mcp-tools/__tests__/data-calc-editing.test.js +516 -0
  52. package/tools/mcp-tools/__tests__/entry-editing.test.js +866 -0
  53. package/tools/mcp-tools/__tests__/huggingface.test.ts +49 -0
  54. package/tools/mcp-tools/__tests__/icons.test.ts +21 -0
  55. package/tools/mcp-tools/__tests__/mcp-env.test.js +19 -0
  56. package/tools/mcp-tools/_editing-helpers.ts +98 -0
  57. package/tools/mcp-tools/_verify.ts +50 -0
  58. package/tools/mcp-tools/compile.ts +21 -9
  59. package/tools/mcp-tools/data-calc-editing.ts +1311 -0
  60. package/tools/mcp-tools/entry-editing.ts +2297 -0
  61. package/tools/mcp-tools/huggingface.ts +23 -13
  62. package/tools/mcp-tools/icons.ts +23 -7
  63. package/tools/mcp-tools/media.ts +4 -1
  64. package/tools/postinstall.ts +95 -38
  65. package/tools/pull.ts +100 -23
  66. package/tools/push-config.ts +114 -0
  67. package/tools/{preview-main.mjs → simulator-main.mjs} +207 -12
  68. package/tools/simulator-preload.cjs +16 -0
  69. package/tools/{preview.ts → simulator.ts} +4 -4
  70. package/types/{animation.ts → animation.d.ts} +24 -8
  71. package/types/{automation.ts → automation.d.ts} +16 -20
  72. package/types/{brick-base.ts → brick-base.d.ts} +1 -1
  73. package/types/bricks/{Camera.ts → Camera.d.ts} +8 -8
  74. package/types/bricks/{Chart.ts → Chart.d.ts} +4 -4
  75. package/types/bricks/{GenerativeMedia.ts → GenerativeMedia.d.ts} +15 -15
  76. package/types/bricks/{Icon.ts → Icon.d.ts} +7 -7
  77. package/types/bricks/{Image.ts → Image.d.ts} +21 -9
  78. package/types/bricks/{Items.ts → Items.d.ts} +11 -7
  79. package/types/bricks/{Lottie.ts → Lottie.d.ts} +10 -10
  80. package/types/bricks/{Maps.ts → Maps.d.ts} +11 -11
  81. package/types/bricks/{QrCode.ts → QrCode.d.ts} +7 -7
  82. package/types/bricks/{Rect.ts → Rect.d.ts} +7 -7
  83. package/types/bricks/{RichText.ts → RichText.d.ts} +12 -9
  84. package/types/bricks/{Rive.ts → Rive.d.ts} +9 -9
  85. package/types/bricks/Scene3D.d.ts +676 -0
  86. package/types/bricks/{Sketch.ts → Sketch.d.ts} +10 -8
  87. package/types/bricks/{Slideshow.ts → Slideshow.d.ts} +7 -7
  88. package/types/bricks/{Svg.ts → Svg.d.ts} +7 -7
  89. package/types/bricks/{Text.ts → Text.d.ts} +9 -9
  90. package/types/bricks/{TextInput.ts → TextInput.d.ts} +10 -10
  91. package/types/bricks/{Video.ts → Video.d.ts} +80 -13
  92. package/types/bricks/{VideoStreaming.ts → VideoStreaming.d.ts} +10 -10
  93. package/types/bricks/{WebRtcStream.ts → WebRtcStream.d.ts} +1 -1
  94. package/types/bricks/{WebView.ts → WebView.d.ts} +4 -4
  95. package/types/bricks/{index.ts → index.d.ts} +1 -0
  96. package/types/{common.ts → common.d.ts} +3 -6
  97. package/types/data-calc-command/base.d.ts +57 -0
  98. package/types/data-calc-command/collection.d.ts +418 -0
  99. package/types/data-calc-command/color.d.ts +432 -0
  100. package/types/data-calc-command/constant.d.ts +50 -0
  101. package/types/data-calc-command/datetime.d.ts +147 -0
  102. package/types/data-calc-command/file.d.ts +129 -0
  103. package/types/data-calc-command/index.d.ts +13 -0
  104. package/types/data-calc-command/iteratee.d.ts +23 -0
  105. package/types/data-calc-command/logictype.d.ts +190 -0
  106. package/types/data-calc-command/math.d.ts +275 -0
  107. package/types/data-calc-command/object.d.ts +119 -0
  108. package/types/data-calc-command/sandbox.d.ts +66 -0
  109. package/types/data-calc-command/string.d.ts +407 -0
  110. package/types/{data-calc.ts → data-calc.d.ts} +1 -0
  111. package/types/{data.ts → data.d.ts} +4 -2
  112. package/types/generators/{Assistant.ts → Assistant.d.ts} +19 -0
  113. package/types/generators/{HttpServer.ts → HttpServer.d.ts} +56 -2
  114. package/types/generators/{LlmGgml.ts → LlmGgml.d.ts} +43 -1
  115. package/types/generators/{LlmMlx.ts → LlmMlx.d.ts} +1 -0
  116. package/types/generators/{RerankerGgml.ts → RerankerGgml.d.ts} +5 -1
  117. package/types/generators/{SoundRecorder.ts → SoundRecorder.d.ts} +10 -1
  118. package/types/generators/{SpeechToTextGgml.ts → SpeechToTextGgml.d.ts} +6 -1
  119. package/types/generators/{SttAppleBuiltin.ts → SttAppleBuiltin.d.ts} +27 -4
  120. package/types/generators/{ThermalPrinter.ts → ThermalPrinter.d.ts} +9 -7
  121. package/types/generators/{Tick.ts → Tick.d.ts} +1 -1
  122. package/types/generators/{VadGgml.ts → VadGgml.d.ts} +12 -2
  123. package/types/{subspace.ts → subspace.d.ts} +1 -1
  124. package/utils/__tests__/calc.test.js +25 -0
  125. package/utils/__tests__/id.test.js +154 -0
  126. package/utils/calc.ts +5 -1
  127. package/utils/data.ts +5 -7
  128. package/utils/event-props.ts +27 -1
  129. package/utils/id.ts +109 -56
  130. package/skills/bricks-ctor/rules/buttress.md +0 -156
  131. package/skills/bricks-ctor/rules/data-calculation.md +0 -209
  132. package/skills/bricks-design/LICENSE.txt +0 -180
  133. package/types/data-calc-command.ts +0 -7005
  134. /package/skills/bricks-ctor/{rules → references}/local-sync.md +0 -0
  135. /package/skills/bricks-ctor/{rules → references}/remote-data-bank.md +0 -0
  136. /package/skills/bricks-ctor/{rules → references}/standby-transition.md +0 -0
  137. /package/types/{canvas.ts → canvas.d.ts} +0 -0
  138. /package/types/{data-calc-script.ts → data-calc-script.d.ts} +0 -0
  139. /package/types/generators/{AlarmClock.ts → AlarmClock.d.ts} +0 -0
  140. /package/types/generators/{BleCentral.ts → BleCentral.d.ts} +0 -0
  141. /package/types/generators/{BlePeripheral.ts → BlePeripheral.d.ts} +0 -0
  142. /package/types/generators/{CanvasMap.ts → CanvasMap.d.ts} +0 -0
  143. /package/types/generators/{CastlesPay.ts → CastlesPay.d.ts} +0 -0
  144. /package/types/generators/{DataBank.ts → DataBank.d.ts} +0 -0
  145. /package/types/generators/{File.ts → File.d.ts} +0 -0
  146. /package/types/generators/{GraphQl.ts → GraphQl.d.ts} +0 -0
  147. /package/types/generators/{Http.ts → Http.d.ts} +0 -0
  148. /package/types/generators/{Information.ts → Information.d.ts} +0 -0
  149. /package/types/generators/{Intent.ts → Intent.d.ts} +0 -0
  150. /package/types/generators/{Iterator.ts → Iterator.d.ts} +0 -0
  151. /package/types/generators/{Keyboard.ts → Keyboard.d.ts} +0 -0
  152. /package/types/generators/{LlmAnthropicCompat.ts → LlmAnthropicCompat.d.ts} +0 -0
  153. /package/types/generators/{LlmAppleBuiltin.ts → LlmAppleBuiltin.d.ts} +0 -0
  154. /package/types/generators/{LlmMediaTekNeuroPilot.ts → LlmMediaTekNeuroPilot.d.ts} +0 -0
  155. /package/types/generators/{LlmOnnx.ts → LlmOnnx.d.ts} +0 -0
  156. /package/types/generators/{LlmOpenAiCompat.ts → LlmOpenAiCompat.d.ts} +0 -0
  157. /package/types/generators/{LlmQualcommAiEngine.ts → LlmQualcommAiEngine.d.ts} +0 -0
  158. /package/types/generators/{Mcp.ts → Mcp.d.ts} +0 -0
  159. /package/types/generators/{McpServer.ts → McpServer.d.ts} +0 -0
  160. /package/types/generators/{MediaFlow.ts → MediaFlow.d.ts} +0 -0
  161. /package/types/generators/{MqttBroker.ts → MqttBroker.d.ts} +0 -0
  162. /package/types/generators/{MqttClient.ts → MqttClient.d.ts} +0 -0
  163. /package/types/generators/{Question.ts → Question.d.ts} +0 -0
  164. /package/types/generators/{RealtimeTranscription.ts → RealtimeTranscription.d.ts} +0 -0
  165. /package/types/generators/{SerialPort.ts → SerialPort.d.ts} +0 -0
  166. /package/types/generators/{SoundPlayer.ts → SoundPlayer.d.ts} +0 -0
  167. /package/types/generators/{SpeechToTextOnnx.ts → SpeechToTextOnnx.d.ts} +0 -0
  168. /package/types/generators/{SpeechToTextPlatform.ts → SpeechToTextPlatform.d.ts} +0 -0
  169. /package/types/generators/{SqLite.ts → SqLite.d.ts} +0 -0
  170. /package/types/generators/{Step.ts → Step.d.ts} +0 -0
  171. /package/types/generators/{Tcp.ts → Tcp.d.ts} +0 -0
  172. /package/types/generators/{TcpServer.ts → TcpServer.d.ts} +0 -0
  173. /package/types/generators/{TextToSpeechAppleBuiltin.ts → TextToSpeechAppleBuiltin.d.ts} +0 -0
  174. /package/types/generators/{TextToSpeechGgml.ts → TextToSpeechGgml.d.ts} +0 -0
  175. /package/types/generators/{TextToSpeechOnnx.ts → TextToSpeechOnnx.d.ts} +0 -0
  176. /package/types/generators/{TextToSpeechOpenAiLike.ts → TextToSpeechOpenAiLike.d.ts} +0 -0
  177. /package/types/generators/{Udp.ts → Udp.d.ts} +0 -0
  178. /package/types/generators/{VadOnnx.ts → VadOnnx.d.ts} +0 -0
  179. /package/types/generators/{VadTraditional.ts → VadTraditional.d.ts} +0 -0
  180. /package/types/generators/{VectorStore.ts → VectorStore.d.ts} +0 -0
  181. /package/types/generators/{Watchdog.ts → Watchdog.d.ts} +0 -0
  182. /package/types/generators/{WebCrawler.ts → WebCrawler.d.ts} +0 -0
  183. /package/types/generators/{WebRtc.ts → WebRtc.d.ts} +0 -0
  184. /package/types/generators/{WebSocket.ts → WebSocket.d.ts} +0 -0
  185. /package/types/generators/{index.ts → index.d.ts} +0 -0
  186. /package/types/{index.ts → index.d.ts} +0 -0
  187. /package/types/{switch.ts → switch.d.ts} +0 -0
  188. /package/types/{system.ts → system.d.ts} +0 -0
@@ -0,0 +1,2297 @@
1
+ import generateModule from '@babel/generator'
2
+ import { parse, parseExpression } from '@babel/parser'
3
+ import traverseModule from '@babel/traverse'
4
+ import * as t from '@babel/types'
5
+ import { parse as parseRuntimeExpression } from 'acorn'
6
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
7
+ import { format as formatWithOxfmt } from 'oxfmt'
8
+ import { readFile, readdir, writeFile } from 'node:fs/promises'
9
+ import path from 'node:path'
10
+ import { z } from 'zod'
11
+
12
+ import { verifyProject } from './_verify'
13
+ import {
14
+ ensureBricksCtorImport,
15
+ getObjectProperty,
16
+ getPropertyKeyName,
17
+ getStringProperty,
18
+ insertImport,
19
+ isRecord,
20
+ makeObjectKey,
21
+ oxfmtOptions,
22
+ projectRelativePath,
23
+ removeObjectProperty,
24
+ setObjectProperty,
25
+ } from './_editing-helpers'
26
+ import { appendEditRecord, editProvenance } from '../_edits-log'
27
+
28
+ const generate = (generateModule as any).default || generateModule
29
+ const traverse = (traverseModule as any).default || traverseModule
30
+
31
+ const entryKinds = {
32
+ 'bricks.ts': {
33
+ kind: 'brick',
34
+ namespace: 'bricks',
35
+ varPrefix: 'b',
36
+ idType: 'brick',
37
+ idPrefix: 'BRICK_',
38
+ typename: 'Brick',
39
+ },
40
+ 'generators.ts': {
41
+ kind: 'generator',
42
+ namespace: 'generators',
43
+ varPrefix: 'g',
44
+ idType: 'generator',
45
+ idPrefix: 'GENERATOR_',
46
+ typename: 'Generator',
47
+ },
48
+ 'canvases.ts': {
49
+ kind: 'canvas',
50
+ namespace: 'canvases',
51
+ varPrefix: 'c',
52
+ idType: 'canvas',
53
+ idPrefix: 'CANVAS_',
54
+ typename: 'Canvas',
55
+ },
56
+ 'data.ts': {
57
+ kind: 'data',
58
+ namespace: 'data',
59
+ varPrefix: 'd',
60
+ idType: 'data',
61
+ idPrefix: 'PROPERTY_BANK_DATA_NODE_',
62
+ typename: 'Data',
63
+ },
64
+ 'animations.ts': {
65
+ kind: 'animation',
66
+ namespace: 'animations',
67
+ varPrefix: 'a',
68
+ idType: 'animation',
69
+ idPrefix: 'ANIMATION_',
70
+ typename: 'Animation',
71
+ },
72
+ } as const
73
+
74
+ type EntryKindFile = keyof typeof entryKinds
75
+ type EntryKind = (typeof entryKinds)[EntryKindFile]
76
+
77
+ type ParsedFile = {
78
+ ast: t.File
79
+ source: string
80
+ absPath: string
81
+ relPath: string
82
+ kind: EntryKind
83
+ }
84
+
85
+ type ExportEntry = {
86
+ name: string
87
+ node: t.ExportNamedDeclaration
88
+ declaration: t.VariableDeclaration
89
+ declarator: t.VariableDeclarator
90
+ object?: t.ObjectExpression
91
+ id?: string
92
+ alias?: string
93
+ typeName?: string
94
+ }
95
+
96
+ type ReferenceResolution = {
97
+ input: string
98
+ id?: string
99
+ alias?: string
100
+ varName: string
101
+ namespace: EntryKind['namespace']
102
+ display: string
103
+ targetAbsPath: string
104
+ sameFile: boolean
105
+ subspaceLabel: string
106
+ importSource?: string
107
+ importLocal?: string
108
+ }
109
+
110
+ type EditContext = {
111
+ projectDir: string
112
+ parsed: ParsedFile
113
+ references: ReferenceResolution[]
114
+ typeImports: Set<string>
115
+ valueImports: Set<string>
116
+ }
117
+
118
+ class EntryEditingError extends Error {
119
+ code: string
120
+ details?: Record<string, unknown>
121
+ isMcpError: boolean
122
+
123
+ constructor(
124
+ code: string,
125
+ message: string,
126
+ details?: Record<string, unknown>,
127
+ isMcpError = false,
128
+ ) {
129
+ super(message)
130
+ this.name = 'EntryEditingError'
131
+ this.code = code
132
+ this.details = details
133
+ this.isMcpError = isMcpError
134
+ }
135
+ }
136
+
137
+ const resolveProjectPath = (projectDir: string, file: string) => {
138
+ if (path.isAbsolute(file)) {
139
+ throw new EntryEditingError('invalid_file', 'File must be project-relative', { file })
140
+ }
141
+
142
+ const absPath = path.resolve(projectDir, file)
143
+ const relative = path.relative(projectDir, absPath)
144
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
145
+ throw new EntryEditingError('invalid_file', 'File must stay inside the project directory', {
146
+ file,
147
+ })
148
+ }
149
+ return absPath
150
+ }
151
+
152
+ const getEntryKindForPath = (filePath: string): EntryKind => {
153
+ const filename = path.basename(filePath) as EntryKindFile
154
+ const kind = entryKinds[filename]
155
+ if (!kind) {
156
+ throw new EntryEditingError('unsupported_file', `Unsupported entry file: ${filename}`, {
157
+ file: filePath,
158
+ expected: Object.keys(entryKinds),
159
+ })
160
+ }
161
+ return kind
162
+ }
163
+
164
+ const parseFileSource = (source: string, relPath: string) => {
165
+ try {
166
+ return parse(source, {
167
+ sourceType: 'module',
168
+ plugins: ['typescript', 'topLevelAwait'],
169
+ errorRecovery: false,
170
+ })
171
+ } catch (err: any) {
172
+ throw new EntryEditingError(
173
+ 'parse_failed',
174
+ `Cannot parse ${relPath} as TypeScript: ${err.message}. Edit this file directly.`,
175
+ { file: relPath },
176
+ true,
177
+ )
178
+ }
179
+ }
180
+
181
+ const readParsedFile = async (projectDir: string, absPath: string): Promise<ParsedFile> => {
182
+ const source = await readFile(absPath, 'utf8')
183
+ const relPath = projectRelativePath(projectDir, absPath)
184
+ return {
185
+ source,
186
+ ast: parseFileSource(source, relPath),
187
+ absPath,
188
+ relPath,
189
+ kind: getEntryKindForPath(absPath),
190
+ }
191
+ }
192
+
193
+ const getExportEntries = (ast: t.File): ExportEntry[] =>
194
+ ast.program.body.flatMap((statement) => {
195
+ if (!t.isExportNamedDeclaration(statement)) return []
196
+ if (!t.isVariableDeclaration(statement.declaration)) return []
197
+ const declaration = statement.declaration
198
+
199
+ return declaration.declarations.flatMap((declarator) => {
200
+ if (!t.isIdentifier(declarator.id)) return []
201
+
202
+ const typeAnnotation = declarator.id.typeAnnotation
203
+ let typeName: string | undefined
204
+ if (
205
+ t.isTSTypeAnnotation(typeAnnotation) &&
206
+ t.isTSTypeReference(typeAnnotation.typeAnnotation) &&
207
+ t.isIdentifier(typeAnnotation.typeAnnotation.typeName)
208
+ ) {
209
+ typeName = typeAnnotation.typeAnnotation.typeName.name
210
+ }
211
+
212
+ const object = t.isObjectExpression(declarator.init) ? declarator.init : undefined
213
+ return [
214
+ {
215
+ name: declarator.id.name,
216
+ node: statement,
217
+ declaration,
218
+ declarator,
219
+ object,
220
+ id: object ? getStringProperty(object, 'id') : undefined,
221
+ alias: object ? getStringProperty(object, 'alias') : undefined,
222
+ typeName,
223
+ },
224
+ ]
225
+ })
226
+ })
227
+
228
+ const ensureStandardEntryObject = (entry: ExportEntry, relPath: string) => {
229
+ if (!entry.object) {
230
+ throw new EntryEditingError(
231
+ 'fallback_recommended',
232
+ `Entry ${entry.name} in ${relPath} is not a top-level exported object literal. Edit this file directly for this one.`,
233
+ { file: relPath, entry: entry.name, check: 'top-level export const object literal' },
234
+ )
235
+ }
236
+ return entry.object
237
+ }
238
+
239
+ const findEntryInParsedFile = (
240
+ parsed: ParsedFile,
241
+ target: { entry?: string; id?: string },
242
+ ): ExportEntry => {
243
+ const entries = getExportEntries(parsed.ast)
244
+
245
+ if (target.entry) {
246
+ const entry = entries.find((item) => item.name === target.entry)
247
+ if (entry) return entry
248
+ // Project docs tell agents aliases work in MCP tools, so accept them here too.
249
+ const aliasMatches = entries.filter((item) => item.alias === target.entry)
250
+ if (aliasMatches.length === 1) return aliasMatches[0]
251
+ if (aliasMatches.length > 1) {
252
+ throw new EntryEditingError('ambiguous_reference', `Alias is ambiguous: ${target.entry}`, {
253
+ file: parsed.relPath,
254
+ entry: target.entry,
255
+ matches: aliasMatches.map((item) => item.name),
256
+ })
257
+ }
258
+ throw new EntryEditingError(
259
+ 'entry_not_found',
260
+ `Entry not found (by name or alias): ${target.entry}`,
261
+ {
262
+ file: parsed.relPath,
263
+ entry: target.entry,
264
+ },
265
+ )
266
+ }
267
+
268
+ if (target.id) {
269
+ const entry = entries.find((item) => item.id === target.id)
270
+ if (!entry) {
271
+ throw new EntryEditingError('entry_not_found', `Entry id not found: ${target.id}`, {
272
+ file: parsed.relPath,
273
+ id: target.id,
274
+ })
275
+ }
276
+ return entry
277
+ }
278
+
279
+ throw new EntryEditingError('missing_target', 'Provide entry or id')
280
+ }
281
+
282
+ const getSubspaceLabelFromPath = (absPath: string) => {
283
+ const parts = absPath.split(path.sep)
284
+ const subspace = parts.find((part) => /^subspace-\d+$/.test(part))
285
+ return subspace || 'unknown-subspace'
286
+ }
287
+
288
+ const getSubspaceDirFromPath = (absPath: string) => {
289
+ const parts = absPath.split(path.sep)
290
+ const index = parts.findIndex((part) => /^subspace-\d+$/.test(part))
291
+ if (index < 0) {
292
+ throw new EntryEditingError('invalid_file', 'Entry file must be inside subspaces/subspace-N', {
293
+ file: absPath,
294
+ })
295
+ }
296
+ return parts.slice(0, index + 1).join(path.sep)
297
+ }
298
+
299
+ const getSubspaceDirs = async (projectDir: string) => {
300
+ const subspacesDir = path.join(projectDir, 'subspaces')
301
+ const entries = await readdir(subspacesDir, { withFileTypes: true }).catch(() => [])
302
+ return entries
303
+ .filter((entry) => entry.isDirectory() && /^subspace-\d+$/.test(entry.name))
304
+ .map((entry) => path.join(subspacesDir, entry.name))
305
+ .sort()
306
+ }
307
+
308
+ const getEntryFilesInSubspace = async (subspaceDir: string) => {
309
+ const names = Object.keys(entryKinds)
310
+ const entries = await readdir(subspaceDir, { withFileTypes: true }).catch(() => [])
311
+ const existing = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name))
312
+ return names.filter((name) => existing.has(name)).map((name) => path.join(subspaceDir, name))
313
+ }
314
+
315
+ const getEntryFiles = async (projectDir: string) => {
316
+ const subspaces = await getSubspaceDirs(projectDir)
317
+ const nested = await Promise.all(subspaces.map(getEntryFilesInSubspace))
318
+ return nested.flat()
319
+ }
320
+
321
+ const resolveTargetFile = async (
322
+ projectDir: string,
323
+ input: { file?: string; entry?: string; id?: string },
324
+ ) => {
325
+ if (input.file) {
326
+ return resolveProjectPath(projectDir, input.file)
327
+ }
328
+
329
+ if (!input.id) {
330
+ throw new EntryEditingError('missing_file', 'Provide file, or provide id for global lookup')
331
+ }
332
+
333
+ const files = await getEntryFiles(projectDir)
334
+ for (const file of files) {
335
+ const parsed = await readParsedFile(projectDir, file)
336
+ const entry = getExportEntries(parsed.ast).find((item) => item.id === input.id)
337
+ if (entry) return file
338
+ }
339
+
340
+ throw new EntryEditingError('entry_not_found', `Entry id not found: ${input.id}`, {
341
+ id: input.id,
342
+ })
343
+ }
344
+
345
+ const resolveEditableTarget = async (
346
+ projectDir: string,
347
+ input: { file?: string; entry?: string; id?: string },
348
+ ) => {
349
+ const absPath = await resolveTargetFile(projectDir, input)
350
+ const parsed = await readParsedFile(projectDir, absPath)
351
+ const entry = findEntryInParsedFile(parsed, input)
352
+ const object = ensureStandardEntryObject(entry, parsed.relPath)
353
+ return { parsed, entry, object }
354
+ }
355
+
356
+ const shortId = (id: string) => {
357
+ const uuid = id.replace(/^[A-Z_]+/g, '')
358
+ return `${uuid.substr(15, 3)}${uuid.substr(20, 1)}`
359
+ }
360
+
361
+ const sanitizeAlias = (alias?: string) => {
362
+ if (!alias) return null
363
+ let sanitized = alias.replace(/[^a-zA-Z0-9_$]/g, '')
364
+ if (!sanitized) return null
365
+ if (/^[0-9]/.test(sanitized)) sanitized = `_${sanitized}`
366
+ return sanitized
367
+ }
368
+
369
+ const makeUniqueVarName = (existing: Set<string>, kind: EntryKind, id?: string, alias?: string) => {
370
+ const base =
371
+ sanitizeAlias(alias) || (id ? `${kind.varPrefix}${shortId(id)}` : `${kind.varPrefix}Entry`)
372
+ if (!existing.has(base)) return base
373
+ let index = 1
374
+ while (existing.has(`${base}${index}`)) index += 1
375
+ return `${base}${index}`
376
+ }
377
+
378
+ // Alias-keyed makeId calls hash to the same uuid regardless of declaration order, so
379
+ // recompiles stay stable when sibling entries are added or removed. The bare form
380
+ // falls back to the order-based counter.
381
+ const makeIdCallExpression = (idType: string, alias?: string) =>
382
+ t.callExpression(
383
+ t.identifier('makeId'),
384
+ alias ? [t.stringLiteral(idType), t.stringLiteral(alias)] : [t.stringLiteral(idType)],
385
+ )
386
+
387
+ const parsePath = (pathValue: string) => {
388
+ const tokens: Array<{ key: string } | { index: number }> = []
389
+ const parts = pathValue.split('.').filter(Boolean)
390
+ for (const part of parts) {
391
+ const pattern = /([^\[\]]+)|\[(\d+)\]/g
392
+ let match: RegExpExecArray | null
393
+ while ((match = pattern.exec(part))) {
394
+ if (match[1]) tokens.push({ key: match[1] })
395
+ if (match[2]) tokens.push({ index: Number(match[2]) })
396
+ }
397
+ }
398
+ if (tokens.length === 0) {
399
+ throw new EntryEditingError('invalid_path', `Invalid path: ${pathValue}`, { path: pathValue })
400
+ }
401
+ return tokens
402
+ }
403
+
404
+ const getExpectedContainer = (token: { key: string } | { index: number }) =>
405
+ 'index' in token ? 'array' : 'object'
406
+
407
+ // Generated sources wrap action objects in `as SystemAction...` casts; path edits
408
+ // should see through type-only wrappers to the literal inside.
409
+ const unwrapExpression = (node: t.Node): t.Node => {
410
+ if (
411
+ t.isTSAsExpression(node) ||
412
+ t.isTSSatisfiesExpression(node) ||
413
+ t.isTSNonNullExpression(node) ||
414
+ t.isParenthesizedExpression(node)
415
+ ) {
416
+ return unwrapExpression(node.expression)
417
+ }
418
+ return node
419
+ }
420
+
421
+ const assertObject = (node: t.Node, pathValue: string) => {
422
+ const unwrapped = unwrapExpression(node)
423
+ if (!t.isObjectExpression(unwrapped)) {
424
+ throw new EntryEditingError(
425
+ 'fallback_recommended',
426
+ `Target path ${pathValue} is not an object literal. Edit this file directly for this one.`,
427
+ { path: pathValue, expected: 'object literal', actual: unwrapped.type },
428
+ )
429
+ }
430
+ return unwrapped
431
+ }
432
+
433
+ const assertArray = (node: t.Node, pathValue: string) => {
434
+ const unwrapped = unwrapExpression(node)
435
+ if (!t.isArrayExpression(unwrapped)) {
436
+ throw new EntryEditingError(
437
+ 'fallback_recommended',
438
+ `Target path ${pathValue} is not an array literal. Edit this file directly for this one.`,
439
+ { path: pathValue, expected: 'array literal', actual: unwrapped.type },
440
+ )
441
+ }
442
+ return unwrapped
443
+ }
444
+
445
+ const createContainerForNext = (token: { key: string } | { index: number }) =>
446
+ getExpectedContainer(token) === 'array' ? t.arrayExpression([]) : t.objectExpression([])
447
+
448
+ const setPathValue = async (
449
+ root: t.ObjectExpression,
450
+ pathValue: string,
451
+ rawValue: unknown,
452
+ ctx: EditContext,
453
+ ) => {
454
+ const tokens = parsePath(pathValue)
455
+ let current: t.Node = root
456
+
457
+ for (let index = 0; index < tokens.length; index += 1) {
458
+ const token = tokens[index]
459
+ const isLast = index === tokens.length - 1
460
+ if ('key' in token) {
461
+ const object = assertObject(current, pathValue)
462
+ if (isLast) {
463
+ setObjectProperty(object, token.key, await expressionFromInput(rawValue, ctx))
464
+ return
465
+ }
466
+
467
+ const nextToken = tokens[index + 1]
468
+ let property = getObjectProperty(object, token.key)
469
+ if (!property) {
470
+ property = t.objectProperty(makeObjectKey(token.key), createContainerForNext(nextToken))
471
+ object.properties.push(property)
472
+ }
473
+ current = property.value as t.Node
474
+ continue
475
+ }
476
+
477
+ const array = assertArray(current, pathValue)
478
+ if (token.index < 0 || token.index > array.elements.length) {
479
+ throw new EntryEditingError('invalid_path', `Array index out of range for ${pathValue}`, {
480
+ path: pathValue,
481
+ index: token.index,
482
+ })
483
+ }
484
+ if (isLast) {
485
+ array.elements[token.index] = await expressionFromInput(rawValue, ctx)
486
+ return
487
+ }
488
+ if (!array.elements[token.index])
489
+ array.elements[token.index] = createContainerForNext(tokens[index + 1])
490
+ current = array.elements[token.index] as t.Node
491
+ }
492
+ }
493
+
494
+ const unsetPathValue = (root: t.ObjectExpression, pathValue: string) => {
495
+ const tokens = parsePath(pathValue)
496
+ let current: t.Node = root
497
+
498
+ for (let index = 0; index < tokens.length; index += 1) {
499
+ const token = tokens[index]
500
+ const isLast = index === tokens.length - 1
501
+ if ('key' in token) {
502
+ const object = assertObject(current, pathValue)
503
+ if (isLast) {
504
+ removeObjectProperty(object, token.key)
505
+ return
506
+ }
507
+ const property = getObjectProperty(object, token.key)
508
+ if (!property) return
509
+ current = property.value as t.Node
510
+ continue
511
+ }
512
+
513
+ const array = assertArray(current, pathValue)
514
+ if (token.index < 0 || token.index >= array.elements.length) return
515
+ if (isLast) {
516
+ array.elements.splice(token.index, 1)
517
+ return
518
+ }
519
+ const element = array.elements[token.index]
520
+ if (!element) return
521
+ current = element
522
+ }
523
+ }
524
+
525
+ const getSwitchTarget = (entryObject: t.ObjectExpression, switchTarget: string | number) => {
526
+ const switchesProperty = getObjectProperty(entryObject, 'switches')
527
+ if (!switchesProperty) {
528
+ throw new EntryEditingError('fallback_recommended', 'Entry has no switches array', {
529
+ path: 'switches',
530
+ })
531
+ }
532
+ const switches = assertArray(switchesProperty.value as t.Node, 'switches')
533
+ const index =
534
+ typeof switchTarget === 'number'
535
+ ? switchTarget
536
+ : switches.elements.findIndex(
537
+ (element) =>
538
+ t.isObjectExpression(element) && getStringProperty(element, 'id') === switchTarget,
539
+ )
540
+
541
+ if (index < 0 || index >= switches.elements.length) {
542
+ throw new EntryEditingError('entry_not_found', `Switch not found: ${switchTarget}`, {
543
+ switch: switchTarget,
544
+ })
545
+ }
546
+
547
+ const item = switches.elements[index]
548
+ if (!t.isObjectExpression(item)) {
549
+ throw new EntryEditingError(
550
+ 'fallback_recommended',
551
+ `Switch ${String(switchTarget)} is not an object literal. Edit this file directly for this one.`,
552
+ { switch: switchTarget },
553
+ )
554
+ }
555
+ return item
556
+ }
557
+
558
+ const relativeImportSource = (fromFile: string, toFile: string) => {
559
+ const withoutExt = toFile.replace(/\.[tj]sx?$/, '')
560
+ let relative = path.relative(path.dirname(fromFile), withoutExt).replace(/\\/g, '/')
561
+ if (!relative.startsWith('.')) relative = `./${relative}`
562
+ return relative
563
+ }
564
+
565
+ const getTopLevelNames = (ast: t.File) => {
566
+ const names = new Set<string>()
567
+ ast.program.body.forEach((statement) => {
568
+ if (t.isImportDeclaration(statement)) {
569
+ statement.specifiers.forEach((specifier) => names.add(specifier.local.name))
570
+ return
571
+ }
572
+ if (t.isExportNamedDeclaration(statement) && t.isVariableDeclaration(statement.declaration)) {
573
+ statement.declaration.declarations.forEach((declarator) => {
574
+ if (t.isIdentifier(declarator.id)) names.add(declarator.id.name)
575
+ })
576
+ }
577
+ })
578
+ return names
579
+ }
580
+
581
+ const ensureNamespaceImport = (
582
+ parsed: ParsedFile,
583
+ namespace: string,
584
+ source: string,
585
+ preferredLocal: string,
586
+ ) => {
587
+ for (const statement of parsed.ast.program.body) {
588
+ if (!t.isImportDeclaration(statement) || statement.source.value !== source) continue
589
+ const namespaceImport = statement.specifiers.find(t.isImportNamespaceSpecifier)
590
+ if (namespaceImport) return namespaceImport.local.name
591
+ }
592
+
593
+ const usedNames = getTopLevelNames(parsed.ast)
594
+ let localName = preferredLocal
595
+ if (usedNames.has(localName)) {
596
+ let index = 1
597
+ while (usedNames.has(`${localName}${index}`)) index += 1
598
+ localName = `${localName}${index}`
599
+ }
600
+
601
+ const declaration = t.importDeclaration(
602
+ [t.importNamespaceSpecifier(t.identifier(localName))],
603
+ t.stringLiteral(source),
604
+ )
605
+ insertImport(parsed.ast, declaration)
606
+ return localName
607
+ }
608
+
609
+ const applyPendingImports = (ctx: EditContext) => {
610
+ ensureBricksCtorImport(ctx.parsed.ast, 'type', ctx.typeImports)
611
+ ensureBricksCtorImport(ctx.parsed.ast, 'value', ctx.valueImports)
612
+ }
613
+
614
+ const resolveSubspaceDir = async (
615
+ projectDir: string,
616
+ currentSubspaceDir: string,
617
+ subspace?: string | number,
618
+ ) => {
619
+ if (subspace == null || subspace === '') return currentSubspaceDir
620
+ if (typeof subspace === 'number')
621
+ return path.join(projectDir, 'subspaces', `subspace-${subspace}`)
622
+
623
+ const normalized = String(subspace)
624
+ if (/^\d+$/.test(normalized)) return path.join(projectDir, 'subspaces', `subspace-${normalized}`)
625
+ if (/^subspace-\d+$/.test(normalized)) return path.join(projectDir, 'subspaces', normalized)
626
+ if (normalized.includes('/')) return resolveProjectPath(projectDir, normalized)
627
+
628
+ const subspaceDirs = await getSubspaceDirs(projectDir)
629
+ for (const subspaceDir of subspaceDirs) {
630
+ const indexPath = path.join(subspaceDir, 'index.ts')
631
+ const source = await readFile(indexPath, 'utf8').catch(() => '')
632
+ if (!source) continue
633
+ const ast = parseFileSource(source, projectRelativePath(projectDir, indexPath))
634
+ let matched = false
635
+ traverse(ast, {
636
+ ObjectProperty(propertyPath: any) {
637
+ if (getPropertyKeyName(propertyPath.node.key) !== 'id') return
638
+ if (
639
+ t.isStringLiteral(propertyPath.node.value) &&
640
+ propertyPath.node.value.value === normalized
641
+ ) {
642
+ matched = true
643
+ propertyPath.stop()
644
+ }
645
+ },
646
+ })
647
+ if (matched) return subspaceDir
648
+ }
649
+
650
+ throw new EntryEditingError('reference_not_found', `Subspace not found: ${normalized}`, {
651
+ subspace: normalized,
652
+ })
653
+ }
654
+
655
+ const resolveReference = async (
656
+ projectDir: string,
657
+ currentFile: string,
658
+ ref: string,
659
+ subspace?: string | number,
660
+ ): Promise<ReferenceResolution> => {
661
+ const currentSubspaceDir = getSubspaceDirFromPath(currentFile)
662
+ const targetSubspaceDir = await resolveSubspaceDir(projectDir, currentSubspaceDir, subspace)
663
+ const files = await getEntryFilesInSubspace(targetSubspaceDir)
664
+ const matches: ReferenceResolution[] = []
665
+
666
+ for (const file of files) {
667
+ const parsed = await readParsedFile(projectDir, file)
668
+ const kind = parsed.kind
669
+ for (const entry of getExportEntries(parsed.ast)) {
670
+ if (entry.name !== ref && entry.id !== ref && entry.alias !== ref) continue
671
+ const sameFile = path.resolve(file) === path.resolve(currentFile)
672
+ const subspaceLabel = path.basename(targetSubspaceDir)
673
+ matches.push({
674
+ input: ref,
675
+ id: entry.id,
676
+ alias: entry.alias,
677
+ varName: entry.name,
678
+ namespace: kind.namespace,
679
+ display: `${kind.namespace}.${entry.name}`,
680
+ targetAbsPath: file,
681
+ sameFile,
682
+ subspaceLabel,
683
+ importSource: sameFile ? undefined : relativeImportSource(currentFile, file),
684
+ })
685
+ }
686
+ }
687
+
688
+ if (matches.length === 0) {
689
+ throw new EntryEditingError('reference_not_found', `Reference not found: ${ref}`, {
690
+ ref,
691
+ subspace: subspace ?? path.basename(currentSubspaceDir),
692
+ })
693
+ }
694
+ if (matches.length > 1) {
695
+ throw new EntryEditingError('ambiguous_reference', `Reference is ambiguous: ${ref}`, {
696
+ ref,
697
+ matches: matches.map((match) => ({
698
+ file: projectRelativePath(projectDir, match.targetAbsPath),
699
+ entry: match.varName,
700
+ id: match.id,
701
+ alias: match.alias,
702
+ })),
703
+ })
704
+ }
705
+ return matches[0]
706
+ }
707
+
708
+ const resolvedReferenceExpression = async (
709
+ ref: string,
710
+ ctx: EditContext,
711
+ subspace?: string | number,
712
+ ) => {
713
+ const resolved = await resolveReference(ctx.projectDir, ctx.parsed.absPath, ref, subspace)
714
+ if (resolved.sameFile) {
715
+ ctx.references.push(resolved)
716
+ return t.identifier(resolved.varName)
717
+ }
718
+
719
+ const currentSubspace = getSubspaceDirFromPath(ctx.parsed.absPath)
720
+ const targetSubspace = getSubspaceDirFromPath(resolved.targetAbsPath)
721
+ const sameSubspace = currentSubspace === targetSubspace
722
+ const subspaceSuffix = resolved.subspaceLabel.replace(/[^a-zA-Z0-9_$]/g, '')
723
+ const preferredLocal = sameSubspace
724
+ ? resolved.namespace
725
+ : `${subspaceSuffix}${resolved.namespace[0].toUpperCase()}${resolved.namespace.slice(1)}`
726
+ const local = ensureNamespaceImport(
727
+ ctx.parsed,
728
+ resolved.namespace,
729
+ resolved.importSource!,
730
+ preferredLocal,
731
+ )
732
+ resolved.importLocal = local
733
+ ctx.references.push({ ...resolved, display: `${local}.${resolved.varName}`, importLocal: local })
734
+ return t.memberExpression(t.identifier(local), t.identifier(resolved.varName))
735
+ }
736
+
737
+ const expressionFromRaw = (raw: string) => {
738
+ try {
739
+ return parseExpression(raw, { plugins: ['typescript'] }) as t.Expression
740
+ } catch (err: any) {
741
+ throw new EntryEditingError('invalid_expr', `Invalid TypeScript expression: ${err.message}`, {
742
+ expr: raw,
743
+ })
744
+ }
745
+ }
746
+
747
+ const expressionFromInput = async (value: unknown, ctx: EditContext): Promise<t.Expression> => {
748
+ if (isRecord(value) && typeof value.expr === 'string' && Object.keys(value).length === 1) {
749
+ return expressionFromRaw(value.expr)
750
+ }
751
+
752
+ if (isRecord(value) && typeof value.link === 'string') {
753
+ ctx.valueImports.add('linkData')
754
+ const refExpr = await resolvedReferenceExpression(value.link, ctx, value.subspace as any)
755
+ return t.callExpression(t.identifier('linkData'), [t.arrowFunctionExpression([], refExpr)])
756
+ }
757
+
758
+ if (isRecord(value) && typeof value.ref === 'string') {
759
+ const refExpr = await resolvedReferenceExpression(value.ref, ctx, value.subspace as any)
760
+ return t.arrowFunctionExpression([], refExpr)
761
+ }
762
+
763
+ if (Array.isArray(value)) {
764
+ return t.arrayExpression(await Promise.all(value.map((item) => expressionFromInput(item, ctx))))
765
+ }
766
+
767
+ if (isRecord(value)) {
768
+ const properties = await Promise.all(
769
+ Object.entries(value).map(async ([key, item]) =>
770
+ t.objectProperty(makeObjectKey(key), await expressionFromInput(item, ctx)),
771
+ ),
772
+ )
773
+ return t.objectExpression(properties)
774
+ }
775
+
776
+ if (value === undefined) return t.identifier('undefined')
777
+ return t.valueToNode(value) as t.Expression
778
+ }
779
+
780
+ const referenceInputDetails = (references: ReferenceResolution[]) =>
781
+ references.map((reference) => ({
782
+ input: reference.input,
783
+ id: reference.id,
784
+ alias: reference.alias,
785
+ resolved: reference.importLocal
786
+ ? `${reference.importLocal}.${reference.varName}`
787
+ : reference.sameFile
788
+ ? reference.varName
789
+ : reference.display,
790
+ file: reference.targetAbsPath,
791
+ }))
792
+
793
+ const printAndFormat = async (parsed: ParsedFile) => {
794
+ const generated = generate(parsed.ast, { comments: true }, parsed.source).code
795
+ const formatted = await formatWithOxfmt(parsed.absPath, generated, oxfmtOptions)
796
+ if (formatted.errors.length > 0) {
797
+ throw new EntryEditingError('format_failed', `oxfmt failed for ${parsed.relPath}`, {
798
+ file: parsed.relPath,
799
+ errors: formatted.errors,
800
+ })
801
+ }
802
+ return formatted.code
803
+ }
804
+
805
+ const writeParsedFile = async (parsed: ParsedFile) => {
806
+ const code = await printAndFormat(parsed)
807
+ parseFileSource(code, parsed.relPath)
808
+ await writeFile(parsed.absPath, code)
809
+ return code
810
+ }
811
+
812
+ const summarizeTarget = (parsed?: ParsedFile, entry?: ExportEntry) => {
813
+ if (!parsed) return undefined
814
+ const subspace = getSubspaceLabelFromPath(parsed.absPath)
815
+ return `${entry?.name || 'entry'} (${parsed.kind.kind}, ${subspace})`
816
+ }
817
+
818
+ const runOperation = async (
819
+ projectDir: string,
820
+ tool: string,
821
+ input: any,
822
+ operation: () => Promise<Record<string, unknown>>,
823
+ ) => {
824
+ const baseRecord = {
825
+ ts: new Date().toISOString(),
826
+ tool,
827
+ op: input?.op || (input?.set ? 'set' : input?.unset ? 'unset' : tool),
828
+ target: {
829
+ file: input?.file,
830
+ entry: input?.entry,
831
+ id: input?.id,
832
+ },
833
+ provenance: editProvenance(),
834
+ }
835
+
836
+ try {
837
+ const result = await operation()
838
+ const record: any = { ...baseRecord, ...result }
839
+ await appendEditRecord(projectDir, record).catch((err) => {
840
+ record.auditError = err.message
841
+ })
842
+ return record
843
+ } catch (err: any) {
844
+ const error =
845
+ err instanceof EntryEditingError
846
+ ? err
847
+ : new EntryEditingError('error', err.message || String(err), undefined, true)
848
+ const outcome = error.code === 'fallback_recommended' ? 'fallback_recommended' : 'error'
849
+ const record: any = {
850
+ ...baseRecord,
851
+ outcome,
852
+ // Keep the failing input in the audit record so error reports are diagnosable
853
+ // (success records already carry the applied change).
854
+ input,
855
+ error: {
856
+ code: error.code,
857
+ message: error.message,
858
+ details: error.details,
859
+ },
860
+ summary:
861
+ outcome === 'fallback_recommended'
862
+ ? `${tool} could not safely edit this standard path; edit the file directly`
863
+ : `${tool} failed: ${error.message}`,
864
+ isError: error.isMcpError || outcome === 'error',
865
+ }
866
+ await appendEditRecord(projectDir, record).catch((auditErr) => {
867
+ record.auditError = auditErr.message
868
+ })
869
+ return record
870
+ }
871
+ }
872
+
873
+ const editEntry = async (projectDir: string, input: any) =>
874
+ runOperation(projectDir, 'edit_entry', input, async () => {
875
+ const { parsed, entry, object } = await resolveEditableTarget(projectDir, input)
876
+ const ctx: EditContext = {
877
+ projectDir,
878
+ parsed,
879
+ references: [],
880
+ typeImports: new Set(),
881
+ valueImports: new Set(),
882
+ }
883
+ const targetObject = input.switch == null ? object : getSwitchTarget(object, input.switch)
884
+
885
+ for (const [pathValue, value] of Object.entries(input.set || {})) {
886
+ await setPathValue(targetObject, pathValue, value, ctx)
887
+ }
888
+ // Apply unset paths highest array-index first: unsetPathValue splices arrays
889
+ // in place, so removing a lower index would shift the higher indices down and
890
+ // a later unset would then hit the wrong element (or no-op). Sorting descending
891
+ // by the numeric indices in each path keeps same-array removals correct;
892
+ // object-key and distinct-array removals are order-independent.
893
+ const orderedUnset = [...(input.unset || [])].sort((a: string, b: string) => {
894
+ const ka = parsePath(a).map((token) => ('index' in token ? token.index : -1))
895
+ const kb = parsePath(b).map((token) => ('index' in token ? token.index : -1))
896
+ for (let i = 0; i < Math.min(ka.length, kb.length); i += 1) {
897
+ if (ka[i] !== kb[i]) return kb[i] - ka[i]
898
+ }
899
+ return kb.length - ka.length
900
+ })
901
+ for (const pathValue of orderedUnset) {
902
+ unsetPathValue(targetObject, pathValue)
903
+ }
904
+
905
+ applyPendingImports(ctx)
906
+ await writeParsedFile(parsed)
907
+ const verify = await verifyProject(projectDir, input.verify)
908
+ const target = summarizeTarget(parsed, entry)
909
+ return {
910
+ file: parsed.relPath,
911
+ entry: entry.name,
912
+ id: entry.id,
913
+ change: {
914
+ set: input.set || {},
915
+ unset: input.unset || [],
916
+ references: referenceInputDetails(ctx.references),
917
+ },
918
+ outcome: verify.status === 'compile:failed' ? 'verify_failed' : 'ok',
919
+ verify,
920
+ summary: `edited ${target} -> ${verify.status}`,
921
+ }
922
+ })
923
+
924
+ const describeUnsupportedStatement = (type: string) => {
925
+ switch (type) {
926
+ case 'IfStatement':
927
+ return 'an if statement'
928
+ case 'ForStatement':
929
+ case 'ForOfStatement':
930
+ case 'ForInStatement':
931
+ return 'a for loop'
932
+ case 'WhileStatement':
933
+ case 'DoWhileStatement':
934
+ return 'a while loop'
935
+ case 'SwitchStatement':
936
+ return 'a switch statement'
937
+ case 'TryStatement':
938
+ return 'a try/catch block'
939
+ case 'FunctionDeclaration':
940
+ return 'a function declaration'
941
+ case 'ReturnStatement':
942
+ return 'a return before the final statement'
943
+ default:
944
+ return `a ${type}`
945
+ }
946
+ }
947
+
948
+ // Mirrors the runtime expression fold (bricks-core data-operation.js compile()):
949
+ // statements must reduce to a single expression, so only expression statements,
950
+ // simple const/let declarations, and a final return (inside an IIFE) evaluate.
951
+ // Anything else fails at runtime on device and simulator alike, with the error
952
+ // visible only in a DevTools session — reject it at edit time instead.
953
+ const findUnsupportedExpressionPart = (
954
+ statements: any[],
955
+ insideFunction: boolean,
956
+ ): string | null => {
957
+ for (let index = 0; index < statements.length; index += 1) {
958
+ const statement = statements[index]
959
+ const isLast = index === statements.length - 1
960
+ if (statement.type === 'ExpressionStatement') {
961
+ const offending = findUnsupportedInIife(statement.expression)
962
+ if (offending) return offending
963
+ continue
964
+ }
965
+ if (statement.type === 'VariableDeclaration') {
966
+ for (const declaration of statement.declarations) {
967
+ if (declaration.id.type !== 'Identifier' || !declaration.init) {
968
+ return 'a destructuring or uninitialized declaration'
969
+ }
970
+ }
971
+ continue
972
+ }
973
+ if (insideFunction && isLast && statement.type === 'ReturnStatement') {
974
+ const offending = statement.argument ? findUnsupportedInIife(statement.argument) : null
975
+ if (offending) return offending
976
+ continue
977
+ }
978
+ return describeUnsupportedStatement(statement.type)
979
+ }
980
+ return null
981
+ }
982
+
983
+ const findUnsupportedInIife = (expression: any): string | null => {
984
+ if (
985
+ expression?.type !== 'CallExpression' ||
986
+ expression.arguments?.length ||
987
+ (expression.callee?.type !== 'ArrowFunctionExpression' &&
988
+ expression.callee?.type !== 'FunctionExpression')
989
+ ) {
990
+ return null
991
+ }
992
+ const body = expression.callee.body
993
+ if (body?.type === 'BlockStatement') return findUnsupportedExpressionPart(body.body, true)
994
+ return findUnsupportedInIife(body)
995
+ }
996
+
997
+ const assertEvaluableExpression = (expression: string) => {
998
+ let program: any
999
+ try {
1000
+ // Same parser and options the runtime uses (acorn, ES2020, no top-level return).
1001
+ program = parseRuntimeExpression(expression, { ecmaVersion: 2020 })
1002
+ } catch (err: any) {
1003
+ throw new EntryEditingError(
1004
+ 'invalid_action',
1005
+ `PROPERTY_BANK_EXPRESSION does not parse: ${err.message}`,
1006
+ { expression },
1007
+ )
1008
+ }
1009
+ const offending = findUnsupportedExpressionPart(program.body, false)
1010
+ if (offending) {
1011
+ throw new EntryEditingError(
1012
+ 'invalid_action',
1013
+ `PROPERTY_BANK_EXPRESSION cannot evaluate ${offending}: the runtime folds the expression into a single expression (expression statements, simple const/let declarations, and a final return inside an IIFE only). Use ternaries instead of statements, or move branching logic to a DataCalculationScript.`,
1014
+ { expression },
1015
+ )
1016
+ }
1017
+ }
1018
+
1019
+ // Agents copy the compiled EventAction shape from existing source
1020
+ // ({ handler, action: { name, params: [...], dataParams: [...] }, waitAsync }), so
1021
+ // normalize it to the flattened tool grammar instead of rejecting it.
1022
+ const normalizeActionInput = (actionInput: any) => {
1023
+ if (!isRecord(actionInput) || !isRecord(actionInput.action)) return actionInput
1024
+ const compiled: any = actionInput.action
1025
+ const normalized: any = {
1026
+ handler: actionInput.handler,
1027
+ name: compiled.name ?? compiled.__actionName,
1028
+ waitAsync: actionInput.waitAsync,
1029
+ }
1030
+ if (compiled.cast) normalized.cast = compiled.cast
1031
+ if (Array.isArray(compiled.params)) {
1032
+ normalized.params = {}
1033
+ for (const param of compiled.params) {
1034
+ if (!isRecord(param) || typeof param.input !== 'string') {
1035
+ throw new EntryEditingError(
1036
+ 'invalid_action',
1037
+ 'Compiled action params entries require { input: "name", value }',
1038
+ { param },
1039
+ )
1040
+ }
1041
+ normalized.params[param.input] = param.value
1042
+ }
1043
+ }
1044
+ if (Array.isArray(compiled.dataParams)) {
1045
+ normalized.dataParams = {}
1046
+ for (const param of compiled.dataParams) {
1047
+ const ref = isRecord(param?.input) ? param.input.ref : param?.input
1048
+ if (typeof ref !== 'string' || !ref) {
1049
+ throw new EntryEditingError(
1050
+ 'invalid_action',
1051
+ 'Compiled action dataParams entries require { input: dataRef, value }',
1052
+ { param },
1053
+ )
1054
+ }
1055
+ normalized.dataParams[ref] = param.value
1056
+ }
1057
+ }
1058
+ return normalized
1059
+ }
1060
+
1061
+ const buildAction = async (rawActionInput: any, ctx: EditContext) => {
1062
+ if (rawActionInput?.expr) return expressionFromInput(rawActionInput, ctx)
1063
+ const actionInput = normalizeActionInput(rawActionInput)
1064
+
1065
+ const actionName = actionInput?.name
1066
+ if (!actionName) {
1067
+ throw new EntryEditingError('invalid_action', 'Action requires name')
1068
+ }
1069
+
1070
+ if (actionName === 'PROPERTY_BANK_EXPRESSION' && isRecord(actionInput.params)) {
1071
+ const expression = (actionInput.params as any).expression
1072
+ if (typeof expression === 'string') assertEvaluableExpression(expression)
1073
+ }
1074
+
1075
+ let handler: t.Expression
1076
+ let parent = 'System'
1077
+ let cast = actionInput.cast
1078
+
1079
+ if (actionInput.handler === 'system' || actionInput.handler == null) {
1080
+ handler = t.stringLiteral('system')
1081
+ cast ||= `SystemAction${actionName
1082
+ .toLowerCase()
1083
+ .split('_')
1084
+ .filter(Boolean)
1085
+ .map((part: string) => `${part[0].toUpperCase()}${part.slice(1)}`)
1086
+ .join('')}`
1087
+ } else if (isRecord(actionInput.handler) && typeof actionInput.handler.ref === 'string') {
1088
+ const resolved = await resolveReference(
1089
+ ctx.projectDir,
1090
+ ctx.parsed.absPath,
1091
+ actionInput.handler.ref,
1092
+ actionInput.handler.subspace as any,
1093
+ )
1094
+ // Codegen only supports brick/generator getter handlers (subspaces use the
1095
+ // string-id form); any other entity kind would emit an invalid action.
1096
+ if (resolved.namespace !== 'bricks' && resolved.namespace !== 'generators') {
1097
+ throw new EntryEditingError(
1098
+ 'invalid_action',
1099
+ `Action handler must be a brick or generator, got ${resolved.namespace}: ${actionInput.handler.ref}`,
1100
+ { handler: actionInput.handler },
1101
+ )
1102
+ }
1103
+ parent = resolved.namespace === 'bricks' ? 'Brick' : 'Generator'
1104
+ const refExpr = await resolvedReferenceExpression(
1105
+ actionInput.handler.ref,
1106
+ ctx,
1107
+ actionInput.handler.subspace as any,
1108
+ )
1109
+ handler = t.arrowFunctionExpression([], refExpr)
1110
+ } else if (isRecord(actionInput.handler) && typeof actionInput.handler.subspace === 'string') {
1111
+ handler = t.stringLiteral(actionInput.handler.subspace)
1112
+ parent = 'Subspace'
1113
+ } else {
1114
+ throw new EntryEditingError('invalid_action', 'Unsupported action handler', {
1115
+ handler: actionInput.handler,
1116
+ })
1117
+ }
1118
+
1119
+ const actionProperties: t.ObjectProperty[] = [
1120
+ t.objectProperty(t.identifier('__actionName'), t.stringLiteral(actionName)),
1121
+ t.objectProperty(t.identifier('parent'), t.stringLiteral(parent)),
1122
+ t.objectProperty(t.identifier('name'), t.stringLiteral(actionName)),
1123
+ ]
1124
+
1125
+ if (actionInput.params) {
1126
+ const params = await Promise.all(
1127
+ Object.entries(actionInput.params).map(async ([inputName, value]) =>
1128
+ t.objectExpression([
1129
+ t.objectProperty(t.identifier('input'), t.stringLiteral(inputName)),
1130
+ t.objectProperty(t.identifier('value'), await expressionFromInput(value, ctx)),
1131
+ ]),
1132
+ ),
1133
+ )
1134
+ actionProperties.push(t.objectProperty(t.identifier('params'), t.arrayExpression(params)))
1135
+ }
1136
+
1137
+ if (actionInput.dataParams) {
1138
+ const params = await Promise.all(
1139
+ Object.entries(actionInput.dataParams).map(async ([dataRef, value]) => {
1140
+ const refExpr = await resolvedReferenceExpression(dataRef, ctx)
1141
+ return t.objectExpression([
1142
+ t.objectProperty(t.identifier('input'), t.arrowFunctionExpression([], refExpr)),
1143
+ t.objectProperty(t.identifier('value'), await expressionFromInput(value, ctx)),
1144
+ ])
1145
+ }),
1146
+ )
1147
+ actionProperties.push(t.objectProperty(t.identifier('dataParams'), t.arrayExpression(params)))
1148
+ }
1149
+
1150
+ let actionExpression: t.Expression = t.objectExpression(actionProperties)
1151
+ if (cast) {
1152
+ ctx.typeImports.add(cast)
1153
+ actionExpression = t.tsAsExpression(
1154
+ actionExpression,
1155
+ t.tsTypeReference(t.identifier(cast)),
1156
+ ) as any
1157
+ }
1158
+
1159
+ return t.objectExpression([
1160
+ t.objectProperty(t.identifier('handler'), handler),
1161
+ t.objectProperty(t.identifier('action'), actionExpression),
1162
+ t.objectProperty(t.identifier('waitAsync'), t.booleanLiteral(!!actionInput.waitAsync)),
1163
+ ])
1164
+ }
1165
+
1166
+ const getOrCreateObjectProperty = (object: t.ObjectExpression, key: string, pathValue: string) => {
1167
+ let property = getObjectProperty(object, key)
1168
+ if (!property) {
1169
+ property = t.objectProperty(makeObjectKey(key), t.objectExpression([]))
1170
+ object.properties.push(property)
1171
+ }
1172
+ return assertObject(property.value as t.Node, pathValue)
1173
+ }
1174
+
1175
+ const getOrCreateArrayProperty = (object: t.ObjectExpression, key: string, pathValue: string) => {
1176
+ let property = getObjectProperty(object, key)
1177
+ if (!property) {
1178
+ property = t.objectProperty(makeObjectKey(key), t.arrayExpression([]))
1179
+ object.properties.push(property)
1180
+ }
1181
+ return assertArray(property.value as t.Node, pathValue)
1182
+ }
1183
+
1184
+ const editEvents = async (projectDir: string, input: any) =>
1185
+ runOperation(projectDir, 'edit_events', input, async () => {
1186
+ const { parsed, entry, object } = await resolveEditableTarget(projectDir, input)
1187
+ const ctx: EditContext = {
1188
+ projectDir,
1189
+ parsed,
1190
+ references: [],
1191
+ typeImports: new Set(),
1192
+ valueImports: new Set(),
1193
+ }
1194
+ const targetObject = input.switch == null ? object : getSwitchTarget(object, input.switch)
1195
+ const op = input.op
1196
+
1197
+ if (op === 'clear' || op === 'remove' || op === 'move') {
1198
+ const eventsProperty = getObjectProperty(targetObject, 'events')
1199
+ const eventsObject = eventsProperty
1200
+ ? assertObject(eventsProperty.value as t.Node, 'events')
1201
+ : null
1202
+ const eventProperty = eventsObject ? getObjectProperty(eventsObject, input.event) : null
1203
+ const eventArray = eventProperty
1204
+ ? assertArray(eventProperty.value as t.Node, `events.${input.event}`)
1205
+ : null
1206
+ if (op === 'clear') {
1207
+ // Codegen omits empty event arrays, so clear drops the event key entirely.
1208
+ if (eventsObject) removeObjectProperty(eventsObject, input.event)
1209
+ } else if (op === 'move') {
1210
+ if (input.index == null) {
1211
+ throw new EntryEditingError('invalid_index', 'move requires index')
1212
+ }
1213
+ if (!eventArray || input.index < 0 || input.index >= eventArray.elements.length) {
1214
+ throw new EntryEditingError('invalid_index', 'move index out of range', {
1215
+ index: input.index,
1216
+ })
1217
+ }
1218
+ const to = Number(input.to)
1219
+ if (!Number.isFinite(to) || to < 0 || to >= eventArray.elements.length) {
1220
+ throw new EntryEditingError('invalid_index', 'move requires valid to index', {
1221
+ to: input.to,
1222
+ })
1223
+ }
1224
+ const [moved] = eventArray.elements.splice(input.index, 1)
1225
+ eventArray.elements.splice(to, 0, moved)
1226
+ } else {
1227
+ if (input.index == null) {
1228
+ throw new EntryEditingError('invalid_index', 'remove requires index')
1229
+ }
1230
+ if (!eventArray || input.index < 0 || input.index >= eventArray.elements.length) {
1231
+ throw new EntryEditingError('invalid_index', 'remove index out of range', {
1232
+ index: input.index,
1233
+ })
1234
+ }
1235
+ eventArray.elements.splice(input.index, 1)
1236
+ if (eventArray.elements.length === 0 && eventsObject) {
1237
+ removeObjectProperty(eventsObject, input.event)
1238
+ }
1239
+ }
1240
+ } else if (op === 'add' || op === 'replace') {
1241
+ const action = await buildAction(input.action, ctx)
1242
+ const eventsObject = getOrCreateObjectProperty(targetObject, 'events', 'events')
1243
+ const eventArray = getOrCreateArrayProperty(
1244
+ eventsObject,
1245
+ input.event,
1246
+ `events.${input.event}`,
1247
+ )
1248
+ const index = input.index ?? eventArray.elements.length
1249
+ if (op === 'replace') {
1250
+ if (index < 0 || index >= eventArray.elements.length) {
1251
+ throw new EntryEditingError('invalid_index', 'replace index out of range', { index })
1252
+ }
1253
+ eventArray.elements[index] = action
1254
+ } else {
1255
+ if (index < 0 || index > eventArray.elements.length) {
1256
+ throw new EntryEditingError('invalid_index', 'add index out of range', { index })
1257
+ }
1258
+ eventArray.elements.splice(index, 0, action)
1259
+ }
1260
+ } else {
1261
+ throw new EntryEditingError('invalid_op', `Unsupported edit_events op: ${op}`)
1262
+ }
1263
+
1264
+ applyPendingImports(ctx)
1265
+ await writeParsedFile(parsed)
1266
+ const verify = await verifyProject(projectDir, input.verify)
1267
+ return {
1268
+ file: parsed.relPath,
1269
+ entry: entry.name,
1270
+ id: entry.id,
1271
+ change: {
1272
+ event: input.event,
1273
+ op,
1274
+ index: input.index,
1275
+ references: referenceInputDetails(ctx.references),
1276
+ },
1277
+ outcome: verify.status === 'compile:failed' ? 'verify_failed' : 'ok',
1278
+ verify,
1279
+ summary: `${op} event ${input.event} on ${summarizeTarget(parsed, entry)} -> ${verify.status}`,
1280
+ }
1281
+ })
1282
+
1283
+ // The CanvasItem type requires a numeric frame; reject bad shapes here instead of
1284
+ // writing frame: {} and leaving the failure to compile verification.
1285
+ const assertCanvasFrame = (frame: any) => {
1286
+ if (isRecord(frame) && typeof frame.expr === 'string') return frame
1287
+ if (!isRecord(frame)) {
1288
+ throw new EntryEditingError(
1289
+ 'invalid_item',
1290
+ 'Canvas item requires frame { x, y, width, height }',
1291
+ )
1292
+ }
1293
+ for (const key of ['x', 'y', 'width', 'height']) {
1294
+ if (typeof frame[key] !== 'number') {
1295
+ throw new EntryEditingError('invalid_item', `Canvas item frame requires numeric ${key}`, {
1296
+ frame,
1297
+ })
1298
+ }
1299
+ }
1300
+ return frame
1301
+ }
1302
+
1303
+ const buildCanvasItem = async (item: any, ctx: EditContext, fallbackFrame?: any) => {
1304
+ if (!item?.ref) throw new EntryEditingError('invalid_item', 'Canvas item requires ref')
1305
+ const frame = assertCanvasFrame(item.frame ?? fallbackFrame)
1306
+ const refExpr = await resolvedReferenceExpression(item.ref, ctx, item.subspace)
1307
+ return t.objectExpression([
1308
+ t.objectProperty(t.identifier('item'), t.arrowFunctionExpression([], refExpr)),
1309
+ t.objectProperty(t.identifier('frame'), await expressionFromInput(frame, ctx)),
1310
+ ...(item.hidden == null
1311
+ ? []
1312
+ : [t.objectProperty(t.identifier('hidden'), t.booleanLiteral(!!item.hidden))]),
1313
+ ])
1314
+ }
1315
+
1316
+ const editCanvasItems = async (projectDir: string, input: any) =>
1317
+ runOperation(projectDir, 'edit_canvas_items', input, async () => {
1318
+ const { parsed, entry, object } = await resolveEditableTarget(projectDir, input)
1319
+ if (parsed.kind.kind !== 'canvas') {
1320
+ throw new EntryEditingError(
1321
+ 'invalid_file',
1322
+ 'edit_canvas_items only edits canvases.ts entries',
1323
+ {
1324
+ file: parsed.relPath,
1325
+ },
1326
+ )
1327
+ }
1328
+ const ctx: EditContext = {
1329
+ projectDir,
1330
+ parsed,
1331
+ references: [],
1332
+ typeImports: new Set(),
1333
+ valueImports: new Set(),
1334
+ }
1335
+ const items = getOrCreateArrayProperty(object, 'items', 'items')
1336
+ const op = input.op
1337
+
1338
+ if (op === 'add' || op === 'replace') {
1339
+ const item = await buildCanvasItem(input.item, ctx, input.frame)
1340
+ const index = input.index ?? items.elements.length
1341
+ if (op === 'replace') {
1342
+ if (index < 0 || index >= items.elements.length) {
1343
+ throw new EntryEditingError('invalid_index', 'replace index out of range', { index })
1344
+ }
1345
+ items.elements[index] = item
1346
+ } else {
1347
+ if (index < 0 || index > items.elements.length) {
1348
+ throw new EntryEditingError('invalid_index', 'add index out of range', { index })
1349
+ }
1350
+ items.elements.splice(index, 0, item)
1351
+ }
1352
+ } else if (op === 'remove') {
1353
+ if (input.index == null) throw new EntryEditingError('invalid_index', 'remove requires index')
1354
+ if (input.index < 0 || input.index >= items.elements.length) {
1355
+ throw new EntryEditingError('invalid_index', 'remove index out of range', {
1356
+ index: input.index,
1357
+ })
1358
+ }
1359
+ items.elements.splice(input.index, 1)
1360
+ } else if (op === 'move') {
1361
+ if (input.index == null) throw new EntryEditingError('invalid_index', 'move requires index')
1362
+ if (!input.frame) throw new EntryEditingError('invalid_item', 'move requires frame')
1363
+ if (input.index < 0 || input.index >= items.elements.length) {
1364
+ throw new EntryEditingError('invalid_index', 'move index out of range', {
1365
+ index: input.index,
1366
+ })
1367
+ }
1368
+ const item = items.elements[input.index]
1369
+ if (!t.isObjectExpression(item)) {
1370
+ throw new EntryEditingError(
1371
+ 'fallback_recommended',
1372
+ 'Canvas item is not an object literal',
1373
+ {
1374
+ index: input.index,
1375
+ },
1376
+ )
1377
+ }
1378
+ setObjectProperty(
1379
+ item,
1380
+ 'frame',
1381
+ await expressionFromInput(assertCanvasFrame(input.frame), ctx),
1382
+ )
1383
+ } else {
1384
+ throw new EntryEditingError('invalid_op', `Unsupported edit_canvas_items op: ${op}`)
1385
+ }
1386
+
1387
+ applyPendingImports(ctx)
1388
+ await writeParsedFile(parsed)
1389
+ const verify = await verifyProject(projectDir, input.verify)
1390
+ return {
1391
+ file: parsed.relPath,
1392
+ entry: entry.name,
1393
+ id: entry.id,
1394
+ change: { op, index: input.index, references: referenceInputDetails(ctx.references) },
1395
+ outcome: verify.status === 'compile:failed' ? 'verify_failed' : 'ok',
1396
+ verify,
1397
+ summary: `${op} canvas item on ${summarizeTarget(parsed, entry)} -> ${verify.status}`,
1398
+ }
1399
+ })
1400
+
1401
+ // Compiled SwitchCond shapes (__typename form) are what agents see when reading
1402
+ // existing source, so accept them as input by normalizing to the structured forms.
1403
+ const normalizeCondInput = (cond: any) => {
1404
+ switch (cond?.__typename) {
1405
+ case 'SwitchCondInnerStateCurrentCanvas':
1406
+ return {
1407
+ type: 'inner_state',
1408
+ key: 'current_canvas',
1409
+ value: cond.value,
1410
+ subspace: cond.subspace,
1411
+ }
1412
+ case 'SwitchCondInnerStateOutlet':
1413
+ return { type: 'inner_state', outlet: cond.outlet, value: cond.value }
1414
+ case 'SwitchCondData':
1415
+ return {
1416
+ type: 'property_bank',
1417
+ ref: cond.data ?? cond.ref,
1418
+ value: cond.value,
1419
+ subspace: cond.subspace,
1420
+ }
1421
+ case 'SwitchCondPropertyBankByItemKey':
1422
+ return {
1423
+ type: 'property_bank_by_item_key',
1424
+ ref: cond.data ?? cond.ref,
1425
+ value: cond.value,
1426
+ subspace: cond.subspace,
1427
+ }
1428
+ default:
1429
+ return cond
1430
+ }
1431
+ }
1432
+
1433
+ const condExpression = async (condInput: any, ctx: EditContext) => {
1434
+ const cond = normalizeCondInput(condInput)
1435
+ if (cond?.expr) return expressionFromInput(cond, ctx)
1436
+ if (cond?.type === 'inner_state' && cond.key === 'current_canvas') {
1437
+ const refExpr = await resolvedReferenceExpression(cond.value, ctx, cond.subspace)
1438
+ return t.objectExpression([
1439
+ t.objectProperty(
1440
+ t.identifier('__typename'),
1441
+ t.stringLiteral('SwitchCondInnerStateCurrentCanvas'),
1442
+ ),
1443
+ t.objectProperty(t.identifier('value'), t.arrowFunctionExpression([], refExpr)),
1444
+ ])
1445
+ }
1446
+ if (cond?.type === 'inner_state') {
1447
+ const outlet = cond.outlet || cond.key
1448
+ if (!outlet) {
1449
+ throw new EntryEditingError(
1450
+ 'invalid_switch',
1451
+ 'Switch inner-state outlet condition requires outlet or key',
1452
+ )
1453
+ }
1454
+ return t.objectExpression([
1455
+ t.objectProperty(t.identifier('__typename'), t.stringLiteral('SwitchCondInnerStateOutlet')),
1456
+ t.objectProperty(t.identifier('outlet'), t.stringLiteral(outlet)),
1457
+ t.objectProperty(t.identifier('value'), await expressionFromInput(cond.value, ctx)),
1458
+ ])
1459
+ }
1460
+ const dataRef = cond?.ref || cond?.data || cond?.key
1461
+ if (!dataRef) throw new EntryEditingError('invalid_switch', 'Switch condition requires data ref')
1462
+ const refExpr = await resolvedReferenceExpression(dataRef, ctx, cond.subspace)
1463
+ return t.objectExpression([
1464
+ t.objectProperty(
1465
+ t.identifier('__typename'),
1466
+ t.stringLiteral(
1467
+ cond?.type === 'property_bank_by_item_key'
1468
+ ? 'SwitchCondPropertyBankByItemKey'
1469
+ : 'SwitchCondData',
1470
+ ),
1471
+ ),
1472
+ t.objectProperty(t.identifier('data'), t.arrowFunctionExpression([], refExpr)),
1473
+ t.objectProperty(t.identifier('value'), await expressionFromInput(cond.value, ctx)),
1474
+ ])
1475
+ }
1476
+
1477
+ const switchKnownFields = new Set([
1478
+ 'id',
1479
+ 'title',
1480
+ 'description',
1481
+ 'conds',
1482
+ 'override',
1483
+ 'property',
1484
+ 'outlets',
1485
+ 'animation',
1486
+ 'events',
1487
+ 'disabled',
1488
+ 'break',
1489
+ ])
1490
+
1491
+ const buildSwitch = async (switchInput: any, ctx: EditContext) => {
1492
+ for (const key of Object.keys(switchInput)) {
1493
+ if (!switchKnownFields.has(key)) {
1494
+ // Silently dropping unknown fields would lose caller intent (typos included).
1495
+ throw new EntryEditingError('invalid_switch', `Unknown switch field: ${key}`, {
1496
+ field: key,
1497
+ supported: Array.from(switchKnownFields),
1498
+ })
1499
+ }
1500
+ }
1501
+ if (switchInput.events !== undefined) {
1502
+ throw new EntryEditingError(
1503
+ 'invalid_switch',
1504
+ 'Set switch events with edit_events using its switch parameter',
1505
+ )
1506
+ }
1507
+ if (switchInput.id && !/^BRICK_STATE_GROUP_[0-9a-fA-F-]{36}$/.test(switchInput.id)) {
1508
+ // Compile asserts this pattern, so reject early instead of failing later.
1509
+ throw new EntryEditingError(
1510
+ 'invalid_switch',
1511
+ 'Switch id must match BRICK_STATE_GROUP_<uuid>; omit id to generate one',
1512
+ { id: switchInput.id },
1513
+ )
1514
+ }
1515
+ const idExpression = switchInput.id
1516
+ ? t.stringLiteral(switchInput.id)
1517
+ : makeIdCallExpression('switch')
1518
+ if (!switchInput.id) ctx.valueImports.add('makeId')
1519
+ const properties: t.ObjectProperty[] = [t.objectProperty(t.identifier('id'), idExpression)]
1520
+ if (switchInput.title != null) {
1521
+ properties.push(t.objectProperty(t.identifier('title'), t.stringLiteral(switchInput.title)))
1522
+ }
1523
+ if (switchInput.description != null) {
1524
+ properties.push(
1525
+ t.objectProperty(t.identifier('description'), t.stringLiteral(switchInput.description)),
1526
+ )
1527
+ }
1528
+ if (switchInput.conds) {
1529
+ properties.push(
1530
+ t.objectProperty(
1531
+ t.identifier('conds'),
1532
+ t.arrayExpression(
1533
+ await Promise.all(
1534
+ switchInput.conds.map(async (item: any) =>
1535
+ t.objectExpression([
1536
+ t.objectProperty(t.identifier('method'), t.stringLiteral(item.method || '==')),
1537
+ t.objectProperty(
1538
+ t.identifier('cond'),
1539
+ await condExpression(item.cond || item, ctx),
1540
+ ),
1541
+ ]),
1542
+ ),
1543
+ ),
1544
+ ),
1545
+ ),
1546
+ )
1547
+ }
1548
+ if (switchInput.override) {
1549
+ properties.push(
1550
+ t.objectProperty(
1551
+ t.identifier('override'),
1552
+ await expressionFromInput(switchInput.override, ctx),
1553
+ ),
1554
+ )
1555
+ }
1556
+ for (const facet of ['property', 'outlets', 'animation']) {
1557
+ if (switchInput[facet] != null) {
1558
+ properties.push(
1559
+ t.objectProperty(t.identifier(facet), await expressionFromInput(switchInput[facet], ctx)),
1560
+ )
1561
+ }
1562
+ }
1563
+ if (switchInput.disabled != null) {
1564
+ properties.push(
1565
+ t.objectProperty(t.identifier('disabled'), t.booleanLiteral(!!switchInput.disabled)),
1566
+ )
1567
+ }
1568
+ if (switchInput.break != null) {
1569
+ properties.push(t.objectProperty(t.identifier('break'), t.booleanLiteral(!!switchInput.break)))
1570
+ }
1571
+ return t.objectExpression(properties)
1572
+ }
1573
+
1574
+ const editSwitches = async (projectDir: string, input: any) =>
1575
+ runOperation(projectDir, 'edit_switches', input, async () => {
1576
+ const { parsed, entry, object } = await resolveEditableTarget(projectDir, input)
1577
+ const ctx: EditContext = {
1578
+ projectDir,
1579
+ parsed,
1580
+ references: [],
1581
+ typeImports: new Set(),
1582
+ valueImports: new Set(),
1583
+ }
1584
+ const switches = getOrCreateArrayProperty(object, 'switches', 'switches')
1585
+ const op = input.op
1586
+
1587
+ const findIndex = (value: string | number | undefined) => {
1588
+ if (typeof value === 'number') return value
1589
+ if (!value) return -1
1590
+ return switches.elements.findIndex(
1591
+ (element) => t.isObjectExpression(element) && getStringProperty(element, 'id') === value,
1592
+ )
1593
+ }
1594
+
1595
+ if (op === 'add' || op === 'replace') {
1596
+ const switchObject = await buildSwitch(input.switch || {}, ctx)
1597
+ const index = input.index == null ? switches.elements.length : findIndex(input.index)
1598
+ if (op === 'replace') {
1599
+ if (index < 0 || index >= switches.elements.length) {
1600
+ throw new EntryEditingError('invalid_index', 'replace index out of range', { index })
1601
+ }
1602
+ switches.elements[index] = switchObject
1603
+ } else {
1604
+ if (index < 0 || index > switches.elements.length) {
1605
+ throw new EntryEditingError('invalid_index', 'add requires a valid switch id or index', {
1606
+ index: input.index,
1607
+ })
1608
+ }
1609
+ switches.elements.splice(index, 0, switchObject)
1610
+ }
1611
+ } else if (op === 'remove') {
1612
+ const index = findIndex(input.index)
1613
+ if (index < 0 || index >= switches.elements.length) {
1614
+ throw new EntryEditingError('invalid_index', 'remove requires a valid switch id or index', {
1615
+ index: input.index,
1616
+ })
1617
+ }
1618
+ switches.elements.splice(index, 1)
1619
+ } else if (op === 'move') {
1620
+ const from = findIndex(input.index)
1621
+ if (from < 0 || from >= switches.elements.length) {
1622
+ throw new EntryEditingError('invalid_index', 'move requires a valid switch id or index', {
1623
+ index: input.index,
1624
+ })
1625
+ }
1626
+ const to = Number(input.to)
1627
+ if (!Number.isFinite(to) || to < 0 || to >= switches.elements.length) {
1628
+ throw new EntryEditingError('invalid_index', 'move requires valid to index', {
1629
+ to: input.to,
1630
+ })
1631
+ }
1632
+ const [item] = switches.elements.splice(from, 1)
1633
+ switches.elements.splice(to, 0, item)
1634
+ } else {
1635
+ throw new EntryEditingError('invalid_op', `Unsupported edit_switches op: ${op}`)
1636
+ }
1637
+
1638
+ applyPendingImports(ctx)
1639
+ await writeParsedFile(parsed)
1640
+ const verify = await verifyProject(projectDir, input.verify)
1641
+ return {
1642
+ file: parsed.relPath,
1643
+ entry: entry.name,
1644
+ id: entry.id,
1645
+ change: { op, index: input.index, references: referenceInputDetails(ctx.references) },
1646
+ outcome: verify.status === 'compile:failed' ? 'verify_failed' : 'ok',
1647
+ verify,
1648
+ summary: `${op} switch on ${summarizeTarget(parsed, entry)} -> ${verify.status}`,
1649
+ }
1650
+ })
1651
+
1652
+ const createBaseEntryObject = async (
1653
+ kind: EntryKind,
1654
+ input: any,
1655
+ idExpression: t.Expression,
1656
+ ctx: EditContext,
1657
+ ) => {
1658
+ const properties: t.ObjectProperty[] = [
1659
+ t.objectProperty(t.identifier('__typename'), t.stringLiteral(kind.typename)),
1660
+ t.objectProperty(t.identifier('id'), idExpression),
1661
+ ]
1662
+ if (input.alias)
1663
+ properties.push(t.objectProperty(t.identifier('alias'), t.stringLiteral(input.alias)))
1664
+ if (input.title != null)
1665
+ properties.push(t.objectProperty(t.identifier('title'), t.stringLiteral(input.title)))
1666
+ if (input.description != null) {
1667
+ properties.push(
1668
+ t.objectProperty(t.identifier('description'), t.stringLiteral(input.description)),
1669
+ )
1670
+ }
1671
+
1672
+ if (kind.kind === 'brick' || kind.kind === 'generator') {
1673
+ properties.push(
1674
+ t.objectProperty(t.identifier('templateKey'), t.stringLiteral(input.templateKey || '')),
1675
+ )
1676
+ properties.push(t.objectProperty(t.identifier('property'), t.objectExpression([])))
1677
+ properties.push(t.objectProperty(t.identifier('events'), t.objectExpression([])))
1678
+ properties.push(t.objectProperty(t.identifier('outlets'), t.objectExpression([])))
1679
+ // Codegen emits an animation facet for bricks only.
1680
+ if (kind.kind === 'brick') {
1681
+ properties.push(t.objectProperty(t.identifier('animation'), t.objectExpression([])))
1682
+ }
1683
+ properties.push(t.objectProperty(t.identifier('switches'), t.arrayExpression([])))
1684
+ } else if (kind.kind === 'canvas') {
1685
+ properties.push(t.objectProperty(t.identifier('property'), t.objectExpression([])))
1686
+ properties.push(t.objectProperty(t.identifier('events'), t.objectExpression([])))
1687
+ properties.push(t.objectProperty(t.identifier('switches'), t.arrayExpression([])))
1688
+ properties.push(t.objectProperty(t.identifier('items'), t.arrayExpression([])))
1689
+ } else if (kind.kind === 'data') {
1690
+ properties.push(
1691
+ t.objectProperty(t.identifier('type'), t.stringLiteral(input.dataType || 'any')),
1692
+ )
1693
+ properties.push(t.objectProperty(t.identifier('schema'), t.objectExpression([])))
1694
+ properties.push(t.objectProperty(t.identifier('events'), t.objectExpression([])))
1695
+ properties.push(t.objectProperty(t.identifier('value'), t.identifier('undefined')))
1696
+ } else if (kind.kind === 'animation') {
1697
+ properties.push(t.objectProperty(t.identifier('runType'), t.stringLiteral('once')))
1698
+ properties.push(t.objectProperty(t.identifier('property'), t.stringLiteral('opacity')))
1699
+ properties.push(
1700
+ t.objectProperty(
1701
+ t.identifier('config'),
1702
+ await expressionFromInput(
1703
+ {
1704
+ __type: 'AnimationTimingConfig',
1705
+ toValue: 1,
1706
+ duration: 300,
1707
+ easing: '',
1708
+ delay: 0,
1709
+ isInteraction: true,
1710
+ },
1711
+ ctx,
1712
+ ),
1713
+ ),
1714
+ )
1715
+ }
1716
+ return t.objectExpression(properties)
1717
+ }
1718
+
1719
+ // True when another entry of the same kind (same filename across subspaces) already
1720
+ // uses the alias — the aliased makeId form would collide at compile time.
1721
+ const aliasUsedInKind = async (projectDir: string, currentFile: string, alias: string) => {
1722
+ const filename = path.basename(currentFile)
1723
+ const files = (await getEntryFiles(projectDir)).filter((file) => path.basename(file) === filename)
1724
+ for (const file of files) {
1725
+ const parsed = await readParsedFile(projectDir, file)
1726
+ if (getExportEntries(parsed.ast).some((entry) => entry.alias === alias)) return true
1727
+ }
1728
+ return false
1729
+ }
1730
+
1731
+ const newEntry = async (projectDir: string, input: any) =>
1732
+ runOperation(projectDir, 'new_entry', input, async () => {
1733
+ if (!input.file) throw new EntryEditingError('missing_file', 'new_entry requires file')
1734
+ if (!input.type)
1735
+ throw new EntryEditingError('missing_type', 'new_entry requires TypeScript type')
1736
+ const absPath = resolveProjectPath(projectDir, input.file)
1737
+ const parsed = await readParsedFile(projectDir, absPath)
1738
+ const existingNames = new Set(getExportEntries(parsed.ast).map((entry) => entry.name))
1739
+ const explicitId = typeof input.id === 'string' ? input.id : undefined
1740
+ const stableAlias =
1741
+ !explicitId &&
1742
+ typeof input.alias === 'string' &&
1743
+ input.alias &&
1744
+ !(await aliasUsedInKind(projectDir, absPath, input.alias))
1745
+ ? input.alias
1746
+ : undefined
1747
+ const idExpression = explicitId
1748
+ ? t.stringLiteral(explicitId)
1749
+ : makeIdCallExpression(parsed.kind.idType, stableAlias)
1750
+ const varName =
1751
+ input.entry ||
1752
+ makeUniqueVarName(existingNames, parsed.kind, explicitId, input.alias || input.title)
1753
+ const ctx: EditContext = {
1754
+ projectDir,
1755
+ parsed,
1756
+ references: [],
1757
+ typeImports: new Set([input.type]),
1758
+ valueImports: new Set(),
1759
+ }
1760
+ if (!explicitId) ctx.valueImports.add('makeId')
1761
+ const object = await createBaseEntryObject(parsed.kind, input, idExpression, ctx)
1762
+ const declarator = t.variableDeclarator(t.identifier(varName), object)
1763
+ ;(declarator.id as t.Identifier).typeAnnotation = t.tsTypeAnnotation(
1764
+ t.tsTypeReference(t.identifier(input.type)),
1765
+ )
1766
+ parsed.ast.program.body.push(
1767
+ t.exportNamedDeclaration(t.variableDeclaration('const', [declarator])),
1768
+ )
1769
+
1770
+ for (const [pathValue, value] of Object.entries(input.set || {})) {
1771
+ await setPathValue(object, pathValue, value, ctx)
1772
+ }
1773
+ for (const event of input.events || []) {
1774
+ if (!isRecord(event) || typeof event.event !== 'string' || !event.event || !event.action) {
1775
+ throw new EntryEditingError('invalid_action', 'events entries require { event, action }', {
1776
+ event,
1777
+ })
1778
+ }
1779
+ const eventsObject = getOrCreateObjectProperty(object, 'events', 'events')
1780
+ const eventArray = getOrCreateArrayProperty(
1781
+ eventsObject,
1782
+ event.event,
1783
+ `events.${event.event}`,
1784
+ )
1785
+ eventArray.elements.push(await buildAction(event.action, ctx))
1786
+ }
1787
+
1788
+ applyPendingImports(ctx)
1789
+ await writeParsedFile(parsed)
1790
+ const verify = await verifyProject(projectDir, input.verify)
1791
+ return {
1792
+ file: parsed.relPath,
1793
+ entry: varName,
1794
+ id: explicitId,
1795
+ idExpression:
1796
+ explicitId ??
1797
+ (stableAlias
1798
+ ? `makeId('${parsed.kind.idType}', '${stableAlias}')`
1799
+ : `makeId('${parsed.kind.idType}')`),
1800
+ change: {
1801
+ set: input.set || {},
1802
+ events: input.events || [],
1803
+ references: referenceInputDetails(ctx.references),
1804
+ },
1805
+ outcome: verify.status === 'compile:failed' ? 'verify_failed' : 'ok',
1806
+ verify,
1807
+ summary: `created ${varName} (${parsed.kind.kind}, ${getSubspaceLabelFromPath(parsed.absPath)}) -> ${verify.status}`,
1808
+ }
1809
+ })
1810
+
1811
+ const memberReferenceMatches = (
1812
+ node: t.Node | null | undefined,
1813
+ target: { varName: string; namespace: string },
1814
+ ) => {
1815
+ if (!node) return false
1816
+ if (t.isIdentifier(node)) return node.name === target.varName
1817
+ if (!t.isMemberExpression(node)) return false
1818
+ if (!t.isIdentifier(node.object) || !t.isIdentifier(node.property)) return false
1819
+ return node.object.name === target.namespace && node.property.name === target.varName
1820
+ }
1821
+
1822
+ const isGetterReference = (
1823
+ node: t.Node | null | undefined,
1824
+ target: { varName: string; namespace: string },
1825
+ ) =>
1826
+ t.isArrowFunctionExpression(node) &&
1827
+ node.params.length === 0 &&
1828
+ memberReferenceMatches(node.body as t.Node, target)
1829
+
1830
+ const isLinkReference = (
1831
+ node: t.Node | null | undefined,
1832
+ target: { varName: string; namespace: string },
1833
+ ) =>
1834
+ t.isCallExpression(node) &&
1835
+ t.isIdentifier(node.callee, { name: 'linkData' }) &&
1836
+ isGetterReference(node.arguments[0] as t.Node, target)
1837
+
1838
+ const containsTargetReference = (
1839
+ node: t.Node | null | undefined,
1840
+ target: { varName: string; namespace: string },
1841
+ ) => {
1842
+ if (!node) return false
1843
+ if (isGetterReference(node, target) || isLinkReference(node, target)) return true
1844
+ let found = false
1845
+ traverse(t.file(t.program([t.expressionStatement(t.cloneNode(node as any, true) as any)])), {
1846
+ ArrowFunctionExpression(pathValue: any) {
1847
+ if (isGetterReference(pathValue.node, target)) {
1848
+ found = true
1849
+ pathValue.stop()
1850
+ }
1851
+ },
1852
+ CallExpression(pathValue: any) {
1853
+ if (isLinkReference(pathValue.node, target)) {
1854
+ found = true
1855
+ pathValue.stop()
1856
+ }
1857
+ },
1858
+ })
1859
+ return found
1860
+ }
1861
+
1862
+ const cleanReferencesInNode = (
1863
+ node: t.Node,
1864
+ target: { varName: string; namespace: string },
1865
+ touched: Array<Record<string, unknown>>,
1866
+ currentPath: string[] = [],
1867
+ opts: { dataCalc?: boolean } = {},
1868
+ ) => {
1869
+ // Strict mode lists every getter/linkData reference via traversal. Cascade mode
1870
+ // only rewrites refs in standard entry object/array containers; expression-position
1871
+ // refs such as custom data-calc code are left for verify/compile to catch.
1872
+ if (t.isProgram(node)) {
1873
+ node.body.forEach((statement, index) =>
1874
+ cleanReferencesInNode(statement, target, touched, [...currentPath, String(index)], opts),
1875
+ )
1876
+ return
1877
+ }
1878
+
1879
+ if (t.isExportNamedDeclaration(node) && node.declaration) {
1880
+ cleanReferencesInNode(node.declaration, target, touched, currentPath, opts)
1881
+ return
1882
+ }
1883
+
1884
+ if (t.isVariableDeclaration(node)) {
1885
+ node.declarations.forEach((declaration, index) =>
1886
+ cleanReferencesInNode(declaration, target, touched, [...currentPath, String(index)], opts),
1887
+ )
1888
+ return
1889
+ }
1890
+
1891
+ if (t.isVariableDeclarator(node) && node.init) {
1892
+ cleanReferencesInNode(node.init, target, touched, currentPath, opts)
1893
+ return
1894
+ }
1895
+
1896
+ if (t.isObjectExpression(node)) {
1897
+ for (let index = node.properties.length - 1; index >= 0; index -= 1) {
1898
+ const property = node.properties[index]
1899
+ if (!t.isObjectProperty(property)) continue
1900
+ const key = getPropertyKeyName(property.key) || `property-${index}`
1901
+ const nextPath = [...currentPath, key]
1902
+ if (
1903
+ isGetterReference(property.value as t.Node, target) ||
1904
+ isLinkReference(property.value as t.Node, target)
1905
+ ) {
1906
+ if (currentPath.includes('outlets')) {
1907
+ node.properties.splice(index, 1)
1908
+ touched.push({ action: 'drop_outlet', path: nextPath.join('.') })
1909
+ } else if (opts.dataCalc && (key === 'output' || key === 'error')) {
1910
+ // DataCalculationScript output/error are typed `(() => Data) | null`.
1911
+ property.value = t.nullLiteral()
1912
+ touched.push({ action: 'null_output', path: nextPath.join('.') })
1913
+ } else {
1914
+ property.value = t.identifier('undefined')
1915
+ touched.push({ action: 'null_link', path: nextPath.join('.') })
1916
+ }
1917
+ continue
1918
+ }
1919
+ cleanReferencesInNode(property.value as t.Node, target, touched, nextPath, opts)
1920
+ }
1921
+ return
1922
+ }
1923
+
1924
+ if (t.isArrayExpression(node)) {
1925
+ const parentKey = currentPath[currentPath.length - 1]
1926
+ const isEventArray = currentPath.includes('events') && parentKey !== 'events'
1927
+ // Compile calls item.data() on every IO item, so a dangling data-calc input/output
1928
+ // must be removed wholesale rather than left with data: undefined.
1929
+ const removableParents = opts.dataCalc
1930
+ ? ['items', 'params', 'dataParams', 'conds', 'switches', 'inputs', 'outputs']
1931
+ : ['items', 'params', 'dataParams', 'conds', 'switches']
1932
+ for (let index = node.elements.length - 1; index >= 0; index -= 1) {
1933
+ const element = node.elements[index] as t.Node | null
1934
+ if (!element) continue
1935
+ const nextPath = [...currentPath, String(index)]
1936
+ if (containsTargetReference(element, target)) {
1937
+ if (
1938
+ removableParents.includes(parentKey) ||
1939
+ isEventArray ||
1940
+ isGetterReference(element, target)
1941
+ ) {
1942
+ node.elements.splice(index, 1)
1943
+ touched.push({
1944
+ action: isEventArray
1945
+ ? 'delete_event_handler'
1946
+ : opts.dataCalc && (parentKey === 'inputs' || parentKey === 'outputs')
1947
+ ? 'remove_io_item'
1948
+ : 'remove_reference_item',
1949
+ path: nextPath.join('.'),
1950
+ })
1951
+ continue
1952
+ }
1953
+ }
1954
+ cleanReferencesInNode(element, target, touched, nextPath, opts)
1955
+ }
1956
+ }
1957
+ }
1958
+
1959
+ const listReferenceSites = (ast: t.File, target: { varName: string; namespace: string }) => {
1960
+ const sites: Array<Record<string, unknown>> = []
1961
+ traverse(ast, {
1962
+ ArrowFunctionExpression(pathValue: any) {
1963
+ if (isGetterReference(pathValue.node, target)) {
1964
+ sites.push({ type: 'ref', loc: pathValue.node.loc?.start })
1965
+ }
1966
+ },
1967
+ CallExpression(pathValue: any) {
1968
+ if (isLinkReference(pathValue.node, target)) {
1969
+ sites.push({ type: 'link', loc: pathValue.node.loc?.start })
1970
+ }
1971
+ },
1972
+ })
1973
+ return sites
1974
+ }
1975
+
1976
+ const cloneWithoutEntry = (parsed: ParsedFile, entryName: string) => {
1977
+ const ast = t.cloneNode(parsed.ast, true)
1978
+ const cloned = {
1979
+ ...parsed,
1980
+ ast,
1981
+ }
1982
+ removeEntryDeclaration(cloned, findEntryInParsedFile(cloned, { entry: entryName }))
1983
+ return cloned
1984
+ }
1985
+
1986
+ const removeEntryDeclaration = (parsed: ParsedFile, entry: ExportEntry) => {
1987
+ const index = parsed.ast.program.body.indexOf(entry.node)
1988
+ if (index >= 0) parsed.ast.program.body.splice(index, 1)
1989
+ }
1990
+
1991
+ const getReferenceScanFiles = async (projectDir: string, targetFile: string, scope?: string) => {
1992
+ if (scope === 'project') return getEntryFiles(projectDir)
1993
+ const subspaceDir = getSubspaceDirFromPath(targetFile)
1994
+ const files = await getEntryFilesInSubspace(subspaceDir)
1995
+ const dataCalcDir = path.join(subspaceDir, 'data-calc')
1996
+ const dataCalcFiles = await readdir(dataCalcDir, { withFileTypes: true }).catch(() => [])
1997
+ return [
1998
+ ...files,
1999
+ ...dataCalcFiles
2000
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.ts'))
2001
+ .map((entry) => path.join(dataCalcDir, entry.name)),
2002
+ ]
2003
+ }
2004
+
2005
+ const removeEntry = async (projectDir: string, input: any) =>
2006
+ runOperation(projectDir, 'remove_entry', input, async () => {
2007
+ const { parsed, entry } = await resolveEditableTarget(projectDir, input)
2008
+ const target = {
2009
+ varName: entry.name,
2010
+ namespace: parsed.kind.namespace,
2011
+ id: entry.id,
2012
+ }
2013
+ const files = await getReferenceScanFiles(projectDir, parsed.absPath, input.scope)
2014
+ const touchedSites: Array<Record<string, unknown>> = []
2015
+ const parsedFiles = await Promise.all(
2016
+ files.map(async (file) => {
2017
+ const source = await readFile(file, 'utf8')
2018
+ return {
2019
+ absPath: file,
2020
+ relPath: projectRelativePath(projectDir, file),
2021
+ source,
2022
+ ast: parseFileSource(source, projectRelativePath(projectDir, file)),
2023
+ kind: entryKinds[path.basename(file) as EntryKindFile] || parsed.kind,
2024
+ } as ParsedFile
2025
+ }),
2026
+ )
2027
+
2028
+ for (const file of parsedFiles) {
2029
+ const scanFile = file.absPath === parsed.absPath ? cloneWithoutEntry(file, entry.name) : file
2030
+ const sites = listReferenceSites(scanFile.ast, target)
2031
+ sites.forEach((site) => touchedSites.push({ file: file.relPath, ...site }))
2032
+ }
2033
+
2034
+ if (input.strict && touchedSites.length > 0) {
2035
+ return {
2036
+ file: parsed.relPath,
2037
+ entry: entry.name,
2038
+ id: entry.id,
2039
+ outcome: 'refused',
2040
+ touchedSites,
2041
+ verify: { status: 'skipped', errors: [] },
2042
+ summary: `refused to remove ${summarizeTarget(parsed, entry)}; ${touchedSites.length} reference(s) found`,
2043
+ }
2044
+ }
2045
+
2046
+ for (const file of parsedFiles) {
2047
+ const touched: Array<Record<string, unknown>> = []
2048
+ const cleanOpts = { dataCalc: file.absPath.split(path.sep).includes('data-calc') }
2049
+ if (file.absPath === parsed.absPath) {
2050
+ removeEntryDeclaration(file, findEntryInParsedFile(file, { entry: entry.name }))
2051
+ touched.push({ action: 'remove_entry', path: entry.name })
2052
+ cleanReferencesInNode(file.ast.program, target, touched, [], cleanOpts)
2053
+ } else {
2054
+ cleanReferencesInNode(file.ast.program, target, touched, [], cleanOpts)
2055
+ }
2056
+ if (touched.length === 0) continue
2057
+ const code = await printAndFormat(file)
2058
+ parseFileSource(code, file.relPath)
2059
+ await writeFile(file.absPath, code)
2060
+ touched.forEach((site) => touchedSites.push({ file: file.relPath, ...site }))
2061
+ }
2062
+
2063
+ const verify = await verifyProject(projectDir, input.verify)
2064
+ return {
2065
+ file: parsed.relPath,
2066
+ entry: entry.name,
2067
+ id: entry.id,
2068
+ outcome: verify.status === 'compile:failed' ? 'verify_failed' : 'ok',
2069
+ touchedSites,
2070
+ verify,
2071
+ summary: `removed ${summarizeTarget(parsed, entry)} with ${touchedSites.length} touched site(s) -> ${verify.status}`,
2072
+ }
2073
+ })
2074
+
2075
+ const responseFor = (result: any): any => ({
2076
+ content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
2077
+ isError: result.isError || undefined,
2078
+ })
2079
+
2080
+ const valueSchema = z
2081
+ .any()
2082
+ .describe(
2083
+ 'Value grammar: JSON scalars/arrays/objects emit literals; { link: "dataIdOrAlias" } emits linkData(() => data.dX) for property/data-link values; { ref: "idOrAliasOrVar", subspace?: "subspace-1"|1|"SUBSPACE_id" } emits () => namespace.varName; { expr: "raw TypeScript expression" } is spliced verbatim.',
2084
+ )
2085
+ const pathSchema = z
2086
+ .string()
2087
+ .describe(
2088
+ 'Dotted entry path to edit, for example title, property.url, outlets.response, animation.show, value, type, switches[0].property.text, or items[0].frame.',
2089
+ )
2090
+ const fileSchema = z
2091
+ .string()
2092
+ .describe(
2093
+ 'Project-relative entry file path, for example subspaces/subspace-0/bricks.ts, generators.ts, canvases.ts, data.ts, or animations.ts.',
2094
+ )
2095
+ const entrySchema = z
2096
+ .string()
2097
+ .describe(
2098
+ 'Exported const name or entity alias to edit, for example bWelcomeText, gApiClient, cMain, or an alias like welcomeText.',
2099
+ )
2100
+ const idSchema = z
2101
+ .string()
2102
+ .describe('Entry id fallback. When file is omitted, scans project subspaces for this id.')
2103
+ const verifySchema = z
2104
+ .boolean()
2105
+ .describe(
2106
+ 'Override compile verification for this call. Defaults to BRICKS_CTOR_MCP_EDIT_VERIFY, otherwise true.',
2107
+ )
2108
+ const targetSchema = {
2109
+ file: fileSchema.optional(),
2110
+ entry: entrySchema.optional(),
2111
+ id: idSchema.optional(),
2112
+ verify: verifySchema.optional(),
2113
+ }
2114
+
2115
+ export function register(server: McpServer, projectDir: string) {
2116
+ server.tool(
2117
+ 'edit_entry',
2118
+ 'Edit map-shaped facets on an existing bricks-ctor entry by source AST. Use set/unset with dotted paths and the shared value grammar.',
2119
+ {
2120
+ ...targetSchema,
2121
+ set: z
2122
+ .record(pathSchema, valueSchema)
2123
+ .describe(
2124
+ 'Map of dotted paths to values. Values use the shared grammar: JSON literals, { link }, { ref }, or { expr }.',
2125
+ )
2126
+ .optional(),
2127
+ unset: z.array(pathSchema).describe('Dotted paths to remove.').optional(),
2128
+ switch: z
2129
+ .union([z.string(), z.number()])
2130
+ .describe(
2131
+ 'Optional switch id or zero-based switch index; edits that switch object instead of the entry root.',
2132
+ )
2133
+ .optional(),
2134
+ },
2135
+ async (input: any) => responseFor(await editEntry(projectDir, input)),
2136
+ )
2137
+
2138
+ server.tool(
2139
+ 'edit_events',
2140
+ 'Edit an event action array on an entry or switch. Adds/removes/replaces/moves/clears EventAction objects.',
2141
+ {
2142
+ ...targetSchema,
2143
+ event: z
2144
+ .string()
2145
+ .describe(
2146
+ 'Camel-case source event key such as onPress, showStart, update, enter, or firstEnter.',
2147
+ ),
2148
+ op: z
2149
+ .enum(['add', 'remove', 'replace', 'move', 'clear'])
2150
+ .describe(
2151
+ 'Array operation. add inserts at index or appends; replace/remove require index; move reorders index to `to`; clear removes the event key.',
2152
+ ),
2153
+ index: z
2154
+ .number()
2155
+ .describe('Zero-based event action index. For add this is the insertion position.')
2156
+ .optional(),
2157
+ to: z.number().describe('Destination zero-based index for move.').optional(),
2158
+ action: valueSchema
2159
+ .describe(
2160
+ 'For add/replace, the flattened form: { handler: "system"|{ref}|{subspace}, name: "ACTION_NAME", params?: { inputName: value }, dataParams?: { dataRef: value }, cast?: "TypeName", waitAsync?: boolean }. The compiled source form { handler, action: { name, params: [...], dataParams: [...] }, waitAsync } and { expr: "raw EventAction" } are also accepted.',
2161
+ )
2162
+ .optional(),
2163
+ switch: z
2164
+ .union([z.string(), z.number()])
2165
+ .describe('Optional switch id or zero-based switch index; edits that switch events object.')
2166
+ .optional(),
2167
+ },
2168
+ async (input: any) => responseFor(await editEvents(projectDir, input)),
2169
+ )
2170
+
2171
+ server.tool(
2172
+ 'edit_canvas_items',
2173
+ 'Edit a Canvas entry items array. Use add/remove/replace to place bricks or move to update an item frame.',
2174
+ {
2175
+ ...targetSchema,
2176
+ op: z
2177
+ .enum(['add', 'remove', 'replace', 'move'])
2178
+ .describe(
2179
+ 'Canvas item operation. add inserts/appends, replace/remove use index, move updates frame at index.',
2180
+ ),
2181
+ index: z.number().describe('Zero-based canvas item index.').optional(),
2182
+ item: valueSchema
2183
+ .describe(
2184
+ 'For add/replace: { ref: "brickIdOrAliasOrVar", subspace?: ..., frame: { x, y, width, height, ... }, hidden?: boolean }. Frames require numeric x/y/width/height.',
2185
+ )
2186
+ .optional(),
2187
+ frame: valueSchema
2188
+ .describe(
2189
+ 'Replacement frame such as { x, y, width, height }. Required for move; for add/replace it is used when item.frame is omitted.',
2190
+ )
2191
+ .optional(),
2192
+ },
2193
+ async (input: any) => responseFor(await editCanvasItems(projectDir, input)),
2194
+ )
2195
+
2196
+ server.tool(
2197
+ 'edit_switches',
2198
+ 'Edit an entry switches array. Use edit_entry/edit_events with switch when changing switch facet bodies.',
2199
+ {
2200
+ ...targetSchema,
2201
+ op: z
2202
+ .enum(['add', 'remove', 'replace', 'move'])
2203
+ .describe(
2204
+ 'Switch array operation. add inserts/appends, replace/remove use index, move reorders to `to`.',
2205
+ ),
2206
+ index: z
2207
+ .union([z.string(), z.number()])
2208
+ .describe(
2209
+ 'Switch id or zero-based switch index. For add, this is optional insertion position.',
2210
+ )
2211
+ .optional(),
2212
+ to: z.number().describe('Destination zero-based index for move.').optional(),
2213
+ switch: valueSchema
2214
+ .describe(
2215
+ 'For add/replace: { id? (BRICK_STATE_GROUP_<uuid>; omit to auto-generate), title?, description?, conds?: [{ method: "=="|"!="|">"|"<"|">="|"<=", cond }], override?: { animation?, event?, property?, outlet? }, property?, outlets?, animation?, disabled?, break? }. Cond forms: { type: "inner_state", key: "current_canvas", value: canvasRef }, { type: "inner_state", outlet: "outletName", value }, { type: "property_bank", ref: dataRef, value }, { type: "property_bank_by_item_key", ref: dataRef, value }, a compiled { __typename: "SwitchCond..." } object, or { expr }. Set switch events with edit_events using its switch parameter.',
2216
+ )
2217
+ .optional(),
2218
+ },
2219
+ async (input: any) => responseFor(await editSwitches(projectDir, input)),
2220
+ )
2221
+
2222
+ server.tool(
2223
+ 'new_entry',
2224
+ 'Create a minimal standard-style exported entry skeleton in an entry file, then optionally apply set/events edits.',
2225
+ {
2226
+ file: fileSchema,
2227
+ entry: entrySchema
2228
+ .describe('Optional exported const name. Defaults from alias/title/id.')
2229
+ .optional(),
2230
+ templateKey: z
2231
+ .string()
2232
+ .describe('Brick/generator templateKey such as BRICK_TEXT or GENERATOR_HTTP.')
2233
+ .optional(),
2234
+ type: z
2235
+ .string()
2236
+ .describe(
2237
+ 'TypeScript type imported from bricks-ctor, for example BrickText, GeneratorHTTP, Canvas, Data, or Animation.',
2238
+ ),
2239
+ alias: z
2240
+ .string()
2241
+ .describe('Optional entry alias. Also influences default exported const name.')
2242
+ .optional(),
2243
+ title: z.string().describe('Optional entry title.').optional(),
2244
+ description: z.string().describe('Optional entry description.').optional(),
2245
+ id: idSchema
2246
+ .describe(
2247
+ 'Optional explicit id. If omitted, the tool generates one with the file-kind prefix.',
2248
+ )
2249
+ .optional(),
2250
+ dataType: z
2251
+ .string()
2252
+ .describe('For data.ts only: initial Data.type value. Defaults to any.')
2253
+ .optional(),
2254
+ set: z
2255
+ .record(pathSchema, valueSchema)
2256
+ .describe(
2257
+ 'Optional initial dotted-path values applied after creating the skeleton. Values use JSON literals, { link }, { ref }, or { expr }.',
2258
+ )
2259
+ .optional(),
2260
+ events: z
2261
+ .array(valueSchema)
2262
+ .describe('Optional initial event actions as objects with { event, action }.')
2263
+ .optional(),
2264
+ verify: verifySchema.optional(),
2265
+ },
2266
+ async (input: any) => responseFor(await newEntry(projectDir, input)),
2267
+ )
2268
+
2269
+ server.tool(
2270
+ 'remove_entry',
2271
+ 'Remove an entry. By default cascades same-subspace references in standard entry object/array shapes; strict mode refuses when references exist.',
2272
+ {
2273
+ ...targetSchema,
2274
+ strict: z
2275
+ .boolean()
2276
+ .describe(
2277
+ 'When true, do not edit files; refuse and return reference sites if the entry is referenced.',
2278
+ )
2279
+ .optional(),
2280
+ scope: z
2281
+ .enum(['subspace', 'project'])
2282
+ .describe('Reference scan scope. subspace is default; project scans all subspaces.')
2283
+ .optional(),
2284
+ },
2285
+ async (input: any) => responseFor(await removeEntry(projectDir, input)),
2286
+ )
2287
+ }
2288
+
2289
+ export const __test__ = {
2290
+ editEntry,
2291
+ editEvents,
2292
+ editCanvasItems,
2293
+ editSwitches,
2294
+ newEntry,
2295
+ removeEntry,
2296
+ resolveReference,
2297
+ }