@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/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 child = spawn(process.execPath, [output], { stdio: 'inherit' })
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 graph(input, format) {
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 pipelines = analyzePipelines(source)
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 parseGraphFlags(args, startIndex) {
276
- let format = 'text'
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 === '--format') {
282
- const value = args[++i]
283
- if (value !== 'text' && value !== 'dot') fail('--format must be "text" or "dot"')
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 } = parseGraphFlags(args, 2)
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.9.3",
4
- "description": "A tiny JavaScript-compatible language layer with Jojo syntax and pipelines.",
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
+ }