@fugood/bricks-ctor 2.25.0-beta.42 → 2.25.0-beta.45

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.
@@ -0,0 +1,61 @@
1
+ import { computeConfigChange } from '../config-diff'
2
+
3
+ describe('computeConfigChange', () => {
4
+ test('ignores the volatile top-level title and update_timestamp', () => {
5
+ const before = { title: 'App(1)', update_timestamp: 1, subspace_map: {} }
6
+ const after = { title: 'App(2)', update_timestamp: 2, subspace_map: {} }
7
+ expect(computeConfigChange(before, after)).toEqual({ status: 'ok', ops: [], opCount: 0 })
8
+ })
9
+
10
+ test('still diffs nested (subspace/brick) titles', () => {
11
+ const before = { title: 'App(1)', subspace_map: { S: { title: 'Home' } } }
12
+ const after = { title: 'App(2)', subspace_map: { S: { title: 'Start' } } }
13
+ expect(computeConfigChange(before, after).ops).toEqual([
14
+ { op: 'set', path: ['subspace_map', 'S', 'title'], value: 'Start' },
15
+ ])
16
+ })
17
+
18
+ test('emits a leaf set for a scalar property change', () => {
19
+ const wrap = (url) => ({ subspace_map: { S: { brick_map: { B: { property: { url } } } } } })
20
+ expect(computeConfigChange(wrap('a'), wrap('b')).ops).toEqual([
21
+ { op: 'set', path: ['subspace_map', 'S', 'brick_map', 'B', 'property', 'url'], value: 'b' },
22
+ ])
23
+ })
24
+
25
+ test('added key → set, removed key → unset', () => {
26
+ const change = computeConfigChange({ m: { a: 1 } }, { m: { b: 2 } })
27
+ expect(change.opCount).toBe(2)
28
+ expect(change.ops).toContainEqual({ op: 'unset', path: ['m', 'a'] })
29
+ expect(change.ops).toContainEqual({ op: 'set', path: ['m', 'b'], value: 2 })
30
+ })
31
+
32
+ test('equal-length arrays diff element-wise', () => {
33
+ const before = { items: [{ x: 1 }, { x: 2 }] }
34
+ const after = { items: [{ x: 1 }, { x: 9 }] }
35
+ expect(computeConfigChange(before, after).ops).toEqual([
36
+ { op: 'set', path: ['items', 1, 'x'], value: 9 },
37
+ ])
38
+ })
39
+
40
+ test('length-changed arrays emit a single whole-array set', () => {
41
+ const before = { items: [1, 2] }
42
+ const after = { items: [1, 2, 3] }
43
+ expect(computeConfigChange(before, after).ops).toEqual([
44
+ { op: 'set', path: ['items'], value: [1, 2, 3] },
45
+ ])
46
+ })
47
+
48
+ test('identical configs produce no ops', () => {
49
+ const config = { subspace_map: { S: { brick_map: { B: { property: { a: 1, b: [1, 2] } } } } } }
50
+ expect(computeConfigChange(config, structuredClone(config))).toEqual({
51
+ status: 'ok',
52
+ ops: [],
53
+ opCount: 0,
54
+ })
55
+ })
56
+
57
+ test('reports no_baseline / unavailable for missing sides', () => {
58
+ expect(computeConfigChange(null, { a: 1 })).toEqual({ status: 'no_baseline' })
59
+ expect(computeConfigChange({ a: 1 }, null)).toEqual({ status: 'unavailable' })
60
+ })
61
+ })
@@ -1,4 +1,13 @@
1
- import { compile } from '../index'
1
+ jest.mock('../../tools/_shell', () => ({
2
+ sh: jest.fn(() => Promise.resolve({})),
3
+ }))
4
+
5
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
6
+ import os from 'node:os'
7
+ import path from 'node:path'
8
+
9
+ import { sh } from '../../tools/_shell'
10
+ import { checkConfig, compile } from '../index'
2
11
 
3
12
  const SUBSPACE_ID = 'SUBSPACE_00000000-0000-0000-0000-000000000001'
4
13
  const CANVAS_ID = 'CANVAS_00000000-0000-0000-0000-000000000001'
