@fugood/bricks-ctor 2.25.0-beta.53 → 2.25.0-beta.56

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.
@@ -335,3 +335,116 @@ describe('generateCalulationMap', () => {
335
335
  expect(result.map[refs[1].id].properties.command).toBe('OBJECT_GET')
336
336
  })
337
337
  })
338
+
339
+ // Direction A: generateCalulationMap also emits a single-node `script_map` the new runtime
340
+ // prefers. These assert that derived graph, independent of the legacy `map` above.
341
+ const scriptNode = (scriptMap) =>
342
+ Object.entries(scriptMap).find(([, node]) => node.type === 'command-node-script')
343
+
344
+ describe('generateScriptMap (script_map)', () => {
345
+ test('empty config produces a single command-node-script node', () => {
346
+ const { script_map } = genMap(baseConfig())
347
+ expect(Object.values(script_map)).toHaveLength(1)
348
+ const [, node] = scriptNode(script_map)
349
+ expect(node.properties.command).toBe('SCRIPT_RUN')
350
+ expect(node.properties.args.code).toBe('return inputs')
351
+ expect(node.properties.args.inputs).toEqual({})
352
+ expect(node.properties.args.outputs).toEqual({})
353
+ expect(node.in).toEqual({})
354
+ expect(node.out).toEqual({})
355
+ })
356
+
357
+ test('wires keyed input ports and input data nodes, preserving order', () => {
358
+ const { script_map } = genMap(baseConfig({ inputs: { a: 'foo.bar', b: 'baz' } }))
359
+ const [scriptId, node] = scriptNode(script_map)
360
+ expect(script_map.a.out.value).toEqual([{ id: scriptId, port: 'a' }])
361
+ expect(script_map.b.out.value).toEqual([{ id: scriptId, port: 'b' }])
362
+ expect(script_map.a.in.change).toBeNull()
363
+ expect(node.in.a).toEqual([{ id: 'a', port: 'value' }])
364
+ expect(node.in.b).toEqual([{ id: 'b', port: 'value' }])
365
+ // args.inputs maps each input port (data-node id) to its inputs-object path, in order.
366
+ expect(node.properties.args.inputs).toEqual({ a: 'foo.bar', b: 'baz' })
367
+ expect(Object.keys(node.properties.args.inputs)).toEqual(['a', 'b'])
368
+ })
369
+
370
+ test('fans out output / error / outputs to distinct ports and target data nodes', () => {
371
+ const { script_map } = genMap(
372
+ baseConfig({ output: 'outNode', error: 'errNode', outputs: { sum: ['pb1', 'pb2'] } }),
373
+ )
374
+ const [scriptId, node] = scriptNode(script_map)
375
+ expect(node.out.output).toEqual([{ id: 'outNode', port: 'change' }])
376
+ expect(node.out.error).toEqual([{ id: 'errNode', port: 'change' }])
377
+ expect(node.out['out:sum']).toEqual([
378
+ { id: 'pb1', port: 'change' },
379
+ { id: 'pb2', port: 'change' },
380
+ ])
381
+ expect(node.properties.args.outputs).toEqual({ 'out:sum': 'sum' })
382
+ expect(script_map.outNode.in.change).toEqual([{ id: scriptId, port: 'output' }])
383
+ expect(script_map.errNode.in.change).toEqual([{ id: scriptId, port: 'error' }])
384
+ expect(script_map.pb1.in.change).toEqual([{ id: scriptId, port: 'out:sum' }])
385
+ expect(script_map.pb2.in.change).toEqual([{ id: scriptId, port: 'out:sum' }])
386
+ })
387
+
388
+ test('manual mode disables every input edge', () => {
389
+ const { script_map } = genMap(
390
+ baseConfig({ trigger_mode: 'manual', inputs: { a: 'foo', b: 'bar' } }),
391
+ )
392
+ const [, node] = scriptNode(script_map)
393
+ expect(node.in.a[0].disable_trigger_command).toBe(true)
394
+ expect(node.in.b[0].disable_trigger_command).toBe(true)
395
+ })
396
+
397
+ test('auto mode honours per-key disabled_triggers', () => {
398
+ const { script_map } = genMap(
399
+ baseConfig({ inputs: { a: 'foo', b: 'bar' }, disabled_triggers: { a: true, b: false } }),
400
+ )
401
+ const [, node] = scriptNode(script_map)
402
+ expect(node.in.a[0].disable_trigger_command).toBe(true)
403
+ expect(node.in.b[0].disable_trigger_command).toBeUndefined()
404
+ })
405
+
406
+ test('a node reused as input and output keeps both edges (manual mode)', () => {
407
+ const { script_map } = genMap(
408
+ baseConfig({
409
+ trigger_mode: 'manual',
410
+ inputs: { shared: 'foo' },
411
+ outputs: { sum: ['shared'] },
412
+ }),
413
+ )
414
+ const [scriptId] = scriptNode(script_map)
415
+ // Input side (out.value) is preserved alongside the output side (in.change).
416
+ expect(script_map.shared.out.value).toEqual([{ id: scriptId, port: 'shared' }])
417
+ expect(script_map.shared.in.change).toEqual([{ id: scriptId, port: 'out:sum' }])
418
+ })
419
+
420
+ test('outputs port names never collide with the reserved output port', () => {
421
+ const { script_map } = genMap(baseConfig({ output: 'outNode', outputs: { output: ['pb1'] } }))
422
+ const [, node] = scriptNode(script_map)
423
+ // The whole-return-value port stays `output`; an outputs path literally named "output"
424
+ // is namespaced to `out:output`.
425
+ expect(node.out.output).toEqual([{ id: 'outNode', port: 'change' }])
426
+ expect(node.out['out:output']).toEqual([{ id: 'pb1', port: 'change' }])
427
+ expect(node.properties.args.outputs).toEqual({ 'out:output': 'output' })
428
+ })
429
+
430
+ test('script node id is byte-stable across recompiles and unique per calc', () => {
431
+ const config = baseConfig({ inputs: { a: 'foo' }, outputs: { out: ['pb1'] } })
432
+ const first = generateCalulationMap(config, CALC_ID)
433
+ const second = generateCalulationMap(config, CALC_ID)
434
+ expect(Object.keys(second.script_map)).toEqual(Object.keys(first.script_map))
435
+ const [idA] = scriptNode(first.script_map)
436
+ const [idB] = scriptNode(
437
+ generateCalulationMap(
438
+ config,
439
+ 'PROPERTY_BANK_COMMAND_MAP_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
440
+ ).script_map,
441
+ )
442
+ expect(idA).not.toBe(idB)
443
+ const uuid = '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'
444
+ expect(idA).toMatch(new RegExp(`^PROPERTY_BANK_COMMAND_NODE_${uuid}$`))
445
+ })
446
+
447
+ test('validateConfig still guards the script_map path (auto mode overlap)', () => {
448
+ expect(() => genMap(baseConfig({ inputs: { a: 'foo' }, error: 'a' }))).toThrow(/key: error/)
449
+ })
450
+ })
package/compile/index.ts CHANGED
@@ -4,7 +4,7 @@ import upperFirst from 'lodash/upperFirst'
4
4
  import snakeCase from 'lodash/snakeCase'
