@fugood/bricks-ctor 2.25.0-beta.43 → 2.25.0-beta.46
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 +40 -12
- package/compile/__tests__/index.test.js +56 -25
- package/compile/__tests__/util.test.js +40 -23
- package/compile/config-diff.ts +64 -17
- package/compile/index.ts +30 -20
- package/compile/util.ts +10 -7
- package/package.json +3 -3
- package/skills/bricks-ctor/references/simulator.md +3 -2
- package/skills/bricks-ctor/references/verification-toolchain.md +2 -0
- package/tools/_edits-log.ts +41 -0
- package/tools/mcp-env.ts +1 -1
- package/tools/mcp-tools/__tests__/mcp-env.test.js +3 -5
- package/tools/mcp-tools/_verify.ts +6 -1
- package/tools/mcp-tools/compile.ts +2 -2
- package/tools/mcp-tools/data-calc-editing.ts +3 -33
- package/tools/mcp-tools/entry-editing.ts +3 -33
- 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/id.ts +39 -37
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computeConfigChange
|
|
1
|
+
import { computeConfigChange } from '../config-diff'
|
|
2
2
|
|
|
3
3
|
describe('computeConfigChange', () => {
|
|
4
4
|
test('ignores the volatile top-level title and update_timestamp', () => {
|
|
@@ -54,19 +54,47 @@ describe('computeConfigChange', () => {
|
|
|
54
54
|
})
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
+
test('ignores object fields omitted by JSON.stringify', () => {
|
|
58
|
+
const before = { subspace_map: { S: { title: 'Home' } } }
|
|
59
|
+
const after = {
|
|
60
|
+
subspace_map: {
|
|
61
|
+
S: {
|
|
62
|
+
title: 'Home',
|
|
63
|
+
description: undefined,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
expect(computeConfigChange(before, after)).toEqual({ status: 'ok', ops: [], opCount: 0 })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('emits added values in JSON-compatible shape', () => {
|
|
71
|
+
const before = { m: {} }
|
|
72
|
+
const after = {
|
|
73
|
+
m: {
|
|
74
|
+
nested: {
|
|
75
|
+
keep: 1,
|
|
76
|
+
drop: undefined,
|
|
77
|
+
},
|
|
78
|
+
items: [undefined, 2],
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
const change = computeConfigChange(before, after)
|
|
82
|
+
expect(change.ops).toEqual([
|
|
83
|
+
{ op: 'set', path: ['m', 'nested'], value: { keep: 1 } },
|
|
84
|
+
{ op: 'set', path: ['m', 'items'], value: [null, 2] },
|
|
85
|
+
])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('compares array undefined entries like JSON nulls', () => {
|
|
89
|
+
expect(computeConfigChange({ items: [null] }, { items: [undefined] })).toEqual({
|
|
90
|
+
status: 'ok',
|
|
91
|
+
ops: [],
|
|
92
|
+
opCount: 0,
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
57
96
|
test('reports no_baseline / unavailable for missing sides', () => {
|
|
58
97
|
expect(computeConfigChange(null, { a: 1 })).toEqual({ status: 'no_baseline' })
|
|
59
98
|
expect(computeConfigChange({ a: 1 }, null)).toEqual({ status: 'unavailable' })
|
|
60
99
|
})
|
|
61
100
|
})
|
|
62
|
-
|
|
63
|
-
describe('summarizeConfigChange', () => {
|
|
64
|
-
test('summarizes each status', () => {
|
|
65
|
-
expect(summarizeConfigChange({ status: 'no_baseline' })).toMatch(/no prior build/)
|
|
66
|
-
expect(summarizeConfigChange({ status: 'unavailable' })).toMatch(/unavailable/)
|
|
67
|
-
expect(summarizeConfigChange({ status: 'ok', ops: [], opCount: 0 })).toBe('config change: none')
|
|
68
|
-
expect(summarizeConfigChange({ status: 'ok', ops: [{}, {}], opCount: 2 })).toBe(
|
|
69
|
-
'config change: 2 op(s)',
|
|
70
|
-
)
|
|
71
|
-
})
|
|
72
|
-
})
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
2
|
-
import os from 'node:os'
|
|
3
|
-
import path from 'node:path'
|
|
4
|
-
|
|
5
1
|
jest.mock('../../tools/_shell', () => ({
|
|
6
2
|
sh: jest.fn(() => Promise.resolve({})),
|
|
7
3
|
}))
|
|
8
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
9
|
import { sh } from '../../tools/_shell'
|
|
10
10
|
import { checkConfig, compile } from '../index'
|
|
11
11
|
|
|
@@ -178,34 +178,65 @@ describe('compile data remote update', () => {
|
|
|
178
178
|
})
|
|
179
179
|
})
|
|
180
180
|
|
|
181
|
-
describe('compile config
|
|
182
|
-
|
|
183
|
-
|
|
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 () => {
|
|
184
195
|
const prevCwd = process.cwd()
|
|
196
|
+
const prevEnv = process.env.BRICKS_CTOR_ENABLE_EDITING_TOOLS
|
|
185
197
|
let projectDir
|
|
186
198
|
try {
|
|
187
|
-
|
|
188
|
-
const before = await compile(makeApp([], [makeData(undefined)]))
|
|
189
|
-
expect(logSpy.mock.calls.flat().join('\n')).not.toContain('config change')
|
|
190
|
-
|
|
191
|
-
projectDir = await mkdtemp(path.join(os.tmpdir(), 'bricks-compile-diff-'))
|
|
192
|
-
const artifactPath = path.join(projectDir, '.bricks/build/application-config.json')
|
|
193
|
-
await mkdir(path.dirname(artifactPath), { recursive: true })
|
|
194
|
-
await writeFile(artifactPath, JSON.stringify(before))
|
|
199
|
+
projectDir = await mkdtemp(path.join(os.tmpdir(), 'bricks-compile-audit-'))
|
|
195
200
|
process.chdir(projectDir)
|
|
196
201
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
await
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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)
|
|
206
236
|
} finally {
|
|
237
|
+
if (prevEnv === undefined) delete process.env.BRICKS_CTOR_ENABLE_EDITING_TOOLS
|
|
238
|
+
else process.env.BRICKS_CTOR_ENABLE_EDITING_TOOLS = prevEnv
|
|
207
239
|
process.chdir(prevCwd)
|
|
208
|
-
logSpy.mockRestore()
|
|
209
240
|
if (projectDir) await rm(projectDir, { recursive: true, force: true })
|
|
210
241
|
}
|
|
211
242
|
})
|
|
@@ -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'] },
|
package/compile/config-diff.ts
CHANGED
|
@@ -27,17 +27,60 @@ export type ConfigChange =
|
|
|
27
27
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
28
28
|
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
29
29
|
|
|
30
|
+
const isJsonDroppedValue = (value: unknown) =>
|
|
31
|
+
value === undefined || typeof value === 'function' || typeof value === 'symbol'
|
|
32
|
+
|
|
33
|
+
const toJsonComparableScalar = (value: unknown) => {
|
|
34
|
+
if (typeof value === 'number' && !Number.isFinite(value)) return null
|
|
35
|
+
return value
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const toJsonCompatibleValue = (value: unknown): unknown => {
|
|
39
|
+
if (isJsonDroppedValue(value)) return undefined
|
|
40
|
+
if (Array.isArray(value)) {
|
|
41
|
+
return value.map((item) => (isJsonDroppedValue(item) ? null : toJsonCompatibleValue(item)))
|
|
42
|
+
}
|
|
43
|
+
if (isRecord(value)) {
|
|
44
|
+
return Object.entries(value).reduce((acc, [key, item]) => {
|
|
45
|
+
if (!isJsonDroppedValue(item)) acc[key] = toJsonCompatibleValue(item)
|
|
46
|
+
return acc
|
|
47
|
+
}, {})
|
|
48
|
+
}
|
|
49
|
+
return toJsonComparableScalar(value)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const hasJsonObjectKey = (value: Record<string, unknown>, key: string) =>
|
|
53
|
+
Object.prototype.hasOwnProperty.call(value, key) && !isJsonDroppedValue(value[key])
|
|
54
|
+
|
|
55
|
+
const getJsonArrayItem = (value: unknown[], index: number) => {
|
|
56
|
+
const item = value[index]
|
|
57
|
+
return isJsonDroppedValue(item) ? null : item
|
|
58
|
+
}
|
|
59
|
+
|
|
30
60
|
const deepEqual = (a: unknown, b: unknown): boolean => {
|
|
61
|
+
a = toJsonComparableScalar(a)
|
|
62
|
+
b = toJsonComparableScalar(b)
|
|
31
63
|
if (a === b) return true
|
|
32
64
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
33
|
-
|
|
65
|
+
if (a.length !== b.length) return false
|
|
66
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
67
|
+
if (!deepEqual(getJsonArrayItem(a, index), getJsonArrayItem(b, index))) return false
|
|
68
|
+
}
|
|
69
|
+
return true
|
|
34
70
|
}
|
|
35
71
|
if (isRecord(a) && isRecord(b)) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
72
|
+
let comparableAKeys = 0
|
|
73
|
+
for (const key of Object.keys(a)) {
|
|
74
|
+
if (isJsonDroppedValue(a[key])) continue
|
|
75
|
+
comparableAKeys += 1
|
|
76
|
+
if (!hasJsonObjectKey(b, key) || !deepEqual(a[key], b[key])) return false
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let comparableBKeys = 0
|
|
80
|
+
for (const key of Object.keys(b)) {
|
|
81
|
+
if (!isJsonDroppedValue(b[key])) comparableBKeys += 1
|
|
82
|
+
}
|
|
83
|
+
return comparableAKeys === comparableBKeys
|
|
41
84
|
}
|
|
42
85
|
return false
|
|
43
86
|
}
|
|
@@ -64,22 +107,32 @@ const diffInto = (
|
|
|
64
107
|
if (isRecord(before) && isRecord(after)) {
|
|
65
108
|
const keys = new Set([...Object.keys(before), ...Object.keys(after)])
|
|
66
109
|
for (const key of keys) {
|
|
110
|
+
const beforeHasKey = hasJsonObjectKey(before, key)
|
|
111
|
+
const afterHasKey = hasJsonObjectKey(after, key)
|
|
112
|
+
if (!beforeHasKey && !afterHasKey) continue
|
|
113
|
+
|
|
67
114
|
const nextPath = [...currentPath, key]
|
|
68
|
-
if (!
|
|
69
|
-
else if (!
|
|
70
|
-
|
|
115
|
+
if (!afterHasKey) ops.push({ op: 'unset', path: nextPath })
|
|
116
|
+
else if (!beforeHasKey) {
|
|
117
|
+
ops.push({ op: 'set', path: nextPath, value: toJsonCompatibleValue(after[key]) })
|
|
118
|
+
} else diffInto(before[key], after[key], nextPath, ops)
|
|
71
119
|
}
|
|
72
120
|
return
|
|
73
121
|
}
|
|
74
122
|
|
|
75
123
|
if (Array.isArray(before) && Array.isArray(after) && before.length === after.length) {
|
|
76
124
|
for (let index = 0; index < after.length; index += 1) {
|
|
77
|
-
diffInto(
|
|
125
|
+
diffInto(
|
|
126
|
+
getJsonArrayItem(before, index),
|
|
127
|
+
getJsonArrayItem(after, index),
|
|
128
|
+
[...currentPath, index],
|
|
129
|
+
ops,
|
|
130
|
+
)
|
|
78
131
|
}
|
|
79
132
|
return
|
|
80
133
|
}
|
|
81
134
|
|
|
82
|
-
ops.push({ op: 'set', path: currentPath, value: after })
|
|
135
|
+
ops.push({ op: 'set', path: currentPath, value: toJsonCompatibleValue(after) })
|
|
83
136
|
}
|
|
84
137
|
|
|
85
138
|
// Diff two compiled configs. `before == null` means there was no prior build to compare
|
|
@@ -100,9 +153,3 @@ export const readBuildConfig = async (projectDir: string): Promise<unknown> => {
|
|
|
100
153
|
return null
|
|
101
154
|
}
|
|
102
155
|
}
|
|
103
|
-
|
|
104
|
-
export const summarizeConfigChange = (change: ConfigChange): string => {
|
|
105
|
-
if (change.status === 'no_baseline') return 'config change: no prior build to compare'
|
|
106
|
-
if (change.status === 'unavailable') return 'config change: build artifact unavailable'
|
|
107
|
-
return change.opCount === 0 ? 'config change: none' : `config change: ${change.opCount} op(s)`
|
|
108
|
-
}
|
package/compile/index.ts
CHANGED
|
@@ -8,10 +8,12 @@ import type { ExportNamedDeclaration, FunctionDeclaration } from 'acorn'
|
|
|
8
8
|
import escodegen from 'escodegen'
|
|
9
9
|
import { makeId } from '../utils/id'
|
|
10
10
|
import { generateCalulationMap } from './util'
|
|
11
|
-
import { computeConfigChange, readBuildConfig, summarizeConfigChange } from './config-diff'
|
|
12
11
|
import { templateActionNameMap } from './action-name-map'
|
|
13
12
|
import { templateEventPropsMap } from '../utils/event-props'
|
|
14
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'
|
|
15
17
|
import type {
|
|
16
18
|
Application,
|
|
17
19
|
Data,
|
|
@@ -702,24 +704,37 @@ const compileAutomation = (automationMap: AutomationMap) =>
|
|
|
702
704
|
}),
|
|
703
705
|
)
|
|
704
706
|
|
|
705
|
-
//
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
|
|
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) => {
|
|
709
715
|
if (previousConfig == null) return
|
|
710
|
-
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
const change = computeConfigChange(previousConfig,
|
|
716
|
+
if (!isTruthyEnv(process.env.BRICKS_CTOR_ENABLE_EDITING_TOOLS)) return
|
|
717
|
+
// The baseline was parsed from JSON; `computeConfigChange` applies the same
|
|
718
|
+
// JSON-omitted-field rules lazily so compile avoids cloning the full config.
|
|
719
|
+
const change = computeConfigChange(previousConfig, config)
|
|
714
720
|
if (change.status !== 'ok') return
|
|
715
|
-
|
|
716
|
-
|
|
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)
|
|
717
732
|
}
|
|
718
733
|
|
|
719
734
|
export const compile = async (app: Application) => {
|
|
720
735
|
await new Promise((resolve) => setImmediate(resolve, 0))
|
|
721
|
-
// Snapshot the
|
|
722
|
-
//
|
|
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.
|
|
723
738
|
const previousConfig = await readBuildConfig(process.cwd())
|
|
724
739
|
const timestamp = Date.now()
|
|
725
740
|
// Pre-index subspace ids so the canvas-item validation below stays O(1).
|
|
@@ -1433,12 +1448,7 @@ export const compile = async (app: Application) => {
|
|
|
1433
1448
|
: null,
|
|
1434
1449
|
}
|
|
1435
1450
|
|
|
1436
|
-
Object.assign(
|
|
1437
|
-
calc,
|
|
1438
|
-
generateCalulationMap(calc.script_config, {
|
|
1439
|
-
snapshotMode: process.env.BRICKS_SNAPSHOT_MODE === '1',
|
|
1440
|
-
}),
|
|
1441
|
-
)
|
|
1451
|
+
Object.assign(calc, generateCalulationMap(calc.script_config, dataCalcId))
|
|
1442
1452
|
}
|
|
1443
1453
|
map[dataCalcId] = calc
|
|
1444
1454
|
return map
|
|
@@ -1475,7 +1485,7 @@ export const compile = async (app: Application) => {
|
|
|
1475
1485
|
automation_map: compiledAutomationMap || app.metadata?.TEMP_automation_map || {},
|
|
1476
1486
|
update_timestamp: timestamp,
|
|
1477
1487
|
}
|
|
1478
|
-
|
|
1488
|
+
await recordConfigChange(previousConfig, config)
|
|
1479
1489
|
return config
|
|
1480
1490
|
}
|
|
1481
1491
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fugood/bricks-ctor",
|
|
3
|
-
"version": "2.25.0-beta.
|
|
3
|
+
"version": "2.25.0-beta.46",
|
|
4
4
|
"main": "index.ts",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"typecheck": "tsc --noEmit",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"@babel/parser": "7.28.5",
|
|
12
12
|
"@babel/traverse": "7.28.5",
|
|
13
13
|
"@babel/types": "7.28.5",
|
|
14
|
-
"@fugood/bricks-cli": "^2.25.0-beta.
|
|
14
|
+
"@fugood/bricks-cli": "^2.25.0-beta.46",
|
|
15
15
|
"@huggingface/gguf": "^0.3.2",
|
|
16
16
|
"@iarna/toml": "^3.0.0",
|
|
17
17
|
"@modelcontextprotocol/sdk": "^1.15.0",
|
|
@@ -29,5 +29,5 @@
|
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"oxfmt": "^0.36.0"
|
|
31
31
|
},
|
|
32
|
-
"gitHead": "
|
|
32
|
+
"gitHead": "877bdee4953bcdc09b2dd04f28c8d7edc37824ab"
|
|
33
33
|
}
|
|
@@ -46,7 +46,7 @@ These work, but with browser caveats:
|
|
|
46
46
|
| WebView / WebCrawler | Subject to browser CORS — a load/fetch that works on device may be blocked |
|
|
47
47
|
| On-device AI (LLM / STT / VAD / Vector Store / Reranker) | Runs **single-threaded** — far slower than the device, not representative of real latency. Also subject to the model fallbacks below |
|
|
48
48
|
| On-device database (SQLite — `GENERATOR_SQLITE`) | Runs for real on the in-memory WASM `sqlite-vec` build — `execute` / `query` / `transaction` / batch all work. `storageType: file` is transparently treated as in-memory, so nothing persists across reloads (see above) |
|
|
49
|
-
| Scene3D /
|
|
49
|
+
| Scene3D / Sketch / WebRTC | Supported |
|
|
50
50
|
|
|
51
51
|
Feature availability also varies across the device platforms themselves (iOS / tvOS / Android / the desktop OSes). When a deployment targets a specific platform's capability, confirm it on that platform.
|
|
52
52
|
|
|
@@ -89,6 +89,7 @@ So that camera and AI features are usable without device permissions, multi-giga
|
|
|
89
89
|
| Brick / Generator | In the Simulator | Does NOT prove |
|
|
90
90
|
|-------------------|------------------|----------------|
|
|
91
91
|
| Camera (`BRICK_CAMERA`) | A 3D mock canvas, no camera permission prompt. `takePicture` snapshots the canvas; recording produces a placeholder clip | Real camera feed, focus, recording, permission flow |
|
|
92
|
+
| Maps (`BRICK_MAPS`) | A real interactive map on free OpenStreetMap-based tiles — no Google Maps API key needed. Markers, path polyline, the six themes / map types (approximated with free tile sets + CSS tints), and the zoom / pan / navigate / focus / reset / fit actions all work | Google / Apple Maps rendering, exact `customMapStyle` / theme styling (approximated), traffic / buildings / indoors layers, real device geolocation |
|
|
92
93
|
| Thermal Printer (`GENERATOR_THERMAL_PRINTER`) | A simulated printer — `init` / `checkStatus` / `scan` fake per-driver status and discovered devices (ESC/POS, Star, TSC, Castles); `print` renders an approximate on-screen receipt. A bottom-left bubble shows live status with a fault toggle to exercise error wiring. Print results can be exported as PNG via `bricks-cli` (see below) | Real device connection, actual paper output, exact native driver status codes |
|
|
93
94
|
| LLM (`GENERATOR_LLM`) | Swapped to a tiny local stand-in model | Output quality / latency of your real model |
|
|
94
95
|
| Reranker — GGML (`GENERATOR_RERANKER`) | Swapped to a small local multilingual reranker model | Ranking quality / latency of your real model |
|
|
@@ -119,7 +120,7 @@ The PNG is the same approximate receipt the on-screen preview shows (rendered fr
|
|
|
119
120
|
|
|
120
121
|
### Running the real implementation instead
|
|
121
122
|
|
|
122
|
-
Each substituted brick/generator can be switched back to its real implementation per item: open the **gear (Simulator settings)** in the editor's preview toolbar, uncheck the item, and **Apply**. Apply persists the choice and reloads the preview so it takes effect (a plain refresh won't). Use this to, e.g., point a Vector Store at a real API key in the preview. The browser limits above still apply, and **Buttress stays disabled regardless** — there's no backend for it here.
|
|
123
|
+
Each substituted brick/generator can be switched back to its real implementation per item: open the **gear (Simulator settings)** in the editor's preview toolbar, uncheck the item, and **Apply**. Apply persists the choice and reloads the preview so it takes effect (a plain refresh won't). Use this to, e.g., point a Vector Store at a real API key in the preview, or render the real Google/Apple Maps brick (which needs a Maps API key on web). The browser limits above still apply, and **Buttress stays disabled regardless** — there's no backend for it here.
|
|
123
124
|
|
|
124
125
|
The Thermal Printer is the exception: it has no real web implementation to switch to (the native drivers can't run in a browser), so it is **always simulated** and is not in the gear list.
|
|
125
126
|
|
|
@@ -71,6 +71,8 @@ Useful flags:
|
|
|
71
71
|
|
|
72
72
|
For ad-hoc CDP inspection against this local preview, connect any CDP client to `localhost:19852` — Chrome DevTools front-end works directly. For an agent-friendly CLI over CDP (screenshot, brick tree/query, input emulation, storage reads, runtime eval, network capture), the `bricks-cli` skill documents the `bricks devtools` command surface — read that skill if it is installed in this workspace. If it is not installed, run `bricks --help` and `bricks devtools --help`; the CLI's own help output is authoritative.
|
|
73
73
|
|
|
74
|
+
To inspect Data / Property Bank or storage state, prefer the dedicated `bricks devtools storage` subcommands — `storage data-bank get <S_xxxx>` (saved Data values), `storage system persist|memory`, `storage system get <key>` — over hand-written `runtime eval`. Reach for `runtime eval` only for *live* store internals that aren't persisted to a data bank (e.g. a current transient value: `runtime eval "system.data.property('S_xxxx', '<alias>')"`); don't reverse-engineer the `system.*` globals.
|
|
75
|
+
|
|
74
76
|
### Project Automations
|
|
75
77
|
|
|
76
78
|
E2E tests authored in TypeScript inside the project (`AutomationTest` / `TestCase`). Test cases include:
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
const auditLogIgnoreEntry = '.bricks/edits.jsonl'
|
|
5
|
+
|
|
6
|
+
// Ensure the project's .gitignore excludes the audit log. No-op when it is already
|
|
7
|
+
// ignored directly or via a broader `.bricks` rule.
|
|
8
|
+
const ensureAuditLogIgnored = async (projectDir: string) => {
|
|
9
|
+
const gitignorePath = path.join(projectDir, '.gitignore')
|
|
10
|
+
const content = await readFile(gitignorePath, 'utf8').catch((err: any) => {
|
|
11
|
+
if (err?.code === 'ENOENT') return ''
|
|
12
|
+
throw err
|
|
13
|
+
})
|
|
14
|
+
const ignored = content
|
|
15
|
+
.split(/\r?\n/)
|
|
16
|
+
.map((line) => line.trim())
|
|
17
|
+
.some((line) => line === auditLogIgnoreEntry || line === '.bricks/' || line === '.bricks')
|
|
18
|
+
if (ignored) return
|
|
19
|
+
|
|
20
|
+
const prefix = content && !content.endsWith('\n') ? '\n' : ''
|
|
21
|
+
await writeFile(
|
|
22
|
+
gitignorePath,
|
|
23
|
+
`${content}${prefix}\n# MCP entry-editing audit log\n${auditLogIgnoreEntry}\n`,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Append one JSON record to `.bricks/edits.jsonl`, creating the directory and the
|
|
28
|
+
// gitignore entry as needed. Shared by the source-editing tools and `compile()` so
|
|
29
|
+
// every project mutation lands in the same audit log.
|
|
30
|
+
export const appendEditRecord = async (projectDir: string, record: Record<string, unknown>) => {
|
|
31
|
+
const bricksDir = path.join(projectDir, '.bricks')
|
|
32
|
+
await mkdir(bricksDir, { recursive: true })
|
|
33
|
+
await ensureAuditLogIgnored(projectDir)
|
|
34
|
+
await appendFile(path.join(bricksDir, 'edits.jsonl'), `${JSON.stringify(record)}\n`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Provenance stamped on every audit record: which agent/session produced the change.
|
|
38
|
+
export const editProvenance = () => ({
|
|
39
|
+
session: process.env.BRICKS_CTOR_SESSION_ID || process.env.CODEX_SESSION_ID,
|
|
40
|
+
agent: process.env.BRICKS_CTOR_AGENT_ID || process.env.USER,
|
|
41
|
+
})
|
package/tools/mcp-env.ts
CHANGED
|
@@ -9,5 +9,5 @@ export function isTruthyEnv(value: string | undefined) {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export function shouldRegisterEditingTools(env: Record<string, string | undefined> = process.env) {
|
|
12
|
-
return isTruthyEnv(env.
|
|
12
|
+
return isTruthyEnv(env.BRICKS_CTOR_ENABLE_EDITING_TOOLS)
|
|
13
13
|
}
|
|
@@ -9,13 +9,11 @@ describe('mcp env helpers', () => {
|
|
|
9
9
|
|
|
10
10
|
test('does not enable editing tools by default', () => {
|
|
11
11
|
expect(shouldRegisterEditingTools({})).toBe(false)
|
|
12
|
-
expect(shouldRegisterEditingTools({
|
|
13
|
-
expect(shouldRegisterEditingTools({
|
|
14
|
-
false,
|
|
15
|
-
)
|
|
12
|
+
expect(shouldRegisterEditingTools({ BRICKS_CTOR_ENABLE_EDITING_TOOLS: '0' })).toBe(false)
|
|
13
|
+
expect(shouldRegisterEditingTools({ BRICKS_CTOR_ENABLE_EDITING_TOOLS: 'false' })).toBe(false)
|
|
16
14
|
})
|
|
17
15
|
|
|
18
16
|
test('enables editing tools when explicitly requested', () => {
|
|
19
|
-
expect(shouldRegisterEditingTools({
|
|
17
|
+
expect(shouldRegisterEditingTools({ BRICKS_CTOR_ENABLE_EDITING_TOOLS: '1' })).toBe(true)
|
|
20
18
|
})
|
|
21
19
|
})
|
|
@@ -17,7 +17,12 @@ export const compileAndDiff = async (projectDir: string): Promise<VerificationRe
|
|
|
17
17
|
// Snapshot the prior compiled config before recompiling (the new artifact overwrites it).
|
|
18
18
|
const before = await readBuildConfig(projectDir)
|
|
19
19
|
try {
|
|
20
|
-
|
|
20
|
+
// Turn off the editing-tools audit for this spawned compile so compile() doesn't write
|
|
21
|
+
// a duplicate generic entry — the calling editing tool records its own richer entry.
|
|
22
|
+
await sh`bun compile`
|
|
23
|
+
.cwd(projectDir)
|
|
24
|
+
.env({ ...noColorEnv, BRICKS_CTOR_ENABLE_EDITING_TOOLS: '0' })
|
|
25
|
+
.text()
|
|
21
26
|
} catch (err: any) {
|
|
22
27
|
const stdout = err.stdout?.toString() ?? ''
|
|
23
28
|
const stderr = err.stderr?.toString() ?? ''
|
|
@@ -10,9 +10,9 @@ const noColorEnv = { FORCE_COLOR: '0', NO_COLOR: '1' }
|
|
|
10
10
|
export function register(server: McpServer, projectDir: string) {
|
|
11
11
|
const { dirname } = import.meta
|
|
12
12
|
|
|
13
|
+
// `bun compile` records the resulting config delta to `.bricks/edits.jsonl` itself
|
|
14
|
+
// (see compile() in compile/index.ts), so the spawned output below already reflects it.
|
|
13
15
|
server.tool('compile', {}, async () => {
|
|
14
|
-
// The config-change report is printed by compile() itself (compile/config-diff.ts),
|
|
15
|
-
// so the spawned `bun compile` output below already carries it.
|
|
16
16
|
let log = 'Type checking & Compiling...\n'
|
|
17
17
|
try {
|
|
18
18
|
log += await sh`bun compile`.cwd(projectDir).env(noColorEnv).text()
|
|
@@ -4,16 +4,15 @@ import * as t from '@babel/types'
|
|
|
4
4
|
import { parse as parseSandboxModule } from 'acorn'
|
|
5
5
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
6
6
|
import { format as formatWithOxfmt } from 'oxfmt'
|
|
7
|
-
import {
|
|
7
|
+
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import { z } from 'zod'
|
|
10
10
|
|
|
11
11
|
import { verifyProject } from './_verify'
|
|
12
|
+
import { appendEditRecord, editProvenance } from '../_edits-log'
|
|
12
13
|
|
|
13
14
|
const generate = (generateModule as any).default || generateModule
|
|
14
15
|
|
|
15
|
-
const auditLogIgnoreEntry = '.bricks/edits.jsonl'
|
|
16
|
-
|
|
17
16
|
const oxfmtOptions = {
|
|
18
17
|
trailingComma: 'all',
|
|
19
18
|
tabWidth: 2,
|
|
@@ -646,32 +645,6 @@ const writeParsedFile = async (parsed: ParsedFile) => {
|
|
|
646
645
|
return code
|
|
647
646
|
}
|
|
648
647
|
|
|
649
|
-
const ensureAuditLogIgnored = async (projectDir: string) => {
|
|
650
|
-
const gitignorePath = path.join(projectDir, '.gitignore')
|
|
651
|
-
const content = await readFile(gitignorePath, 'utf8').catch((err: any) => {
|
|
652
|
-
if (err?.code === 'ENOENT') return ''
|
|
653
|
-
throw err
|
|
654
|
-
})
|
|
655
|
-
const ignored = content
|
|
656
|
-
.split(/\r?\n/)
|
|
657
|
-
.map((line) => line.trim())
|
|
658
|
-
.some((line) => line === auditLogIgnoreEntry || line === '.bricks/' || line === '.bricks')
|
|
659
|
-
if (ignored) return
|
|
660
|
-
|
|
661
|
-
const prefix = content && !content.endsWith('\n') ? '\n' : ''
|
|
662
|
-
await writeFile(
|
|
663
|
-
gitignorePath,
|
|
664
|
-
`${content}${prefix}\n# MCP entry-editing audit log\n${auditLogIgnoreEntry}\n`,
|
|
665
|
-
)
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
const appendEditRecord = async (projectDir: string, record: Record<string, unknown>) => {
|
|
669
|
-
const bricksDir = path.join(projectDir, '.bricks')
|
|
670
|
-
await mkdir(bricksDir, { recursive: true })
|
|
671
|
-
await ensureAuditLogIgnored(projectDir)
|
|
672
|
-
await appendFile(path.join(bricksDir, 'edits.jsonl'), `${JSON.stringify(record)}\n`)
|
|
673
|
-
}
|
|
674
|
-
|
|
675
648
|
const responseFor = (result: any): any => ({
|
|
676
649
|
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
|
677
650
|
isError: result.isError || undefined,
|
|
@@ -692,10 +665,7 @@ const runOperation = async (
|
|
|
692
665
|
operation: () => Promise<any>,
|
|
693
666
|
) => {
|
|
694
667
|
const startedAt = new Date().toISOString()
|
|
695
|
-
const provenance =
|
|
696
|
-
session: process.env.BRICKS_CTOR_SESSION_ID || process.env.CODEX_SESSION_ID,
|
|
697
|
-
agent: process.env.BRICKS_CTOR_AGENT_ID || process.env.USER,
|
|
698
|
-
}
|
|
668
|
+
const provenance = editProvenance()
|
|
699
669
|
try {
|
|
700
670
|
const result = await operation()
|
|
701
671
|
await appendEditRecord(projectDir, {
|
|
@@ -5,17 +5,16 @@ import * as t from '@babel/types'
|
|
|
5
5
|
import { parse as parseRuntimeExpression } from 'acorn'
|
|
6
6
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
7
7
|
import { format as formatWithOxfmt } from 'oxfmt'
|
|
8
|
-
import {
|
|
8
|
+
import { readFile, readdir, writeFile } from 'node:fs/promises'
|
|
9
9
|
import path from 'node:path'
|
|
10
10
|
import { z } from 'zod'
|
|
11
11
|
|
|
12
12
|
import { verifyProject } from './_verify'
|
|
13
|
+
import { appendEditRecord, editProvenance } from '../_edits-log'
|
|
13
14
|
|
|
14
15
|
const generate = (generateModule as any).default || generateModule
|
|
15
16
|
const traverse = (traverseModule as any).default || traverseModule
|
|
16
17
|
|
|
17
|
-
const auditLogIgnoreEntry = '.bricks/edits.jsonl'
|
|
18
|
-
|
|
19
18
|
const oxfmtOptions = {
|
|
20
19
|
trailingComma: 'all',
|
|
21
20
|
tabWidth: 2,
|
|
@@ -894,32 +893,6 @@ const writeParsedFile = async (parsed: ParsedFile) => {
|
|
|
894
893
|
return code
|
|
895
894
|
}
|
|
896
895
|
|
|
897
|
-
const appendEditRecord = async (projectDir: string, record: Record<string, unknown>) => {
|
|
898
|
-
const bricksDir = path.join(projectDir, '.bricks')
|
|
899
|
-
await mkdir(bricksDir, { recursive: true })
|
|
900
|
-
await ensureAuditLogIgnored(projectDir)
|
|
901
|
-
await appendFile(path.join(bricksDir, 'edits.jsonl'), `${JSON.stringify(record)}\n`)
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
const ensureAuditLogIgnored = async (projectDir: string) => {
|
|
905
|
-
const gitignorePath = path.join(projectDir, '.gitignore')
|
|
906
|
-
const content = await readFile(gitignorePath, 'utf8').catch((err: any) => {
|
|
907
|
-
if (err?.code === 'ENOENT') return ''
|
|
908
|
-
throw err
|
|
909
|
-
})
|
|
910
|
-
const ignored = content
|
|
911
|
-
.split(/\r?\n/)
|
|
912
|
-
.map((line) => line.trim())
|
|
913
|
-
.some((line) => line === auditLogIgnoreEntry || line === '.bricks/' || line === '.bricks')
|
|
914
|
-
if (ignored) return
|
|
915
|
-
|
|
916
|
-
const prefix = content && !content.endsWith('\n') ? '\n' : ''
|
|
917
|
-
await writeFile(
|
|
918
|
-
gitignorePath,
|
|
919
|
-
`${content}${prefix}\n# MCP entry-editing audit log\n${auditLogIgnoreEntry}\n`,
|
|
920
|
-
)
|
|
921
|
-
}
|
|
922
|
-
|
|
923
896
|
const summarizeTarget = (parsed?: ParsedFile, entry?: ExportEntry) => {
|
|
924
897
|
if (!parsed) return undefined
|
|
925
898
|
const subspace = getSubspaceLabelFromPath(parsed.absPath)
|
|
@@ -941,10 +914,7 @@ const runOperation = async (
|
|
|
941
914
|
entry: input?.entry,
|
|
942
915
|
id: input?.id,
|
|
943
916
|
},
|
|
944
|
-
provenance:
|
|
945
|
-
session: process.env.BRICKS_CTOR_SESSION_ID || process.env.CODEX_SESSION_ID,
|
|
946
|
-
agent: process.env.BRICKS_CTOR_AGENT_ID || process.env.USER,
|
|
947
|
-
},
|
|
917
|
+
provenance: editProvenance(),
|
|
948
918
|
}
|
|
949
919
|
|
|
950
920
|
try {
|
|
@@ -250,7 +250,7 @@ export type DataCommandColorRandom = DataCommand & {
|
|
|
250
250
|
outputs?: Array<DataCalcOutput<'result'> /* target: string */>
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
/* RGBA — Generate color
|
|
253
|
+
/* RGBA — Generate a color from RGBA channels (red/green/blue: 0-255, alpha: 0-1) */
|
|
254
254
|
export type DataCommandColorRgba = DataCommand & {
|
|
255
255
|
__commandName: 'COLOR_RGBA'
|
|
256
256
|
inputs?: Array<
|
|
@@ -22,7 +22,7 @@ export type DataCommandDatetimeDate = DataCommand & {
|
|
|
22
22
|
outputs?: Array<DataCalcOutput<'result'> /* target: number */>
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/* Day — Get day (Sunday is
|
|
25
|
+
/* Day — Get day of week (0-based: Sunday is 0, Saturday is 6) */
|
|
26
26
|
export type DataCommandDatetimeDay = DataCommand & {
|
|
27
27
|
__commandName: 'DATETIME_DAY'
|
|
28
28
|
inputs?: Array<
|
|
@@ -64,7 +64,7 @@ export type DataCommandDatetimeMinute = DataCommand & {
|
|
|
64
64
|
outputs?: Array<DataCalcOutput<'result'> /* target: number */>
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
/* Month — Get month (January is
|
|
67
|
+
/* Month — Get month (0-based: January is 0, December is 11) */
|
|
68
68
|
export type DataCommandDatetimeMonth = DataCommand & {
|
|
69
69
|
__commandName: 'DATETIME_MONTH'
|
|
70
70
|
inputs?: Array<
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { generateDataCalculationMapEditorInfo } from '../calc'
|
|
2
|
+
|
|
3
|
+
describe('generateDataCalculationMapEditorInfo', () => {
|
|
4
|
+
test('indexes source nodes while resolving array input connections', () => {
|
|
5
|
+
const firstSource = { id: 'PROPERTY_BANK_DATA_NODE_first', title: 'First source' }
|
|
6
|
+
const secondSource = { id: 'PROPERTY_BANK_DATA_NODE_second', title: 'Second source' }
|
|
7
|
+
const command = {
|
|
8
|
+
id: 'PROPERTY_BANK_COMMAND_NODE_merge',
|
|
9
|
+
title: 'Merge values',
|
|
10
|
+
inputs: [
|
|
11
|
+
[
|
|
12
|
+
{ id: firstSource.id, port: 'value' },
|
|
13
|
+
{ id: secondSource.id, port: 'value' },
|
|
14
|
+
{ id: 'PROPERTY_BANK_DATA_NODE_missing', port: 'value' },
|
|
15
|
+
],
|
|
16
|
+
],
|
|
17
|
+
outputs: [[{ id: firstSource.id, port: 'value' }]],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const editorInfo = generateDataCalculationMapEditorInfo([firstSource, secondSource, command])
|
|
21
|
+
const commandInfo = editorInfo.find((info) => info.node === command)
|
|
22
|
+
|
|
23
|
+
expect(commandInfo?.position.x).toBe(615)
|
|
24
|
+
})
|
|
25
|
+
})
|
package/utils/calc.ts
CHANGED
|
@@ -28,6 +28,10 @@ export const generateDataCalculationMapEditorInfo = (
|
|
|
28
28
|
DataCalculationData | DataCommand,
|
|
29
29
|
Set<DataCalculationData | DataCommand>
|
|
30
30
|
>()
|
|
31
|
+
const nodeById = new Map<string, DataCalculationData | DataCommand>()
|
|
32
|
+
for (const node of nodes) {
|
|
33
|
+
if ('id' in node) nodeById.set(node.id, node)
|
|
34
|
+
}
|
|
31
35
|
|
|
32
36
|
// Analyze node connections
|
|
33
37
|
nodes.forEach((node) => {
|
|
@@ -48,7 +52,7 @@ export const generateDataCalculationMapEditorInfo = (
|
|
|
48
52
|
if (!connectedTo.has(node)) {
|
|
49
53
|
connectedTo.set(node, new Set())
|
|
50
54
|
}
|
|
51
|
-
const sourceNode =
|
|
55
|
+
const sourceNode = nodeById.get(conn.id)
|
|
52
56
|
if (sourceNode) {
|
|
53
57
|
connectedTo.get(node)!.add(sourceNode)
|
|
54
58
|
}
|
package/utils/id.ts
CHANGED
|
@@ -69,63 +69,65 @@ const makeStableUuid = (type: string, alias?: string) => {
|
|
|
69
69
|
})
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
export const makeId = (type: IdType, aliasOrOpts?: string | IdOptions, opts?: IdOptions) => {
|
|
74
|
-
if (type === 'subspace') {
|
|
75
|
-
throw new Error('Currently subspace is not supported for ID generation, please use a fixed ID')
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const alias = typeof aliasOrOpts === 'string' ? aliasOrOpts : undefined
|
|
79
|
-
const options = typeof aliasOrOpts === 'string' ? opts : (aliasOrOpts ?? opts)
|
|
80
|
-
|
|
81
|
-
let prefix = ''
|
|
72
|
+
const idPrefix = (type: IdType): string => {
|
|
82
73
|
switch (type) {
|
|
83
74
|
case 'animation':
|
|
84
|
-
|
|
85
|
-
break
|
|
75
|
+
return 'ANIMATION_'
|
|
86
76
|
case 'brick':
|
|
87
|
-
|
|
88
|
-
break
|
|
77
|
+
return 'BRICK_'
|
|
89
78
|
case 'dynamic-brick':
|
|
90
|
-
|
|
91
|
-
break
|
|
79
|
+
return 'DYNAMIC_BRICK_'
|
|
92
80
|
case 'canvas':
|
|
93
|
-
|
|
94
|
-
break
|
|
81
|
+
return 'CANVAS_'
|
|
95
82
|
case 'generator':
|
|
96
|
-
|
|
97
|
-
break
|
|
83
|
+
return 'GENERATOR_'
|
|
98
84
|
case 'data':
|
|
99
|
-
|
|
100
|
-
break
|
|
85
|
+
return 'PROPERTY_BANK_DATA_NODE_'
|
|
101
86
|
case 'switch':
|
|
102
|
-
|
|
103
|
-
break
|
|
87
|
+
return 'BRICK_STATE_GROUP_'
|
|
104
88
|
case 'property_bank_command':
|
|
105
|
-
|
|
106
|
-
break
|
|
89
|
+
return 'PROPERTY_BANK_COMMAND_NODE_'
|
|
107
90
|
case 'property_bank_calc':
|
|
108
|
-
|
|
109
|
-
break
|
|
91
|
+
return 'PROPERTY_BANK_COMMAND_MAP_'
|
|
110
92
|
case 'automation_map':
|
|
111
|
-
|
|
112
|
-
break
|
|
93
|
+
return 'AUTOMATION_MAP_'
|
|
113
94
|
case 'test':
|
|
114
|
-
|
|
115
|
-
break
|
|
95
|
+
return 'TEST_'
|
|
116
96
|
case 'test_case':
|
|
117
|
-
|
|
118
|
-
break
|
|
97
|
+
return 'TEST_CASE_'
|
|
119
98
|
case 'test_var':
|
|
120
|
-
|
|
121
|
-
break
|
|
99
|
+
return 'TEST_VAR_'
|
|
122
100
|
default:
|
|
101
|
+
return ''
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Make stable ids by default; explicit snapshotMode: false preserves the random escape hatch.
|
|
106
|
+
export const makeId = (type: IdType, aliasOrOpts?: string | IdOptions, opts?: IdOptions) => {
|
|
107
|
+
if (type === 'subspace') {
|
|
108
|
+
throw new Error('Currently subspace is not supported for ID generation, please use a fixed ID')
|
|
123
109
|
}
|
|
124
110
|
|
|
111
|
+
const alias = typeof aliasOrOpts === 'string' ? aliasOrOpts : undefined
|
|
112
|
+
const options = typeof aliasOrOpts === 'string' ? opts : (aliasOrOpts ?? opts)
|
|
113
|
+
|
|
125
114
|
const useCountFallback = aliasOrOpts === undefined && opts === undefined
|
|
126
115
|
const id =
|
|
127
116
|
alias !== undefined || options?.snapshotMode || useCountFallback
|
|
128
117
|
? makeStableUuid(type, alias)
|
|
129
118
|
: uuid()
|
|
130
|
-
return `${
|
|
119
|
+
return `${idPrefix(type)}${id}`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Deterministic id derived solely from a caller-supplied seed. Unlike makeId it keeps no
|
|
123
|
+
// global state — no incrementing counter, no alias registry — so the same (type, seed)
|
|
124
|
+
// always maps to the same id on every compile and in any process. Compiled artifacts seed
|
|
125
|
+
// their ids this way (e.g. generateCalulationMap's command nodes, seeded by their calc id +
|
|
126
|
+
// structural role) so unchanged source recompiles byte-identically and editing one calc
|
|
127
|
+
// never shifts another's ids. The seed must be unique within a single config.
|
|
128
|
+
export const makeSeededId = (type: IdType, seed: string) => {
|
|
129
|
+
if (type === 'subspace') {
|
|
130
|
+
throw new Error('Currently subspace is not supported for ID generation, please use a fixed ID')
|
|
131
|
+
}
|
|
132
|
+
return `${idPrefix(type)}${uuid({ random: hashToRandomBytes([readApplicationId(), type, seed]) })}`
|
|
131
133
|
}
|