@@ -43,6 +52,24 @@ const makeData = (remoteUpdate) => ({
43
52
  remoteUpdate,
44
53
  })
45
54
 
55
+ const commandOf = ([strings, ...values]) =>
56
+ strings.reduce((acc, chunk, index) => `${acc}${chunk}${values[index] ?? ''}`, '')
57
+
58
+ describe('checkConfig', () => {
59
+ beforeEach(() => {
60
+ sh.mockClear()
61
+ })
62
+
63
+ test('runs doctor after check-config', async () => {
64
+ await checkConfig('.bricks/build/application-config.json')
65
+
66
+ expect(sh.mock.calls.map(commandOf)).toEqual([
67
+ 'bricks app check-config --validate-automation .bricks/build/application-config.json',
68
+ 'bricks app doctor --validate-automation .bricks/build/application-config.json',
69
+ ])
70
+ })
71
+ })
72
+
46
73
  describe('compile animations', () => {
47
74
  test('normalizes mixed legacy spring config', async () => {
48
75
  const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
@@ -150,3 +177,67 @@ describe('compile data remote update', () => {
150
177
  )
151
178
  })
152
179
  })
180
+
181
+ describe('compile config-change audit log', () => {
182
+ const readAudit = async (projectDir) =>
183
+ (await readFile(path.join(projectDir, '.bricks/edits.jsonl'), 'utf8'))
184
+ .trim()
185
+ .split('\n')
186
+ .map((line) => JSON.parse(line))
187
+
188
+ const seedArtifact = async (projectDir, config) => {
189
+ const artifactPath = path.join(projectDir, '.bricks/build/application-config.json')
190
+ await mkdir(path.dirname(artifactPath), { recursive: true })
191
+ await writeFile(artifactPath, JSON.stringify(config))
192
+ }
193
+
194
+ test('records the config delta only in the editing-tools context', async () => {
195
+ const prevCwd = process.cwd()
196
+ const prevEnv = process.env.BRICKS_CTOR_ENABLE_EDITING_TOOLS
197
+ let projectDir
198
+ try {
199
+ projectDir = await mkdtemp(path.join(os.tmpdir(), 'bricks-compile-audit-'))
200
+ process.chdir(projectDir)
201
+
202
+ // (1) Editing tools disabled: even a real delta against a baseline is not recorded.
203
+ delete process.env.BRICKS_CTOR_ENABLE_EDITING_TOOLS
204
+ const empty = await compile(makeApp([], []))
205
+ await seedArtifact(projectDir, empty)
206
+ await compile(makeApp([], [makeData(undefined)]))
207
+ await expect(readFile(path.join(projectDir, '.bricks/edits.jsonl'))).rejects.toThrow()
208
+
209
+ // (2) Editing tools enabled: the compiled-config delta is recorded.
210
+ process.env.BRICKS_CTOR_ENABLE_EDITING_TOOLS = '1'
211
+ await seedArtifact(projectDir, empty)
212
+ const withData = await compile(makeApp([], [makeData(undefined)]))
213
+ const audit = await readAudit(projectDir)
214
+ expect(audit).toHaveLength(1)
215
+ expect(audit[0].tool).toBe('compile')
216
+ expect(audit[0].outcome).toBe('ok')
217
+ expect(audit[0].summary).toMatch(/compile: \d+ config op\(s\)/)
218
+ expect(JSON.stringify(audit[0].configChange)).toContain('property_bank_map')
219
+ // The audit log gets gitignored on first write.
220
+ const gitignore = await readFile(path.join(projectDir, '.gitignore'), 'utf8')
221
+ expect(gitignore).toContain('.bricks/edits.jsonl')
222
+
223
+ // (3) Recompiling with no source change records a "no config change" entry.
224
+ await seedArtifact(projectDir, withData)
225
+ await compile(makeApp([], [makeData(undefined)]))
226
+ const afterNoop = await readAudit(projectDir)
227
+ expect(afterNoop).toHaveLength(2)
228
+ expect(afterNoop[1].summary).toBe('compile: no config change')
229
+
230
+ // (4) Turning the flag off again (as the editing tools do for their verify compiles)
231
+ // suppresses the record even though there is a real delta.
232
+ process.env.BRICKS_CTOR_ENABLE_EDITING_TOOLS = '0'
233
+ await seedArtifact(projectDir, withData)
234
+ await compile(makeApp([], []))
235
+ expect(await readAudit(projectDir)).toHaveLength(2)
236
+ } finally {
237
+ if (prevEnv === undefined) delete process.env.BRICKS_CTOR_ENABLE_EDITING_TOOLS
238
+ else process.env.BRICKS_CTOR_ENABLE_EDITING_TOOLS = prevEnv
239
+ process.chdir(prevCwd)
240
+ if (projectDir) await rm(projectDir, { recursive: true, force: true })
241
+ }
242
+ })
243
+ })
@@ -66,9 +66,14 @@ describe('validateConfig', () => {
66
66
  })
67
67
  })