5
5
  import omit from 'lodash/omit'
6
6
  import { parse as parseAST } from 'acorn'
7
- import type { ExportNamedDeclaration, FunctionDeclaration } from 'acorn'
7
+ import type { BlockStatement, ExportNamedDeclaration, FunctionDeclaration } from 'acorn'
8
8
  import escodegen from 'escodegen'
9
9
  import { makeSeededId } from '../utils/id'
10
10
  import { generateCalulationMap } from './util'
@@ -126,17 +126,28 @@ const compileProperty = (property, errorReference: string, result = {}) => {
126
126
  const compileScriptCalculationCode = (code = '') => {
127
127
  try {
128
128
  const program = parseAST(code, { sourceType: 'module', ecmaVersion: 2020 })
129
- // export function main() { ... }
130
- const declarationBody = (
131
- (program.body[0] as ExportNamedDeclaration).declaration as FunctionDeclaration
132
- )?.body
133
- return escodegen.generate(declarationBody, {
134
- format: {
135
- indent: { style: ' ' },
136
- semicolons: false,
129
+ // The stored config holds the bare function body, which codegen re-wraps as
130
+ // `export function main() { <code> }`. Unwrap it back to that body here.
131
+ let block = ((program.body[0] as ExportNamedDeclaration).declaration as FunctionDeclaration)
132
+ ?.body as BlockStatement | undefined
133
+ if (!block) return code || ''
134
+ // Earlier versions emitted the whole BlockStatement (braces included), so every
135
+ // compile -> codegen round-trip nested the body in one more `{ }`. Emit the inner
136
+ // statements instead, collapsing any wrapper blocks previous round-trips added so
137
+ // existing over-wrapped sandboxes heal on the next compile.
138
+ while (block.body.length === 1 && block.body[0].type === 'BlockStatement') {
139
+ block = block.body[0] as BlockStatement
140
+ }
141
+ return escodegen.generate(
142
+ { type: 'Program', body: block.body },
143
+ {
144
+ format: {
145
+ indent: { style: ' ' },
146
+ semicolons: false,
147
+ },
148
+ comment: true,
137
149
  },
138
- comment: true,
139
- })
150
+ )
140
151
  } catch {
141
152
  return code || ''
142
153
  }
package/compile/util.ts CHANGED
@@ -54,6 +54,104 @@ const padding = 15
54
54
  const layerXInterval = 300
55
55
  const layerYInterval = 150
56
56
 
57
+ // Direction A — first-class script node (see local-plan/PLAN-data-calculation-refactor.md).
58
+ //
59
+ // `generateCalulationMap` keeps emitting the legacy OBJECT_SET → SANDBOX_RUN_JAVASCRIPT →
60
+ // SANDBOX_GET_*/OBJECT_GET expansion in `map` (consumed by older runtimes). In parallel,
61
+ // `generateScriptMap` derives an equivalent single-node graph in `script_map` that a newer
62
+ // runtime prefers: one `command-node-script` node that assembles `inputs` from keyed input
63
+ // ports and fans out `error` / `output` / each `outputs` path directly to the target data
64
+ // nodes. Both are byte-stable functions of `script_config`, so codegen (which only reads
65
+ // `script_config`) round-trips losslessly and recompiling unchanged source re-derives an
66
+ // identical `script_map`.
67
+ //
68
+ // Trigger semantics stay edge-level (`disable_trigger_command` on the script node's input
69
+ // edges, same as the legacy OBJECT_SET inputs) so the circular-reference check and the
70
+ // manual-trigger overlay keep working unchanged — only the node count collapses N+3 → 1.
71
+ const generateScriptMap = (config: ScriptConfig, calcId: string) => {
72
+ const scriptId = makeSeededId('property_bank_command', `${calcId}:script`)
73
+ const map: Record<string, any> = {}
74
+
75
+ // Compiled run-time config for the script node. `inputs` maps each input port name (the
76
+ // source data-node id) to its OBJECT_SET path; `outputs` maps each fan-out port name to the
77
+ // OBJECT_GET path it extracts from the return value. The runtime reads these from
78
+ // properties.args, keeping the node `value` reserved purely for incoming input-port values.
79
+ const inputArgs: Record<string, string> = {}
80
+ const outputArgs: Record<string, string> = {}
81
+
82
+ const scriptIn: Record<string, any> = {}
83
+ const scriptOut: Record<string, any> = {}
84
+
85
+ // One in-port per input, keyed by the source data-node id (unique per input, so each input
86
+ // keeps its own `disable_trigger_command` flag — a shared path could not). Manual mode
87
+ // disables every input edge; auto mode honours per-key `disabled_triggers`.
88
+ Object.entries(config.inputs).forEach(([dataNodeId, path]) => {
89
+ inputArgs[dataNodeId] = path
90
+ scriptIn[dataNodeId] = [
91
+ {
92
+ id: dataNodeId,
93
+ port: 'value',
94
+ disable_trigger_command:
95
+ config.trigger_mode === 'manual' || config.disabled_triggers?.[dataNodeId] || undefined,
96
+ },
97
+ ]
98
+ map[dataNodeId] = {
99
+ type: 'data-node',
100
+ properties: {},
101
+ in: { change: null },
102
+ out: { value: [{ id: scriptId, port: dataNodeId }] },
103
+ }
104
+ })
105
+
106
+ // Wire an output target data-node's `change` port back from a script out-port, preserving
107
+ // any `out.value` it already has (a node reused as both input and output — allowed in manual
108
+ // mode). Mirrors the last-wins reuse handling in generateCalulationMap's outputs section.
109
+ const wireOutputTarget = (dataNodeId: string, scriptPort: string) => {
110
+ const existing = map[dataNodeId]
111
+ map[dataNodeId] = {
112
+ type: 'data-node',
113
+ properties: {},
114
+ in: { change: [{ id: scriptId, port: scriptPort }] },
115
+ out: { value: existing?.out?.value ?? null },
116
+ }
117
+ }
118
+
119
+ if (config.error) {
120
+ scriptOut.error = [{ id: config.error, port: 'change' }]
121
+ wireOutputTarget(config.error, 'error')
122
+ }
123
+ if (config.output) {
124
+ scriptOut.output = [{ id: config.output, port: 'change' }]
125
+ wireOutputTarget(config.output, 'output')
126
+ }
127
+ // `out:`-prefixed port names keep `outputs` paths from colliding with the reserved
128
+ // `error` / `output` ports (a path could legitimately be the string "output").
129
+ Object.entries(config.outputs).forEach(([path, pbList]) => {
130
+ const portName = `out:${path}`
131
+ outputArgs[portName] = path
132
+ scriptOut[portName] = pbList.map((pb) => ({ id: pb, port: 'change' }))
133
+ pbList.forEach((pb) => wireOutputTarget(pb, portName))
134
+ })
135
+
136
+ map[scriptId] = {
137
+ type: 'command-node-script',
138
+ title: 'Command: SCRIPT_RUN',
139
+ properties: {
140
+ command: 'SCRIPT_RUN',
141
+ args: {
142
+ code: config.code,
143
+ enable_async: config.enable_async,
144
+ inputs: inputArgs,
145
+ outputs: outputArgs,
146
+ },
147
+ },
148
+ in: scriptIn,
149
+ out: scriptOut,
150
+ }
151
+
152
+ return map
153
+ }
154
+
57
155
  // `calcId` (the owning script calc's stable id) seeds every derived command-node id, so an
58
156
  // unchanged calc recompiles to identical ids and editing one calc never shifts another's —
59
157
  // keeping the compiled config byte-stable for change detection. See makeSeededId in utils/id.
@@ -219,6 +317,8 @@ export const generateCalulationMap = (config: ScriptConfig, calcId: string) => {
219
317
  )
220
318
 
221
319
  return {
320
+ // Newer runtimes prefer this single-node graph; older runtimes fall back to `map` below.
321
+ script_map: generateScriptMap(config, calcId),
222
322
  map: {
223
323
  ...inputs.map,
224
324
  [sandboxId]: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fugood/bricks-ctor",
3
- "version": "2.25.0-beta.53",
3
+ "version": "2.25.0-beta.56",
4
4
  "main": "index.ts",
5
5
  "scripts": {
6
6
  "typecheck": "tsc --noEmit",
@@ -29,5 +29,5 @@
29
29
  "peerDependencies": {
30
30
  "oxfmt": "^0.36.0"
31
31
  },
32
- "gitHead": "b08f881540787ad817115880ebcee1487a0a81ab"
32
+ "gitHead": "5d3de40404adc334a9a64046c5514e1cd86132db"
33
33
  }
@@ -0,0 +1,108 @@
1
+ import { execFile as execFileCallback } from 'node:child_process'
2
+ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join, resolve } from 'node:path'
5
+ import { promisify } from 'node:util'
6
+
7
+ // bricks-project's tsconfig has no @types/jest, so declare the globals this test uses
8
+ // (mirrors tools/__tests__/_mcp-config.test.ts).
9
+ declare const describe: (name: string, fn: () => void) => void
10
+ declare const it: (name: string, fn: () => void | Promise<void>, timeout?: number) => void
11
+ declare const expect: (actual: unknown) => {
12
+ toBe: (expected: unknown) => void
13
+ }
14
+
15
+ const execFile = promisify(execFileCallback)
16
+
17
+ async function run(cmd: string, args: string[], cwd: string, env?: NodeJS.ProcessEnv) {
18
+ return execFile(cmd, args, {
19
+ cwd,
20
+ env: { ...process.env, ...env },
21
+ maxBuffer: 1024 * 1024,
22
+ })
23
+ }
24
+
25
+ async function initGitProject(projectDir: string) {
26
+ await run('git', ['init', '--initial-branch=master'], projectDir)
27
+ await run('git', ['config', 'user.email', 'test@example.com'], projectDir)
28
+ await run('git', ['config', 'user.name', 'Test User'], projectDir)
29
+ await writeFile(join(projectDir, 'application.json'), JSON.stringify({ id: 'app-1' }))
30
+ await run('git', ['add', '.'], projectDir)
31
+ await run('git', ['commit', '-m', 'init'], projectDir)
32
+ }
33
+
34
+ async function writeFakeBricks(binDir: string) {
35
+ const payload = {
36
+ files: [
37
+ {
38
+ name: 'subspaces/subspace-13/data-calc/data-calculation-0.sandbox.js',
39
+ input: 'export const value = 1\n',
40
+ formatable: false,
41
+ },
42
+ ],
43
+ lastCommitId: 'server-commit',
44
+ }
45
+ const bricksPath = join(binDir, 'bricks')
46
+ await writeFile(
47
+ bricksPath,
48
+ `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(JSON.stringify(payload))})\n`,
49
+ )
50
+ await chmod(bricksPath, 0o755)
51
+ }
52
+
53
+ describe('pull.ts', () => {
54
+ it('creates parent directories before writing pulled project files', async () => {
55
+ const root = await mkdtemp(join(tmpdir(), 'bricks-project-pull-'))
56
+ const projectDir = join(root, 'project')
57
+ const binDir = join(root, 'bin')
58
+
59
+ try {
60
+ await mkdir(projectDir)
61
+ await mkdir(binDir)
62
+ await initGitProject(projectDir)
63
+ await writeFakeBricks(binDir)
64
+
65
+ const pullScript = resolve(__dirname, '../pull.ts')
66
+ await run('bun', [pullScript, '--force'], projectDir, {
67
+ PATH: `${binDir}:${process.env.PATH || ''}`,
68
+ })
69
+
70
+ const pulledFile = join(
71
+ projectDir,
72
+ 'subspaces/subspace-13/data-calc/data-calculation-0.sandbox.js',
73
+ )
74
+ expect(await readFile(pulledFile, 'utf8')).toBe('export const value = 1\n')
75
+ } finally {
76
+ await rm(root, { recursive: true, force: true })
77
+ }
78
+ }, 30000)
79
+
80
+ it('merges pulled project files back into the starting branch', async () => {
81
+ const root = await mkdtemp(join(tmpdir(), 'bricks-project-pull-'))
82
+ const projectDir = join(root, 'project')
83
+ const binDir = join(root, 'bin')
84
+
85
+ try {
86
+ await mkdir(projectDir)
87
+ await mkdir(binDir)
88
+ await initGitProject(projectDir)
89
+ await writeFakeBricks(binDir)
90
+
91
+ const pullScript = resolve(__dirname, '../pull.ts')
92
+ await run('bun', [pullScript], projectDir, {
93
+ PATH: `${binDir}:${process.env.PATH || ''}`,
94
+ })
95
+
96
+ const { stdout: branch } = await run('git', ['branch', '--show-current'], projectDir)
97
+ const pulledFile = join(
98
+ projectDir,
99
+ 'subspaces/subspace-13/data-calc/data-calculation-0.sandbox.js',
100
+ )
101
+
102
+ expect(branch.trim()).toBe('master')
103
+ expect(await readFile(pulledFile, 'utf8')).toBe('export const value = 1\n')
104
+ } finally {
105
+ await rm(root, { recursive: true, force: true })
106
+ }
107
+ }, 30000)
108
+ })
package/tools/pull.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { readdir, readFile, unlink, writeFile } from 'node:fs/promises'
1
+ import { mkdir, readdir, readFile, unlink, writeFile } from 'node:fs/promises'
2
2
  import { existsSync } from 'node:fs'
3
- import { join, relative } from 'node:path'
3
+ import { dirname, join, relative } from 'node:path'
4
4
  import { format } from 'oxfmt'
5
5
  import { sh } from './_shell'
6
6
  import { extractCliErrorMessage } from './_cli-error'
@@ -86,7 +86,11 @@ const branchName = isModule
86
86
  ? 'BRICKS_PROJECT_try-pull-module'
87
87
  : 'BRICKS_PROJECT_try-pull-application'
88
88
 
89
+ let landingBranch = ''
89
90
  if (isGitRepo && !force) {
91
+ landingBranch = (await sh`cd ${cwd} && git branch --show-current`.text()).trim()
92
+ if (!landingBranch) throw new Error('Cannot pull from a detached HEAD')
93
+
90
94
  console.log(`Checking commit ${baseCommitId}...`)
91
95
  const found = (await sh`cd ${cwd} && git rev-list -1 ${baseCommitId}`.nothrow().text())
92
96
  .trim()
@@ -100,8 +104,8 @@ if (isGitRepo && !force) {
100
104
 
101
105
  // When the base commit isn't reachable in this clone (server stored a
102
106
  // nanoid, or the commit was pruned), fall back to forking from current
103
- // HEAD. The downstream merge into main collapses both paths into the
104
- // same result, just with different merge bases.
107
+ // HEAD. The downstream merge into the starting branch collapses both paths
108
+ // into the same result, just with different merge bases.
105
109
  if (found) {
106
110
  await sh`cd ${cwd} && git checkout -b ${branchName} ${baseCommitId}`.nothrow()
107
111
  } else {
@@ -143,7 +147,9 @@ await Promise.all(
143
147
  const result = await format(file.name, file.input, oxfmtConfig)
144
148
  content = result.code
145
149
  }
146
- return writeFile(`${cwd}/${file.name}`, content)
150
+ const target = join(cwd, file.name)
151
+ await mkdir(dirname(target), { recursive: true })
152
+ return writeFile(target, content)
147
153
  }),
148
154
  )
149
155
 
@@ -160,11 +166,11 @@ if (isGitRepo) {
160
166
  await sh`cd ${cwd} && git ${commitArgs}`
161
167
  }
162
168
  if (!force) {
163
- // Land the pulled commits on main with a single 3-way merge using
169
+ // Land the pulled commits on the starting branch with a single 3-way merge using
164
170
  // baseCommit as the merge base. The user doesn't have to manage a side
165
- // branch, and conflicts (if any) land in the working tree on main where
171
+ // branch, and conflicts (if any) land in the working tree on the starting branch where
166
172
  // auto-compile surfaces them as typecheck errors to resolve in-place.
167
- await sh`cd ${cwd} && git checkout main`
173
+ await sh`cd ${cwd} && git checkout ${landingBranch}`
168
174
  const mergeResult = await sh`cd ${cwd} && git merge ${branchName} --no-edit`.nothrow()
169
175
  if (mergeResult.exitCode !== 0) {
170
176
  // Conflict markers are in the working tree — commit them so the tree
@@ -174,7 +180,7 @@ if (isGitRepo) {
174
180
  await sh`cd ${cwd} && git add .`
175
181
  const conflictArgs = await buildCommitArgs(
176
182
  cwd,
177
- ['chore(project): merge with conflicts (resolve in main)'],
183
+ [`chore(project): merge with conflicts (resolve in ${landingBranch})`],
178
184
  ['--no-verify'],
179
185
  )
180
186
  await sh`cd ${cwd} && git ${conflictArgs}`
package/utils/data.ts CHANGED
@@ -83,7 +83,7 @@ type SystemDataName =
83
83
  type SystemDataInfo = {
84
84
  name: SystemDataName
85
85
  id: string
86
- type: 'string' | 'number' | 'bool' | 'boolean' | 'array' | 'object' | 'any'
86
+ type: Data['type']
87
87
  title?: string
88
88
  description?: string
89
89
  schema?: object