@fugood/bricks-ctor 2.25.0-beta.5 → 2.25.0-beta.50

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