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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/compile/__tests__/config-diff.test.js +100 -0
  2. package/compile/__tests__/index.test.js +461 -0
  3. package/compile/__tests__/util.test.js +450 -0
  4. package/compile/action-name-map.ts +64 -0
  5. package/compile/config-diff.ts +155 -0
  6. package/compile/index.ts +668 -352
  7. package/compile/util.ts +134 -10
  8. package/package.json +7 -3
  9. package/skills/bricks-ctor/SKILL.md +23 -17
  10. package/skills/bricks-ctor/{rules → references}/animation.md +3 -2
  11. package/skills/bricks-ctor/{rules → references}/architecture-patterns.md +19 -0
  12. package/skills/bricks-ctor/{rules → references}/automations.md +11 -0
  13. package/skills/bricks-ctor/references/buttress.md +245 -0
  14. package/skills/bricks-ctor/references/data-calculation.md +252 -0
  15. package/skills/bricks-ctor/{rules → references}/media-flow.md +7 -0
  16. package/skills/bricks-ctor/references/simulator.md +132 -0
  17. package/skills/bricks-ctor/references/source-editing-tools.md +81 -0
  18. package/skills/bricks-ctor/references/verification-toolchain.md +200 -0
  19. package/skills/bricks-design/SKILL.md +150 -45
  20. package/skills/bricks-design/references/architecture-truths.md +132 -0
  21. package/skills/bricks-design/references/avoiding-complexity.md +91 -0
  22. package/skills/bricks-design/references/design-critique.md +195 -0
  23. package/skills/bricks-design/references/design-languages.md +265 -0
  24. package/skills/bricks-design/references/performance.md +116 -0
  25. package/skills/bricks-design/references/presentation-and-slideshow.md +137 -0
  26. package/skills/bricks-design/references/translating-inputs.md +152 -0
  27. package/skills/bricks-design/references/variations-and-tweaks.md +124 -0
  28. package/skills/bricks-design/references/when-the-brief-is-branded.md +284 -0
  29. package/skills/bricks-design/references/when-the-brief-is-vague.md +85 -0
  30. package/skills/bricks-design/references/workflow.md +134 -0
  31. package/skills/bricks-ux/SKILL.md +114 -0
  32. package/skills/bricks-ux/references/accessibility.md +162 -0
  33. package/skills/bricks-ux/references/flow-states.md +175 -0
  34. package/skills/bricks-ux/references/interaction-archetypes.md +189 -0
  35. package/skills/bricks-ux/references/monitoring-screens.md +153 -0
  36. package/skills/bricks-ux/references/pressable-composition.md +126 -0
  37. package/skills/bricks-ux/references/user-journey.md +168 -0
  38. package/skills/bricks-ux/references/ux-critique.md +256 -0
  39. package/tools/__tests__/_cli-error.test.ts +35 -0
  40. package/tools/__tests__/_mcp-config.test.ts +67 -0
  41. package/tools/__tests__/pull.test.ts +108 -0
  42. package/tools/_cli-error.ts +17 -0
  43. package/tools/_edits-log.ts +41 -0
  44. package/tools/_git-author.ts +10 -2
  45. package/tools/_last-pushed-commit.ts +28 -0
  46. package/tools/_mcp-config.ts +42 -0
  47. package/tools/_shell.ts +8 -1
  48. package/tools/deploy.ts +17 -6
  49. package/tools/mcp-env.ts +13 -0
  50. package/tools/mcp-server.ts +8 -0
  51. package/tools/mcp-tools/__tests__/data-calc-editing.test.js +516 -0
  52. package/tools/mcp-tools/__tests__/entry-editing.test.js +866 -0
  53. package/tools/mcp-tools/__tests__/huggingface.test.ts +49 -0
  54. package/tools/mcp-tools/__tests__/icons.test.ts +21 -0
  55. package/tools/mcp-tools/__tests__/mcp-env.test.js +19 -0
  56. package/tools/mcp-tools/_editing-helpers.ts +98 -0
  57. package/tools/mcp-tools/_verify.ts +50 -0
  58. package/tools/mcp-tools/compile.ts +21 -9
  59. package/tools/mcp-tools/data-calc-editing.ts +1311 -0
  60. package/tools/mcp-tools/entry-editing.ts +2297 -0
  61. package/tools/mcp-tools/huggingface.ts +23 -13
  62. package/tools/mcp-tools/icons.ts +23 -7
  63. package/tools/mcp-tools/media.ts +4 -1
  64. package/tools/postinstall.ts +95 -38
  65. package/tools/pull.ts +100 -23
  66. package/tools/push-config.ts +114 -0
  67. package/tools/{preview-main.mjs → simulator-main.mjs} +207 -12
  68. package/tools/simulator-preload.cjs +16 -0
  69. package/tools/{preview.ts → simulator.ts} +4 -4
  70. package/types/{animation.ts → animation.d.ts} +24 -8
  71. package/types/{automation.ts → automation.d.ts} +16 -20
  72. package/types/{brick-base.ts → brick-base.d.ts} +1 -1
  73. package/types/bricks/{Camera.ts → Camera.d.ts} +8 -8
  74. package/types/bricks/{Chart.ts → Chart.d.ts} +4 -4
  75. package/types/bricks/{GenerativeMedia.ts → GenerativeMedia.d.ts} +15 -15
  76. package/types/bricks/{Icon.ts → Icon.d.ts} +7 -7
  77. package/types/bricks/{Image.ts → Image.d.ts} +21 -9
  78. package/types/bricks/{Items.ts → Items.d.ts} +11 -7
  79. package/types/bricks/{Lottie.ts → Lottie.d.ts} +10 -10
  80. package/types/bricks/{Maps.ts → Maps.d.ts} +11 -11
  81. package/types/bricks/{QrCode.ts → QrCode.d.ts} +7 -7
  82. package/types/bricks/{Rect.ts → Rect.d.ts} +7 -7
  83. package/types/bricks/{RichText.ts → RichText.d.ts} +12 -9
  84. package/types/bricks/{Rive.ts → Rive.d.ts} +9 -9
  85. package/types/bricks/Scene3D.d.ts +676 -0
  86. package/types/bricks/{Sketch.ts → Sketch.d.ts} +10 -8
  87. package/types/bricks/{Slideshow.ts → Slideshow.d.ts} +7 -7
  88. package/types/bricks/{Svg.ts → Svg.d.ts} +7 -7
  89. package/types/bricks/{Text.ts → Text.d.ts} +9 -9
  90. package/types/bricks/{TextInput.ts → TextInput.d.ts} +10 -10
  91. package/types/bricks/{Video.ts → Video.d.ts} +80 -13
  92. package/types/bricks/{VideoStreaming.ts → VideoStreaming.d.ts} +10 -10
  93. package/types/bricks/{WebRtcStream.ts → WebRtcStream.d.ts} +1 -1
  94. package/types/bricks/{WebView.ts → WebView.d.ts} +4 -4
  95. package/types/bricks/{index.ts → index.d.ts} +1 -0
  96. package/types/{common.ts → common.d.ts} +3 -6
  97. package/types/data-calc-command/base.d.ts +57 -0
  98. package/types/data-calc-command/collection.d.ts +418 -0
  99. package/types/data-calc-command/color.d.ts +432 -0
  100. package/types/data-calc-command/constant.d.ts +50 -0
  101. package/types/data-calc-command/datetime.d.ts +147 -0
  102. package/types/data-calc-command/file.d.ts +129 -0
  103. package/types/data-calc-command/index.d.ts +13 -0
  104. package/types/data-calc-command/iteratee.d.ts +23 -0
  105. package/types/data-calc-command/logictype.d.ts +190 -0
  106. package/types/data-calc-command/math.d.ts +275 -0
  107. package/types/data-calc-command/object.d.ts +119 -0
  108. package/types/data-calc-command/sandbox.d.ts +66 -0
  109. package/types/data-calc-command/string.d.ts +407 -0
  110. package/types/{data-calc.ts → data-calc.d.ts} +1 -0
  111. package/types/{data.ts → data.d.ts} +4 -2
  112. package/types/generators/{Assistant.ts → Assistant.d.ts} +19 -0
  113. package/types/generators/{HttpServer.ts → HttpServer.d.ts} +56 -2
  114. package/types/generators/{LlmGgml.ts → LlmGgml.d.ts} +43 -1
  115. package/types/generators/{LlmMlx.ts → LlmMlx.d.ts} +1 -0
  116. package/types/generators/{RerankerGgml.ts → RerankerGgml.d.ts} +5 -1
  117. package/types/generators/{SoundRecorder.ts → SoundRecorder.d.ts} +10 -1
  118. package/types/generators/{SpeechToTextGgml.ts → SpeechToTextGgml.d.ts} +6 -1
  119. package/types/generators/{SttAppleBuiltin.ts → SttAppleBuiltin.d.ts} +27 -4
  120. package/types/generators/{ThermalPrinter.ts → ThermalPrinter.d.ts} +9 -7
  121. package/types/generators/{Tick.ts → Tick.d.ts} +1 -1
  122. package/types/generators/{VadGgml.ts → VadGgml.d.ts} +12 -2
  123. package/types/{subspace.ts → subspace.d.ts} +1 -1
  124. package/utils/__tests__/calc.test.js +25 -0
  125. package/utils/__tests__/id.test.js +154 -0
  126. package/utils/calc.ts +5 -1
  127. package/utils/data.ts +5 -7
  128. package/utils/event-props.ts +27 -1
  129. package/utils/id.ts +109 -56
  130. package/skills/bricks-ctor/rules/buttress.md +0 -156
  131. package/skills/bricks-ctor/rules/data-calculation.md +0 -209
  132. package/skills/bricks-design/LICENSE.txt +0 -180
  133. package/types/data-calc-command.ts +0 -7005
  134. /package/skills/bricks-ctor/{rules → references}/local-sync.md +0 -0
  135. /package/skills/bricks-ctor/{rules → references}/remote-data-bank.md +0 -0
  136. /package/skills/bricks-ctor/{rules → references}/standby-transition.md +0 -0
  137. /package/types/{canvas.ts → canvas.d.ts} +0 -0
  138. /package/types/{data-calc-script.ts → data-calc-script.d.ts} +0 -0
  139. /package/types/generators/{AlarmClock.ts → AlarmClock.d.ts} +0 -0
  140. /package/types/generators/{BleCentral.ts → BleCentral.d.ts} +0 -0
  141. /package/types/generators/{BlePeripheral.ts → BlePeripheral.d.ts} +0 -0
  142. /package/types/generators/{CanvasMap.ts → CanvasMap.d.ts} +0 -0
  143. /package/types/generators/{CastlesPay.ts → CastlesPay.d.ts} +0 -0
  144. /package/types/generators/{DataBank.ts → DataBank.d.ts} +0 -0
  145. /package/types/generators/{File.ts → File.d.ts} +0 -0
  146. /package/types/generators/{GraphQl.ts → GraphQl.d.ts} +0 -0
  147. /package/types/generators/{Http.ts → Http.d.ts} +0 -0
  148. /package/types/generators/{Information.ts → Information.d.ts} +0 -0
  149. /package/types/generators/{Intent.ts → Intent.d.ts} +0 -0
  150. /package/types/generators/{Iterator.ts → Iterator.d.ts} +0 -0
  151. /package/types/generators/{Keyboard.ts → Keyboard.d.ts} +0 -0
  152. /package/types/generators/{LlmAnthropicCompat.ts → LlmAnthropicCompat.d.ts} +0 -0
  153. /package/types/generators/{LlmAppleBuiltin.ts → LlmAppleBuiltin.d.ts} +0 -0
  154. /package/types/generators/{LlmMediaTekNeuroPilot.ts → LlmMediaTekNeuroPilot.d.ts} +0 -0
  155. /package/types/generators/{LlmOnnx.ts → LlmOnnx.d.ts} +0 -0
  156. /package/types/generators/{LlmOpenAiCompat.ts → LlmOpenAiCompat.d.ts} +0 -0
  157. /package/types/generators/{LlmQualcommAiEngine.ts → LlmQualcommAiEngine.d.ts} +0 -0
  158. /package/types/generators/{Mcp.ts → Mcp.d.ts} +0 -0
  159. /package/types/generators/{McpServer.ts → McpServer.d.ts} +0 -0
  160. /package/types/generators/{MediaFlow.ts → MediaFlow.d.ts} +0 -0
  161. /package/types/generators/{MqttBroker.ts → MqttBroker.d.ts} +0 -0
  162. /package/types/generators/{MqttClient.ts → MqttClient.d.ts} +0 -0
  163. /package/types/generators/{Question.ts → Question.d.ts} +0 -0
  164. /package/types/generators/{RealtimeTranscription.ts → RealtimeTranscription.d.ts} +0 -0
  165. /package/types/generators/{SerialPort.ts → SerialPort.d.ts} +0 -0
  166. /package/types/generators/{SoundPlayer.ts → SoundPlayer.d.ts} +0 -0
  167. /package/types/generators/{SpeechToTextOnnx.ts → SpeechToTextOnnx.d.ts} +0 -0
  168. /package/types/generators/{SpeechToTextPlatform.ts → SpeechToTextPlatform.d.ts} +0 -0
  169. /package/types/generators/{SqLite.ts → SqLite.d.ts} +0 -0
  170. /package/types/generators/{Step.ts → Step.d.ts} +0 -0
  171. /package/types/generators/{Tcp.ts → Tcp.d.ts} +0 -0
  172. /package/types/generators/{TcpServer.ts → TcpServer.d.ts} +0 -0
  173. /package/types/generators/{TextToSpeechAppleBuiltin.ts → TextToSpeechAppleBuiltin.d.ts} +0 -0
  174. /package/types/generators/{TextToSpeechGgml.ts → TextToSpeechGgml.d.ts} +0 -0
  175. /package/types/generators/{TextToSpeechOnnx.ts → TextToSpeechOnnx.d.ts} +0 -0
  176. /package/types/generators/{TextToSpeechOpenAiLike.ts → TextToSpeechOpenAiLike.d.ts} +0 -0
  177. /package/types/generators/{Udp.ts → Udp.d.ts} +0 -0
  178. /package/types/generators/{VadOnnx.ts → VadOnnx.d.ts} +0 -0
  179. /package/types/generators/{VadTraditional.ts → VadTraditional.d.ts} +0 -0
  180. /package/types/generators/{VectorStore.ts → VectorStore.d.ts} +0 -0
  181. /package/types/generators/{Watchdog.ts → Watchdog.d.ts} +0 -0
  182. /package/types/generators/{WebCrawler.ts → WebCrawler.d.ts} +0 -0
  183. /package/types/generators/{WebRtc.ts → WebRtc.d.ts} +0 -0
  184. /package/types/generators/{WebSocket.ts → WebSocket.d.ts} +0 -0
  185. /package/types/generators/{index.ts → index.d.ts} +0 -0
  186. /package/types/{index.ts → index.d.ts} +0 -0
  187. /package/types/{switch.ts → switch.d.ts} +0 -0
  188. /package/types/{system.ts → system.d.ts} +0 -0
