@fugood/bricks-project 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/config-diff.ts +102 -0
- package/compile/index.ts +39 -7
- package/compile/util.ts +10 -7
- package/package.json +6 -2
- package/package.json.bak +6 -2
- 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/_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/calc.ts +5 -1
- package/utils/data.ts +3 -5
- package/utils/id.ts +39 -37
|
@@ -0,0 +1,1395 @@
|
|
|
1
|
+
import generateModule from '@babel/generator'
|
|
2
|
+
import { parse, parseExpression } from '@babel/parser'
|
|
3
|
+
import * as t from '@babel/types'
|
|
4
|
+
import { parse as parseSandboxModule } from 'acorn'
|
|
5
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
6
|
+
import { format as formatWithOxfmt } from 'oxfmt'
|
|
7
|
+
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
|
|
11
|
+
import { verifyProject } from './_verify'
|
|
12
|
+
import { appendEditRecord, editProvenance } from '../_edits-log'
|
|
13
|
+
|
|
14
|
+
const generate = (generateModule as any).default || generateModule
|
|
15
|
+
|
|
16
|
+
const oxfmtOptions = {
|
|
17
|
+
trailingComma: 'all',
|
|
18
|
+
tabWidth: 2,
|
|
19
|
+
semi: false,
|
|
20
|
+
singleQuote: true,
|
|
21
|
+
printWidth: 100,
|
|
22
|
+
} as const
|
|
23
|
+
|
|
24
|
+
type ParsedFile = {
|
|
25
|
+
ast: t.File
|
|
26
|
+
source: string
|
|
27
|
+
absPath: string
|
|
28
|
+
relPath: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type ExportEntry = {
|
|
32
|
+
name: string
|
|
33
|
+
object?: t.ObjectExpression
|
|
34
|
+
id?: string
|
|
35
|
+
alias?: string
|
|
36
|
+
typeName?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type ReferenceResolution = {
|
|
40
|
+
input: string
|
|
41
|
+
id?: string
|
|
42
|
+
alias?: string
|
|
43
|
+
varName: string
|
|
44
|
+
sameFile: boolean
|
|
45
|
+
importSource?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type EditContext = {
|
|
49
|
+
projectDir: string
|
|
50
|
+
parsed: ParsedFile
|
|
51
|
+
references: ReferenceResolution[]
|
|
52
|
+
typeImports: Set<string>
|
|
53
|
+
valueImports: Set<string>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type CalcTarget = {
|
|
57
|
+
parsed: ParsedFile
|
|
58
|
+
object: t.ObjectExpression
|
|
59
|
+
slug: string
|
|
60
|
+
sandboxFile: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
class DataCalcEditingError extends Error {
|
|
64
|
+
code: string
|
|
65
|
+
details?: Record<string, unknown>
|
|
66
|
+
isMcpError: boolean
|
|
67
|
+
|
|
68
|
+
constructor(
|
|
69
|
+
code: string,
|
|
70
|
+
message: string,
|
|
71
|
+
details?: Record<string, unknown>,
|
|
72
|
+
isMcpError = false,
|
|
73
|
+
) {
|
|
74
|
+
super(message)
|
|
75
|
+
this.name = 'DataCalcEditingError'
|
|
76
|
+
this.code = code
|
|
77
|
+
this.details = details
|
|
78
|
+
this.isMcpError = isMcpError
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
83
|
+
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
84
|
+
|
|
85
|
+
const isIdentifierName = (value: string) => /^[$A-Z_a-z][$\w]*$/.test(value)
|
|
86
|
+
|
|
87
|
+
const normalizeRelPath = (file: string) => file.replace(/\\/g, '/').replace(/^\.\/+/, '')
|
|
88
|
+
|
|
89
|
+
const projectRelativePath = (projectDir: string, absPath: string) =>
|
|
90
|
+
normalizeRelPath(path.relative(projectDir, absPath))
|
|
91
|
+
|
|
92
|
+
const resolveProjectPath = (projectDir: string, file: string) => {
|
|
93
|
+
if (path.isAbsolute(file)) {
|
|
94
|
+
throw new DataCalcEditingError('invalid_file', 'File must be project-relative', { file })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const absPath = path.resolve(projectDir, file)
|
|
98
|
+
const relative = path.relative(projectDir, absPath)
|
|
99
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
100
|
+
throw new DataCalcEditingError('invalid_file', 'File must stay inside the project directory', {
|
|
101
|
+
file,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
return absPath
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const parseFileSource = (source: string, relPath: string) => {
|
|
108
|
+
try {
|
|
109
|
+
return parse(source, {
|
|
110
|
+
sourceType: 'module',
|
|
111
|
+
plugins: ['typescript', 'topLevelAwait'],
|
|
112
|
+
errorRecovery: false,
|
|
113
|
+
})
|
|
114
|
+
} catch (err: any) {
|
|
115
|
+
throw new DataCalcEditingError(
|
|
116
|
+
'parse_failed',
|
|
117
|
+
`Cannot parse ${relPath} as TypeScript: ${err.message}. Edit this file directly.`,
|
|
118
|
+
{ file: relPath },
|
|
119
|
+
true,
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const readParsedFile = async (projectDir: string, absPath: string): Promise<ParsedFile> => {
|
|
125
|
+
const source = await readFile(absPath, 'utf8')
|
|
126
|
+
const relPath = projectRelativePath(projectDir, absPath)
|
|
127
|
+
return {
|
|
128
|
+
source,
|
|
129
|
+
ast: parseFileSource(source, relPath),
|
|
130
|
+
absPath,
|
|
131
|
+
relPath,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const getPropertyKeyName = (key: t.Expression | t.PrivateName) => {
|
|
136
|
+
if (t.isIdentifier(key)) return key.name
|
|
137
|
+
if (t.isStringLiteral(key) || t.isNumericLiteral(key)) return String(key.value)
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const makeObjectKey = (key: string) =>
|
|
142
|
+
isIdentifierName(key) ? t.identifier(key) : t.stringLiteral(key)
|
|
143
|
+
|
|
144
|
+
const getObjectProperty = (object: t.ObjectExpression, key: string) =>
|
|
145
|
+
object.properties.find((property): property is t.ObjectProperty => {
|
|
146
|
+
if (!t.isObjectProperty(property)) return false
|
|
147
|
+
return getPropertyKeyName(property.key) === key
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const getStringProperty = (object: t.ObjectExpression, key: string) => {
|
|
151
|
+
const property = getObjectProperty(object, key)
|
|
152
|
+
if (!property || !t.isStringLiteral(property.value)) return undefined
|
|
153
|
+
return property.value.value
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const setObjectProperty = (object: t.ObjectExpression, key: string, value: t.Expression) => {
|
|
157
|
+
const existing = getObjectProperty(object, key)
|
|
158
|
+
if (existing) {
|
|
159
|
+
existing.value = value
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
object.properties.push(t.objectProperty(makeObjectKey(key), value))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const removeObjectProperty = (object: t.ObjectExpression, key: string) => {
|
|
166
|
+
const index = object.properties.findIndex((property) => {
|
|
167
|
+
if (!t.isObjectProperty(property)) return false
|
|
168
|
+
return getPropertyKeyName(property.key) === key
|
|
169
|
+
})
|
|
170
|
+
if (index >= 0) object.properties.splice(index, 1)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const getExportEntries = (ast: t.File): ExportEntry[] =>
|
|
174
|
+
ast.program.body.flatMap((statement) => {
|
|
175
|
+
if (!t.isExportNamedDeclaration(statement)) return []
|
|
176
|
+
if (!t.isVariableDeclaration(statement.declaration)) return []
|
|
177
|
+
return statement.declaration.declarations.flatMap((declarator) => {
|
|
178
|
+
if (!t.isIdentifier(declarator.id)) return []
|
|
179
|
+
|
|
180
|
+
const typeAnnotation = declarator.id.typeAnnotation
|
|
181
|
+
let typeName: string | undefined
|
|
182
|
+
if (
|
|
183
|
+
t.isTSTypeAnnotation(typeAnnotation) &&
|
|
184
|
+
t.isTSTypeReference(typeAnnotation.typeAnnotation) &&
|
|
185
|
+
t.isIdentifier(typeAnnotation.typeAnnotation.typeName)
|
|
186
|
+
) {
|
|
187
|
+
typeName = typeAnnotation.typeAnnotation.typeName.name
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const object = t.isObjectExpression(declarator.init) ? declarator.init : undefined
|
|
191
|
+
return [
|
|
192
|
+
{
|
|
193
|
+
name: declarator.id.name,
|
|
194
|
+
object,
|
|
195
|
+
id: object ? getStringProperty(object, 'id') : undefined,
|
|
196
|
+
alias: object ? getStringProperty(object, 'alias') : undefined,
|
|
197
|
+
typeName,
|
|
198
|
+
},
|
|
199
|
+
]
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const getCalcDirFromSubspaceDir = (subspaceDir: string) => path.join(subspaceDir, 'data-calc')
|
|
204
|
+
|
|
205
|
+
const getSubspaceDirFromCalcFile = (absPath: string) => {
|
|
206
|
+
const parts = absPath.split(path.sep)
|
|
207
|
+
const index = parts.findIndex((part) => /^subspace-\d+$/.test(part))
|
|
208
|
+
if (index < 0) {
|
|
209
|
+
throw new DataCalcEditingError(
|
|
210
|
+
'invalid_file',
|
|
211
|
+
'Data-calc file must be inside subspaces/subspace-N/data-calc',
|
|
212
|
+
{ file: absPath },
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
return parts.slice(0, index + 1).join(path.sep)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const resolveSubspaceDir = (projectDir: string, subspace?: string | number) => {
|
|
219
|
+
if (subspace == null || subspace === '') return path.join(projectDir, 'subspaces/subspace-0')
|
|
220
|
+
if (typeof subspace === 'number') {
|
|
221
|
+
return path.join(projectDir, 'subspaces', `subspace-${subspace}`)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const normalized = String(subspace)
|
|
225
|
+
if (/^\d+$/.test(normalized)) return path.join(projectDir, 'subspaces', `subspace-${normalized}`)
|
|
226
|
+
if (/^subspace-\d+$/.test(normalized)) return path.join(projectDir, 'subspaces', normalized)
|
|
227
|
+
if (normalized.includes('/')) return resolveProjectPath(projectDir, normalized)
|
|
228
|
+
|
|
229
|
+
throw new DataCalcEditingError(
|
|
230
|
+
'unsupported_subspace',
|
|
231
|
+
`Unsupported subspace locator: ${normalized}`,
|
|
232
|
+
{
|
|
233
|
+
subspace: normalized,
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const calcSlugFromFile = (absPath: string) => {
|
|
239
|
+
const basename = path.basename(absPath)
|
|
240
|
+
const match = /^data-calculation-(.+)\.ts$/.exec(basename)
|
|
241
|
+
if (!match) {
|
|
242
|
+
throw new DataCalcEditingError('invalid_file', 'Expected data-calculation-{slug}.ts file', {
|
|
243
|
+
file: absPath,
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
return match[1]
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const getDataCalcFiles = async (dataCalcDir: string) => {
|
|
250
|
+
const entries = await readdir(dataCalcDir, { withFileTypes: true }).catch(() => [])
|
|
251
|
+
return entries
|
|
252
|
+
.filter(
|
|
253
|
+
(entry) =>
|
|
254
|
+
entry.isFile() &&
|
|
255
|
+
/^data-calculation-.+\.ts$/.test(entry.name) &&
|
|
256
|
+
!entry.name.endsWith('.sandbox.ts'),
|
|
257
|
+
)
|
|
258
|
+
.map((entry) => path.join(dataCalcDir, entry.name))
|
|
259
|
+
.sort()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const extractCalcObject = (parsed: ParsedFile) => {
|
|
263
|
+
const entries = getExportEntries(parsed.ast)
|
|
264
|
+
const calcEntry = entries.find((entry) => entry.name === 'dataCalculation')
|
|
265
|
+
if (!calcEntry || !calcEntry.object) {
|
|
266
|
+
throw new DataCalcEditingError(
|
|
267
|
+
'fallback_recommended',
|
|
268
|
+
`Data calc ${parsed.relPath} is not a single exported object literal. Edit this file directly for this one.`,
|
|
269
|
+
{ file: parsed.relPath, check: 'export const dataCalculation object literal' },
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const typename = getStringProperty(calcEntry.object, '__typename')
|
|
274
|
+
if (typename !== 'DataCalculationScript') {
|
|
275
|
+
throw new DataCalcEditingError(
|
|
276
|
+
'fallback_recommended',
|
|
277
|
+
`Data calc ${parsed.relPath} is ${typename || 'not DataCalculationScript'}. DataCalculationMap is out of scope; edit this file directly.`,
|
|
278
|
+
{ file: parsed.relPath, typename },
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return calcEntry.object
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const getCodeExpression = (object: t.ObjectExpression): t.Expression | null => {
|
|
286
|
+
const value = getObjectProperty(object, 'code')?.value
|
|
287
|
+
return value && t.isExpression(value) ? value : null
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Sibling sandbox files only: a plain `./name.sandbox.js` basename. Anything else
|
|
291
|
+
// (nested paths, ../ traversal, non-sandbox files) is not ours to rewrite or delete.
|
|
292
|
+
const isSandboxFilename = (value: string) => /^(\.\/)?[^/\\]+\.sandbox\.js$/.test(value)
|
|
293
|
+
|
|
294
|
+
const readCodeUrlFilename = (code: t.Expression | null | undefined) => {
|
|
295
|
+
const expression = t.isAwaitExpression(code) ? code.argument : code
|
|
296
|
+
if (!t.isCallExpression(expression)) return null
|
|
297
|
+
const firstArg = expression.arguments[0]
|
|
298
|
+
if (!t.isNewExpression(firstArg)) return null
|
|
299
|
+
if (!t.isIdentifier(firstArg.callee) || firstArg.callee.name !== 'URL') return null
|
|
300
|
+
const filenameArg = firstArg.arguments[0]
|
|
301
|
+
if (!t.isStringLiteral(filenameArg)) return null
|
|
302
|
+
return filenameArg.value
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const sandboxFilenameFromCodeExpression = (
|
|
306
|
+
code: t.Expression | null | undefined,
|
|
307
|
+
fallbackSlug: string,
|
|
308
|
+
) => {
|
|
309
|
+
const filename = readCodeUrlFilename(code)
|
|
310
|
+
if (!filename || !isSandboxFilename(filename)) {
|
|
311
|
+
return `data-calculation-${fallbackSlug}.sandbox.js`
|
|
312
|
+
}
|
|
313
|
+
return filename.replace(/^\.\//, '')
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const isRecognizedCodeExpression = (code: t.Expression | null | undefined) => {
|
|
317
|
+
if (!code) return false
|
|
318
|
+
if (t.isStringLiteral(code) || t.isTemplateLiteral(code)) return true
|
|
319
|
+
const filename = readCodeUrlFilename(code)
|
|
320
|
+
return filename != null && isSandboxFilename(filename)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const resolveCalcTarget = async (projectDir: string, input: any): Promise<CalcTarget> => {
|
|
324
|
+
let absPath: string | undefined
|
|
325
|
+
|
|
326
|
+
if (input.file) {
|
|
327
|
+
absPath = resolveProjectPath(projectDir, input.file)
|
|
328
|
+
} else {
|
|
329
|
+
const subspaceDir = resolveSubspaceDir(projectDir, input.subspace)
|
|
330
|
+
const calcDir = getCalcDirFromSubspaceDir(subspaceDir)
|
|
331
|
+
const files = await getDataCalcFiles(calcDir)
|
|
332
|
+
const calc = input.calc
|
|
333
|
+
if (!calc) {
|
|
334
|
+
throw new DataCalcEditingError('missing_calc', 'Provide file or subspace+calc')
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
for (const file of files) {
|
|
338
|
+
if (calc === calcSlugFromFile(file)) {
|
|
339
|
+
absPath = file
|
|
340
|
+
break
|
|
341
|
+
}
|
|
342
|
+
// Tolerate unparseable / non-Script siblings while scanning: they only opt out
|
|
343
|
+
// of alias/id matching here; standard-style checks run on the resolved target.
|
|
344
|
+
try {
|
|
345
|
+
const parsed = await readParsedFile(projectDir, file)
|
|
346
|
+
const entry = getExportEntries(parsed.ast).find((item) => item.name === 'dataCalculation')
|
|
347
|
+
if (entry && (calc === entry.alias || calc === entry.id)) {
|
|
348
|
+
absPath = file
|
|
349
|
+
break
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
continue
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!absPath) {
|
|
358
|
+
throw new DataCalcEditingError('calc_not_found', `Data calc not found: ${input.calc}`, {
|
|
359
|
+
calc: input.calc,
|
|
360
|
+
subspace: input.subspace ?? 'subspace-0',
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const parsed = await readParsedFile(projectDir, absPath)
|
|
365
|
+
const object = extractCalcObject(parsed)
|
|
366
|
+
const slug = calcSlugFromFile(absPath)
|
|
367
|
+
const sandboxFile = sandboxFilenameFromCodeExpression(getCodeExpression(object), slug)
|
|
368
|
+
return { parsed, object, slug, sandboxFile }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const relativeImportSource = (fromFile: string, toFile: string) => {
|
|
372
|
+
let rel = path.relative(path.dirname(fromFile), toFile).replace(/\\/g, '/')
|
|
373
|
+
if (!rel.startsWith('.')) rel = `./${rel}`
|
|
374
|
+
return rel.replace(/\.(ts|tsx|js|jsx)$/, '')
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const insertImport = (ast: t.File, declaration: t.ImportDeclaration) => {
|
|
378
|
+
const body = ast.program.body
|
|
379
|
+
const lastImportIndex = body.findLastIndex((statement) => t.isImportDeclaration(statement))
|
|
380
|
+
body.splice(lastImportIndex + 1, 0, declaration)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const ensureNamespaceImport = (parsed: ParsedFile, source: string, preferredLocal: string) => {
|
|
384
|
+
for (const statement of parsed.ast.program.body) {
|
|
385
|
+
if (!t.isImportDeclaration(statement) || statement.source.value !== source) continue
|
|
386
|
+
const namespace = statement.specifiers.find(
|
|
387
|
+
(specifier): specifier is t.ImportNamespaceSpecifier =>
|
|
388
|
+
t.isImportNamespaceSpecifier(specifier),
|
|
389
|
+
)
|
|
390
|
+
if (namespace) return namespace.local.name
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const declaration = t.importDeclaration(
|
|
394
|
+
[t.importNamespaceSpecifier(t.identifier(preferredLocal))],
|
|
395
|
+
t.stringLiteral(source),
|
|
396
|
+
)
|
|
397
|
+
insertImport(parsed.ast, declaration)
|
|
398
|
+
return preferredLocal
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const ensureBricksCtorImport = (
|
|
402
|
+
ast: t.File,
|
|
403
|
+
importKind: 'type' | 'value',
|
|
404
|
+
names: Iterable<string>,
|
|
405
|
+
) => {
|
|
406
|
+
const missing = new Set(Array.from(names).filter(Boolean))
|
|
407
|
+
if (missing.size === 0) return
|
|
408
|
+
|
|
409
|
+
for (const statement of ast.program.body) {
|
|
410
|
+
if (!t.isImportDeclaration(statement) || statement.source.value !== 'bricks-ctor') continue
|
|
411
|
+
const isTypeImport = statement.importKind === 'type'
|
|
412
|
+
if ((importKind === 'type') !== isTypeImport) continue
|
|
413
|
+
|
|
414
|
+
statement.specifiers.forEach((specifier) => {
|
|
415
|
+
if (!t.isImportSpecifier(specifier)) return
|
|
416
|
+
const imported = specifier.imported
|
|
417
|
+
if (t.isIdentifier(imported)) missing.delete(imported.name)
|
|
418
|
+
if (t.isStringLiteral(imported)) missing.delete(imported.value)
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
missing.forEach((name) => {
|
|
422
|
+
statement.specifiers.push(t.importSpecifier(t.identifier(name), t.identifier(name)))
|
|
423
|
+
})
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const declaration = t.importDeclaration(
|
|
428
|
+
Array.from(missing).map((name) => t.importSpecifier(t.identifier(name), t.identifier(name))),
|
|
429
|
+
t.stringLiteral('bricks-ctor'),
|
|
430
|
+
)
|
|
431
|
+
if (importKind === 'type') declaration.importKind = 'type'
|
|
432
|
+
insertImport(ast, declaration)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const ensureReadFileImport = (ast: t.File) => {
|
|
436
|
+
for (const statement of ast.program.body) {
|
|
437
|
+
if (!t.isImportDeclaration(statement) || statement.source.value !== 'node:fs/promises') {
|
|
438
|
+
continue
|
|
439
|
+
}
|
|
440
|
+
const hasReadFile = statement.specifiers.some(
|
|
441
|
+
(specifier) =>
|
|
442
|
+
t.isImportSpecifier(specifier) &&
|
|
443
|
+
t.isIdentifier(specifier.imported) &&
|
|
444
|
+
specifier.imported.name === 'readFile',
|
|
445
|
+
)
|
|
446
|
+
if (!hasReadFile) {
|
|
447
|
+
statement.specifiers.push(
|
|
448
|
+
t.importSpecifier(t.identifier('readFile'), t.identifier('readFile')),
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
insertImport(
|
|
455
|
+
ast,
|
|
456
|
+
t.importDeclaration(
|
|
457
|
+
[t.importSpecifier(t.identifier('readFile'), t.identifier('readFile'))],
|
|
458
|
+
t.stringLiteral('node:fs/promises'),
|
|
459
|
+
),
|
|
460
|
+
)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const applyPendingImports = (ctx: EditContext) => {
|
|
464
|
+
ensureBricksCtorImport(ctx.parsed.ast, 'type', ctx.typeImports)
|
|
465
|
+
ensureBricksCtorImport(ctx.parsed.ast, 'value', ctx.valueImports)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const resolveDataReference = async (
|
|
469
|
+
projectDir: string,
|
|
470
|
+
currentFile: string,
|
|
471
|
+
ref: string,
|
|
472
|
+
subspace?: string | number,
|
|
473
|
+
): Promise<ReferenceResolution> => {
|
|
474
|
+
const currentSubspaceDir = getSubspaceDirFromCalcFile(currentFile)
|
|
475
|
+
const targetSubspaceDir =
|
|
476
|
+
subspace == null ? currentSubspaceDir : resolveSubspaceDir(projectDir, subspace)
|
|
477
|
+
const dataFile = path.join(targetSubspaceDir, 'data.ts')
|
|
478
|
+
const parsed = await readParsedFile(projectDir, dataFile)
|
|
479
|
+
const matches = getExportEntries(parsed.ast).filter(
|
|
480
|
+
(entry) => entry.name === ref || entry.id === ref || entry.alias === ref,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
if (matches.length === 0) {
|
|
484
|
+
throw new DataCalcEditingError('reference_not_found', `Data reference not found: ${ref}`, {
|
|
485
|
+
ref,
|
|
486
|
+
subspace: path.basename(targetSubspaceDir),
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
if (matches.length > 1) {
|
|
490
|
+
throw new DataCalcEditingError('ambiguous_reference', `Data reference is ambiguous: ${ref}`, {
|
|
491
|
+
ref,
|
|
492
|
+
matches: matches.map((match) => ({
|
|
493
|
+
file: projectRelativePath(projectDir, dataFile),
|
|
494
|
+
entry: match.name,
|
|
495
|
+
id: match.id,
|
|
496
|
+
alias: match.alias,
|
|
497
|
+
})),
|
|
498
|
+
})
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const sameFile = path.resolve(dataFile) === path.resolve(currentFile)
|
|
502
|
+
return {
|
|
503
|
+
input: ref,
|
|
504
|
+
id: matches[0].id,
|
|
505
|
+
alias: matches[0].alias,
|
|
506
|
+
varName: matches[0].name,
|
|
507
|
+
sameFile,
|
|
508
|
+
importSource: sameFile ? undefined : relativeImportSource(currentFile, dataFile),
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const dataGetterExpression = async (input: any, ctx: EditContext): Promise<t.Expression> => {
|
|
513
|
+
if (isRecord(input) && typeof input.expr === 'string') {
|
|
514
|
+
return parseExpression(input.expr, { plugins: ['typescript'] }) as t.Expression
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const ref = typeof input === 'string' ? input : isRecord(input) ? input.ref : undefined
|
|
518
|
+
if (typeof ref !== 'string' || !ref) {
|
|
519
|
+
throw new DataCalcEditingError('invalid_ref', 'Data getter requires a data ref string')
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const resolved = await resolveDataReference(
|
|
523
|
+
ctx.projectDir,
|
|
524
|
+
ctx.parsed.absPath,
|
|
525
|
+
ref,
|
|
526
|
+
isRecord(input) ? (input.subspace as any) : undefined,
|
|
527
|
+
)
|
|
528
|
+
const local = resolved.sameFile
|
|
529
|
+
? 'data'
|
|
530
|
+
: ensureNamespaceImport(ctx.parsed, resolved.importSource || '../data', 'data')
|
|
531
|
+
ctx.references.push(resolved)
|
|
532
|
+
return t.arrowFunctionExpression(
|
|
533
|
+
[],
|
|
534
|
+
t.memberExpression(t.identifier(local), t.identifier(resolved.varName)),
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const nullableDataGetterExpression = async (value: any, ctx: EditContext) => {
|
|
539
|
+
if (value == null) return t.nullLiteral()
|
|
540
|
+
return dataGetterExpression(value, ctx)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Alias-keyed makeId calls hash to the same uuid regardless of declaration order, so
|
|
544
|
+
// recompiles stay stable when sibling entries are added or removed.
|
|
545
|
+
const makeIdCallExpression = (idType: string, alias?: string) =>
|
|
546
|
+
t.callExpression(
|
|
547
|
+
t.identifier('makeId'),
|
|
548
|
+
alias ? [t.stringLiteral(idType), t.stringLiteral(alias)] : [t.stringLiteral(idType)],
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
// True when another calc anywhere in the project already uses the alias — the aliased
|
|
552
|
+
// makeId form would collide at compile time.
|
|
553
|
+
const aliasUsedInCalcs = async (projectDir: string, alias: string) => {
|
|
554
|
+
const subspacesDir = path.join(projectDir, 'subspaces')
|
|
555
|
+
const entries = await readdir(subspacesDir, { withFileTypes: true }).catch(() => [])
|
|
556
|
+
for (const entry of entries) {
|
|
557
|
+
if (!entry.isDirectory() || !/^subspace-\d+$/.test(entry.name)) continue
|
|
558
|
+
const files = await getDataCalcFiles(path.join(subspacesDir, entry.name, 'data-calc'))
|
|
559
|
+
for (const file of files) {
|
|
560
|
+
try {
|
|
561
|
+
const parsed = await readParsedFile(projectDir, file)
|
|
562
|
+
const calcEntry = getExportEntries(parsed.ast).find(
|
|
563
|
+
(item) => item.name === 'dataCalculation',
|
|
564
|
+
)
|
|
565
|
+
if (calcEntry?.alias === alias) return true
|
|
566
|
+
} catch {
|
|
567
|
+
continue
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return false
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Required by the DataCalculationScript type; unsetting them yields a calc that
|
|
575
|
+
// crashes compile with an unrelated-looking error instead of a clear refusal here.
|
|
576
|
+
const requiredCalcFields = new Set([
|
|
577
|
+
'__typename',
|
|
578
|
+
'id',
|
|
579
|
+
'code',
|
|
580
|
+
'enableAsync',
|
|
581
|
+
'inputs',
|
|
582
|
+
'output',
|
|
583
|
+
'outputs',
|
|
584
|
+
'error',
|
|
585
|
+
])
|
|
586
|
+
|
|
587
|
+
const makeCodeReadFileExpression = (sandboxFile: string) =>
|
|
588
|
+
t.awaitExpression(
|
|
589
|
+
t.callExpression(t.identifier('readFile'), [
|
|
590
|
+
t.newExpression(t.identifier('URL'), [
|
|
591
|
+
t.stringLiteral(`./${sandboxFile}`),
|
|
592
|
+
t.memberExpression(
|
|
593
|
+
t.metaProperty(t.identifier('import'), t.identifier('meta')),
|
|
594
|
+
t.identifier('url'),
|
|
595
|
+
),
|
|
596
|
+
]),
|
|
597
|
+
t.stringLiteral('utf8'),
|
|
598
|
+
]),
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
const expressionFromInput = async (input: any, ctx: EditContext): Promise<t.Expression> => {
|
|
602
|
+
if (isRecord(input)) {
|
|
603
|
+
if (typeof input.expr === 'string') {
|
|
604
|
+
return parseExpression(input.expr, { plugins: ['typescript'] }) as t.Expression
|
|
605
|
+
}
|
|
606
|
+
if (typeof input.ref === 'string') return dataGetterExpression(input, ctx)
|
|
607
|
+
}
|
|
608
|
+
if (input === null) return t.nullLiteral()
|
|
609
|
+
if (input === undefined) return t.identifier('undefined')
|
|
610
|
+
if (typeof input === 'string') return t.stringLiteral(input)
|
|
611
|
+
if (typeof input === 'number') return t.numericLiteral(input)
|
|
612
|
+
if (typeof input === 'boolean') return t.booleanLiteral(input)
|
|
613
|
+
if (Array.isArray(input)) {
|
|
614
|
+
return t.arrayExpression(
|
|
615
|
+
await Promise.all(input.map((value) => expressionFromInput(value, ctx))),
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
if (isRecord(input)) {
|
|
619
|
+
const properties = await Promise.all(
|
|
620
|
+
Object.entries(input).map(async ([key, value]) =>
|
|
621
|
+
t.objectProperty(makeObjectKey(key), await expressionFromInput(value, ctx)),
|
|
622
|
+
),
|
|
623
|
+
)
|
|
624
|
+
return t.objectExpression(properties)
|
|
625
|
+
}
|
|
626
|
+
throw new DataCalcEditingError('invalid_value', `Unsupported value: ${String(input)}`)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const printAndFormat = async (parsed: ParsedFile) => {
|
|
630
|
+
const generated = generate(parsed.ast, { comments: true }, parsed.source).code
|
|
631
|
+
const formatted = await formatWithOxfmt(parsed.absPath, generated, oxfmtOptions)
|
|
632
|
+
if (formatted.errors.length > 0) {
|
|
633
|
+
throw new DataCalcEditingError('format_failed', `oxfmt failed for ${parsed.relPath}`, {
|
|
634
|
+
file: parsed.relPath,
|
|
635
|
+
errors: formatted.errors,
|
|
636
|
+
})
|
|
637
|
+
}
|
|
638
|
+
return formatted.code
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const writeParsedFile = async (parsed: ParsedFile) => {
|
|
642
|
+
const code = await printAndFormat(parsed)
|
|
643
|
+
parseFileSource(code, parsed.relPath)
|
|
644
|
+
await writeFile(parsed.absPath, code)
|
|
645
|
+
return code
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const responseFor = (result: any): any => ({
|
|
649
|
+
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
|
650
|
+
isError: result.isError || undefined,
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
const referenceInputDetails = (references: ReferenceResolution[]) =>
|
|
654
|
+
references.map((reference) => ({
|
|
655
|
+
input: reference.input,
|
|
656
|
+
id: reference.id,
|
|
657
|
+
alias: reference.alias,
|
|
658
|
+
entry: reference.varName,
|
|
659
|
+
}))
|
|
660
|
+
|
|
661
|
+
const runOperation = async (
|
|
662
|
+
projectDir: string,
|
|
663
|
+
tool: string,
|
|
664
|
+
input: any,
|
|
665
|
+
operation: () => Promise<any>,
|
|
666
|
+
) => {
|
|
667
|
+
const startedAt = new Date().toISOString()
|
|
668
|
+
const provenance = editProvenance()
|
|
669
|
+
try {
|
|
670
|
+
const result = await operation()
|
|
671
|
+
await appendEditRecord(projectDir, {
|
|
672
|
+
ts: startedAt,
|
|
673
|
+
tool,
|
|
674
|
+
input,
|
|
675
|
+
provenance,
|
|
676
|
+
outcome: result.outcome,
|
|
677
|
+
summary: result.summary,
|
|
678
|
+
result,
|
|
679
|
+
})
|
|
680
|
+
return result
|
|
681
|
+
} catch (err: any) {
|
|
682
|
+
const isMcpError = err instanceof DataCalcEditingError
|
|
683
|
+
const outcome =
|
|
684
|
+
isMcpError && err.code === 'fallback_recommended' ? 'fallback_recommended' : 'error'
|
|
685
|
+
const result = {
|
|
686
|
+
outcome,
|
|
687
|
+
// Fallback recommendations are guidance, not MCP errors (mirrors entry-editing).
|
|
688
|
+
isError: outcome === 'error',
|
|
689
|
+
error: {
|
|
690
|
+
code: isMcpError ? err.code : 'unexpected_error',
|
|
691
|
+
message: err.message,
|
|
692
|
+
details: isMcpError ? err.details : undefined,
|
|
693
|
+
},
|
|
694
|
+
summary: `${tool} failed: ${err.message}`,
|
|
695
|
+
}
|
|
696
|
+
await appendEditRecord(projectDir, {
|
|
697
|
+
ts: startedAt,
|
|
698
|
+
tool,
|
|
699
|
+
input,
|
|
700
|
+
provenance,
|
|
701
|
+
outcome: result.outcome,
|
|
702
|
+
summary: result.summary,
|
|
703
|
+
error: result.error,
|
|
704
|
+
}).catch(() => undefined)
|
|
705
|
+
return result
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const slugify = (value: string) => {
|
|
710
|
+
const slug = value
|
|
711
|
+
.trim()
|
|
712
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
713
|
+
.toLowerCase()
|
|
714
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
715
|
+
.replace(/^-+|-+$/g, '')
|
|
716
|
+
return slug || 'calc'
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const nextFreeSlug = async (dataCalcDir: string, preferred?: string) => {
|
|
720
|
+
const existing = new Set((await getDataCalcFiles(dataCalcDir)).map(calcSlugFromFile))
|
|
721
|
+
if (preferred) {
|
|
722
|
+
let slug = slugify(preferred)
|
|
723
|
+
if (!existing.has(slug)) return slug
|
|
724
|
+
let index = 1
|
|
725
|
+
while (existing.has(`${slug}-${index}`)) index += 1
|
|
726
|
+
return `${slug}-${index}`
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
let index = 0
|
|
730
|
+
while (existing.has(String(index))) index += 1
|
|
731
|
+
return String(index)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Compile unwraps the sandbox with the same acorn parse (compile/index.ts
|
|
735
|
+
// compileScriptCalculationCode, ecmaVersion 2020) and silently falls back to the raw
|
|
736
|
+
// wrapped string when it fails, so an unparseable sandbox must be rejected here.
|
|
737
|
+
const sandboxParses = (source: string) => {
|
|
738
|
+
try {
|
|
739
|
+
parseSandboxModule(source, { sourceType: 'module', ecmaVersion: 2020 })
|
|
740
|
+
return true
|
|
741
|
+
} catch {
|
|
742
|
+
return false
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const wrapSandboxCode = (code?: string) => {
|
|
747
|
+
const body = code ?? ''
|
|
748
|
+
if (/export\s+(async\s+)?function\s+main\s*\(/.test(body)) {
|
|
749
|
+
const wrapped = body.endsWith('\n') ? body : `${body}\n`
|
|
750
|
+
if (!sandboxParses(wrapped)) {
|
|
751
|
+
throw new DataCalcEditingError(
|
|
752
|
+
'invalid_code',
|
|
753
|
+
'Sandbox code does not parse as a module (note: top-level await requires export async function main)',
|
|
754
|
+
)
|
|
755
|
+
}
|
|
756
|
+
return wrapped
|
|
757
|
+
}
|
|
758
|
+
const trimmed = body.trim()
|
|
759
|
+
if (!trimmed) return 'export function main() {\n}\n'
|
|
760
|
+
// Bodies using top-level await (enableAsync mode) only parse inside an async main;
|
|
761
|
+
// compile unwraps either wrapper form to the same script body.
|
|
762
|
+
for (const asyncPrefix of ['', 'async ']) {
|
|
763
|
+
const wrapped = `export ${asyncPrefix}function main() {\n${trimmed}\n}\n`
|
|
764
|
+
if (sandboxParses(wrapped)) return wrapped
|
|
765
|
+
}
|
|
766
|
+
throw new DataCalcEditingError(
|
|
767
|
+
'invalid_code',
|
|
768
|
+
'Sandbox code does not parse as a script body (statements with top-level return)',
|
|
769
|
+
)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const regenerateDataCalcIndex = async (projectDir: string, dataCalcDir: string) => {
|
|
773
|
+
await mkdir(dataCalcDir, { recursive: true })
|
|
774
|
+
const files = await getDataCalcFiles(dataCalcDir)
|
|
775
|
+
const imports = files
|
|
776
|
+
.map((file, index) => {
|
|
777
|
+
const slug = calcSlugFromFile(file)
|
|
778
|
+
return `import { dataCalculation as dataCalculation${index} } from './data-calculation-${slug}'`
|
|
779
|
+
})
|
|
780
|
+
.join('\n')
|
|
781
|
+
const list = files.map((_, index) => `dataCalculation${index}`).join(',\n ')
|
|
782
|
+
const source = `${imports}
|
|
783
|
+
|
|
784
|
+
export const dataCalculation = [
|
|
785
|
+
${list}
|
|
786
|
+
]
|
|
787
|
+
`
|
|
788
|
+
const indexPath = path.join(dataCalcDir, 'index.ts')
|
|
789
|
+
const parsed: ParsedFile = {
|
|
790
|
+
absPath: indexPath,
|
|
791
|
+
relPath: projectRelativePath(projectDir, indexPath),
|
|
792
|
+
source,
|
|
793
|
+
ast: parseFileSource(source, projectRelativePath(projectDir, indexPath)),
|
|
794
|
+
}
|
|
795
|
+
await writeParsedFile(parsed)
|
|
796
|
+
return projectRelativePath(projectDir, indexPath)
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const getDefaultExportObject = (ast: t.File) => {
|
|
800
|
+
for (const statement of ast.program.body) {
|
|
801
|
+
if (!t.isExportDefaultDeclaration(statement)) continue
|
|
802
|
+
const declaration = statement.declaration
|
|
803
|
+
if (t.isObjectExpression(declaration)) return declaration
|
|
804
|
+
if (t.isTSAsExpression(declaration) && t.isObjectExpression(declaration.expression)) {
|
|
805
|
+
return declaration.expression
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return null
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const ensureSubspaceRootDataCalcImport = async (
|
|
812
|
+
projectDir: string,
|
|
813
|
+
subspaceDir: string,
|
|
814
|
+
): Promise<{ touched?: string; warning?: string } | null> => {
|
|
815
|
+
const indexPath = path.join(subspaceDir, 'index.ts')
|
|
816
|
+
const relPath = projectRelativePath(projectDir, indexPath)
|
|
817
|
+
const source = await readFile(indexPath, 'utf8').catch((err: any) => {
|
|
818
|
+
if (err?.code === 'ENOENT') return null
|
|
819
|
+
throw err
|
|
820
|
+
})
|
|
821
|
+
if (source == null) {
|
|
822
|
+
return { warning: `${relPath} not found; wire the data-calc import manually` }
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const parsed = await readParsedFile(projectDir, indexPath)
|
|
826
|
+
const object = getDefaultExportObject(parsed.ast)
|
|
827
|
+
if (!object) {
|
|
828
|
+
return {
|
|
829
|
+
warning: `${relPath} has no default-export object; wire the data-calc import manually`,
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const property = getObjectProperty(object, 'dataCalculation')
|
|
834
|
+
if (property && t.isIdentifier(property.value)) return null
|
|
835
|
+
if (property && !(t.isArrayExpression(property.value) && property.value.elements.length === 0)) {
|
|
836
|
+
// Only the codegen minimal-subspace shape (dataCalculation: []) is safe to rewire;
|
|
837
|
+
// replacing any other value (inline calcs, spreads) would silently drop config.
|
|
838
|
+
return {
|
|
839
|
+
warning: `${relPath} has a non-empty dataCalculation value; wire the data-calc import manually`,
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const alreadyImported = parsed.ast.program.body.some(
|
|
844
|
+
(statement) =>
|
|
845
|
+
t.isImportDeclaration(statement) &&
|
|
846
|
+
(statement.source.value === './data-calc' || statement.source.value === './data-calc/index'),
|
|
847
|
+
)
|
|
848
|
+
if (!alreadyImported) {
|
|
849
|
+
insertImport(
|
|
850
|
+
parsed.ast,
|
|
851
|
+
t.importDeclaration(
|
|
852
|
+
[t.importSpecifier(t.identifier('dataCalculation'), t.identifier('dataCalculation'))],
|
|
853
|
+
t.stringLiteral('./data-calc'),
|
|
854
|
+
),
|
|
855
|
+
)
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (!property) {
|
|
859
|
+
const dataCalculationProperty = t.objectProperty(
|
|
860
|
+
t.identifier('dataCalculation'),
|
|
861
|
+
t.identifier('dataCalculation'),
|
|
862
|
+
)
|
|
863
|
+
dataCalculationProperty.shorthand = true
|
|
864
|
+
object.properties.push(dataCalculationProperty)
|
|
865
|
+
} else {
|
|
866
|
+
property.value = t.identifier('dataCalculation')
|
|
867
|
+
if (t.isIdentifier(property.key)) property.shorthand = true
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
await writeParsedFile(parsed)
|
|
871
|
+
return { touched: parsed.relPath }
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const buildIoObject = async (field: 'inputs' | 'outputs', input: any, ctx: EditContext) => {
|
|
875
|
+
if (!input.key) {
|
|
876
|
+
throw new DataCalcEditingError('missing_key', `${field} item requires key`)
|
|
877
|
+
}
|
|
878
|
+
if (input.data == null) {
|
|
879
|
+
throw new DataCalcEditingError('missing_data', `${field} item requires data`)
|
|
880
|
+
}
|
|
881
|
+
const properties: t.ObjectProperty[] = [
|
|
882
|
+
t.objectProperty(t.identifier('key'), t.stringLiteral(input.key)),
|
|
883
|
+
t.objectProperty(t.identifier('data'), await dataGetterExpression(input.data, ctx)),
|
|
884
|
+
]
|
|
885
|
+
if (field === 'inputs') {
|
|
886
|
+
properties.push(
|
|
887
|
+
t.objectProperty(t.identifier('trigger'), t.booleanLiteral(input.trigger !== false)),
|
|
888
|
+
)
|
|
889
|
+
}
|
|
890
|
+
return t.objectExpression(properties)
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const buildIoArrayExpression = async (
|
|
894
|
+
field: 'inputs' | 'outputs',
|
|
895
|
+
items: any[],
|
|
896
|
+
ctx: EditContext,
|
|
897
|
+
) => {
|
|
898
|
+
if (field === 'inputs') {
|
|
899
|
+
const keys = new Set<string>()
|
|
900
|
+
for (const item of items) {
|
|
901
|
+
if (keys.has(item?.key)) {
|
|
902
|
+
throw new DataCalcEditingError('duplicate_key', `Input key already exists: ${item.key}`, {
|
|
903
|
+
key: item.key,
|
|
904
|
+
})
|
|
905
|
+
}
|
|
906
|
+
keys.add(item?.key)
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return t.arrayExpression(await Promise.all(items.map((item) => buildIoObject(field, item, ctx))))
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const getOrCreateArrayProperty = (object: t.ObjectExpression, key: string) => {
|
|
913
|
+
const existing = getObjectProperty(object, key)
|
|
914
|
+
if (existing) {
|
|
915
|
+
if (!t.isArrayExpression(existing.value)) {
|
|
916
|
+
throw new DataCalcEditingError(
|
|
917
|
+
'fallback_recommended',
|
|
918
|
+
`${key} is not an array. Edit this file directly.`,
|
|
919
|
+
)
|
|
920
|
+
}
|
|
921
|
+
return existing.value
|
|
922
|
+
}
|
|
923
|
+
const array = t.arrayExpression([])
|
|
924
|
+
object.properties.push(t.objectProperty(t.identifier(key), array))
|
|
925
|
+
return array
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const findIoIndex = (array: t.ArrayExpression, target: { key?: string; index?: number }) => {
|
|
929
|
+
if (typeof target.index === 'number') return target.index
|
|
930
|
+
if (!target.key) return -1
|
|
931
|
+
const matches = array.elements
|
|
932
|
+
.map((element, index) => ({ element, index }))
|
|
933
|
+
.filter(
|
|
934
|
+
({ element }) =>
|
|
935
|
+
t.isObjectExpression(element) && getStringProperty(element, 'key') === target.key,
|
|
936
|
+
)
|
|
937
|
+
if (matches.length > 1) {
|
|
938
|
+
throw new DataCalcEditingError('ambiguous_key', `IO key is ambiguous: ${target.key}`, {
|
|
939
|
+
key: target.key,
|
|
940
|
+
count: matches.length,
|
|
941
|
+
})
|
|
942
|
+
}
|
|
943
|
+
return matches[0]?.index ?? -1
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const assertInputKeyUnique = (array: t.ArrayExpression, key: string, ignoreIndex?: number) => {
|
|
947
|
+
const duplicate = array.elements.some(
|
|
948
|
+
(element, index) =>
|
|
949
|
+
index !== ignoreIndex &&
|
|
950
|
+
t.isObjectExpression(element) &&
|
|
951
|
+
getStringProperty(element, 'key') === key,
|
|
952
|
+
)
|
|
953
|
+
if (duplicate) {
|
|
954
|
+
throw new DataCalcEditingError('duplicate_key', `Input key already exists: ${key}`, { key })
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const buildCalcObject = async (
|
|
959
|
+
input: any,
|
|
960
|
+
sandboxFile: string,
|
|
961
|
+
ctx: EditContext,
|
|
962
|
+
stableIdAlias?: string,
|
|
963
|
+
) => {
|
|
964
|
+
ctx.typeImports.add('DataCalculationScript')
|
|
965
|
+
ctx.valueImports.add('makeId')
|
|
966
|
+
ensureReadFileImport(ctx.parsed.ast)
|
|
967
|
+
|
|
968
|
+
const properties: t.ObjectProperty[] = [
|
|
969
|
+
t.objectProperty(t.identifier('__typename'), t.stringLiteral('DataCalculationScript')),
|
|
970
|
+
t.objectProperty(
|
|
971
|
+
t.identifier('id'),
|
|
972
|
+
input.id
|
|
973
|
+
? t.stringLiteral(input.id)
|
|
974
|
+
: makeIdCallExpression('property_bank_calc', stableIdAlias),
|
|
975
|
+
),
|
|
976
|
+
]
|
|
977
|
+
if (input.alias)
|
|
978
|
+
properties.push(t.objectProperty(t.identifier('alias'), t.stringLiteral(input.alias)))
|
|
979
|
+
if (input.title != null)
|
|
980
|
+
properties.push(t.objectProperty(t.identifier('title'), t.stringLiteral(input.title)))
|
|
981
|
+
if (input.description != null) {
|
|
982
|
+
properties.push(
|
|
983
|
+
t.objectProperty(t.identifier('description'), t.stringLiteral(input.description)),
|
|
984
|
+
)
|
|
985
|
+
}
|
|
986
|
+
if (input.note != null)
|
|
987
|
+
properties.push(t.objectProperty(t.identifier('note'), t.stringLiteral(input.note)))
|
|
988
|
+
|
|
989
|
+
properties.push(t.objectProperty(t.identifier('code'), makeCodeReadFileExpression(sandboxFile)))
|
|
990
|
+
properties.push(
|
|
991
|
+
t.objectProperty(t.identifier('enableAsync'), t.booleanLiteral(!!input.enableAsync)),
|
|
992
|
+
)
|
|
993
|
+
if (input.triggerMode != null) {
|
|
994
|
+
properties.push(
|
|
995
|
+
t.objectProperty(t.identifier('triggerMode'), t.stringLiteral(input.triggerMode)),
|
|
996
|
+
)
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
properties.push(
|
|
1000
|
+
t.objectProperty(
|
|
1001
|
+
t.identifier('inputs'),
|
|
1002
|
+
await buildIoArrayExpression('inputs', input.inputs || [], ctx),
|
|
1003
|
+
),
|
|
1004
|
+
)
|
|
1005
|
+
properties.push(
|
|
1006
|
+
t.objectProperty(t.identifier('output'), await nullableDataGetterExpression(input.output, ctx)),
|
|
1007
|
+
)
|
|
1008
|
+
properties.push(
|
|
1009
|
+
t.objectProperty(
|
|
1010
|
+
t.identifier('outputs'),
|
|
1011
|
+
await buildIoArrayExpression('outputs', input.outputs || [], ctx),
|
|
1012
|
+
),
|
|
1013
|
+
)
|
|
1014
|
+
properties.push(
|
|
1015
|
+
t.objectProperty(t.identifier('error'), await nullableDataGetterExpression(input.error, ctx)),
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
return t.objectExpression(properties)
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const createCalcParsedFile = (projectDir: string, absPath: string): ParsedFile => {
|
|
1022
|
+
const relPath = projectRelativePath(projectDir, absPath)
|
|
1023
|
+
const source = 'export const dataCalculation = {}\n'
|
|
1024
|
+
const ast = parseFileSource(source, relPath)
|
|
1025
|
+
ast.program.body = []
|
|
1026
|
+
return { ast, source, absPath, relPath }
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const newDataCalc = async (projectDir: string, input: any) =>
|
|
1030
|
+
runOperation(projectDir, 'new_data_calc', input, async () => {
|
|
1031
|
+
const subspaceDir = resolveSubspaceDir(projectDir, input.subspace)
|
|
1032
|
+
const dataCalcDir = getCalcDirFromSubspaceDir(subspaceDir)
|
|
1033
|
+
await mkdir(dataCalcDir, { recursive: true })
|
|
1034
|
+
const slug = await nextFreeSlug(dataCalcDir, input.alias)
|
|
1035
|
+
const sandboxFile = `data-calculation-${slug}.sandbox.js`
|
|
1036
|
+
const calcPath = path.join(dataCalcDir, `data-calculation-${slug}.ts`)
|
|
1037
|
+
const sandboxPath = path.join(dataCalcDir, sandboxFile)
|
|
1038
|
+
const parsed = createCalcParsedFile(projectDir, calcPath)
|
|
1039
|
+
const ctx: EditContext = {
|
|
1040
|
+
projectDir,
|
|
1041
|
+
parsed,
|
|
1042
|
+
references: [],
|
|
1043
|
+
typeImports: new Set(),
|
|
1044
|
+
valueImports: new Set(),
|
|
1045
|
+
}
|
|
1046
|
+
const stableIdAlias =
|
|
1047
|
+
!input.id &&
|
|
1048
|
+
typeof input.alias === 'string' &&
|
|
1049
|
+
input.alias &&
|
|
1050
|
+
!(await aliasUsedInCalcs(projectDir, input.alias))
|
|
1051
|
+
? input.alias
|
|
1052
|
+
: undefined
|
|
1053
|
+
const object = await buildCalcObject(input, sandboxFile, ctx, stableIdAlias)
|
|
1054
|
+
const declarator = t.variableDeclarator(t.identifier('dataCalculation'), object)
|
|
1055
|
+
;(declarator.id as t.Identifier).typeAnnotation = t.tsTypeAnnotation(
|
|
1056
|
+
t.tsTypeReference(t.identifier('DataCalculationScript')),
|
|
1057
|
+
)
|
|
1058
|
+
parsed.ast.program.body.push(
|
|
1059
|
+
t.exportNamedDeclaration(t.variableDeclaration('const', [declarator])),
|
|
1060
|
+
)
|
|
1061
|
+
applyPendingImports(ctx)
|
|
1062
|
+
|
|
1063
|
+
await writeFile(sandboxPath, wrapSandboxCode(input.code))
|
|
1064
|
+
await writeParsedFile(parsed)
|
|
1065
|
+
const touchedSites = [
|
|
1066
|
+
{ file: projectRelativePath(projectDir, calcPath), action: 'create_calc' },
|
|
1067
|
+
{ file: projectRelativePath(projectDir, sandboxPath), action: 'write_sandbox' },
|
|
1068
|
+
]
|
|
1069
|
+
touchedSites.push({
|
|
1070
|
+
file: await regenerateDataCalcIndex(projectDir, dataCalcDir),
|
|
1071
|
+
action: 'regenerate_index',
|
|
1072
|
+
})
|
|
1073
|
+
const rootWiring = await ensureSubspaceRootDataCalcImport(projectDir, subspaceDir)
|
|
1074
|
+
if (rootWiring?.touched) {
|
|
1075
|
+
touchedSites.push({ file: rootWiring.touched, action: 'wire_subspace_index' })
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const verify = await verifyProject(projectDir, input.verify)
|
|
1079
|
+
return {
|
|
1080
|
+
file: projectRelativePath(projectDir, calcPath),
|
|
1081
|
+
subspace: path.basename(subspaceDir),
|
|
1082
|
+
calc: slug,
|
|
1083
|
+
idExpression:
|
|
1084
|
+
input.id ??
|
|
1085
|
+
(stableIdAlias
|
|
1086
|
+
? `makeId('property_bank_calc', '${stableIdAlias}')`
|
|
1087
|
+
: "makeId('property_bank_calc')"),
|
|
1088
|
+
outcome: verify.status === 'compile:failed' ? 'verify_failed' : 'ok',
|
|
1089
|
+
touchedSites,
|
|
1090
|
+
...(rootWiring?.warning ? { warnings: [rootWiring.warning] } : {}),
|
|
1091
|
+
verify,
|
|
1092
|
+
references: referenceInputDetails(ctx.references),
|
|
1093
|
+
summary: `created data calc ${slug} in ${path.basename(subspaceDir)} -> ${verify.status}`,
|
|
1094
|
+
}
|
|
1095
|
+
})
|
|
1096
|
+
|
|
1097
|
+
const editDataCalc = async (projectDir: string, input: any) =>
|
|
1098
|
+
runOperation(projectDir, 'edit_data_calc', input, async () => {
|
|
1099
|
+
const target = await resolveCalcTarget(projectDir, input)
|
|
1100
|
+
const ctx: EditContext = {
|
|
1101
|
+
projectDir,
|
|
1102
|
+
parsed: target.parsed,
|
|
1103
|
+
references: [],
|
|
1104
|
+
typeImports: new Set(),
|
|
1105
|
+
valueImports: new Set(),
|
|
1106
|
+
}
|
|
1107
|
+
const touchedSites: Array<Record<string, unknown>> = []
|
|
1108
|
+
|
|
1109
|
+
for (const [key, value] of Object.entries(input.set || {})) {
|
|
1110
|
+
if (key === 'output' || key === 'error') {
|
|
1111
|
+
setObjectProperty(target.object, key, await nullableDataGetterExpression(value, ctx))
|
|
1112
|
+
} else if (key === 'inputs' || key === 'outputs') {
|
|
1113
|
+
if (!Array.isArray(value)) {
|
|
1114
|
+
throw new DataCalcEditingError('invalid_value', `${key} must be an array of IO items`)
|
|
1115
|
+
}
|
|
1116
|
+
setObjectProperty(target.object, key, await buildIoArrayExpression(key, value, ctx))
|
|
1117
|
+
} else if (key === 'code') {
|
|
1118
|
+
throw new DataCalcEditingError(
|
|
1119
|
+
'invalid_field',
|
|
1120
|
+
'Set code via the dedicated code parameter so it stays normalized to the sandbox file',
|
|
1121
|
+
)
|
|
1122
|
+
} else {
|
|
1123
|
+
setObjectProperty(target.object, key, await expressionFromInput(value, ctx))
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
for (const key of input.unset || []) {
|
|
1127
|
+
if (requiredCalcFields.has(key)) {
|
|
1128
|
+
throw new DataCalcEditingError('invalid_field', `Cannot unset required field: ${key}`, {
|
|
1129
|
+
field: key,
|
|
1130
|
+
})
|
|
1131
|
+
}
|
|
1132
|
+
removeObjectProperty(target.object, key)
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if (input.code != null) {
|
|
1136
|
+
const codeExpression = getCodeExpression(target.object)
|
|
1137
|
+
if (!isRecognizedCodeExpression(codeExpression)) {
|
|
1138
|
+
throw new DataCalcEditingError(
|
|
1139
|
+
'fallback_recommended',
|
|
1140
|
+
`Data calc ${target.parsed.relPath} has an unsupported code expression. Edit this file directly for this one.`,
|
|
1141
|
+
{ file: target.parsed.relPath, field: 'code' },
|
|
1142
|
+
)
|
|
1143
|
+
}
|
|
1144
|
+
const sandboxFile = target.sandboxFile
|
|
1145
|
+
const sandboxPath = path.join(path.dirname(target.parsed.absPath), sandboxFile)
|
|
1146
|
+
await writeFile(sandboxPath, wrapSandboxCode(input.code))
|
|
1147
|
+
ensureReadFileImport(target.parsed.ast)
|
|
1148
|
+
setObjectProperty(target.object, 'code', makeCodeReadFileExpression(sandboxFile))
|
|
1149
|
+
touchedSites.push({
|
|
1150
|
+
file: projectRelativePath(projectDir, sandboxPath),
|
|
1151
|
+
action: 'write_sandbox',
|
|
1152
|
+
})
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
applyPendingImports(ctx)
|
|
1156
|
+
await writeParsedFile(target.parsed)
|
|
1157
|
+
touchedSites.unshift({ file: target.parsed.relPath, action: 'edit_calc' })
|
|
1158
|
+
const verify = await verifyProject(projectDir, input.verify)
|
|
1159
|
+
return {
|
|
1160
|
+
file: target.parsed.relPath,
|
|
1161
|
+
calc: target.slug,
|
|
1162
|
+
outcome: verify.status === 'compile:failed' ? 'verify_failed' : 'ok',
|
|
1163
|
+
touchedSites,
|
|
1164
|
+
verify,
|
|
1165
|
+
references: referenceInputDetails(ctx.references),
|
|
1166
|
+
summary: `edited data calc ${target.slug} -> ${verify.status}`,
|
|
1167
|
+
}
|
|
1168
|
+
})
|
|
1169
|
+
|
|
1170
|
+
const editDataCalcIo = async (projectDir: string, input: any) =>
|
|
1171
|
+
runOperation(projectDir, 'edit_data_calc_io', input, async () => {
|
|
1172
|
+
const target = await resolveCalcTarget(projectDir, input)
|
|
1173
|
+
const ctx: EditContext = {
|
|
1174
|
+
projectDir,
|
|
1175
|
+
parsed: target.parsed,
|
|
1176
|
+
references: [],
|
|
1177
|
+
typeImports: new Set(),
|
|
1178
|
+
valueImports: new Set(),
|
|
1179
|
+
}
|
|
1180
|
+
const field = input.field as 'inputs' | 'outputs'
|
|
1181
|
+
const array = getOrCreateArrayProperty(target.object, field)
|
|
1182
|
+
const op = input.op
|
|
1183
|
+
|
|
1184
|
+
if (op === 'clear') {
|
|
1185
|
+
array.elements = []
|
|
1186
|
+
} else if (op === 'remove') {
|
|
1187
|
+
const index = findIoIndex(array, input)
|
|
1188
|
+
if (index < 0 || index >= array.elements.length) {
|
|
1189
|
+
throw new DataCalcEditingError('invalid_index', 'remove requires a valid key or index')
|
|
1190
|
+
}
|
|
1191
|
+
array.elements.splice(index, 1)
|
|
1192
|
+
} else if (op === 'add' || op === 'replace') {
|
|
1193
|
+
const index = op === 'replace' ? findIoIndex(array, input) : input.index
|
|
1194
|
+
if (op === 'replace' && (index < 0 || index >= array.elements.length)) {
|
|
1195
|
+
throw new DataCalcEditingError('invalid_index', 'replace requires a valid key or index')
|
|
1196
|
+
}
|
|
1197
|
+
if (
|
|
1198
|
+
op === 'add' &&
|
|
1199
|
+
typeof index === 'number' &&
|
|
1200
|
+
(index < 0 || index > array.elements.length)
|
|
1201
|
+
) {
|
|
1202
|
+
throw new DataCalcEditingError('invalid_index', 'add index out of range', { index })
|
|
1203
|
+
}
|
|
1204
|
+
if (field === 'inputs')
|
|
1205
|
+
assertInputKeyUnique(array, input.key, op === 'replace' ? index : undefined)
|
|
1206
|
+
const item = await buildIoObject(field, input, ctx)
|
|
1207
|
+
if (op === 'replace') array.elements[index] = item
|
|
1208
|
+
else array.elements.splice(typeof index === 'number' ? index : array.elements.length, 0, item)
|
|
1209
|
+
} else {
|
|
1210
|
+
throw new DataCalcEditingError('invalid_op', `Unsupported edit_data_calc_io op: ${op}`)
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
applyPendingImports(ctx)
|
|
1214
|
+
await writeParsedFile(target.parsed)
|
|
1215
|
+
const verify = await verifyProject(projectDir, input.verify)
|
|
1216
|
+
return {
|
|
1217
|
+
file: target.parsed.relPath,
|
|
1218
|
+
calc: target.slug,
|
|
1219
|
+
outcome: verify.status === 'compile:failed' ? 'verify_failed' : 'ok',
|
|
1220
|
+
change: { field, op, key: input.key, index: input.index },
|
|
1221
|
+
verify,
|
|
1222
|
+
references: referenceInputDetails(ctx.references),
|
|
1223
|
+
summary: `${op} ${field} on data calc ${target.slug} -> ${verify.status}`,
|
|
1224
|
+
}
|
|
1225
|
+
})
|
|
1226
|
+
|
|
1227
|
+
const removeDataCalc = async (projectDir: string, input: any) =>
|
|
1228
|
+
runOperation(projectDir, 'remove_data_calc', input, async () => {
|
|
1229
|
+
const target = await resolveCalcTarget(projectDir, input)
|
|
1230
|
+
const dataCalcDir = path.dirname(target.parsed.absPath)
|
|
1231
|
+
const sandboxPath = path.join(dataCalcDir, target.sandboxFile)
|
|
1232
|
+
await rm(target.parsed.absPath, { force: true })
|
|
1233
|
+
await rm(sandboxPath, { force: true })
|
|
1234
|
+
const indexRelPath = await regenerateDataCalcIndex(projectDir, dataCalcDir)
|
|
1235
|
+
const verify = await verifyProject(projectDir, input.verify)
|
|
1236
|
+
return {
|
|
1237
|
+
file: target.parsed.relPath,
|
|
1238
|
+
calc: target.slug,
|
|
1239
|
+
outcome: verify.status === 'compile:failed' ? 'verify_failed' : 'ok',
|
|
1240
|
+
touchedSites: [
|
|
1241
|
+
{ file: target.parsed.relPath, action: 'delete_calc' },
|
|
1242
|
+
{ file: projectRelativePath(projectDir, sandboxPath), action: 'delete_sandbox' },
|
|
1243
|
+
{ file: indexRelPath, action: 'regenerate_index' },
|
|
1244
|
+
],
|
|
1245
|
+
verify,
|
|
1246
|
+
summary: `removed data calc ${target.slug} -> ${verify.status}`,
|
|
1247
|
+
}
|
|
1248
|
+
})
|
|
1249
|
+
|
|
1250
|
+
const valueSchema = z
|
|
1251
|
+
.any()
|
|
1252
|
+
.describe(
|
|
1253
|
+
'Value grammar: JSON literals, bare data ref strings for output/error/IO data, { ref, subspace }, or { expr: "raw TypeScript expression" }.',
|
|
1254
|
+
)
|
|
1255
|
+
const fileSchema = z
|
|
1256
|
+
.string()
|
|
1257
|
+
.describe(
|
|
1258
|
+
'Project-relative data-calc file path, for example subspaces/subspace-0/data-calc/data-calculation-total.ts.',
|
|
1259
|
+
)
|
|
1260
|
+
const subspaceSchema = z
|
|
1261
|
+
.union([z.string(), z.number()])
|
|
1262
|
+
.describe(
|
|
1263
|
+
'Subspace locator. Defaults to subspace-0; accepts 0, "0", "subspace-0", or a project-relative subspace path.',
|
|
1264
|
+
)
|
|
1265
|
+
const calcSchema = z
|
|
1266
|
+
.string()
|
|
1267
|
+
.describe('Data calc locator inside the subspace: alias, explicit string id, or filename slug.')
|
|
1268
|
+
const verifySchema = z
|
|
1269
|
+
.boolean()
|
|
1270
|
+
.describe(
|
|
1271
|
+
'Override compile verification. Defaults to BRICKS_CTOR_MCP_EDIT_VERIFY, otherwise true.',
|
|
1272
|
+
)
|
|
1273
|
+
const targetSchema = {
|
|
1274
|
+
file: fileSchema.optional(),
|
|
1275
|
+
subspace: subspaceSchema.optional(),
|
|
1276
|
+
calc: calcSchema.optional(),
|
|
1277
|
+
verify: verifySchema.optional(),
|
|
1278
|
+
}
|
|
1279
|
+
const ioItemSchema = z
|
|
1280
|
+
.object({
|
|
1281
|
+
key: z.string().describe('Sandbox input/output key, for example "price" or "total".'),
|
|
1282
|
+
data: valueSchema.describe('Data ref as alias/id/varName, { ref, subspace }, or { expr }.'),
|
|
1283
|
+
trigger: z.boolean().describe('Inputs only; defaults true.').optional(),
|
|
1284
|
+
})
|
|
1285
|
+
.describe('DataCalculationScript IO item.')
|
|
1286
|
+
|
|
1287
|
+
export function register(server: McpServer, projectDir: string) {
|
|
1288
|
+
server.tool(
|
|
1289
|
+
'new_data_calc',
|
|
1290
|
+
'Create a standard DataCalculationScript in data-calc/{calc}.ts plus its sandbox JS file, regenerate data-calc/index.ts, and wire minimal subspace indexes.',
|
|
1291
|
+
{
|
|
1292
|
+
subspace: subspaceSchema.optional(),
|
|
1293
|
+
alias: z.string().describe('Optional alias; also drives the filename slug.').optional(),
|
|
1294
|
+
title: z.string().describe('Optional title.').optional(),
|
|
1295
|
+
description: z.string().describe('Optional description.').optional(),
|
|
1296
|
+
note: z.string().describe('Optional script note.').optional(),
|
|
1297
|
+
triggerMode: z
|
|
1298
|
+
.enum(['auto', 'manual'])
|
|
1299
|
+
.describe('Optional trigger mode; omitted by default.')
|
|
1300
|
+
.optional(),
|
|
1301
|
+
enableAsync: z
|
|
1302
|
+
.boolean()
|
|
1303
|
+
.describe('Whether the sandbox main may be async. Defaults false.')
|
|
1304
|
+
.optional(),
|
|
1305
|
+
code: z
|
|
1306
|
+
.string()
|
|
1307
|
+
.describe(
|
|
1308
|
+
'Sandbox body. The tool wraps it as export function main() { ... } unless already wrapped.',
|
|
1309
|
+
)
|
|
1310
|
+
.optional(),
|
|
1311
|
+
inputs: z
|
|
1312
|
+
.array(ioItemSchema)
|
|
1313
|
+
.describe('Initial script inputs. Input keys must be unique.')
|
|
1314
|
+
.optional(),
|
|
1315
|
+
outputs: z
|
|
1316
|
+
.array(ioItemSchema)
|
|
1317
|
+
.describe('Initial fan-out outputs. Output keys may repeat.')
|
|
1318
|
+
.optional(),
|
|
1319
|
+
output: valueSchema.describe('Single output data ref or null.').optional(),
|
|
1320
|
+
error: valueSchema.describe('Error output data ref or null.').optional(),
|
|
1321
|
+
id: z
|
|
1322
|
+
.string()
|
|
1323
|
+
.describe(
|
|
1324
|
+
"Optional explicit id. Defaults to source expression makeId('property_bank_calc').",
|
|
1325
|
+
)
|
|
1326
|
+
.optional(),
|
|
1327
|
+
verify: verifySchema.optional(),
|
|
1328
|
+
},
|
|
1329
|
+
async (input: any) => responseFor(await newDataCalc(projectDir, input)),
|
|
1330
|
+
)
|
|
1331
|
+
|
|
1332
|
+
server.tool(
|
|
1333
|
+
'edit_data_calc',
|
|
1334
|
+
'Edit DataCalculationScript scalar fields, output/error refs, or sandbox code. Code edits normalize inline code to sandbox file-form.',
|
|
1335
|
+
{
|
|
1336
|
+
...targetSchema,
|
|
1337
|
+
set: z
|
|
1338
|
+
.record(
|
|
1339
|
+
z
|
|
1340
|
+
.string()
|
|
1341
|
+
.describe(
|
|
1342
|
+
'Top-level field path such as title, description, note, alias, triggerMode, enableAsync, output, or error.',
|
|
1343
|
+
),
|
|
1344
|
+
valueSchema,
|
|
1345
|
+
)
|
|
1346
|
+
.describe(
|
|
1347
|
+
'Fields to set. output/error accept bare data ref strings, { ref, subspace }, { expr }, or null; inputs/outputs accept whole replacement arrays of { key, data, trigger? }. Use the code parameter for code.',
|
|
1348
|
+
)
|
|
1349
|
+
.optional(),
|
|
1350
|
+
unset: z.array(z.string()).describe('Top-level fields to remove.').optional(),
|
|
1351
|
+
code: z
|
|
1352
|
+
.string()
|
|
1353
|
+
.describe(
|
|
1354
|
+
'Replacement sandbox body. The tool wraps it as export function main() { ... } unless already wrapped.',
|
|
1355
|
+
)
|
|
1356
|
+
.optional(),
|
|
1357
|
+
},
|
|
1358
|
+
async (input: any) => responseFor(await editDataCalc(projectDir, input)),
|
|
1359
|
+
)
|
|
1360
|
+
|
|
1361
|
+
server.tool(
|
|
1362
|
+
'edit_data_calc_io',
|
|
1363
|
+
'Edit DataCalculationScript inputs or outputs arrays. Inputs require unique keys; outputs may repeat keys for fan-out.',
|
|
1364
|
+
{
|
|
1365
|
+
...targetSchema,
|
|
1366
|
+
field: z.enum(['inputs', 'outputs']).describe('Which IO array to edit.'),
|
|
1367
|
+
op: z.enum(['add', 'remove', 'replace', 'clear']).describe('Array operation.'),
|
|
1368
|
+
key: z
|
|
1369
|
+
.string()
|
|
1370
|
+
.describe('IO key target. Ambiguous repeated output keys must use index.')
|
|
1371
|
+
.optional(),
|
|
1372
|
+
index: z.number().describe('Zero-based IO index target or add insertion index.').optional(),
|
|
1373
|
+
data: valueSchema
|
|
1374
|
+
.describe('For add/replace: data ref as string, { ref, subspace }, or { expr }.')
|
|
1375
|
+
.optional(),
|
|
1376
|
+
trigger: z.boolean().describe('Inputs only; defaults true.').optional(),
|
|
1377
|
+
},
|
|
1378
|
+
async (input: any) => responseFor(await editDataCalcIo(projectDir, input)),
|
|
1379
|
+
)
|
|
1380
|
+
|
|
1381
|
+
server.tool(
|
|
1382
|
+
'remove_data_calc',
|
|
1383
|
+
'Remove a standard DataCalculationScript .ts file plus its sandbox file and regenerate data-calc/index.ts. No reverse cascade is needed.',
|
|
1384
|
+
targetSchema,
|
|
1385
|
+
async (input: any) => responseFor(await removeDataCalc(projectDir, input)),
|
|
1386
|
+
)
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
export const __test__ = {
|
|
1390
|
+
newDataCalc,
|
|
1391
|
+
editDataCalc,
|
|
1392
|
+
editDataCalcIo,
|
|
1393
|
+
removeDataCalc,
|
|
1394
|
+
resolveCalcTarget,
|
|
1395
|
+
}
|