@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.
- package/compile/__tests__/config-diff.test.js +61 -0
- package/compile/__tests__/index.test.js +92 -1
- package/compile/__tests__/util.test.js +40 -23
- package/compile/config-diff.ts +102 -0
- package/compile/index.ts +39 -7
- package/compile/util.ts +10 -7
- package/package.json +7 -3
- package/skills/bricks-ctor/SKILL.md +2 -0
- package/skills/bricks-ctor/references/architecture-patterns.md +6 -0
- package/skills/bricks-ctor/references/source-editing-tools.md +81 -0
- package/skills/bricks-ctor/references/verification-toolchain.md +2 -0
- package/tools/_edits-log.ts +41 -0
- package/tools/mcp-env.ts +13 -0
- package/tools/mcp-server.ts +8 -0
- package/tools/mcp-tools/__tests__/data-calc-editing.test.js +516 -0
- package/tools/mcp-tools/__tests__/entry-editing.test.js +836 -0
- package/tools/mcp-tools/__tests__/mcp-env.test.js +19 -0
- package/tools/mcp-tools/_verify.ts +50 -0
- package/tools/mcp-tools/compile.ts +2 -0
- package/tools/mcp-tools/data-calc-editing.ts +1395 -0
- package/tools/mcp-tools/entry-editing.ts +2368 -0
- package/tools/postinstall.ts +80 -3
- package/types/data-calc-command/color.d.ts +1 -1
- package/types/data-calc-command/datetime.d.ts +2 -2
- package/utils/__tests__/calc.test.js +25 -0
- package/utils/calc.ts +5 -1
- package/utils/data.ts +3 -5
- package/utils/id.ts +39 -37
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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('
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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('
|
|
258
|
-
|
|
259
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
39
|
-
const sandboxErrorId =
|
|
40
|
-
const sandboxResultId =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
-
"@
|
|
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": "
|
|
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
|