68
68
 
69
+ // generateCalulationMap now seeds command ids from the owning calc id; tests that don't
70
+ // assert id values share one fixed id (ids stay deterministic with no global state).
71
+ const CALC_ID = 'PROPERTY_BANK_COMMAND_MAP_00000000-0000-4000-8000-000000000000'
72
+ const genMap = (config, calcId = CALC_ID) => generateCalulationMap(config, calcId)
73
+
69
74
  describe('generateCalulationMap', () => {
70
75
  test('produces only the three sandbox nodes for an empty config', () => {
71
- const result = generateCalulationMap(baseConfig())
76
+ const result = genMap(baseConfig())
72
77
 
73
78
  expect(Object.keys(result.map)).toHaveLength(3)
74
79
  const { run, error, result: returnValue } = findSandboxIds(result.map)
@@ -80,7 +85,7 @@ describe('generateCalulationMap', () => {
80
85
  })
81
86
 
82
87
  test('chains multiple inputs through OBJECT_SET commands', () => {
83
- const result = generateCalulationMap(baseConfig({ inputs: { a: 'foo.bar', b: 'baz' } }))
88
+ const result = genMap(baseConfig({ inputs: { a: 'foo.bar', b: 'baz' } }))
84
89
 
85
90
  const { run } = findSandboxIds(result.map)
86
91
 
@@ -110,7 +115,7 @@ describe('generateCalulationMap', () => {
110
115
  })
111
116
 
112
117
  test('builds OBJECT_GET commands and target data-nodes for outputs', () => {
113
- const result = generateCalulationMap(baseConfig({ outputs: { resultPath: ['pb1', 'pb2'] } }))
118
+ const result = genMap(baseConfig({ outputs: { resultPath: ['pb1', 'pb2'] } }))
114
119
 
115
120
  const { result: returnValue } = findSandboxIds(result.map)
116
121
 
@@ -152,7 +157,7 @@ describe('generateCalulationMap', () => {
152
157
  ['output', { output: 'shared' }],
153
158
  ['error', { error: 'shared' }],
154
159
  ])('preserves input out.value when %s target reuses an input id', (_, overrides) => {
155
- const result = generateCalulationMap(
160
+ const result = genMap(
156
161
  baseConfig({ trigger_mode: 'manual', inputs: { shared: 'foo' }, ...overrides }),
157
162
  )
158
163
  expect(Array.isArray(result.map.shared.out.value)).toBe(true)
@@ -160,7 +165,7 @@ describe('generateCalulationMap', () => {
160
165
  })
161
166
 
162
167
  test('also rewires in.change to OBJECT_GET when an outputs target reuses an input id', () => {
163
- const result = generateCalulationMap(
168
+ const result = genMap(
164
169
  baseConfig({
165
170
  trigger_mode: 'manual',
166
171
  inputs: { shared: 'foo' },
@@ -175,9 +180,7 @@ describe('generateCalulationMap', () => {
175
180
  // Two outputs entries both target `pb1`. The reduce visits each entry in turn
176
181
  // and must preserve the accumulated `out.value` from the first iteration via
177
182
  // `acc.map[pb]` rather than wiping it on the second.
178
- const result = generateCalulationMap(
179
- baseConfig({ outputs: { first: ['pb1'], second: ['pb1'] } }),
180
- )
183
+ const result = genMap(baseConfig({ outputs: { first: ['pb1'], second: ['pb1'] } }))
181
184
  expect(result.map.pb1.type).toBe('data-node')
182
185
  // Without an input, out.value remains null after both passes.
183
186
  expect(result.map.pb1.out.value).toBeNull()
@@ -187,7 +190,7 @@ describe('generateCalulationMap', () => {
187
190
  })
188
191
 
189
192
  test('wires the error data-node when error is configured', () => {
190
- const result = generateCalulationMap(baseConfig({ error: 'errNode' }))
193
+ const result = genMap(baseConfig({ error: 'errNode' }))
191
194
 
192
195
  const { error } = findSandboxIds(result.map)
193
196
  // SANDBOX_GET_ERROR now broadcasts to the error data-node's `change` port.
@@ -201,7 +204,7 @@ describe('generateCalulationMap', () => {
201
204
  })
202
205
 
203
206
  test('wires the output data-node when output is configured', () => {
204
- const result = generateCalulationMap(baseConfig({ output: 'outNode' }))
207
+ const result = genMap(baseConfig({ output: 'outNode' }))
205
208
 
206
209
  const { result: returnValue } = findSandboxIds(result.map)
207
210
  expect(result.map[returnValue].out.result).toEqual([{ id: 'outNode', port: 'change' }])
@@ -211,7 +214,7 @@ describe('generateCalulationMap', () => {
211
214
  })
212
215
 
213
216
  test('manual trigger mode sets disable_trigger_command on input value ports', () => {
214
- const result = generateCalulationMap(
217
+ const result = genMap(
215
218
  baseConfig({
216
219
  trigger_mode: 'manual',
217
220
  inputs: { a: 'foo', b: 'bar' },
@@ -224,7 +227,7 @@ describe('generateCalulationMap', () => {
224
227
  })
225
228
 
226
229
  test('auto trigger mode honours per-key disabled_triggers', () => {
227
- const result = generateCalulationMap(
230
+ const result = genMap(
228
231
  baseConfig({
229
232
  inputs: { a: 'foo', b: 'bar' },
230
233
  disabled_triggers: { a: true, b: false },
@@ -238,30 +241,44 @@ describe('generateCalulationMap', () => {
238
241
  })
239
242
 
240
243
  test('auto trigger mode without disabled_triggers leaves disable_trigger_command undefined', () => {
241
- const result = generateCalulationMap(baseConfig({ inputs: { a: 'foo' } }))
244
+ const result = genMap(baseConfig({ inputs: { a: 'foo' } }))
242
245
  const aCmdId = result.map.a.out.value[0].id
243
246
  expect(result.map[aCmdId].in.value[0].disable_trigger_command).toBeUndefined()
244
247
  })
245
248
 
246
- test('snapshotMode forwards to makeId so generated ids are deterministic v4 uuids', () => {
247
- const result = generateCalulationMap(baseConfig({ inputs: { a: 'foo' } }), {
248
- snapshotMode: true,
249
- })
250
- const sandboxIds = sandboxNodeIds(result.map).map((s) => s.id)
249
+ test('derives command ids deterministically from the calc id (stable across compiles)', () => {
250
+ const config = baseConfig({ inputs: { a: 'foo' }, outputs: { out: ['pb1'] } })
251
+ const first = generateCalulationMap(config, CALC_ID)
252
+ const second = generateCalulationMap(config, CALC_ID)
253
+ // Same calc id + same config must yield byte-identical ids, so recompiling unchanged
254
+ // source produces no spurious property_bank_calc_map diff.
255
+ expect(Object.keys(second.map)).toEqual(Object.keys(first.map))
256
+
251
257
  const uuid = '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'
252
- sandboxIds.forEach((id) => {
258
+ sandboxNodeIds(first.map).forEach(({ id }) => {
253
259
  expect(id).toMatch(new RegExp(`^PROPERTY_BANK_COMMAND_NODE_${uuid}$`))
254
260
  })
255
261
  })
256
262
 
257
- test('validateConfig errors propagate out of generateCalulationMap', () => {
258
- expect(() => generateCalulationMap(baseConfig({ inputs: { a: 'foo' }, error: 'a' }))).toThrow(
259
- /key: error/,
263
+ test('seeds command ids per calc so different calcs never collide (edit isolation)', () => {
264
+ const config = baseConfig({ inputs: { a: 'foo' } })
265
+ const a = generateCalulationMap(
266
+ config,
267
+ 'PROPERTY_BANK_COMMAND_MAP_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
260
268
  )
269
+ const b = generateCalulationMap(
270
+ config,
271
+ 'PROPERTY_BANK_COMMAND_MAP_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
272
+ )
273
+ expect(findSandboxIds(a.map).run).not.toBe(findSandboxIds(b.map).run)
274
+ })
275
+
276
+ test('validateConfig errors propagate out of generateCalulationMap', () => {
277
+ expect(() => genMap(baseConfig({ inputs: { a: 'foo' }, error: 'a' }))).toThrow(/key: error/)
261
278
  })
262
279
 
263
280
  test('SANDBOX_GET_RETURN_VALUE broadcasts to both output target and outputs commands', () => {
264
- const result = generateCalulationMap(
281
+ const result = genMap(
265
282
  baseConfig({
266
283
  output: 'outNode',
267
284
  outputs: { foo: ['pb1'] },
@@ -0,0 +1,102 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ // The compiled config artifact written by `bun compile` (see the generated
5
+ // project's `compile.ts`; bricks-project-generator/index.js:762).
6
+ export const BUILD_CONFIG_RELATIVE = '.bricks/build/application-config.json'
7
+
8
+ // `compile()` derives these top-level fields from `Date.now()`
9
+ // (compile/index.ts `title: \`${app.name}(${timestamp})\`` and `update_timestamp`),
10
+ // so they differ on every run regardless of source. Excluded from the comparison —
11
+ // only the *top-level* keys are dropped, so nested subspace/brick `title`s still diff.
12
+ const VOLATILE_TOP_LEVEL_FIELDS = ['title', 'update_timestamp']
13
+
14
+ // A minimal, path-keyed config change (Option A): a generic patch, not coupled to
15
+ // bricks-config-editor's action vocabulary. `path` is an array of object keys /
16
+ // array indices, ready for the editor's `setYValueAtPath` (or a delete) so applying
17
+ // the patch is a single Yjs `'local'` transaction == one undo/redo entry.
18
+ export type ConfigPatchOp =
19
+ | { op: 'set'; path: Array<string | number>; value: unknown }
20
+ | { op: 'unset'; path: Array<string | number> }
21
+
22
+ export type ConfigChange =
23
+ | { status: 'ok'; ops: ConfigPatchOp[]; opCount: number }
24
+ | { status: 'no_baseline' }
25
+ | { status: 'unavailable' }
26
+
27
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
28
+ typeof value === 'object' && value !== null && !Array.isArray(value)
29
+
30
+ const deepEqual = (a: unknown, b: unknown): boolean => {
31
+ if (a === b) return true
32
+ if (Array.isArray(a) && Array.isArray(b)) {
33
+ return a.length === b.length && a.every((item, index) => deepEqual(item, b[index]))
34
+ }
35
+ if (isRecord(a) && isRecord(b)) {
36
+ const aKeys = Object.keys(a)
37
+ return (
38
+ aKeys.length === Object.keys(b).length &&
39
+ aKeys.every((key) => key in b && deepEqual(a[key], b[key]))
40
+ )
41
+ }
42
+ return false
43
+ }
44
+
45
+ const normalizeConfig = (config: unknown) => {
46
+ if (!isRecord(config)) return config
47
+ const normalized = { ...config }
48
+ for (const field of VOLATILE_TOP_LEVEL_FIELDS) delete normalized[field]
49
+ return normalized
50
+ }
51
+
52
+ // Objects recurse key-wise; equal-length arrays recurse element-wise; everything else
53
+ // (scalars, type changes, length-changed arrays) emits one whole-value `set` at that
54
+ // path. The editor's `applyJsonDiffToYType` minimizes the actual Yjs ops downstream,
55
+ // so a whole-array `set` on insert/remove still yields a minimal CRDT mutation.
56
+ const diffInto = (
57
+ before: unknown,
58
+ after: unknown,
59
+ currentPath: Array<string | number>,
60
+ ops: ConfigPatchOp[],
61
+ ) => {
62
+ if (deepEqual(before, after)) return
63
+
64
+ if (isRecord(before) && isRecord(after)) {
65
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)])
66
+ for (const key of keys) {
67
+ const nextPath = [...currentPath, key]
68
+ if (!(key in after)) ops.push({ op: 'unset', path: nextPath })
69
+ else if (!(key in before)) ops.push({ op: 'set', path: nextPath, value: after[key] })
70
+ else diffInto(before[key], after[key], nextPath, ops)
71
+ }
72
+ return
73
+ }
74
+
75
+ if (Array.isArray(before) && Array.isArray(after) && before.length === after.length) {
76
+ for (let index = 0; index < after.length; index += 1) {
77
+ diffInto(before[index], after[index], [...currentPath, index], ops)
78
+ }
79
+ return
80
+ }
81
+
82
+ ops.push({ op: 'set', path: currentPath, value: after })
83
+ }
84
+
85
+ // Diff two compiled configs. `before == null` means there was no prior build to compare
86
+ // (first compile); `after == null` means the fresh artifact could not be read.
87
+ export const computeConfigChange = (before: unknown, after: unknown): ConfigChange => {
88
+ if (before == null) return { status: 'no_baseline' }
89
+ if (after == null) return { status: 'unavailable' }
90
+ const ops: ConfigPatchOp[] = []
91
+ diffInto(normalizeConfig(before), normalizeConfig(after), [], ops)
92
+ return { status: 'ok', ops, opCount: ops.length }
93
+ }
94
+
95
+ // Read the last-compiled config artifact. Returns null when it is absent or unreadable.
96
+ export const readBuildConfig = async (projectDir: string): Promise<unknown> => {
97
+ try {
98
+ return JSON.parse(await readFile(path.join(projectDir, BUILD_CONFIG_RELATIVE), 'utf8'))
99
+ } catch {
100
+ return null
101
+ }
102
+ }
package/compile/index.ts CHANGED
@@ -10,6 +10,10 @@ import { makeId } 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,
@@ -700,8 +704,38 @@ const compileAutomation = (automationMap: AutomationMap) =>
700
704
  }),
701
705
  )
702
706
 
707
+ // Record the minimal compiled-config delta this compile produced to the shared audit
708
+ // log (`.bricks/edits.jsonl`), so editing files directly and running `bun compile`
709
+ // leaves the same trail as the MCP source-editing tools. Maintained only in the
710
+ // editing-tools context (`BRICKS_CTOR_ENABLE_EDITING_TOOLS`); the source-editing tools
711
+ // turn it off for their verify compiles (see _verify.ts) so a tool edit records one
712
+ // richer entry instead of an extra generic compile entry. Also silent when there is no
713
+ // prior build to diff against (fresh projects, package tests, tooling outside a project).
714
+ const recordConfigChange = async (previousConfig: unknown, config: unknown) => {
715
+ if (previousConfig == null) return
716
+ if (!isTruthyEnv(process.env.BRICKS_CTOR_ENABLE_EDITING_TOOLS)) return
717
+ // The baseline was parsed from JSON; round-trip the fresh config the same way so keys
718
+ // holding undefined (dropped by the artifact's JSON.stringify) don't diff as phantom sets.
719
+ const change = computeConfigChange(previousConfig, JSON.parse(JSON.stringify(config)))
720
+ if (change.status !== 'ok') return
721
+ await appendEditRecord(process.cwd(), {
722
+ ts: new Date().toISOString(),
723
+ tool: 'compile',
724
+ provenance: editProvenance(),
725
+ outcome: 'ok',
726
+ summary:
727
+ change.opCount === 0
728
+ ? 'compile: no config change'
729
+ : `compile: ${change.opCount} config op(s)`,
730
+ configChange: change,
731
+ }).catch(() => undefined)
732
+ }
733
+
703
734
  export const compile = async (app: Application) => {
704
735
  await new Promise((resolve) => setImmediate(resolve, 0))
736
+ // Snapshot the prior build artifact before the caller's compile.ts overwrites it, so
737
+ // the config change introduced by this compile can be recorded on return.
738
+ const previousConfig = await readBuildConfig(process.cwd())
705
739
  const timestamp = Date.now()
706
740
  // Pre-index subspace ids so the canvas-item validation below stays O(1).
707
741
  const subspaceIdSet = new Set(app.subspaces.map((s) => s.id))
@@ -1414,12 +1448,7 @@ export const compile = async (app: Application) => {
1414
1448
  : null,
1415
1449
  }
1416
1450
 
1417
- Object.assign(
1418
- calc,
1419
- generateCalulationMap(calc.script_config, {
1420
- snapshotMode: process.env.BRICKS_SNAPSHOT_MODE === '1',
1421
- }),
1422
- )
1451
+ Object.assign(calc, generateCalulationMap(calc.script_config, dataCalcId))
1423
1452
  }
1424
1453
  map[dataCalcId] = calc
1425
1454
  return map
@@ -1456,12 +1485,15 @@ export const compile = async (app: Application) => {
1456
1485
  automation_map: compiledAutomationMap || app.metadata?.TEMP_automation_map || {},
1457
1486
  update_timestamp: timestamp,
1458
1487
  }
1488
+ await recordConfigChange(previousConfig, config)
1459
1489
  return config
1460
1490
  }
1461
1491
 
1462
1492
  export const checkConfig = async (configPath: string) => {
1463
- const { sh } = await import('../tools/_shell')
1464
1493
  // --validate-automation surfaces broken automation_map / test_map refs early,
1465
1494
  // which catches agent-authored automations that reference deleted bricks.
1466
1495
  await sh`bricks app check-config --validate-automation ${configPath}`
1496
+ // Doctor adds semantic lint checks after structural validation. Warnings are
1497
+ // surfaced in the compile log, but only errors fail by default.
1498
+ await sh`bricks app doctor --validate-automation ${configPath}`
1467
1499
  }
package/compile/util.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { makeId } from '../utils/id'
1
+ import { makeSeededId } from '../utils/id'
2
2
 
3
3
  type ScriptConfig = {
4
4
  title?: string
@@ -33,15 +33,18 @@ const padding = 15
33
33
  const layerXInterval = 300
34
34
  const layerYInterval = 150
35
35
 
36
- export const generateCalulationMap = (config: ScriptConfig, opts?: { snapshotMode?: boolean }) => {
36
+ // `calcId` (the owning script calc's stable id) seeds every derived command-node id, so an
37
+ // unchanged calc recompiles to identical ids and editing one calc never shifts another's —
38
+ // keeping the compiled config byte-stable for change detection. See makeSeededId in utils/id.
39
+ export const generateCalulationMap = (config: ScriptConfig, calcId: string) => {
37
40
  validateConfig(config)
38
- const sandboxId = makeId('property_bank_command', opts)
39
- const sandboxErrorId = makeId('property_bank_command', opts)
40
- const sandboxResultId = makeId('property_bank_command', opts)
41
+ const sandboxId = makeSeededId('property_bank_command', `${calcId}:sandbox-run`)
42
+ const sandboxErrorId = makeSeededId('property_bank_command', `${calcId}:sandbox-error`)
43
+ const sandboxResultId = makeSeededId('property_bank_command', `${calcId}:sandbox-result`)
41
44
 
42
45
  const inputs = Object.entries(config.inputs).reduce(
43
46
  (acc, [key, value], index) => {
44
- const commandId = makeId('property_bank_command', opts)
47
+ const commandId = makeSeededId('property_bank_command', `${calcId}:input:${key}`)
45
48
  acc.map[key] = {
46
49
  type: 'data-node',
47
50
  properties: {},
@@ -123,7 +126,7 @@ export const generateCalulationMap = (config: ScriptConfig, opts?: { snapshotMod
123
126
  let y = 0
124
127
  const outputs = Object.entries(config.outputs).reduce(
125
128
  (acc, [key, pbList], index) => {
126
- const commandId = makeId('property_bank_command', opts)
129
+ const commandId = makeSeededId('property_bank_command', `${calcId}:output:${key}`)
127
130
  acc.commandIdList.push(commandId)
128
131
  acc.map[commandId] = {
129
132
  type: 'command-node-object',
package/package.json CHANGED
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "@fugood/bricks-ctor",
3
- "version": "2.25.0-beta.42",
3
+ "version": "2.25.0-beta.45",
4
4
  "main": "index.ts",
5
5
  "scripts": {
6
6
  "typecheck": "tsc --noEmit",
7
7
  "build": "bun scripts/build.js"
8
8
  },
9
9
  "dependencies": {
10
- "@fugood/bricks-cli": "^2.25.0-beta.42",
10
+ "@babel/generator": "7.28.5",
11
+ "@babel/parser": "7.28.5",
12
+ "@babel/traverse": "7.28.5",
13
+ "@babel/types": "7.28.5",
14
+ "@fugood/bricks-cli": "^2.25.0-beta.45",
11
15
  "@huggingface/gguf": "^0.3.2",
12
16
  "@iarna/toml": "^3.0.0",
13
17
  "@modelcontextprotocol/sdk": "^1.15.0",
@@ -25,5 +29,5 @@
25
29
  "peerDependencies": {
26
30
  "oxfmt": "^0.36.0"
27
31
  },
28
- "gitHead": "ef7ddb9d131b8e0f429f68d16585eb254f85e8e1"
32
+ "gitHead": "37dd0194e56025e5fe727bab6af7e2b8eb1d7ae1"
29
33
  }
@@ -12,6 +12,7 @@ This skill covers advanced BRICKS features not in the main project instructions.
12
12
  | Rule | Description |
13
13
  |------|-------------|
14
14
  | [Architecture Patterns](references/architecture-patterns.md) | **Read first** — decompose flows and select patterns |
15
+ | [Source-Editing Tools](references/source-editing-tools.md) | MCP tools for editing entries and data-calcs by AST (new/edit/remove, value grammar, verify) |
15
16
  | [Animation](references/animation.md) | Animation system for brick transforms and opacity |
16
17
  | [Standby Transition](references/standby-transition.md) | Canvas enter/exit animations |
17
18
  | [Automations](references/automations.md) | E2E testing and scheduled tasks |
@@ -26,6 +27,7 @@ This skill covers advanced BRICKS features not in the main project instructions.
26
27
  ## Quick Reference
27
28
 
28
29
  - **Complex flows**: See [Architecture Patterns](references/architecture-patterns.md) for decomposing multi-step workflows
30
+ - **Editing entries/data-calcs**: See [Source-Editing Tools](references/source-editing-tools.md) — prefer the MCP editing tools over hand-editing `subspaces/**`
29
31
  - **Multi-device**: See [Local Sync](references/local-sync.md) for LAN coordination
30
32
  - **Cloud data**: See [Remote Data Bank](references/remote-data-bank.md) for sync and API access
31
33
  - **Media assets**: See [Media Flow](references/media-flow.md) for centralized asset management
@@ -32,6 +32,12 @@ Sequential `PROPERTY_BANK` / `PROPERTY_BANK_EXPRESSION` actions in one chain rea
32
32
  Built-in commands for direct state and UI changes.
33
33
  - **PROPERTY_BANK**: set data value
34
34
  - **PROPERTY_BANK_EXPRESSION**: inline JS expression for simple compute
35
+ - The expression engine folds statements into a single expression: only expression
36
+ statements, simple `const`/`let` declarations, and a final return/expression are
37
+ supported — **no `if`/`for`/`while`/`switch`** (use ternaries). The same limit
38
+ applies inside a zero-arg IIFE body. Unsupported statements fail at runtime with
39
+ the error visible only in a DevTools session, so prefer ternary chains or move the
40
+ logic to a DataCalculationScript.
35
41
  - **CHANGE_CANVAS**: navigate to another canvas
36
42
  - **DYNAMIC_ANIMATION**: trigger animation
37
43
  - **ALERT / MESSAGE**: system feedback