@fugood/bricks-ctor 2.25.0-beta.50 → 2.25.0-beta.52

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.
@@ -14,7 +14,7 @@ import os from 'node:os'
14
14
  import path from 'node:path'
15
15
 
16
16
  import { sh } from '../../tools/_shell'
17
- import { checkConfig, compile } from '../index'
17
+ import { checkConfig, compile, compileTestCase } from '../index'
18
18
 
19
19
  const SUBSPACE_ID = 'SUBSPACE_00000000-0000-0000-0000-000000000001'
20
20
  const CANVAS_ID = 'CANVAS_00000000-0000-0000-0000-000000000001'
@@ -362,4 +362,25 @@ describe('compile linked-module subspace', () => {
362
362
  expect(first.subspace_map[LINKED_SUBSPACE_ID].root_canvas_id).toBe(firstKeys[0])
363
363
  expect(second.subspace_map[LINKED_SUBSPACE_ID].root_canvas_id).toBe(firstKeys[0])
364
364
  })
365
+
366
+ test('compileTestCase resolves a jump_to getter to its id (survives serialization)', () => {
367
+ // Regression: jump_cond[].jump_to may be a getter (() => TestCase) for dynamic case ids — the
368
+ // project generator emits `jump_to: () => caseVar`. compileTestCase passed it through verbatim
369
+ // (unlike the `run` array, which resolves getters via compileRunElement), so JSON.stringify of
370
+ // the compiled config dropped the function and the conditional jump silently vanished.
371
+ const compiled = compileTestCase({
372
+ __typename: 'TestCase',
373
+ id: 'TEST_CASE_src',
374
+ name: 'src',
375
+ run: [],
376
+ jump_cond: [
377
+ { type: 'status', status: 'finished', jump_to: () => ({ id: 'TEST_CASE_target' }) },
378
+ { type: 'status', status: 'failed', jump_to: 'TEST_CASE_literal' },
379
+ ],
380
+ })
381
+ // The compiled config is serialized to disk, so the value must survive JSON round-tripping.
382
+ const serialized = JSON.parse(JSON.stringify(compiled))
383
+ expect(serialized.jump_cond[0].jump_to).toBe('TEST_CASE_target') // getter resolved to its id
384
+ expect(serialized.jump_cond[1].jump_to).toBe('TEST_CASE_literal') // string passes through
385
+ })
365
386
  })
@@ -65,6 +65,26 @@ describe('validateConfig', () => {
65
65
  expect(() => validateConfig(baseConfig())).not.toThrow()
66
66
  })
67
67
 
