@panagos/jojoscript 0.9.3 → 1.0.0
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/README.md +848 -25
- package/bin/jojo.js +257 -23
- package/package.json +2 -2
- package/src/check.js +253 -0
- package/src/compiler.js +36 -37
- package/src/contracts.js +124 -0
- package/src/describe.js +63 -0
- package/src/effect-parser.js +4 -2
- package/src/formatter.js +105 -0
- package/src/index.js +31 -0
- package/src/match-parser.js +212 -14
- package/src/named-pipelines.js +87 -0
- package/src/parse.js +124 -0
- package/src/pipeline-explain.js +132 -0
- package/src/pipeline-ir.js +9 -7
- package/src/pipeline-parser.js +24 -2
- package/src/pipeline-plan.js +203 -0
- package/src/purity.js +101 -0
- package/src/runtime/a2a.js +322 -0
- package/src/runtime/checkpoint-store.js +26 -5
- package/src/runtime/checkpoint.js +3 -2
- package/src/runtime/effects.js +136 -2
- package/src/runtime/errors.js +14 -0
- package/src/runtime/index.js +15 -3
- package/src/runtime/mcp.js +237 -35
- package/src/runtime/serialization.js +97 -0
- package/src/runtime/tagged-unions.js +29 -0
- package/src/simulate.js +156 -0
- package/src/type-declarations.js +83 -0
- package/src/why.js +146 -0
package/bin/jojo.js
CHANGED
|
@@ -5,8 +5,16 @@ import path from 'node:path'
|
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
6
6
|
import { spawn, spawnSync } from 'node:child_process'
|
|
7
7
|
|
|
8
|
-
import { compile, compileFile, compileDirectory } from '../src/compiler.js'
|
|
8
|
+
import { compile, compileFile, compileDirectory, findJojoFiles } from '../src/compiler.js'
|
|
9
|
+
import { formatSource } from '../src/formatter.js'
|
|
10
|
+
import { parseSource } from '../src/parse.js'
|
|
9
11
|
import { analyzePipelines, renderGraphText, renderGraphDot } from '../src/pipeline-ir.js'
|
|
12
|
+
import { explainPipelines, renderExplanationText } from '../src/pipeline-explain.js'
|
|
13
|
+
import { planPipelines, planToJojo } from '../src/pipeline-plan.js'
|
|
14
|
+
import { checkSource, checkPlan, renderCheckText } from '../src/check.js'
|
|
15
|
+
import { describeSource } from '../src/describe.js'
|
|
16
|
+
import { simulatePipelines, renderSimulationText } from '../src/simulate.js'
|
|
17
|
+
import { whyPipelines, renderWhyText } from '../src/why.js'
|
|
10
18
|
import { FileCheckpointStore } from '../src/runtime/checkpoint-store.js'
|
|
11
19
|
|
|
12
20
|
const PACKAGE_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
@@ -22,8 +30,17 @@ function usage() {
|
|
|
22
30
|
|
|
23
31
|
Usage:
|
|
24
32
|
jojo build <file.jojo|directory> [--out <directory>] [--watch]
|
|
25
|
-
jojo run <file.jojo> [--out <directory>]
|
|
26
|
-
jojo graph <file.jojo> [--format text|dot]
|
|
33
|
+
jojo run <file.jojo> [--out <directory>] [--deterministic [--seed <n>]]
|
|
34
|
+
jojo graph <file.jojo> [--format text|dot] [--pipeline <name>]
|
|
35
|
+
jojo explain <file.jojo> [--format text|json] [--pipeline <name>]
|
|
36
|
+
jojo plan <file.jojo> [--format json] [--pipeline <name>]
|
|
37
|
+
jojo generate <plan.json>
|
|
38
|
+
jojo check <file.jojo|plan.json> [--format text|json] [--machine]
|
|
39
|
+
jojo describe <file.jojo> [--pipeline <name>]
|
|
40
|
+
jojo simulate <file.jojo> [--input <records>] [--format text|json] [--pipeline <name>]
|
|
41
|
+
jojo why <file.jojo> [--format text|json] [--pipeline <name>]
|
|
42
|
+
jojo format <file.jojo|directory> [--check]
|
|
43
|
+
jojo parse <file.jojo> [--json]
|
|
27
44
|
jojo profile <file.jojo>
|
|
28
45
|
jojo resume <file.jojo> [--out <directory>]
|
|
29
46
|
jojo replay <file.jojo> --from <checkpoint> [--out <directory>]
|
|
@@ -37,8 +54,18 @@ Examples:
|
|
|
37
54
|
jojo build src --out dist
|
|
38
55
|
jojo build src --watch
|
|
39
56
|
jojo run src/main.jojo
|
|
57
|
+
jojo run src/main.jojo --deterministic --seed 42
|
|
40
58
|
jojo graph orders.jojo
|
|
41
59
|
jojo graph orders.jojo --format dot
|
|
60
|
+
jojo explain orders.jojo --format json
|
|
61
|
+
jojo plan orders.jojo --format=json
|
|
62
|
+
jojo generate orders.plan.json
|
|
63
|
+
jojo check generated.jojo --machine
|
|
64
|
+
jojo describe orders.jojo
|
|
65
|
+
jojo simulate orders.jojo --input 1000000
|
|
66
|
+
jojo why orders.jojo --pipeline enrichOrders
|
|
67
|
+
jojo format src --check
|
|
68
|
+
jojo parse program.jojo --json
|
|
42
69
|
jojo profile orders.jojo
|
|
43
70
|
jojo resume orders.jojo
|
|
44
71
|
jojo replay orders.jojo --from enriched
|
|
@@ -114,14 +141,27 @@ function watch(input, out) {
|
|
|
114
141
|
|
|
115
142
|
rebuild()
|
|
116
143
|
|
|
117
|
-
fs.watch(resolvedInput, { recursive: true }, (_, filename) => {
|
|
144
|
+
const watcher = fs.watch(resolvedInput, { recursive: true }, (_, filename) => {
|
|
118
145
|
if (filename && !filename.endsWith('.jojo')) return
|
|
119
146
|
console.log(`\nChange detected${filename ? `: ${filename}` : ''}, rebuilding...`)
|
|
120
147
|
rebuild()
|
|
121
148
|
})
|
|
149
|
+
|
|
150
|
+
const stop = () => {
|
|
151
|
+
watcher.close()
|
|
152
|
+
process.exit(0)
|
|
153
|
+
}
|
|
154
|
+
process.once('SIGINT', stop)
|
|
155
|
+
process.once('SIGTERM', stop)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The whole program, including top-level code, is imported inside the deterministic scope.
|
|
159
|
+
function deterministicBootstrap(output, seed) {
|
|
160
|
+
return `import { deterministic } from ${JSON.stringify(RUNTIME_IMPORT)}\n` +
|
|
161
|
+
`await deterministic(() => import(${JSON.stringify(pathToFileURL(output).href)}), { seed: ${seed} })\n`
|
|
122
162
|
}
|
|
123
163
|
|
|
124
|
-
function run(input, out) {
|
|
164
|
+
function run(input, out, seed = null) {
|
|
125
165
|
const resolvedInput = path.resolve(input)
|
|
126
166
|
|
|
127
167
|
if (!fs.existsSync(resolvedInput) || fs.statSync(resolvedInput).isDirectory()) {
|
|
@@ -130,7 +170,8 @@ function run(input, out) {
|
|
|
130
170
|
|
|
131
171
|
const [{ output }] = build(input, out)
|
|
132
172
|
|
|
133
|
-
const
|
|
173
|
+
const args = seed === null ? [output] : ['--input-type=module', '--eval', deterministicBootstrap(output, seed)]
|
|
174
|
+
const child = spawn(process.execPath, args, { stdio: 'inherit' })
|
|
134
175
|
child.on('exit', code => process.exit(code ?? 0))
|
|
135
176
|
child.on('error', error => fail(error.message))
|
|
136
177
|
}
|
|
@@ -146,10 +187,18 @@ function requireSingleFile(command, input) {
|
|
|
146
187
|
return resolvedInput
|
|
147
188
|
}
|
|
148
189
|
|
|
149
|
-
function
|
|
190
|
+
function selectPipelines(items, names, wanted) {
|
|
191
|
+
if (!wanted) return items
|
|
192
|
+
const selected = items.filter((_, index) => names[index] === wanted)
|
|
193
|
+
if (!selected.length) fail(`No pipeline named "${wanted}"`)
|
|
194
|
+
return selected
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function graph(input, format, wanted) {
|
|
150
198
|
const resolvedInput = requireSingleFile('graph', input)
|
|
151
199
|
const source = fs.readFileSync(resolvedInput, 'utf8')
|
|
152
|
-
const
|
|
200
|
+
const names = explainPipelines(source).map(explanation => explanation.name)
|
|
201
|
+
const pipelines = selectPipelines(analyzePipelines(source), names, wanted)
|
|
153
202
|
|
|
154
203
|
if (!pipelines.length) {
|
|
155
204
|
console.log('No pipelines found.')
|
|
@@ -162,6 +211,119 @@ function graph(input, format) {
|
|
|
162
211
|
})
|
|
163
212
|
}
|
|
164
213
|
|
|
214
|
+
function explain(input, format, wanted) {
|
|
215
|
+
const resolvedInput = requireSingleFile('explain', input)
|
|
216
|
+
const all = explainPipelines(fs.readFileSync(resolvedInput, 'utf8'))
|
|
217
|
+
const explanations = selectPipelines(all, all.map(explanation => explanation.name), wanted)
|
|
218
|
+
|
|
219
|
+
if (format === 'json') {
|
|
220
|
+
console.log(JSON.stringify(explanations, null, 2))
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!explanations.length) {
|
|
225
|
+
console.log('No pipelines found.')
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
console.log(explanations.map(renderExplanationText).join('\n\n'))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function plan(input, wanted) {
|
|
233
|
+
const resolvedInput = requireSingleFile('plan', input)
|
|
234
|
+
const fullPlan = planPipelines(fs.readFileSync(resolvedInput, 'utf8'))
|
|
235
|
+
const pipelines = selectPipelines(fullPlan.pipelines, fullPlan.pipelines.map(item => item.pipeline), wanted)
|
|
236
|
+
console.log(JSON.stringify({ ...fullPlan, pipelines }, null, 2))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function readPlan(file) {
|
|
240
|
+
try {
|
|
241
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
242
|
+
} catch (error) {
|
|
243
|
+
fail(`Invalid plan JSON: ${error.message}`)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function generate(input) {
|
|
248
|
+
const resolvedInput = requireSingleFile('generate', input)
|
|
249
|
+
process.stdout.write(planToJojo(readPlan(resolvedInput)))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function describe(input, wanted) {
|
|
253
|
+
const resolvedInput = requireSingleFile('describe', input)
|
|
254
|
+
const description = describeSource(fs.readFileSync(resolvedInput, 'utf8'))
|
|
255
|
+
const pipelines = selectPipelines(description.pipelines, description.pipelines.map(item => item.name), wanted)
|
|
256
|
+
console.log(JSON.stringify({ ...description, pipelines }, null, 2))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function takeOption(args, name) {
|
|
260
|
+
const index = args.findIndex(arg => arg === name || arg.startsWith(`${name}=`))
|
|
261
|
+
if (index === -1) return { value: null, rest: args }
|
|
262
|
+
|
|
263
|
+
const inline = args[index] !== name
|
|
264
|
+
const value = inline ? args[index].slice(name.length + 1) : args[index + 1]
|
|
265
|
+
return { value, rest: args.filter((_, i) => i !== index && (inline || i !== index + 1)) }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function simulate(input, { format, pipeline, records }) {
|
|
269
|
+
const resolvedInput = requireSingleFile('simulate', input)
|
|
270
|
+
const all = simulatePipelines(fs.readFileSync(resolvedInput, 'utf8'), { input: records ?? undefined })
|
|
271
|
+
const simulations = selectPipelines(all, all.map(simulation => simulation.name), pipeline)
|
|
272
|
+
|
|
273
|
+
if (format === 'json') {
|
|
274
|
+
console.log(JSON.stringify(simulations, null, 2))
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
console.log(simulations.length ? simulations.map(renderSimulationText).join('\n\n') : 'No pipelines found.')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function parse(input) {
|
|
282
|
+
const resolvedInput = requireSingleFile('parse', input)
|
|
283
|
+
console.log(JSON.stringify(parseSource(fs.readFileSync(resolvedInput, 'utf8')), null, 2))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function formatFiles(input, checkOnly) {
|
|
287
|
+
const resolvedInput = path.resolve(input)
|
|
288
|
+
if (!fs.existsSync(resolvedInput)) fail(`Not found: ${resolvedInput}`)
|
|
289
|
+
|
|
290
|
+
const files = fs.statSync(resolvedInput).isDirectory() ? findJojoFiles(resolvedInput) : [resolvedInput]
|
|
291
|
+
const changed = files.filter(file => {
|
|
292
|
+
const source = fs.readFileSync(file, 'utf8')
|
|
293
|
+
const formatted = formatSource(source)
|
|
294
|
+
if (formatted === source) return false
|
|
295
|
+
if (!checkOnly) fs.writeFileSync(file, formatted, 'utf8')
|
|
296
|
+
return true
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
if (checkOnly && !changed.length) console.log('All files are formatted.')
|
|
300
|
+
for (const file of changed) console.log(checkOnly ? `Needs formatting: ${file}` : `Formatted ${file}`)
|
|
301
|
+
if (checkOnly && changed.length) process.exitCode = 1
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function why(input, format, wanted) {
|
|
305
|
+
const resolvedInput = requireSingleFile('why', input)
|
|
306
|
+
const all = whyPipelines(fs.readFileSync(resolvedInput, 'utf8'))
|
|
307
|
+
const answers = selectPipelines(all, all.map(item => item.name), wanted)
|
|
308
|
+
|
|
309
|
+
if (format === 'json') {
|
|
310
|
+
console.log(JSON.stringify(answers, null, 2))
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
console.log(answers.length ? answers.map(renderWhyText).join('\n\n') : 'No pipelines found.')
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function check(input, format) {
|
|
318
|
+
const resolvedInput = requireSingleFile('check', input)
|
|
319
|
+
const result = resolvedInput.endsWith('.json')
|
|
320
|
+
? checkPlan(readPlan(resolvedInput))
|
|
321
|
+
: checkSource(fs.readFileSync(resolvedInput, 'utf8'))
|
|
322
|
+
|
|
323
|
+
console.log(format === 'json' ? JSON.stringify(result, null, 2) : renderCheckText(result))
|
|
324
|
+
if (!result.valid) process.exitCode = 1
|
|
325
|
+
}
|
|
326
|
+
|
|
165
327
|
function profile(input) {
|
|
166
328
|
const resolvedInput = requireSingleFile('profile', input)
|
|
167
329
|
const source = fs.readFileSync(resolvedInput, 'utf8')
|
|
@@ -272,15 +434,22 @@ function replay(input, out, from) {
|
|
|
272
434
|
run(input, out)
|
|
273
435
|
}
|
|
274
436
|
|
|
275
|
-
function
|
|
276
|
-
let format =
|
|
437
|
+
function parseFormatFlags(args, startIndex, formats) {
|
|
438
|
+
let format = formats[0]
|
|
439
|
+
let pipeline = null
|
|
277
440
|
|
|
278
441
|
for (let i = startIndex; i < args.length; i++) {
|
|
279
442
|
const arg = args[i]
|
|
280
443
|
|
|
281
|
-
if (arg === '--
|
|
282
|
-
|
|
283
|
-
if (
|
|
444
|
+
if (arg === '--pipeline' || arg.startsWith('--pipeline=')) {
|
|
445
|
+
pipeline = arg === '--pipeline' ? args[++i] : arg.slice('--pipeline='.length)
|
|
446
|
+
if (!pipeline) fail('--pipeline requires a pipeline name')
|
|
447
|
+
continue
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (arg === '--format' || arg.startsWith('--format=')) {
|
|
451
|
+
const value = arg === '--format' ? args[++i] : arg.slice('--format='.length)
|
|
452
|
+
if (!formats.includes(value)) fail(`--format must be ${formats.map(name => `"${name}"`).join(' or ')}`)
|
|
284
453
|
format = value
|
|
285
454
|
continue
|
|
286
455
|
}
|
|
@@ -288,7 +457,7 @@ function parseGraphFlags(args, startIndex) {
|
|
|
288
457
|
fail(`Unknown option: ${arg}`)
|
|
289
458
|
}
|
|
290
459
|
|
|
291
|
-
return { format }
|
|
460
|
+
return { format, pipeline }
|
|
292
461
|
}
|
|
293
462
|
|
|
294
463
|
function parseReplayFlags(args, startIndex) {
|
|
@@ -350,7 +519,7 @@ function testCmd(input) {
|
|
|
350
519
|
process.exit(result.status ?? 0)
|
|
351
520
|
}
|
|
352
521
|
|
|
353
|
-
const COMMANDS = new Set(['build', 'run', 'graph', 'profile', 'resume', 'replay', 'test'])
|
|
522
|
+
const COMMANDS = new Set(['build', 'run', 'graph', 'explain', 'plan', 'generate', 'check', 'describe', 'simulate', 'why', 'format', 'parse', 'profile', 'resume', 'replay', 'test'])
|
|
354
523
|
|
|
355
524
|
function main() {
|
|
356
525
|
const args = process.argv.slice(2)
|
|
@@ -379,8 +548,67 @@ function main() {
|
|
|
379
548
|
const input = args[1]
|
|
380
549
|
|
|
381
550
|
if (command === 'graph') {
|
|
382
|
-
const { format } =
|
|
383
|
-
graph(input, format)
|
|
551
|
+
const { format, pipeline } = parseFormatFlags(args, 2, ['text', 'dot'])
|
|
552
|
+
graph(input, format, pipeline)
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (command === 'plan') {
|
|
557
|
+
const { pipeline } = parseFormatFlags(args, 2, ['json'])
|
|
558
|
+
plan(input, pipeline)
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (command === 'parse') {
|
|
563
|
+
parseFormatFlags(args.filter(arg => arg !== '--json'), 2, ['json'])
|
|
564
|
+
parse(input)
|
|
565
|
+
return
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (command === 'format') {
|
|
569
|
+
const checkOnly = args.includes('--check')
|
|
570
|
+
const unknown = args.slice(2).find(arg => arg !== '--check')
|
|
571
|
+
if (unknown) fail(`Unknown option: ${unknown}`)
|
|
572
|
+
formatFiles(input, checkOnly)
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (command === 'why') {
|
|
577
|
+
const { format, pipeline } = parseFormatFlags(args, 2, ['text', 'json'])
|
|
578
|
+
why(input, format, pipeline)
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (command === 'simulate') {
|
|
583
|
+
const { value, rest } = takeOption(args, '--input')
|
|
584
|
+
if (value !== null && !/^[1-9]\d*$/.test(value ?? '')) fail('--input must be a positive integer')
|
|
585
|
+
const { format, pipeline } = parseFormatFlags(rest, 2, ['text', 'json'])
|
|
586
|
+
simulate(input, { format, pipeline, records: value === null ? null : Number(value) })
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (command === 'describe') {
|
|
591
|
+
const { pipeline } = parseFormatFlags(args, 2, ['json'])
|
|
592
|
+
describe(input, pipeline)
|
|
593
|
+
return
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (command === 'check') {
|
|
597
|
+
const machine = args.includes('--machine')
|
|
598
|
+
const { format } = parseFormatFlags(args.filter(arg => arg !== '--machine'), 2, ['text', 'json'])
|
|
599
|
+
check(input, machine ? 'json' : format)
|
|
600
|
+
return
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (command === 'generate') {
|
|
604
|
+
if (args[2]) fail(`Unknown option: ${args[2]}`)
|
|
605
|
+
generate(input)
|
|
606
|
+
return
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (command === 'explain') {
|
|
610
|
+
const { format, pipeline } = parseFormatFlags(args, 2, ['text', 'json'])
|
|
611
|
+
explain(input, format, pipeline)
|
|
384
612
|
return
|
|
385
613
|
}
|
|
386
614
|
|
|
@@ -400,6 +628,18 @@ function main() {
|
|
|
400
628
|
return
|
|
401
629
|
}
|
|
402
630
|
|
|
631
|
+
if (command === 'run') {
|
|
632
|
+
const { value: seed, rest } = takeOption(args.filter(arg => arg !== '--deterministic'), '--seed')
|
|
633
|
+
const isDeterministic = args.includes('--deterministic')
|
|
634
|
+
if (seed !== null && !isDeterministic) fail('--seed requires --deterministic')
|
|
635
|
+
if (seed !== null && !/^\d+$/.test(seed ?? '')) fail('--seed must be a non-negative integer')
|
|
636
|
+
|
|
637
|
+
const { out, watch: shouldWatch } = parseFlags(rest, 2)
|
|
638
|
+
if (shouldWatch) fail('--watch is not supported with `jojo run`, use `jojo build --watch` instead')
|
|
639
|
+
run(input, out, isDeterministic ? Number(seed ?? 1) : null)
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
|
|
403
643
|
const { out, watch: shouldWatch } = parseFlags(args, 2)
|
|
404
644
|
|
|
405
645
|
if (command === 'resume') {
|
|
@@ -408,12 +648,6 @@ function main() {
|
|
|
408
648
|
return
|
|
409
649
|
}
|
|
410
650
|
|
|
411
|
-
if (command === 'run') {
|
|
412
|
-
if (shouldWatch) fail('--watch is not supported with `jojo run`, use `jojo build --watch` instead')
|
|
413
|
-
run(input, out)
|
|
414
|
-
return
|
|
415
|
-
}
|
|
416
|
-
|
|
417
651
|
if (shouldWatch) {
|
|
418
652
|
watch(input, out)
|
|
419
653
|
return
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panagos/jojoscript",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "JojoScript is a lightweight language and runtime for data pipelines and agent workflows, built on JavaScript.",
|
|
5
5
|
"license": "Unlicense",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
package/src/check.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { spawnSync } from 'node:child_process'
|
|
5
|
+
|
|
6
|
+
import { compile } from './compiler.js'
|
|
7
|
+
import { maskNonCode, tokenize } from './lexer.js'
|
|
8
|
+
import { findEffectDeclarations, findHandleBlocks } from './effect-parser.js'
|
|
9
|
+
import { findPurityViolations } from './purity.js'
|
|
10
|
+
import { findContracts } from './contracts.js'
|
|
11
|
+
import { findTypeDeclarations } from './type-declarations.js'
|
|
12
|
+
import { findMatchExpressions } from './match-parser.js'
|
|
13
|
+
import { planToJojo } from './pipeline-plan.js'
|
|
14
|
+
|
|
15
|
+
const LITERAL_KEYWORDS = { true: true, false: false, null: null, undefined }
|
|
16
|
+
const CONDITION_KEYWORDS = new Set(['typeof', ...Object.keys(LITERAL_KEYWORDS)])
|
|
17
|
+
|
|
18
|
+
function diagnostic(code, line, message, suggestion) {
|
|
19
|
+
return { code, severity: code.startsWith('W') ? 'warning' : 'error', line, message, suggestion }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function lineAt(source, offset) {
|
|
23
|
+
return source.slice(0, offset).split('\n').length
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function nameUsage(name) {
|
|
27
|
+
return new RegExp(`(?<![.\\w$])${name}(?![\\w$])`, 'g')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Static analyses skip code they cannot parse; the compiler then reports it as E100.
|
|
31
|
+
function analyze(fn) {
|
|
32
|
+
try {
|
|
33
|
+
return fn()
|
|
34
|
+
} catch {
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function missingHandlers(source) {
|
|
40
|
+
const code = maskNonCode(source)
|
|
41
|
+
const handled = new Set(findHandleBlocks(source).flatMap(block => block.handlerNames))
|
|
42
|
+
|
|
43
|
+
return findEffectDeclarations(source)
|
|
44
|
+
.filter(effect => !handled.has(effect.name))
|
|
45
|
+
.flatMap(effect => {
|
|
46
|
+
const use = [...code.matchAll(nameUsage(effect.name))]
|
|
47
|
+
.find(match => match.index < effect.start || match.index >= effect.end)
|
|
48
|
+
if (!use) return []
|
|
49
|
+
|
|
50
|
+
return [diagnostic(
|
|
51
|
+
'W102',
|
|
52
|
+
lineAt(source, use.index),
|
|
53
|
+
`Effect "${effect.name}" has no handler in this file`,
|
|
54
|
+
`Add a handler for "${effect.name}" in a handle { ... } block, or make sure every caller installs one`
|
|
55
|
+
)]
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function purityErrors(source) {
|
|
60
|
+
return findPurityViolations(source).map(violation => diagnostic(
|
|
61
|
+
'E201',
|
|
62
|
+
violation.line,
|
|
63
|
+
`pure fn "${violation.function}" uses ${violation.kind} "${violation.uses}"`,
|
|
64
|
+
`Remove the use of "${violation.uses}" from "${violation.function}", or declare "${violation.function}" as effectful fn`
|
|
65
|
+
))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function literalValue(text) {
|
|
69
|
+
if (/^-?\d+(\.\d+)?$/.test(text)) return { value: Number(text) }
|
|
70
|
+
const string = text.match(/^(["'])([^"'\\]*)\1$/)
|
|
71
|
+
if (string) return { value: string[2] }
|
|
72
|
+
if (Object.hasOwn(LITERAL_KEYWORDS, text)) return { value: LITERAL_KEYWORDS[text] }
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function splitArguments(text) {
|
|
77
|
+
const result = []
|
|
78
|
+
let depth = 0
|
|
79
|
+
let start = 0
|
|
80
|
+
for (let i = 0; i < text.length; i++) {
|
|
81
|
+
if ('([{'.includes(text[i])) depth++
|
|
82
|
+
else if (')]}'.includes(text[i])) depth--
|
|
83
|
+
else if (text[i] === ',' && depth === 0) {
|
|
84
|
+
result.push(text.slice(start, i).trim())
|
|
85
|
+
start = i + 1
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (text.trim()) result.push(text.slice(start).trim())
|
|
89
|
+
return result
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/*
|
|
93
|
+
* A condition is evaluated only when it is built from parameters, literals,
|
|
94
|
+
* and operators: no property access, indexing, calls, or other names, so
|
|
95
|
+
* checking never runs user code.
|
|
96
|
+
*/
|
|
97
|
+
function evaluableCondition(condition, params) {
|
|
98
|
+
const tokens = tokenize(condition)
|
|
99
|
+
return tokens.every((token, index) => {
|
|
100
|
+
if (token.type === 'identifier') return params.includes(token.value) || CONDITION_KEYWORDS.has(token.value)
|
|
101
|
+
if (['.', '[', '?.', '=>'].includes(token.value)) return false
|
|
102
|
+
return !(token.value === '(' && tokens[index - 1]?.type === 'identifier')
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function contractViolations(source) {
|
|
107
|
+
const code = maskNonCode(source)
|
|
108
|
+
|
|
109
|
+
return findContracts(source).flatMap(contract => [...code.matchAll(new RegExp(`(?<![.\\w$])${contract.name}\\s*\\(`, 'g'))]
|
|
110
|
+
.filter(call => !/\bfn\s+$/.test(code.slice(Math.max(0, call.index - 10), call.index)))
|
|
111
|
+
.flatMap(call => {
|
|
112
|
+
const open = call.index + call[0].length - 1
|
|
113
|
+
let depth = 0
|
|
114
|
+
let close = open
|
|
115
|
+
for (; close < code.length; close++) {
|
|
116
|
+
if (code[close] === '(') depth++
|
|
117
|
+
else if (code[close] === ')' && --depth === 0) break
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const literals = splitArguments(source.slice(open + 1, close)).map(literalValue)
|
|
121
|
+
if (literals.length !== contract.params.length || literals.some(literal => !literal)) return []
|
|
122
|
+
|
|
123
|
+
const values = literals.map(literal => literal.value)
|
|
124
|
+
const broken = contract.requires.find(({ condition }) =>
|
|
125
|
+
evaluableCondition(condition, contract.params) &&
|
|
126
|
+
!new Function(...contract.params, `return (${condition})`)(...values))
|
|
127
|
+
if (!broken) return []
|
|
128
|
+
|
|
129
|
+
return [diagnostic(
|
|
130
|
+
'E301',
|
|
131
|
+
lineAt(source, call.index),
|
|
132
|
+
`Call ${source.slice(call.index, close + 1)} violates "requires ${broken.condition}"`,
|
|
133
|
+
`Pass arguments to "${contract.name}" that satisfy ${broken.condition}`
|
|
134
|
+
)]
|
|
135
|
+
}))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function matchErrors(source) {
|
|
139
|
+
const variants = new Map()
|
|
140
|
+
for (const type of findTypeDeclarations(source)) {
|
|
141
|
+
for (const variant of type.variants) variants.set(variant.name, { ...variant, type })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return findMatchExpressions(source).flatMap(match => {
|
|
145
|
+
const known = match.arms.map(arm => arm.variant).filter(variant => variant && variants.has(variant.name))
|
|
146
|
+
if (!known.length) return []
|
|
147
|
+
|
|
148
|
+
const arity = known
|
|
149
|
+
.filter(variant => variant.arity !== variants.get(variant.name).fields.length)
|
|
150
|
+
.map(variant => {
|
|
151
|
+
const declared = variants.get(variant.name)
|
|
152
|
+
return diagnostic(
|
|
153
|
+
'E402',
|
|
154
|
+
variant.line,
|
|
155
|
+
`Pattern ${variant.name}(...) has ${variant.arity} fields, but variant ${variant.name} declares ${declared.fields.length} (${declared.fields.join(', ')})`,
|
|
156
|
+
`Match ${variant.name} with exactly ${declared.fields.length} field pattern(s)`
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const types = new Set(known.map(variant => variants.get(variant.name).type))
|
|
161
|
+
if (match.hasDefault || types.size !== 1) return arity
|
|
162
|
+
|
|
163
|
+
const [type] = types
|
|
164
|
+
const covered = new Set(known.map(variant => variant.name))
|
|
165
|
+
const missing = type.variants
|
|
166
|
+
.filter(variant => !covered.has(variant.name))
|
|
167
|
+
.map(variant => diagnostic(
|
|
168
|
+
'E401',
|
|
169
|
+
match.line,
|
|
170
|
+
`match on ${type.name} does not handle variant ${variant.name}`,
|
|
171
|
+
`Add a "${variant.name}(...) => ..." arm or a final "_ => ..." arm`
|
|
172
|
+
))
|
|
173
|
+
|
|
174
|
+
return [...missing, ...arity]
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function javascriptError(compiled) {
|
|
179
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jojo-check-'))
|
|
180
|
+
const file = path.join(dir, 'program.mjs')
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
fs.writeFileSync(file, compiled)
|
|
184
|
+
const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' })
|
|
185
|
+
if (result.status === 0) return null
|
|
186
|
+
const message = result.stderr.split('\n').find(line => /^\w*Error: /.test(line)) ?? result.stderr.trim()
|
|
187
|
+
return diagnostic('E101', null, `Compiled JavaScript is invalid: ${message}`, 'Fix the JavaScript syntax near the reported construct and run jojo check again')
|
|
188
|
+
} finally {
|
|
189
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function compilerDiagnostics(source, reported) {
|
|
194
|
+
let compiled
|
|
195
|
+
try {
|
|
196
|
+
compiled = compile(source)
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error.message.startsWith('pure fn ') && reported.some(item => item.code === 'E201')) return []
|
|
199
|
+
const line = error.message.match(/\(line (\d+)\)/)?.[1]
|
|
200
|
+
return [diagnostic('E100', line ? Number(line) : null, error.message, 'Fix the reported syntax and run jojo check again')]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const invalid = javascriptError(compiled)
|
|
204
|
+
return invalid ? [invalid] : []
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function byLine(a, b) {
|
|
208
|
+
return (a.line ?? Infinity) - (b.line ?? Infinity)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function result(diagnostics) {
|
|
212
|
+
const errors = diagnostics.filter(item => item.severity === 'error').sort(byLine)
|
|
213
|
+
const warnings = diagnostics.filter(item => item.severity === 'warning').sort(byLine)
|
|
214
|
+
return { valid: errors.length === 0, errors, warnings }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function checkSource(source) {
|
|
218
|
+
const diagnostics = [
|
|
219
|
+
...analyze(() => missingHandlers(source)),
|
|
220
|
+
...analyze(() => purityErrors(source)),
|
|
221
|
+
...analyze(() => contractViolations(source)),
|
|
222
|
+
...analyze(() => matchErrors(source))
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
return result([...diagnostics, ...compilerDiagnostics(source, diagnostics)])
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function checkPlan(plan) {
|
|
229
|
+
try {
|
|
230
|
+
planToJojo(plan)
|
|
231
|
+
return result([])
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (error.name !== 'PlanValidationError') throw error
|
|
234
|
+
return result([diagnostic('E700', null, error.message, 'Fix the plan field named in the message and run jojo check again')])
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function plural(count, word) {
|
|
239
|
+
return `${count} ${word}${count === 1 ? '' : 's'}`
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function renderCheckText({ errors, warnings }) {
|
|
243
|
+
if (!errors.length && !warnings.length) return 'valid: no problems found'
|
|
244
|
+
|
|
245
|
+
const blocks = [...errors, ...warnings].map(item => [
|
|
246
|
+
`${item.severity.toUpperCase()} ${item.code}`,
|
|
247
|
+
...(item.line === null ? [] : [`line ${item.line}`]),
|
|
248
|
+
item.message,
|
|
249
|
+
`suggestion: ${item.suggestion}`
|
|
250
|
+
].join('\n'))
|
|
251
|
+
|
|
252
|
+
return [...blocks, `${plural(errors.length, 'error')}, ${plural(warnings.length, 'warning')}`].join('\n\n')
|
|
253
|
+
}
|