package/compile/index.ts CHANGED
@@ -4,12 +4,16 @@ import upperFirst from 'lodash/upperFirst'
4
4
  import snakeCase from 'lodash/snakeCase'
5
5
  import omit from 'lodash/omit'
6
6
  import { parse as parseAST } from 'acorn'
7
- import type { ExportNamedDeclaration, FunctionDeclaration } from 'acorn'
7
+ import type { BlockStatement, ExportNamedDeclaration, FunctionDeclaration } from 'acorn'
8
8
  import escodegen from 'escodegen'
9
- import { makeId } from '../utils/id'
9
+ import { makeSeededId } from '../utils/id'
10
10
  import { generateCalulationMap } from './util'
11
11
  import { templateActionNameMap } from './action-name-map'
12
12
  import { templateEventPropsMap } from '../utils/event-props'
13
+ import { sh } from '../tools/_shell'
14
+ import { computeConfigChange, readBuildConfig } from './config-diff'
15
+ import { appendEditRecord, editProvenance } from '../tools/_edits-log'
16
+ import { isTruthyEnv } from '../tools/mcp-env'
13
17
  import type {
14
18
  Application,
15
19
  Data,
@@ -71,6 +75,31 @@ const assertEntryId = (
71
75
  return id
72
76
  }
73
77
 
78
+ // Per-compile error collection. Instead of throwing on the first bad entity, record the
79
+ // message and skip that entity, so a single compile surfaces every entity's first error
80
+ // (e.g. an invalid brick id, generator id and data-calc id all in one run). `errors` is
81
+ // local to each compile() call — no shared state — and compile throws an aggregated error
82
+ // at the end when any were collected, so a failed compile still rejects and never returns
83
+ // or writes a partial config.
84
+ const collect = <T>(errors: string[], fn: () => T, fallback: T): T => {
85
+ try {
86
+ return fn()
87
+ } catch (error) {
88
+ errors.push(error instanceof Error ? error.message : String(error))
89
+ return fallback
90
+ }
91
+ }
92
+
93
+ // reduce() into an id-keyed map, recording a per-item compile error and skipping that item
94
+ // instead of aborting the whole compile. The callback is the last argument (and the init is
95
+ // always {}) so the formatter keeps its body inline rather than re-indenting it.
96
+ const collectReduce = <T>(
97
+ errors: string[],
98
+ items: T[],
99
+ fn: (acc: Record<string, unknown>, item: T, index: number) => Record<string, unknown>,
100
+ ): Record<string, unknown> =>
101
+ items.reduce((acc, item, index) => collect(errors, () => fn(acc, item, index), acc), {})
102
+
74
103
  const compileProperty = (property, errorReference: string, result = {}) => {
75
104
  if (Array.isArray(property)) {
76
105
  return property.map((p) => compileProperty(p, errorReference))
@@ -97,17 +126,28 @@ const compileProperty = (property, errorReference: string, result = {}) => {
97
126
  const compileScriptCalculationCode = (code = '') => {
98
127
  try {
99
128
  const program = parseAST(code, { sourceType: 'module', ecmaVersion: 2020 })
100
- // export function main() { ... }
101
- const declarationBody = (
102
- (program.body[0] as ExportNamedDeclaration).declaration as FunctionDeclaration
103
- )?.body
104
- return escodegen.generate(declarationBody, {
105
- format: {
106
- indent: { style: ' ' },
107
- semicolons: false,
129
+ // The stored config holds the bare function body, which codegen re-wraps as
130
+ // `export function main() { <code> }`. Unwrap it back to that body here.
131
+ let block = ((program.body[0] as ExportNamedDeclaration).declaration as FunctionDeclaration)
132
+ ?.body as BlockStatement | undefined
133
+ if (!block) return code || ''
134
+ // Earlier versions emitted the whole BlockStatement (braces included), so every
135
+ // compile -> codegen round-trip nested the body in one more `{ }`. Emit the inner
136
+ // statements instead, collapsing any wrapper blocks previous round-trips added so
137
+ // existing over-wrapped sandboxes heal on the next compile.
138
+ while (block.body.length === 1 && block.body[0].type === 'BlockStatement') {
139
+ block = block.body[0] as BlockStatement
140
+ }
141
+ return escodegen.generate(
142
+ { type: 'Program', body: block.body },
143
+ {
144
+ format: {
145
+ indent: { style: ' ' },
146
+ semicolons: false,
147
+ },
148
+ comment: true,
108
149
  },
109
- comment: true,
110
- })
150
+ )
111
151
  } catch {
112
152
  return code || ''
113
153
  }
@@ -154,11 +194,13 @@ const basicAnimationEvents = ['show', 'standby', 'breatheStart']
154
194
 
155
195
  const compileAnimations = (
156
196
  templateKey: string,
157
- animations: { [key: string]: Animation },
197
+ animations: { [key: string]: Animation | (() => Animation) },
158
198
  errorReference: string,
159
199
  ) =>
160
200
  Object.entries(animations).reduce((acc, [key, animation]) => {
161
- const animationId = assertEntryId(animation?.id, 'ANIMATION', errorReference)
201
+ // Animation events accept either a direct Animation or a getter; unwrap.
202
+ const resolved = typeof animation === 'function' ? animation() : animation
203
+ const animationId = assertEntryId(resolved?.id, 'ANIMATION', errorReference)
162
204
  acc[convertEventKey(basicAnimationEvents.includes(key) ? 'BRICK' : templateKey, key)] =
163
205
  `ANIMATION#${animationId}`
164
206
  return acc
@@ -179,10 +221,17 @@ const compileEvents = (
179
221
 
180
222
  let handlerKey
181
223
  let handlerTemplateKey
182
- if (handler === 'system' || typeof handler === 'string') {
183
- if (handler.startsWith('SUBSPACE_')) handlerKey = handler
184
- else handlerKey = handler.toUpperCase()
185
- if (handlerKey === 'SYSTEM') handlerTemplateKey = 'SYSTEM'
224
+ if (typeof handler === 'string') {
225
+ // Only the literal 'system' handler is normalized to the SYSTEM template key.
226
+ // SubspaceID (SUBSPACE_*) and ItemBrickID handlers are kept verbatim: the runtime
227
+ // resolves them case-sensitively (see mapEventMapHandlersWithNewId), so uppercasing
228
+ // a mixed-case ItemBrickID would break handler-to-item event wiring.
229
+ if (handler === 'system') {
230
+ handlerKey = 'SYSTEM'
231
+ handlerTemplateKey = 'SYSTEM'
232
+ } else {
233
+ handlerKey = handler
234
+ }
186
235
  } else if (typeof handler === 'function') {
187
236
  let instance = handler()
188
237
  if (instance?.id) {
@@ -301,6 +350,162 @@ const animationTypeMap = {
301
350
  AnimationTimingConfig: 'timing',
302
351
  AnimationSpringConfig: 'spring',
303
352
  AnimationDecayConfig: 'decay',
353
+ } as const
354
+
355
+ type CompiledAnimationType = (typeof animationTypeMap)[keyof typeof animationTypeMap]
356
+ type WarningMetadata = Record<string, unknown>
357
+
358
+ const animationProperties = new Set([
359
+ 'transform.translateX',
360
+ 'transform.translateY',
361
+ 'transform.scale',
362
+ 'transform.scaleX',
363
+ 'transform.scaleY',
364
+ 'transform.rotate',
365
+ 'transform.rotateX',
366
+ 'transform.rotateY',
367
+ 'opacity',
368
+ ])
369
+
370
+ const animationComposeTypes = new Set(['parallel', 'sequence'])
371
+ const springConfigFamilies = [
372
+ ['stiffness', 'damping', 'mass'],
373
+ ['tension', 'friction'],
374
+ ['bounciness', 'speed'],
375
+ ]
376
+ const springConfigFamilyKeys = new Set(springConfigFamilies.flat())
377
+
378
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
379
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value)
380
+
381
+ const hasDefinedConfigValue = (config: Record<string, unknown>, key: string) =>
382
+ config[key] !== undefined
383
+
384
+ const assertConfigValue = (
385
+ config: Record<string, unknown>,
386
+ key: string,
387
+ errorReference: string,
388
+ ) => {
389
+ if (!hasDefinedConfigValue(config, key)) {
390
+ throw new Error(`Invalid animation config ${errorReference}: missing "${key}"`)
391
+ }
392
+ }
393
+
394
+ const assertAnimationProperty = (property: unknown, errorReference: string) => {
395
+ if (typeof property !== 'string' || !animationProperties.has(property)) {
396
+ throw new Error(
397
+ `Invalid animation property${errorReference ? ` ${errorReference}` : ''}: ${String(
398
+ property,
399
+ )}`,
400
+ )
401
+ }
402
+ return property
403
+ }
404
+
405
+ const getAnimationType = (config: unknown, errorReference: string): CompiledAnimationType => {
406
+ if (!isRecord(config)) {
407
+ throw new Error(`Invalid animation config ${errorReference}: config must be an object`)
408
+ }
409
+
410
+ const animationType = animationTypeMap[config.__type as keyof typeof animationTypeMap]
411
+ if (!animationType) {
412
+ throw new Error(`Invalid animation config type ${errorReference}: ${String(config.__type)}`)
413
+ }
414
+ return animationType
415
+ }
416
+
417
+ const assertAnimationComposeType = (composeType: unknown, errorReference: string) => {
418
+ if (typeof composeType !== 'string' || !animationComposeTypes.has(composeType)) {
419
+ throw new Error(`Invalid animation compose type ${errorReference}: ${String(composeType)}`)
420
+ }
421
+ return composeType
422
+ }
423
+
424
+ const pickDefinedConfigValues = (config: Record<string, unknown>, keys: string[]) =>
425
+ keys.reduce((acc, key) => {
426
+ if (hasDefinedConfigValue(config, key)) acc[key] = config[key]
427
+ return acc
428
+ }, {})
429
+
430
+ const getDefinedConfigKeys = (config: Record<string, unknown>, keys: string[]) =>
431
+ keys.filter((key) => hasDefinedConfigValue(config, key))
432
+
433
+ const formatWarningMetadata = (metadata: WarningMetadata = {}) =>
434
+ Object.entries(metadata)
435
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
436
+ .map(([key, value]) => `${key}: ${String(value)}`)
437
+ .join(', ')
438
+
439
+ const formatWarningReference = (metadata?: WarningMetadata) => {
440
+ const metadataText = formatWarningMetadata(metadata)
441
+ return metadataText ? ` [${metadataText}]` : ''
442
+ }
443
+
444
+ const normalizeSpringConfig = (
445
+ config: Record<string, unknown>,
446
+ errorReference: string,
447
+ warningMetadata?: WarningMetadata,
448
+ ): Record<string, unknown> => {
449
+ assertConfigValue(config, 'toValue', errorReference)
450
+
451
+ const usedFamilies = springConfigFamilies.filter((keys) =>
452
+ keys.some((key) => hasDefinedConfigValue(config, key)),
453
+ )
454
+ if (usedFamilies.length <= 1) return config
455
+
456
+ const configWithoutSpringFamily = Object.entries(config).reduce((acc, [key, value]) => {
457
+ if (!springConfigFamilyKeys.has(key)) acc[key] = value
458
+ return acc
459
+ }, {})
460
+
461
+ // Match runtime normalization: physical spring values are most explicit,
462
+ // otherwise preserve BRICKS' historical tension/friction controls.
463
+ const resolvedFamily =
464
+ usedFamilies.find((keys) => keys.includes('stiffness')) ||
465
+ usedFamilies.find((keys) => keys.includes('tension')) ||
466
+ usedFamilies[0]
467
+ const resolvedFamilyKeys = getDefinedConfigKeys(config, resolvedFamily)
468
+ const droppedFamilyKeys = usedFamilies
469
+ .filter((keys) => keys !== resolvedFamily)
470
+ .flatMap((keys) => getDefinedConfigKeys(config, keys))
471
+
472
+ console.warn(
473
+ `[Warning] Resolved animation spring config${formatWarningReference(
474
+ warningMetadata,
475
+ )}: using ${resolvedFamilyKeys.join('/')}, dropping ${droppedFamilyKeys.join('/')}`,
476
+ )
477
+
478
+ return {
479
+ ...configWithoutSpringFamily,
480
+ ...pickDefinedConfigValues(config, resolvedFamily),
481
+ }
482
+ }
483
+
484
+ const compileAnimationConfig = (
485
+ animationType: CompiledAnimationType,
486
+ config: unknown,
487
+ errorReference: string,
488
+ warningMetadata?: WarningMetadata,
489
+ ) => {
490
+ if (!isRecord(config)) {
491
+ throw new Error(`Invalid animation config ${errorReference}: config must be an object`)
492
+ }
493
+
494
+ const compiledConfig = compileProperty(omit(config, '__type'), errorReference)
495
+
496
+ if (!isRecord(compiledConfig)) {
497
+ throw new Error(`Invalid animation config ${errorReference}: config must compile to an object`)
498
+ }
499
+
500
+ if (animationType === 'timing') {
501
+ assertConfigValue(compiledConfig, 'toValue', errorReference)
502
+ } else if (animationType === 'spring') {
503
+ return normalizeSpringConfig(compiledConfig, errorReference, warningMetadata)
504
+ } else if (animationType === 'decay') {
505
+ assertConfigValue(compiledConfig, 'velocity', errorReference)
506
+ }
507
+
508
+ return compiledConfig
304
509
  }
305
510
 
306
511
  const compileFrame = (frame: Canvas['items'][number]['frame']) => ({
@@ -324,9 +529,12 @@ const preloadTypes = [
324
529
  'media-resource-audio',
325
530
  'media-resource-file',
326
531
  'lottie-file-uri',
532
+ 'rive-file-uri',
327
533
  'ggml-model-asset',
328
534
  'gguf-model-asset',
329
535
  'binary-asset',
536
+ 'mlx-model-asset',
537
+ 'scene3d-objects',
330
538
  ]
331
539
 
332
540
  const compileKind = (kind: Data['kind']) => {
@@ -353,9 +561,10 @@ const compileKind = (kind: Data['kind']) => {
353
561
  }
354
562
 
355
563
  const compileRemoteUpdate = (remoteUpdate: Data['remoteUpdate']) => {
356
- if (!remoteUpdate) return {}
357
- if (remoteUpdate.type === 'auto') return { enable_remote_update: true }
564
+ if (!remoteUpdate) return { bank_type: 'none' }
565
+ if (remoteUpdate.type === 'auto') return { bank_type: 'create', enable_remote_update: true }
358
566
  return {
567
+ bank_type: remoteUpdate.type === 'device-specific' ? 'create-device-specific' : 'global',
359
568
  enable_remote_update: true,
360
569
  ...(remoteUpdate.type === 'device-specific' ? { use_remote_id_prefix: true } : {}),
361
570
  ...(remoteUpdate.type === 'global-data' ? { global_remote_update_prop: remoteUpdate.id } : {}),
@@ -411,7 +620,7 @@ function compileRunArray(run: unknown[]): unknown[] {
411
620
  /**
412
621
  * Compile typed TestCase to raw format (strips __typename)
413
622
  */
414
- const compileTestCase = (testCase: TestCase) => ({
623
+ export const compileTestCase = (testCase: TestCase) => ({
415
624
  id: testCase.id,
416
625
  name: testCase.name,
417
626
  hide_short_ref: testCase.hideShortRef,
@@ -433,7 +642,10 @@ const compileTestCase = (testCase: TestCase) => ({
433
642
  variable: cond.variable,
434
643
  operator: cond.operator,
435
644
  value: cond.value,
436
- jump_to: cond.jump_to,
645
+ // `jump_to` may be a getter (() => TestCase) for dynamic case ids (the project generator
646
+ // emits this form). Resolve it to its id like the `run` array does — otherwise the function
647
+ // is JSON-serialized to nothing and the conditional jump silently vanishes from the config.
648
+ jump_to: compileRunElement(cond.jump_to),
437
649
  }
438
650
  }),
439
651
  })
@@ -485,6 +697,7 @@ const compileAutomationTest = (
485
697
 
486
698
  return {
487
699
  id: testId,
700
+ alias: test.alias,
488
701
  title: test.title,
489
702
  hide_short_ref: test.hideShortRef,
490
703
  timeout: test.timeout,
@@ -538,12 +751,60 @@ const compileAutomation = (automationMap: AutomationMap) =>
538
751
  }),
539
752
  )
540
753
 
754
+ const buildDefaultExpandedState = (subspace: Subspace) => ({
755
+ brick: false,
756
+ generator: true,
757
+ canvas: (subspace.canvases || []).reduce((acc, canvas) => {
758
+ if (canvas?.id) acc[canvas.id] = false
759
+ return acc
760
+ }, {}),
761
+ property_bank: false,
762
+ property_bank_calc: true,
763
+ })
764
+
765
+ // Record the minimal compiled-config delta this compile produced to the shared audit
766
+ // log (`.bricks/edits.jsonl`), so editing files directly and running `bun compile`
767
+ // leaves the same trail as the MCP source-editing tools. Maintained only in the
768
+ // editing-tools context (`BRICKS_CTOR_ENABLE_EDITING_TOOLS`); the source-editing tools
769
+ // turn it off for their verify compiles (see _verify.ts) so a tool edit records one
770
+ // richer entry instead of an extra generic compile entry. Also silent when there is no
771
+ // prior build to diff against (fresh projects, package tests, tooling outside a project).
772
+ const recordConfigChange = async (previousConfig: unknown, config: unknown) => {
773
+ if (previousConfig == null) return
774
+ if (!isTruthyEnv(process.env.BRICKS_CTOR_ENABLE_EDITING_TOOLS)) return
775
+ // The baseline was parsed from JSON; `computeConfigChange` applies the same
776
+ // JSON-omitted-field rules lazily so compile avoids cloning the full config.
777
+ const change = computeConfigChange(previousConfig, config)
778
+ if (change.status !== 'ok') return
779
+ await appendEditRecord(process.cwd(), {
780
+ ts: new Date().toISOString(),
781
+ tool: 'compile',
782
+ provenance: editProvenance(),
783
+ outcome: 'ok',
784
+ summary:
785
+ change.opCount === 0
786
+ ? 'compile: no config change'
787
+ : `compile: ${change.opCount} config op(s)`,
788
+ configChange: change,
789
+ }).catch(() => undefined)
790
+ }
791
+
541
792
  export const compile = async (app: Application) => {
542
793
  await new Promise((resolve) => setImmediate(resolve, 0))
794
+ // Collected entity-level compile errors (see collect/collectReduce). Aggregated and
795
+ // thrown at the end so one compile reports every entity's first error.
796
+ const errors: string[] = []
797
+ // Snapshot the prior build artifact before the caller's compile.ts overwrites it, so
798
+ // the config change introduced by this compile can be recorded on return.
799
+ const previousConfig = await readBuildConfig(process.cwd())
543
800
  const timestamp = Date.now()
544
801
  // Pre-index subspace ids so the canvas-item validation below stays O(1).
545
802
  const subspaceIdSet = new Set(app.subspaces.map((s) => s.id))
546
- const compiledAutomationMap = app.automationMap ? compileAutomation(app.automationMap) : null
803
+ let compiledAutomationMap: ReturnType<typeof compileAutomation> | null = null
804
+ if (app.automationMap) {
805
+ const { automationMap } = app
806
+ compiledAutomationMap = collect(errors, () => compileAutomation(automationMap), null)
807
+ }
547
808
  const config = {
548
809
  title: `${app.name || 'Unknown'}(${timestamp})`,
549
810
  subspace_map: app.subspaces.reduce((subspaceMap, subspace, subspaceIndex) => {
@@ -559,7 +820,14 @@ export const compile = async (app: Application) => {
559
820
  // validation (root_canvas_id is required before the conditional
560
821
  // schema fix is published).
561
822
  if (subspace.module?.link) {
562
- const placeholderCanvasId = makeId('canvas')
823
+ // Seed the placeholder id from the (stable) subspace id. `makeId('canvas')` would take
824
+ // the count-fallback branch (a process-global counter that is never reset), so the
825
+ // placeholder id depended on how many prior count-fallback ids had been minted — making
826
+ // it differ between recompiles and breaking compile's byte-stable-output contract
827
+ // (phantom config-change ops). `makeSeededId` keeps no global state, so identical source
828
+ // recompiles to an identical id. (`makeId('canvas', alias)` would instead throw
829
+ // "Duplicate makeId alias" on the second compile in a long-lived process.)
830
+ const placeholderCanvasId = makeSeededId('canvas', `${subspaceId}:module-placeholder`)
563
831
  subspaceMap[subspaceId] = {
564
832
  title: subspace.title,
565
833
  description: subspace.description,
@@ -611,7 +879,7 @@ export const compile = async (app: Application) => {
611
879
  property_bank: !subspace.unexpanded.data,
612
880
  property_bank_calc: !subspace.unexpanded.dataCalculation,
613
881
  }
614
- : undefined,
882
+ : buildDefaultExpandedState(subspace),
615
883
  layout: {
616
884
  width: subspace.layout?.width,
617
885
  height: subspace.layout?.height,
@@ -622,50 +890,78 @@ export const compile = async (app: Application) => {
622
890
  change_canvas: subspace.localSyncChangeCanvas,
623
891
  }
624
892
  : undefined,
625
- animation_map: subspace.animations.reduce((map, animation, animationIndex) => {
626
- const animationId = assertEntryId(
627
- animation?.id,
628
- 'ANIMATION',
629
- `(animation index: ${animationIndex}, subspace: ${subspaceId})`,
630
- )
893
+ animation_map: collectReduce(
894
+ errors,
895
+ subspace.animations,
896
+ (map, animation, animationIndex) => {
897
+ const animationId = assertEntryId(
898
+ animation?.id,
899
+ 'ANIMATION',
900
+ `(animation index: ${animationIndex}, subspace: ${subspaceId})`,
901
+ )
631
902
 
632
- if (animation.__typename === 'Animation') {
633
- const animationDef = animation as AnimationDef
634
- map[animationId] = {
635
- alias: animationDef.alias,
636
- title: animationDef.title,
637
- description: animationDef.description,
638
- hide_short_ref: animationDef.hideShortRef,
639
- animationRunType: animationDef.runType,
640
- property: animationDef.property,
641
- type: animationTypeMap[animationDef.config.__type],
642
- config: compileProperty(
643
- omit(animationDef.config, '__type'),
644
- `(animation: ${animationId}, subspace ${subspaceId})`,
645
- ),
646
- }
647
- } else if (animation.__typename === 'AnimationCompose') {
648
- const animationDef = animation as AnimationComposeDef
649
- map[animationId] = {
650
- alias: animationDef.alias,
651
- title: animationDef.title,
652
- description: animationDef.description,
653
- hide_short_ref: animationDef.hideShortRef,
654
- animationRunType: animationDef.runType,
655
- compose_type: animationDef.composeType,
656
- item_list: animationDef.items.map((item, index) => {
657
- const innerAnimation = item()
658
- if (!innerAnimation?.id)
659
- throw new Error(
660
- `Invalid animation index: ${index} (animation: ${innerAnimation.id}, subspace ${subspaceId})`,
903
+ const animationTypename = animation.__typename
904
+ if (animationTypename === 'Animation') {
905
+ const animationDef = animation as AnimationDef
906
+ const animationErrorReference = `(animation: ${animationId}, subspace ${subspaceId})`
907
+ const animationWarningMetadata = {
908
+ animationIndex,
909
+ animationTitle: animationDef.title,
910
+ animationAlias: animationDef.alias,
911
+ animationProperty: animationDef.property,
912
+ subspaceIndex,
913
+ subspaceTitle: subspace.title,
914
+ }
915
+ const animationType = getAnimationType(animationDef.config, animationErrorReference)
916
+ map[animationId] = {
917
+ alias: animationDef.alias,
918
+ title: animationDef.title,
919
+ description: animationDef.description,
920
+ hide_short_ref: animationDef.hideShortRef,
921
+ animationRunType: animationDef.runType,
922
+ property: assertAnimationProperty(animationDef.property, animationErrorReference),
923
+ type: animationType,
924
+ config: compileAnimationConfig(
925
+ animationType,
926
+ animationDef.config,
927
+ animationErrorReference,
928
+ animationWarningMetadata,
929
+ ),
930
+ }
931
+ } else if (animationTypename === 'AnimationCompose') {
932
+ const animationDef = animation as AnimationComposeDef
933
+ const animationErrorReference = `(animation: ${animationId}, subspace ${subspaceId})`
934
+ map[animationId] = {
935
+ alias: animationDef.alias,
936
+ title: animationDef.title,
937
+ description: animationDef.description,
938
+ hide_short_ref: animationDef.hideShortRef,
939
+ animationRunType: animationDef.runType,
940
+ compose_type: assertAnimationComposeType(
941
+ animationDef.composeType,
942
+ animationErrorReference,
943
+ ),
944
+ item_list: animationDef.items.map((item, index) => {
945
+ const innerAnimation = item()
946
+ const innerAnimationId = assertEntryId(
947
+ innerAnimation?.id,
948
+ 'ANIMATION',
949
+ `(animation item index: ${index}, animation: ${animationId}, subspace ${subspaceId})`,
661
950
  )
662
- return { animation_id: innerAnimation.id }
663
- }),
951
+ return { animation_id: innerAnimationId }
952
+ }),
953
+ }
954
+ } else {
955
+ throw new Error(
956
+ `Invalid animation typename (animation: ${animationId}, subspace ${subspaceId}): ${String(
957
+ animationTypename,
958
+ )}`,
959
+ )
664
960
  }
665
- }
666
- return map
667
- }, {}),
668
- brick_map: subspace.bricks.reduce((map, brick, brickIndex) => {
961
+ return map
962
+ },
963
+ ),
964
+ brick_map: collectReduce(errors, subspace.bricks, (map, brick, brickIndex) => {
669
965
  const brickId = assertEntryId(
670
966
  brick.id,
671
967
  'BRICK',
@@ -830,9 +1126,9 @@ export const compile = async (app: Application) => {
830
1126
  }, {}),
831
1127
  }
832
1128
  return map
833
- }, {}),
1129
+ }),
834
1130
  root_canvas_id: rootCanvasId,
835
- canvas_map: subspace.canvases.reduce((map, canvas, canvasIndex) => {
1131
+ canvas_map: collectReduce(errors, subspace.canvases, (map, canvas, canvasIndex) => {
836
1132
  const canvasId = assertEntryId(
837
1133
  canvas.id,
838
1134
  'CANVAS',
@@ -912,81 +1208,85 @@ export const compile = async (app: Application) => {
912
1208
  }),
913
1209
  }
914
1210
  return map
915
- }, {}),
916
- generator_map: subspace.generators.reduce((map, generator, generatorIndex) => {
917
- const generatorId = assertEntryId(
918
- generator.id,
919
- 'GENERATOR',
920
- `(generator index: ${generatorIndex}, subspace: ${subspaceId})`,
921
- )
1211
+ }),
1212
+ generator_map: collectReduce(
1213
+ errors,
1214
+ subspace.generators,
1215
+ (map, generator, generatorIndex) => {
1216
+ const generatorId = assertEntryId(
1217
+ generator.id,
1218
+ 'GENERATOR',
1219
+ `(generator index: ${generatorIndex}, subspace: ${subspaceId})`,
1220
+ )
922
1221
 
923
- map[generatorId] = {
924
- template_key: generator.templateKey,
925
- alias: generator.alias,
926
- title: generator.title,
927
- description: generator.description,
928
- hide_short_ref: generator.hideShortRef,
929
- local_sync: generator.localSyncRunMode
930
- ? {
931
- run_mode: generator.localSyncRunMode,
932
- }
933
- : undefined,
934
- property: compileProperty(
935
- generator.property || {},
936
- `(generator: ${generatorId}, subspace ${subspaceId})`,
937
- ),
938
- event_map: compileEvents(generator.templateKey, generator.events || {}, {
939
- camelCase: false,
940
- errorReference: `(generator: ${generatorId}, subspace ${subspaceId})`,
941
- }),
942
- outlet: compileOutlets(
943
- generator.templateKey,
944
- generator.outlets || {},
945
- `(generator: ${generatorId}, subspace ${subspaceId})`,
946
- ),
947
- state_group: generator.switches?.reduce((acc, switchCase, switchIndex) => {
948
- const switchId = assertEntryId(
949
- switchCase.id,
950
- 'BRICK_STATE_GROUP',
951
- `(generator: ${generatorId}, switch index: ${switchIndex}, subspace ${subspaceId})`,
952
- )
1222
+ map[generatorId] = {
1223
+ template_key: generator.templateKey,
1224
+ alias: generator.alias,
1225
+ title: generator.title,
1226
+ description: generator.description,
1227
+ hide_short_ref: generator.hideShortRef,
1228
+ local_sync: generator.localSyncRunMode
1229
+ ? {
1230
+ run_mode: generator.localSyncRunMode,
1231
+ }
1232
+ : undefined,
1233
+ property: compileProperty(
1234
+ generator.property || {},
1235
+ `(generator: ${generatorId}, subspace ${subspaceId})`,
1236
+ ),
1237
+ event_map: compileEvents(generator.templateKey, generator.events || {}, {
1238
+ camelCase: false,
1239
+ errorReference: `(generator: ${generatorId}, subspace ${subspaceId})`,
1240
+ }),
1241
+ outlet: compileOutlets(
1242
+ generator.templateKey,
1243
+ generator.outlets || {},
1244
+ `(generator: ${generatorId}, subspace ${subspaceId})`,
1245
+ ),
1246
+ state_group: generator.switches?.reduce((acc, switchCase, switchIndex) => {
1247
+ const switchId = assertEntryId(
1248
+ switchCase.id,
1249
+ 'BRICK_STATE_GROUP',
1250
+ `(generator: ${generatorId}, switch index: ${switchIndex}, subspace ${subspaceId})`,
1251
+ )
953
1252
 
954
- acc[switchId] = {
955
- title: switchCase.title,
956
- description: switchCase.description,
957
- break: switchCase.break,
958
- override: switchCase.override,
959
- commented: switchCase.disabled,
960
- conds: compileSwitchConds(
961
- generator.templateKey,
962
- switchCase.conds || [],
963
- `(generator: ${generatorId}, switch: ${switchId}, subspace ${subspaceId})`,
964
- ),
965
- property: compileProperty(
966
- switchCase.property,
967
- `(generator: ${generatorId}, switch: ${switchId}, subspace ${subspaceId})`,
968
- ),
969
- outlet: compileOutlets(
970
- generator.templateKey,
971
- switchCase.outlets || {},
972
- `(generator: ${generatorId}, switch: ${switchId}, subspace ${subspaceId})`,
973
- ),
974
- event_map: compileEvents(generator.templateKey, switchCase.events || {}, {
975
- camelCase: false,
976
- errorReference: `(generator: ${generatorId}, switch: ${switchId}, subspace ${subspaceId})`,
977
- }),
978
- animation: compileAnimations(
979
- generator.templateKey,
980
- switchCase.animation || {},
981
- `(generator: ${generatorId}, switch: ${switchId}, subspace ${subspaceId})`,
982
- ),
983
- }
984
- return acc
985
- }, {}),
986
- }
987
- return map
988
- }, {}),
989
- property_bank_map: subspace.data.reduce((map, data, dataIndex) => {
1253
+ acc[switchId] = {
1254
+ title: switchCase.title,
1255
+ description: switchCase.description,
1256
+ break: switchCase.break,
1257
+ override: switchCase.override,
1258
+ commented: switchCase.disabled,
1259
+ conds: compileSwitchConds(
1260
+ generator.templateKey,
1261
+ switchCase.conds || [],
1262
+ `(generator: ${generatorId}, switch: ${switchId}, subspace ${subspaceId})`,
1263
+ ),
1264
+ property: compileProperty(
1265
+ switchCase.property,
1266
+ `(generator: ${generatorId}, switch: ${switchId}, subspace ${subspaceId})`,
1267
+ ),
1268
+ outlet: compileOutlets(
1269
+ generator.templateKey,
1270
+ switchCase.outlets || {},
1271
+ `(generator: ${generatorId}, switch: ${switchId}, subspace ${subspaceId})`,
1272
+ ),
1273
+ event_map: compileEvents(generator.templateKey, switchCase.events || {}, {
1274
+ camelCase: false,
1275
+ errorReference: `(generator: ${generatorId}, switch: ${switchId}, subspace ${subspaceId})`,
1276
+ }),
1277
+ animation: compileAnimations(
1278
+ generator.templateKey,
1279
+ switchCase.animation || {},
1280
+ `(generator: ${generatorId}, switch: ${switchId}, subspace ${subspaceId})`,
1281
+ ),
1282
+ }
1283
+ return acc
1284
+ }, {}),
1285
+ }
1286
+ return map
1287
+ },
1288
+ ),
1289
+ property_bank_map: collectReduce(errors, subspace.data, (map, data, dataIndex) => {
990
1290
  const dataId = assertEntryId(
991
1291
  data.id,
992
1292
  'PROPERTY_BANK_DATA_NODE',
@@ -1009,7 +1309,7 @@ export const compile = async (app: Application) => {
1009
1309
  ...compileRemoteUpdate(data.remoteUpdate),
1010
1310
  routing: data.routing,
1011
1311
  schema: data.schema,
1012
- type: data.type,
1312
+ type: data.type === 'boolean' ? 'bool' : data.type,
1013
1313
  ...compileKind(data.kind),
1014
1314
  value: compileProperty(data.value, `(data: ${dataId}, subspace ${subspaceId})`),
1015
1315
  event_map: compileEvents('PROPERTY_BANK', data.events || {}, {
@@ -1020,229 +1320,229 @@ export const compile = async (app: Application) => {
1020
1320
  hit_regex: data.hit_regex,
1021
1321
  }
1022
1322
  return map
1023
- }, {}),
1024
- property_bank_calc_map: subspace.dataCalculation.reduce((map, dataCalc, dataCalcIndex) => {
1025
- const dataCalcId = assertEntryId(
1026
- dataCalc.id,
1027
- 'PROPERTY_BANK_COMMAND_MAP',
1028
- `(data calc index: ${dataCalcIndex}, subspace: ${subspaceId})`,
1029
- )
1323
+ }),
1324
+ property_bank_calc_map: collectReduce(
1325
+ errors,
1326
+ subspace.dataCalculation,
1327
+ (map, dataCalc, dataCalcIndex) => {
1328
+ const dataCalcId = assertEntryId(
1329
+ dataCalc.id,
1330
+ 'PROPERTY_BANK_COMMAND_MAP',
1331
+ `(data calc index: ${dataCalcIndex}, subspace: ${subspaceId})`,
1332
+ )
1030
1333
 
1031
- const calc: any = {
1032
- title: dataCalc.title,
1033
- description: dataCalc.description,
1034
- hide_short_ref: dataCalc.hideShortRef,
1035
- }
1036
- if (dataCalc.triggerMode) calc.trigger_type = dataCalc.triggerMode
1037
- if (dataCalc.__typename === 'DataCalculationMap') {
1038
- calc.type = 'general'
1039
- const mapCalc = dataCalc as DataCalculationMap
1040
-
1041
- const getNodeId = (
1042
- node: DataCalculationData | DataCommand,
1043
- reference = '',
1044
- nodeIndex?: number,
1045
- ) => {
1046
- const nodeReference = [
1047
- `data calc: ${dataCalcId}`,
1048
- `subspace: ${subspaceId}`,
1049
- typeof nodeIndex === 'number' ? `node index: ${nodeIndex}` : undefined,
1050
- reference || undefined,
1051
- ]
1052
- .filter(Boolean)
1053
- .join(', ')
1054
-
1055
- if (node.__typename === 'DataCalculationData') {
1056
- return assertEntryId(
1057
- node.data()?.id,
1058
- 'PROPERTY_BANK_DATA_NODE',
1059
- `(${nodeReference})`,
1060
- )
1061
- }
1062
- if (node.__typename === 'DataCommand') {
1063
- return assertEntryId(node.id, 'PROPERTY_BANK_COMMAND_NODE', `(${nodeReference})`)
1064
- }
1065
- throw new Error(`Invalid node: ${JSON.stringify(node)}`)
1334
+ const calc: any = {
1335
+ alias: dataCalc.alias,
1336
+ title: dataCalc.title,
1337
+ description: dataCalc.description,
1338
+ hide_short_ref: dataCalc.hideShortRef,
1066
1339
  }
1067
-
1068
- const generateInputPorts = (inputs) =>
1069
- inputs.reduce((acc, port, portIndex) => {
1070
- if (!acc[port.key]) acc[port.key] = null
1071
-
1072
- let sourceId
1073
- const sourceNode = port.source()
1074
- if (
1075
- sourceNode?.__typename === 'DataCalculationData' ||
1076
- sourceNode?.__typename === 'DataCommand'
1077
- ) {
1078
- sourceId = getNodeId(sourceNode, `input port index: ${portIndex}`)
1340
+ if (dataCalc.triggerMode) calc.trigger_type = dataCalc.triggerMode
1341
+ if (dataCalc.__typename === 'DataCalculationMap') {
1342
+ calc.type = 'general'
1343
+ const mapCalc = dataCalc as DataCalculationMap
1344
+
1345
+ const getNodeId = (
1346
+ node: DataCalculationData | DataCommand,
1347
+ reference = '',
1348
+ nodeIndex?: number,
1349
+ ) => {
1350
+ const nodeReference = [
1351
+ `data calc: ${dataCalcId}`,
1352
+ `subspace: ${subspaceId}`,
1353
+ typeof nodeIndex === 'number' ? `node index: ${nodeIndex}` : undefined,
1354
+ reference || undefined,
1355
+ ]
1356
+ .filter(Boolean)
1357
+ .join(', ')
1358
+
1359
+ if (node.__typename === 'DataCalculationData') {
1360
+ return assertEntryId(
1361
+ node.data()?.id,
1362
+ 'PROPERTY_BANK_DATA_NODE',
1363
+ `(${nodeReference})`,
1364
+ )
1079
1365
  }
1366
+ if (node.__typename === 'DataCommand') {
1367
+ return assertEntryId(node.id, 'PROPERTY_BANK_COMMAND_NODE', `(${nodeReference})`)
1368
+ }
1369
+ throw new Error(`Invalid node: ${JSON.stringify(node)}`)
1370
+ }
1080
1371
 
1081
- if (!sourceId) return acc
1082
- if (!acc[port.key]) acc[port.key] = []
1083
-
1084
- acc[port.key].push({
1085
- id: sourceId,
1086
- port: port.sourceKey,
1087
- disable_trigger_command: !port.trigger ? true : undefined,
1088
- })
1372
+ const generateInputPorts = (inputs) =>
1373
+ inputs.reduce((acc, port, portIndex) => {
1374
+ if (!acc[port.key]) acc[port.key] = null
1375
+
1376
+ let sourceId
1377
+ const sourceNode = port.source()
1378
+ if (
1379
+ sourceNode?.__typename === 'DataCalculationData' ||
1380
+ sourceNode?.__typename === 'DataCommand'
1381
+ ) {
1382
+ sourceId = getNodeId(sourceNode, `input port index: ${portIndex}`)
1383
+ }
1384
+
1385
+ if (!sourceId) return acc
1386
+ if (!acc[port.key]) acc[port.key] = []
1387
+
1388
+ acc[port.key].push({
1389
+ id: sourceId,
1390
+ port: port.sourceKey,
1391
+ disable_trigger_command: !port.trigger ? true : undefined,
1392
+ })
1393
+ return acc
1394
+ }, {})
1395
+
1396
+ const generateOutputPorts = (outputs) =>
1397
+ outputs.reduce((acc, port, portIndex) => {
1398
+ if (!acc[port.key]) acc[port.key] = null
1399
+
1400
+ let targetId
1401
+ const targetNode = port.target()
1402
+ if (
1403
+ targetNode?.__typename === 'DataCalculationData' ||
1404
+ targetNode?.__typename === 'DataCommand'
1405
+ ) {
1406
+ targetId = getNodeId(targetNode, `output port index: ${portIndex}`)
1407
+ }
1408
+
1409
+ if (!targetId) return acc
1410
+ if (!acc[port.key]) acc[port.key] = []
1411
+
1412
+ acc[port.key].push({
1413
+ id: targetId,
1414
+ port: port.targetKey,
1415
+ })
1416
+ return acc
1417
+ }, {})
1418
+
1419
+ calc.map = mapCalc.nodes.reduce((acc, node, nodeIndex) => {
1420
+ if (node.__typename === 'DataCalculationData') {
1421
+ const dataNode = node as DataCalculationData
1422
+ acc[getNodeId(dataNode, 'data node', nodeIndex)] = {
1423
+ title: dataNode.title,
1424
+ description: dataNode.description,
1425
+ hide_short_ref: dataNode.hideShortRef,
1426
+ type: 'data-node',
1427
+ properties: {},
1428
+ in: generateInputPorts(dataNode.inputs),
1429
+ out: generateOutputPorts(dataNode.outputs),
1430
+ }
1431
+ } else if (node.__typename === 'DataCommand') {
1432
+ const commandNode = node as DataCommand
1433
+ const commandName = commandNode.__commandName
1434
+ const type = commandName.split('_')[0].toLowerCase()
1435
+
1436
+ const args = commandNode.inputs.filter(
1437
+ (input) =>
1438
+ typeof input.source !== 'function' ||
1439
+ (input.source()?.__typename !== 'DataCalculationData' &&
1440
+ input.source()?.__typename !== 'DataCommand'),
1441
+ )
1442
+ const inputs = commandNode.inputs.filter(
1443
+ (input) =>
1444
+ typeof input.source === 'function' &&
1445
+ (input.source()?.__typename === 'DataCalculationData' ||
1446
+ input.source()?.__typename === 'DataCommand'),
1447
+ )
1448
+ acc[getNodeId(commandNode, 'command node', nodeIndex)] = {
1449
+ title: commandNode.title,
1450
+ description: commandNode.description,
1451
+ hide_short_ref: commandNode.hideShortRef,
1452
+ type: `command-node-${type}`,
1453
+ properties: {
1454
+ command: commandNode.__commandName,
1455
+ args: args.reduce((argsAcc, input) => {
1456
+ argsAcc[input.key] = input.source
1457
+ return argsAcc
1458
+ }, {}),
1459
+ },
1460
+ in: generateInputPorts(inputs),
1461
+ out: generateOutputPorts(commandNode.outputs),
1462
+ }
1463
+ }
1089
1464
  return acc
1090
1465
  }, {})
1091
-
1092
- const generateOutputPorts = (outputs) =>
1093
- outputs.reduce((acc, port, portIndex) => {
1094
- if (!acc[port.key]) acc[port.key] = null
1095
-
1096
- let targetId
1097
- const targetNode = port.target()
1098
- if (
1099
- targetNode?.__typename === 'DataCalculationData' ||
1100
- targetNode?.__typename === 'DataCommand'
1101
- ) {
1102
- targetId = getNodeId(targetNode, `output port index: ${portIndex}`)
1466
+ calc.editor_info = mapCalc.editorInfo.reduce((acc, editorInfo) => {
1467
+ acc[getNodeId(editorInfo.node, 'editor info node')] = {
1468
+ position: editorInfo.position,
1469
+ points: editorInfo.points.reduce((pointsAcc, point) => {
1470
+ const sourceId = getNodeId(point.source, 'editor info point source')
1471
+ const targetId = getNodeId(point.target, 'editor info point target')
1472
+ const key = `${sourceId}-${point.sourceOutputKey}-${targetId}-${point.targetInputKey}`
1473
+ pointsAcc[key] = point.positions
1474
+ return pointsAcc
1475
+ }, {}),
1103
1476
  }
1104
-
1105
- if (!targetId) return acc
1106
- if (!acc[port.key]) acc[port.key] = []
1107
-
1108
- acc[port.key].push({
1109
- id: targetId,
1110
- port: port.targetKey,
1111
- })
1112
1477
  return acc
1113
1478
  }, {})
1114
-
1115
- calc.map = mapCalc.nodes.reduce((acc, node, nodeIndex) => {
1116
- if (node.__typename === 'DataCalculationData') {
1117
- const dataNode = node as DataCalculationData
1118
- acc[getNodeId(dataNode, 'data node', nodeIndex)] = {
1119
- title: dataNode.title,
1120
- description: dataNode.description,
1121
- hide_short_ref: dataNode.hideShortRef,
1122
- type: 'data-node',
1123
- properties: {},
1124
- in: generateInputPorts(dataNode.inputs),
1125
- out: generateOutputPorts(dataNode.outputs),
1126
- }
1127
- } else if (node.__typename === 'DataCommand') {
1128
- const commandNode = node as DataCommand
1129
- const commandName = commandNode.__commandName
1130
- const type = commandName.split('_')[0].toLowerCase()
1131
-
1132
- const args = commandNode.inputs.filter(
1133
- (input) =>
1134
- typeof input.source !== 'function' ||
1135
- (input.source()?.__typename !== 'DataCalculationData' &&
1136
- input.source()?.__typename !== 'DataCommand'),
1137
- )
1138
- const inputs = commandNode.inputs.filter(
1139
- (input) =>
1140
- typeof input.source === 'function' &&
1141
- (input.source()?.__typename === 'DataCalculationData' ||
1142
- input.source()?.__typename === 'DataCommand'),
1143
- )
1144
- acc[getNodeId(commandNode, 'command node', nodeIndex)] = {
1145
- title: commandNode.title,
1146
- description: commandNode.description,
1147
- hide_short_ref: commandNode.hideShortRef,
1148
- type: `command-node-${type}`,
1149
- properties: {
1150
- command: commandNode.__commandName,
1151
- args: args.reduce((argsAcc, input) => {
1152
- argsAcc[input.key] = input.source
1153
- return argsAcc
1154
- }, {}),
1155
- },
1156
- in: generateInputPorts(inputs),
1157
- out: generateOutputPorts(commandNode.outputs),
1158
- }
1159
- }
1160
- return acc
1161
- }, {})
1162
- calc.editor_info = mapCalc.editorInfo.reduce((acc, editorInfo) => {
1163
- acc[getNodeId(editorInfo.node, 'editor info node')] = {
1164
- position: editorInfo.position,
1165
- points: editorInfo.points.reduce((pointsAcc, point) => {
1166
- const sourceId = getNodeId(point.source, 'editor info point source')
1167
- const targetId = getNodeId(point.target, 'editor info point target')
1168
- const key = `${sourceId}-${point.sourceOutputKey}-${targetId}-${point.targetInputKey}`
1169
- pointsAcc[key] = point.positions
1170
- return pointsAcc
1479
+ } else if (dataCalc.__typename === 'DataCalculationScript') {
1480
+ const scriptCalc = dataCalc as DataCalculationScript
1481
+ calc.type = 'script'
1482
+
1483
+ const code = compileScriptCalculationCode(scriptCalc.code)
1484
+ calc.script_config = {
1485
+ title: scriptCalc.title ?? '',
1486
+ note: scriptCalc.note ?? '',
1487
+ code,
1488
+ enable_async: scriptCalc.enableAsync,
1489
+ trigger_mode: scriptCalc.triggerMode,
1490
+ inputs: scriptCalc.inputs.reduce((acc, input) => {
1491
+ const inputId = assertEntryId(
1492
+ input.data()?.id,
1493
+ 'PROPERTY_BANK_DATA_NODE',
1494
+ `(data calc: ${dataCalcId}, script input: ${input.key}, subspace: ${subspaceId})`,
1495
+ )
1496
+ acc[inputId] = input.key
1497
+ return acc
1171
1498
  }, {}),
1172
- }
1173
- return acc
1174
- }, {})
1175
- } else if (dataCalc.__typename === 'DataCalculationScript') {
1176
- const scriptCalc = dataCalc as DataCalculationScript
1177
- calc.type = 'script'
1178
-
1179
- const code = compileScriptCalculationCode(scriptCalc.code)
1180
- calc.script_config = {
1181
- title: scriptCalc.title ?? '',
1182
- note: scriptCalc.note ?? '',
1183
- code,
1184
- enable_async: scriptCalc.enableAsync,
1185
- trigger_mode: scriptCalc.triggerMode,
1186
- inputs: scriptCalc.inputs.reduce((acc, input) => {
1187
- const inputId = assertEntryId(
1188
- input.data()?.id,
1189
- 'PROPERTY_BANK_DATA_NODE',
1190
- `(data calc: ${dataCalcId}, script input: ${input.key}, subspace: ${subspaceId})`,
1191
- )
1192
- acc[inputId] = input.key
1193
- return acc
1194
- }, {}),
1195
- disabled_triggers: scriptCalc.inputs.reduce((acc, input) => {
1196
- const inputId = assertEntryId(
1197
- input.data()?.id,
1198
- 'PROPERTY_BANK_DATA_NODE',
1199
- `(data calc: ${dataCalcId}, script trigger input: ${input.key}, subspace: ${subspaceId})`,
1200
- )
1201
- acc[inputId] = !input.trigger
1202
- return acc
1203
- }, {}),
1204
- output: scriptCalc.output
1205
- ? assertEntryId(
1206
- scriptCalc.output()?.id,
1499
+ disabled_triggers: scriptCalc.inputs.reduce((acc, input) => {
1500
+ const inputId = assertEntryId(
1501
+ input.data()?.id,
1207
1502
  'PROPERTY_BANK_DATA_NODE',
1208
- `(data calc: ${dataCalcId}, script output, subspace: ${subspaceId})`,
1503
+ `(data calc: ${dataCalcId}, script trigger input: ${input.key}, subspace: ${subspaceId})`,
1209
1504
  )
1210
- : null,
1211
- outputs: scriptCalc.outputs.reduce((acc, output) => {
1212
- if (!acc[output.key]) acc[output.key] = []
1213
- const outputId = assertEntryId(
1214
- output.data()?.id,
1215
- 'PROPERTY_BANK_DATA_NODE',
1216
- `(data calc: ${dataCalcId}, script outputs key: ${output.key}, subspace: ${subspaceId})`,
1217
- )
1218
- acc[output.key].push(outputId)
1219
- return acc
1220
- }, {}),
1221
- error: scriptCalc.error
1222
- ? assertEntryId(
1223
- scriptCalc.error()?.id,
1505
+ acc[inputId] = !input.trigger
1506
+ return acc
1507
+ }, {}),
1508
+ output: scriptCalc.output
1509
+ ? assertEntryId(
1510
+ scriptCalc.output()?.id,
1511
+ 'PROPERTY_BANK_DATA_NODE',
1512
+ `(data calc: ${dataCalcId}, script output, subspace: ${subspaceId})`,
1513
+ )
1514
+ : null,
1515
+ outputs: scriptCalc.outputs.reduce((acc, output) => {
1516
+ if (!acc[output.key]) acc[output.key] = []
1517
+ const outputId = assertEntryId(
1518
+ output.data()?.id,
1224
1519
  'PROPERTY_BANK_DATA_NODE',
1225
- `(data calc: ${dataCalcId}, script error output, subspace: ${subspaceId})`,
1520
+ `(data calc: ${dataCalcId}, script outputs key: ${output.key}, subspace: ${subspaceId})`,
1226
1521
  )
1227
- : null,
1228
- }
1522
+ acc[output.key].push(outputId)
1523
+ return acc
1524
+ }, {}),
1525
+ error: scriptCalc.error
1526
+ ? assertEntryId(
1527
+ scriptCalc.error()?.id,
1528
+ 'PROPERTY_BANK_DATA_NODE',
1529
+ `(data calc: ${dataCalcId}, script error output, subspace: ${subspaceId})`,
1530
+ )
1531
+ : null,
1532
+ }
1229
1533
 
1230
- Object.assign(
1231
- calc,
1232
- generateCalulationMap(calc.script_config, {
1233
- snapshotMode: process.env.BRICKS_SNAPSHOT_MODE === '1',
1234
- }),
1235
- )
1236
- }
1237
- map[dataCalcId] = calc
1238
- return map
1239
- }, {}),
1534
+ Object.assign(calc, generateCalulationMap(calc.script_config, dataCalcId))
1535
+ }
1536
+ map[dataCalcId] = calc
1537
+ return map
1538
+ },
1539
+ ),
1240
1540
  action_map: subspace.actions || undefined,
1241
1541
  event_map: compileEvents('', subspace.events || {}, {
1242
1542
  camelCase: false,
1243
1543
  errorReference: `(subspace ${subspaceId})`,
1244
1544
  }),
1245
- routing: subspace.dataRouting.reduce((acc, data, index) => {
1545
+ routing: collectReduce(errors, subspace.dataRouting, (acc, data, index) => {
1246
1546
  const dataId = assertEntryId(
1247
1547
  data?.id,
1248
1548
  'PROPERTY_BANK_DATA_NODE',
@@ -1250,15 +1550,15 @@ export const compile = async (app: Application) => {
1250
1550
  )
1251
1551
  acc[dataId] = { enabled_routing: true }
1252
1552
  return acc
1253
- }, {}),
1553
+ }),
1254
1554
  ...compileModule(subspace),
1255
1555
  }
1256
1556
  return subspaceMap
1257
1557
  }, {}),
1258
- root_subspace_id: assertEntryId(
1259
- app.rootSubspace?.id,
1260
- 'SUBSPACE',
1261
- '(application root subspace)',
1558
+ root_subspace_id: collect(
1559
+ errors,
1560
+ () => assertEntryId(app.rootSubspace?.id, 'SUBSPACE', '(application root subspace)'),
1561
+ '',
1262
1562
  ),
1263
1563
  fonts: app.fonts,
1264
1564
  ...compileApplicationSettings(app.settings),
@@ -1269,10 +1569,26 @@ export const compile = async (app: Application) => {
1269
1569
  automation_map: compiledAutomationMap || app.metadata?.TEMP_automation_map || {},
1270
1570
  update_timestamp: timestamp,
1271
1571
  }
1572
+ if (errors.length > 0) {
1573
+ throw new Error(
1574
+ `Compile failed with ${errors.length} error(s):\n` +
1575
+ errors.map((message, index) => ` ${index + 1}. ${message}`).join('\n'),
1576
+ )
1577
+ }
1578
+ await recordConfigChange(previousConfig, config)
1272
1579
  return config
1273
1580
  }
1274
1581
 
1275
1582
  export const checkConfig = async (configPath: string) => {
1276
- const { sh } = await import('../tools/_shell')
1277
- await sh`bricks app check-config ${configPath}`
1583
+ // --validate-automation surfaces broken automation_map / test_map refs early,
1584
+ // which catches agent-authored automations that reference deleted bricks.
1585
+ await sh`bricks app check-config --validate-automation ${configPath}`
1586
+ // Doctor adds semantic lint checks after structural validation. Warnings are
1587
+ // surfaced in the compile log, but only errors fail by default. Older published
1588
+ // bricks-cli builds lack `app doctor` — skip rather than fail the compile.
1589
+ const doctor = await sh`bricks app doctor --validate-automation ${configPath}`.nothrow()
1590
+ if (doctor.exitCode !== 0) {
1591
+ if (/unknown command/i.test(doctor.stderr?.toString() ?? '')) return
1592
+ throw new Error(`bricks app doctor failed with exit ${doctor.exitCode}`)
1593
+ }
1278
1594
  }