68
+ // Regression: the overlap checks tested `config.inputs[id]` (the OBJECT_SET path) for
69
+ // truthiness instead of asking whether `id` is an input key. An empty path ('' is
70
+ // OBJECT_SET's default "set whole object") is a legitimate input value, so an input with
71
+ // an empty path that also targets output/error/outputs slipped the guard and compiled into
72
+ // a node double-wired as both input and output.
73
+ test('throws when output collides with an input whose path is empty', () => {
74
+ const config = baseConfig({ inputs: { a: '' }, output: 'a' })
75
+ expect(() => validateConfig(config)).toThrow(/key: output/)
76
+ })
77
+
78
+ test('throws when error collides with an input whose path is empty', () => {
79
+ const config = baseConfig({ inputs: { a: '' }, error: 'a' })
80
+ expect(() => validateConfig(config)).toThrow(/key: error/)
81
+ })
82
+
83
+ test('throws when an outputs entry references an input whose path is empty', () => {
84
+ const config = baseConfig({ inputs: { a: '' }, outputs: { x: ['a'] } })
85
+ expect(() => validateConfig(config)).toThrow(/key: outputs/)
86
+ })
87
+
68
88
  test('throws when error and output target the same id', () => {
69
89
  const config = baseConfig({ output: 'shared', error: 'shared' })
70
90
  expect(() => validateConfig(config)).toThrow(/key: error\/output/)
package/compile/index.ts CHANGED
@@ -584,7 +584,7 @@ function compileRunArray(run: unknown[]): unknown[] {
584
584
  /**
585
585
  * Compile typed TestCase to raw format (strips __typename)
586
586
  */
587
- const compileTestCase = (testCase: TestCase) => ({
587
+ export const compileTestCase = (testCase: TestCase) => ({
588
588
  id: testCase.id,
589
589
  name: testCase.name,
590
590
  hide_short_ref: testCase.hideShortRef,
@@ -606,7 +606,10 @@ const compileTestCase = (testCase: TestCase) => ({
606
606
  variable: cond.variable,
607
607
  operator: cond.operator,
608
608
  value: cond.value,
609
- jump_to: cond.jump_to,
609
+ // `jump_to` may be a getter (() => TestCase) for dynamic case ids (the project generator
610
+ // emits this form). Resolve it to its id like the `run` array does — otherwise the function
611
+ // is JSON-serialized to nothing and the conditional jump silently vanishes from the config.
612
+ jump_to: compileRunElement(cond.jump_to),
610
613
  }
611
614
  }),
612
615
  })
package/compile/util.ts CHANGED
@@ -18,13 +18,18 @@ const errorMsg = 'Not allow duplicate set property id between inputs / outputs /
18
18
  export const validateConfig = (config: ScriptConfig) => {
19
19
  // Skip input/output overlap validation in manual mode (allows same data node in both)
20
20
  if (config.trigger_mode === 'manual') return
21
- if (config.error && config.inputs[config.error]) {
21
+ // `inputs` is keyed by data-node id with the OBJECT_SET path as the *value*. The overlap
22
+ // check is "is this id also an input?" — a key-existence question — so test key presence,
23
+ // not the path's truthiness. An empty path ('' = OBJECT_SET's default "set whole object")
24
+ // is a legitimate input value that would otherwise slip the overlap guard.
25
+ const isInput = (id: string) => Object.prototype.hasOwnProperty.call(config.inputs, id)
26
+ if (config.error && isInput(config.error)) {
22
27
  throw new Error(`${errorMsg}. key: error`)
23
28
  }
24
- if (config.output && config.inputs[config.output]) {
29
+ if (config.output && isInput(config.output)) {
25
30
  throw new Error(`${errorMsg}. key: output`)
26
31
  }
27
- if (Object.values(config.outputs).some((value) => value.some((id) => config.inputs[id]))) {
32
+ if (Object.values(config.outputs).some((value) => value.some(isInput))) {
28
33
  throw new Error(`${errorMsg}. key: outputs`)
29
34
  }
30
35
  // The same data-node id reused across the output-side targets (output / error / outputs)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fugood/bricks-ctor",
3
- "version": "2.25.0-beta.50",
3
+ "version": "2.25.0-beta.52",
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.50",
14
+ "@fugood/bricks-cli": "^2.25.0-beta.52",
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": "3614445cd0166e40634f69c6238299bb63f4f597"
32
+ "gitHead": "f6869285ea21123990934411ea190d9fbf12ea7f"
33
33
  }
@@ -0,0 +1,67 @@
1
+ import { mkdtemp, readFile, writeFile, rm } from 'fs/promises'
2
+ import { tmpdir } from 'os'
3
+ import * as path from 'path'
4
+ import { handleMcpConfigOverride } from '../_mcp-config'
5
+
6
+ // bricks-project's tsconfig has no @types/jest, so declare the globals this test uses
7
+ // (mirrors tools/__tests__/_cli-error.test.ts).
8
+ declare const describe: (name: string, fn: () => void) => void
9
+ declare const it: (name: string, fn: () => void | Promise<void>) => void
10
+ declare const expect: (actual: unknown) => {
11
+ toBe: (expected: unknown) => void
12
+ toEqual: (expected: unknown) => void
13
+ }
14
+
15
+ const projectMcpServer = { command: 'bun', args: ['mcp-server.ts'] }
16
+
17
+ const withTempMcp = async (contents: string | null, fn: (p: string) => Promise<void>) => {
18
+ const dir = await mkdtemp(path.join(tmpdir(), 'bf-mcp-'))
19
+ const mcpPath = path.join(dir, '.mcp.json')
20
+ if (contents !== null) await writeFile(mcpPath, contents)
21
+ try {
22
+ await fn(mcpPath)
23
+ } finally {
24
+ await rm(dir, { recursive: true, force: true })
25
+ }
26
+ }
27
+
28
+ describe('handleMcpConfigOverride', () => {
29
+ it('injects bricks-ctor while preserving the user other servers', async () => {
30
+ await withTempMcp(
31
+ JSON.stringify({ mcpServers: { smartbear: { command: 'npx' } } }),
32
+ async (p) => {
33
+ await handleMcpConfigOverride(p, projectMcpServer)
34
+ const result = JSON.parse(await readFile(p, 'utf-8'))
35
+ expect(result.mcpServers.smartbear).toEqual({ command: 'npx' })
36
+ expect(result.mcpServers['bricks-ctor']).toEqual(projectMcpServer)
37
+ },
38
+ )
39
+ })
40
+
41
+ it('does not clobber a malformed .mcp.json (regression: user servers were wiped)', async () => {
42
+ // trailing comma -> JSON.parse throws; the whole file used to be overwritten with the
43
+ // default, silently deleting every other configured server.
44
+ const malformed = '{ "mcpServers": { "smartbear": { "command": "npx" }, } }'
45
+ await withTempMcp(malformed, async (p) => {
46
+ await handleMcpConfigOverride(p, projectMcpServer)
47
+ expect(await readFile(p, 'utf-8')).toBe(malformed)
48
+ })
49
+ })
50
+
51
+ it('backfills mcpServers when missing without dropping other top-level keys', async () => {
52
+ await withTempMcp(JSON.stringify({ otherKey: 'keep-me' }), async (p) => {
53
+ await handleMcpConfigOverride(p, projectMcpServer)
54
+ const result = JSON.parse(await readFile(p, 'utf-8'))
55
+ expect(result.otherKey).toBe('keep-me')
56
+ expect(result.mcpServers['bricks-ctor']).toEqual(projectMcpServer)
57
+ })
58
+ })
59
+
60
+ it('writes a default config when no .mcp.json exists', async () => {
61
+ await withTempMcp(null, async (p) => {
62
+ await handleMcpConfigOverride(p, projectMcpServer)
63
+ const result = JSON.parse(await readFile(p, 'utf-8'))
64
+ expect(result.mcpServers['bricks-ctor']).toEqual(projectMcpServer)
65
+ })
66
+ })
67
+ })
@@ -0,0 +1,42 @@
1
+ import { readFile, writeFile, stat } from 'fs/promises'
2
+
3
+ const exists = async (f: string) => {
4
+ try {
5
+ await stat(f)
6
+ return true
7
+ } catch {
8
+ return false
9
+ }
10
+ }
11
+
12
+ // Refresh the `bricks-ctor` entry in a project's `.mcp.json` while preserving every other MCP
13
+ // server the user configured. A malformed `.mcp.json` is left untouched (with a warning)
14
+ // rather than overwritten with the default — clobbering it would silently delete the user's
15
+ // other server entries. This mirrors `setupClaudeAutoMode`'s handling of a malformed
16
+ // `settings.local.json` in postinstall.ts.
17
+ export const handleMcpConfigOverride = async (mcpConfigPath: string, projectMcpServer: object) => {
18
+ let mcpConfig: { mcpServers: Record<string, unknown> }
19
+ if (await exists(mcpConfigPath)) {
20
+ let parsed: unknown
21
+ try {
22
+ parsed = JSON.parse(await readFile(mcpConfigPath, 'utf-8'))
23
+ } catch {
24
+ console.warn(`Skipping .mcp.json update; ${mcpConfigPath} is not valid JSON`)
25
+ return
26
+ }
27
+ mcpConfig =
28
+ parsed && typeof parsed === 'object'
29
+ ? (parsed as { mcpServers: Record<string, unknown> })
30
+ : { mcpServers: {} }
31
+ if (!mcpConfig.mcpServers || typeof mcpConfig.mcpServers !== 'object') {
32
+ mcpConfig.mcpServers = {}
33
+ }
34
+ mcpConfig.mcpServers['bricks-ctor'] = projectMcpServer
35
+ delete mcpConfig.mcpServers['bricks-project']
36
+ } else {
37
+ mcpConfig = { mcpServers: { 'bricks-ctor': projectMcpServer } }
38
+ }
39
+
40
+ await writeFile(mcpConfigPath, `${JSON.stringify(mcpConfig, null, 2)}\n`)
41
+ console.log(`Updated ${mcpConfigPath}`)
42
+ }
@@ -12,6 +12,7 @@ import {
12
12
  } from 'fs/promises'
13
13
  import * as path from 'path'
14
14
  import TOML from '@iarna/toml'
15
+ import { handleMcpConfigOverride } from './_mcp-config'
15
16
 
16
17
  const cwd = process.cwd()
17
18
  const projectSkillsDir = path.join(cwd, '.bricks', 'skills')
@@ -80,41 +81,13 @@ type CodexMcpConfig = {
80
81
  mcp_servers: Record<string, typeof codexProjectMcpServer | typeof projectMcpServer>
81
82
  }
82
83
 
83
- // Claude Code and AGENTS.md projects both use the shared project .mcp.json file.
84
- const defaultMcpConfig = {
85
- mcpServers: {
86
- 'bricks-ctor': projectMcpServer,
87
- },
88
- }
89
-
90
- const handleMcpConfigOverride = async (mcpConfigPath: string) => {
91
- let mcpConfig: { mcpServers: Record<string, typeof projectMcpServer> } | null = null
92
- if (await exists(mcpConfigPath)) {
93
- const configStr = await readFile(mcpConfigPath, 'utf-8')
94
- try {
95
- mcpConfig = JSON.parse(configStr)
96
- if (!mcpConfig?.mcpServers) throw new Error('mcpServers is not defined')
97
- mcpConfig.mcpServers['bricks-ctor'] = projectMcpServer
98
- delete mcpConfig.mcpServers['bricks-project']
99
- } catch {
100
- mcpConfig = defaultMcpConfig
101
- }
102
- } else {
103
- mcpConfig = defaultMcpConfig
104
- }
105
-
106
- await writeFile(mcpConfigPath, `${JSON.stringify(mcpConfig, null, 2)}\n`)
107
-
108
- console.log(`Updated ${mcpConfigPath}`)
109
- }
110
-
111
84
  const hasClaudeCode = await exists(`${cwd}/CLAUDE.md`)
112
85
  const hasAgentsMd = await exists(`${cwd}/AGENTS.md`)
113
86
 
114
87
  if (hasClaudeCode || hasAgentsMd) {
115
88
  // Keep the workspace-level JSON MCP config aligned for tools that read .mcp.json.
116
89
  const mcpConfigPath = `${cwd}/.mcp.json`
117
- await handleMcpConfigOverride(mcpConfigPath)
90
+ await handleMcpConfigOverride(mcpConfigPath, projectMcpServer)
118
91
  }
119
92
 
120
93
  const copyMissingSkills = async (sourceDir: string, targetDir: string) => {
@@ -282,18 +255,25 @@ if (hasAgentsMd) {
282
255
  }
283
256
 
284
257
  const handleCodexMcpConfigOverride = async (mcpConfigPath: string) => {
285
- let mcpConfig: CodexMcpConfig | null = null
258
+ let mcpConfig: CodexMcpConfig
286
259
  if (await exists(mcpConfigPath)) {
287
- const configStr = await readFile(mcpConfigPath, 'utf-8')
260
+ let parsed: unknown
288
261
  try {
289
- const parsed = TOML.parse(configStr) as Partial<CodexMcpConfig>
290
- if (!parsed?.mcp_servers) throw new Error('mcp_servers is not defined')
291
- mcpConfig = { mcp_servers: parsed.mcp_servers }
292
- mcpConfig.mcp_servers['bricks-ctor'] = codexProjectMcpServer
293
- delete mcpConfig.mcp_servers['bricks-project']
262
+ parsed = TOML.parse(await readFile(mcpConfigPath, 'utf-8'))
294
263
  } catch {
295
- mcpConfig = defaultCodexMcpConfig
264
+ // A malformed config is left untouched (with a warning) rather than overwritten with
265
+ // the default — clobbering it would silently delete the user's other server entries.
266
+ // Mirrors handleMcpConfigOverride's handling of a malformed .mcp.json.
267
+ console.warn(`Skipping .codex/config.toml update; ${mcpConfigPath} is not valid TOML`)
268
+ return
269
+ }
270
+ mcpConfig =
271
+ parsed && typeof parsed === 'object' ? (parsed as CodexMcpConfig) : { mcp_servers: {} }
272
+ if (!mcpConfig.mcp_servers || typeof mcpConfig.mcp_servers !== 'object') {
273
+ mcpConfig.mcp_servers = {}
296
274
  }
275
+ mcpConfig.mcp_servers['bricks-ctor'] = codexProjectMcpServer
276
+ delete mcpConfig.mcp_servers['bricks-project']
297
277
  } else {
298
278
  mcpConfig = defaultCodexMcpConfig
299
279
  }
@@ -191,9 +191,11 @@ Default property:
191
191
  /* A stroke was just committed (drawn or added programmatically) */
192
192
  onStrokeEnd?: Array<EventAction<string & keyof TemplateEventPropsMap['Sketch']['onStrokeEnd']>>
193
193
  /* The canvas was cleared */
194
- onClear?: Array<EventAction>
194
+ onClear?: Array<EventAction<string & keyof TemplateEventPropsMap['Sketch']['onClear']>>
195
195
  /* Sketch state changed (any commit, undo, redo, clear, or import) */
196
- onStateChange?: Array<EventAction>
196
+ onStateChange?: Array<
197
+ EventAction<string & keyof TemplateEventPropsMap['Sketch']['onStateChange']>
198
+ >
197
199
  /* Active tool changed */
198
200
  onToolChange?: Array<
199
201
  EventAction<string & keyof TemplateEventPropsMap['Sketch']['onToolChange']>
@@ -33,7 +33,7 @@ Default property:
33
33
  property?: {
34
34
  /* Start tick on generator initialized */
35
35
  init?: boolean | DataLink
36
- /* Interval of second for countdown */
36
+ /* Tick interval in milliseconds for countdown */
37
37
  interval?: number | DataLink
38
38
  /* Initial value of countdown */
39
39
  countdownStartValue?: number | DataLink
@@ -142,7 +142,9 @@ export const templateEventPropsMap = {
142
142
  },
143
143
  },
144
144
  Sketch: {
145
- onStrokeEnd: { BRICK_SKETCH_STROKE_COUNT: 'number' },
145
+ onStrokeEnd: { BRICK_SKETCH_STROKE_COUNT: 'number', BRICK_SKETCH_STATE: '{}' },
146
+ onClear: { BRICK_SKETCH_STATE: '{}' },
147
+ onStateChange: { BRICK_SKETCH_STATE: '{}' },
146
148
  onToolChange: { BRICK_SKETCH_TOOL: 'string' },
147
149
  onExportImage: { BRICK_SKETCH_IMAGE_URI: 'string' },
148
150
  },