@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
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Canonical JSON for JojoScript values. Object keys are sorted so equal
|
|
3
|
+
* values always produce identical text, and values plain JSON would lose
|
|
4
|
+
* are encoded as `{ "$type": ... }` objects. An ordinary object that has its
|
|
5
|
+
* own `$type` key is wrapped, so a tag is never ambiguous.
|
|
6
|
+
*/
|
|
7
|
+
export function toJson(value, options = {}) {
|
|
8
|
+
return JSON.stringify(encode(value, new Set()), null, options.indent)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function fromJson(text) {
|
|
12
|
+
return decode(JSON.parse(text))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ERROR_BUILT_INS = new Set(['name', 'message', 'stack', 'cause'])
|
|
16
|
+
|
|
17
|
+
function encodeNumber(value) {
|
|
18
|
+
if (Number.isNaN(value) || !Number.isFinite(value) || Object.is(value, -0)) {
|
|
19
|
+
return { $type: 'Number', value: Object.is(value, -0) ? '-0' : String(value) }
|
|
20
|
+
}
|
|
21
|
+
return value
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function encodeObject(value, ancestors) {
|
|
25
|
+
const result = {}
|
|
26
|
+
for (const key of Object.keys(value).sort()) result[key] = encode(value[key], ancestors)
|
|
27
|
+
return Object.hasOwn(value, '$type') ? { $type: 'Object', value: result } : result
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function encodeError(error, ancestors) {
|
|
31
|
+
const fields = {}
|
|
32
|
+
for (const key of Object.keys(error).sort()) {
|
|
33
|
+
if (!ERROR_BUILT_INS.has(key)) fields[key] = encode(error[key], ancestors)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const encoded = { $type: 'Error', name: error.name, message: error.message, fields }
|
|
37
|
+
if ('cause' in error) encoded.cause = encode(error.cause, ancestors)
|
|
38
|
+
return encoded
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function encode(value, ancestors) {
|
|
42
|
+
if (value === undefined) return { $type: 'undefined' }
|
|
43
|
+
if (typeof value === 'number') return encodeNumber(value)
|
|
44
|
+
if (typeof value === 'bigint') return { $type: 'BigInt', value: String(value) }
|
|
45
|
+
if (typeof value === 'function') throw new TypeError('Cannot serialize a function')
|
|
46
|
+
if (typeof value === 'symbol') throw new TypeError('Cannot serialize a symbol')
|
|
47
|
+
if (value === null || typeof value !== 'object') return value
|
|
48
|
+
|
|
49
|
+
if (ancestors.has(value)) throw new TypeError('Cannot serialize a circular structure')
|
|
50
|
+
ancestors.add(value)
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
if (value instanceof Date) return { $type: 'Date', value: value.toISOString() }
|
|
54
|
+
if (value instanceof Map) {
|
|
55
|
+
return { $type: 'Map', entries: [...value].map(([key, item]) => [encode(key, ancestors), encode(item, ancestors)]) }
|
|
56
|
+
}
|
|
57
|
+
if (value instanceof Set) return { $type: 'Set', values: [...value].map(item => encode(item, ancestors)) }
|
|
58
|
+
if (value instanceof Error) return encodeError(value, ancestors)
|
|
59
|
+
if (Array.isArray(value)) return value.map(item => encode(item, ancestors))
|
|
60
|
+
if (typeof value.toJSON === 'function') return encode(value.toJSON(), ancestors)
|
|
61
|
+
return encodeObject(value, ancestors)
|
|
62
|
+
} finally {
|
|
63
|
+
ancestors.delete(value)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function decodeEntries(object) {
|
|
68
|
+
const result = {}
|
|
69
|
+
for (const [key, item] of Object.entries(object)) result[key] = decode(item)
|
|
70
|
+
return result
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function decodeError(encoded) {
|
|
74
|
+
const error = new Error(encoded.message)
|
|
75
|
+
error.name = encoded.name
|
|
76
|
+
Object.assign(error, decodeEntries(encoded.fields ?? {}))
|
|
77
|
+
if ('cause' in encoded) error.cause = decode(encoded.cause)
|
|
78
|
+
return error
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function decode(value) {
|
|
82
|
+
if (Array.isArray(value)) return value.map(decode)
|
|
83
|
+
if (value === null || typeof value !== 'object') return value
|
|
84
|
+
if (!Object.hasOwn(value, '$type')) return decodeEntries(value)
|
|
85
|
+
|
|
86
|
+
switch (value.$type) {
|
|
87
|
+
case 'undefined': return undefined
|
|
88
|
+
case 'Number': return Number(value.value)
|
|
89
|
+
case 'BigInt': return BigInt(value.value)
|
|
90
|
+
case 'Date': return new Date(value.value)
|
|
91
|
+
case 'Map': return new Map(value.entries.map(([key, item]) => [decode(key), decode(item)]))
|
|
92
|
+
case 'Set': return new Set(value.values.map(decode))
|
|
93
|
+
case 'Error': return decodeError(value)
|
|
94
|
+
case 'Object': return decodeEntries(value.value)
|
|
95
|
+
default: throw new TypeError(`Unknown serialized type "${value.$type}"`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Tagged union values are frozen plain objects (`{ tag: "Ok", value: 1 }`),
|
|
3
|
+
* so they serialize to JSON and still match constructor patterns afterwards.
|
|
4
|
+
*/
|
|
5
|
+
export function defineType(typeName, variants) {
|
|
6
|
+
const constructors = {}
|
|
7
|
+
|
|
8
|
+
for (const [tag, fields] of Object.entries(variants)) {
|
|
9
|
+
if (fields.includes('tag')) {
|
|
10
|
+
throw new TypeError(`Variant "${tag}" cannot declare a field named "tag"`)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const construct = (...args) => {
|
|
14
|
+
if (args.length !== fields.length) {
|
|
15
|
+
const expected = fields.length === 1 ? '1 argument' : `${fields.length} arguments`
|
|
16
|
+
const names = fields.length ? ` (${fields.join(', ')})` : ''
|
|
17
|
+
throw new TypeError(`${tag} expects ${expected}${names}, got ${args.length}`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const value = { tag }
|
|
21
|
+
fields.forEach((field, index) => { value[field] = args[index] })
|
|
22
|
+
return Object.freeze(value)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
constructors[tag] = Object.assign(construct, { tag, type: typeName, fields: Object.freeze([...fields]) })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return Object.freeze(constructors)
|
|
29
|
+
}
|
package/src/simulate.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { explainPipelines } from './pipeline-explain.js'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_INPUT = 1000
|
|
4
|
+
const EFFECT_VOLUME_WARNING = 1000
|
|
5
|
+
const MEMORY_WARNING = 10000
|
|
6
|
+
|
|
7
|
+
const NARROWING_STAGES = new Set(['filter', 'filterAsync', 'unique', 'distinctBy'])
|
|
8
|
+
const SINGLE_RESULT_STAGES = new Set(['count', 'first', 'find', 'some', 'every', 'reduce'])
|
|
9
|
+
const MATERIALIZING_STAGES = new Set(['sort', 'sortBy', 'groupBy', 'partition'])
|
|
10
|
+
|
|
11
|
+
function integerArgument(stage) {
|
|
12
|
+
return /^\d+$/.test(stage.args[0] ?? '') ? Number(stage.args[0]) : null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
* Upper bound on the items a stage emits, given the items it receives.
|
|
17
|
+
* `null` means the count cannot be known statically.
|
|
18
|
+
*/
|
|
19
|
+
function itemsOut(stage, items) {
|
|
20
|
+
if (items === null) return SINGLE_RESULT_STAGES.has(stage.type) ? 1 : null
|
|
21
|
+
const size = integerArgument(stage)
|
|
22
|
+
|
|
23
|
+
switch (stage.type) {
|
|
24
|
+
case 'take': return size === null ? null : Math.min(size, items)
|
|
25
|
+
case 'skip': return size === null ? null : Math.max(items - size, 0)
|
|
26
|
+
case 'chunk':
|
|
27
|
+
case 'batch': return size === null ? null : Math.ceil(items / size)
|
|
28
|
+
case 'window': return size === null ? null : Math.max(items - size + 1, 0)
|
|
29
|
+
case 'flatMap':
|
|
30
|
+
case 'groupBy': return null
|
|
31
|
+
case 'partition': return 2
|
|
32
|
+
default: return SINGLE_RESULT_STAGES.has(stage.type) ? 1 : items
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function exactOut(stage, exact) {
|
|
37
|
+
return exact && !NARROWING_STAGES.has(stage.type)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function format(items, exact) {
|
|
41
|
+
if (items === null) return 'unknown'
|
|
42
|
+
return `${exact ? '' : '≤'}${items.toLocaleString('en-US')}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function simulateStages(explanation, input) {
|
|
46
|
+
let items = input
|
|
47
|
+
let exact = true
|
|
48
|
+
|
|
49
|
+
return explanation.stages.slice(1).map(stage => {
|
|
50
|
+
const effects = [...stage.effects, ...stage.effectfulFunctions]
|
|
51
|
+
const simulated = {
|
|
52
|
+
index: stage.index,
|
|
53
|
+
id: stage.id,
|
|
54
|
+
type: stage.type,
|
|
55
|
+
items,
|
|
56
|
+
exact,
|
|
57
|
+
workers: stage.type === 'parallel' ? integerArgument(stage) : null,
|
|
58
|
+
effectCalls: Object.fromEntries(effects.map(name => [name, items])),
|
|
59
|
+
checkpoint: stage.type === 'checkpoint' ? stage.detail : null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
exact = exactOut(stage, exact)
|
|
63
|
+
items = itemsOut(stage, items)
|
|
64
|
+
return simulated
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findProblems(stages) {
|
|
69
|
+
const problems = []
|
|
70
|
+
const checkpoints = stages.filter(stage => stage.checkpoint)
|
|
71
|
+
|
|
72
|
+
for (const stage of stages) {
|
|
73
|
+
if (MATERIALIZING_STAGES.has(stage.type) && stage.items >= MEMORY_WARNING) {
|
|
74
|
+
const amount = stage.exact ? `all ${format(stage.items, true)}` : `up to ${format(stage.items, true)}`
|
|
75
|
+
problems.push({ stage: stage.index, message: `${stage.type} holds ${amount} items in memory before continuing` })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const [name, calls] of Object.entries(stage.effectCalls)) {
|
|
79
|
+
if (calls !== null && calls >= EFFECT_VOLUME_WARNING) {
|
|
80
|
+
problems.push({ stage: stage.index, message: `up to ${format(calls, true)} calls to effect "${name}"` })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const nextCheckpoint = checkpoints.find(checkpoint => checkpoint.index > stage.index)
|
|
84
|
+
if (nextCheckpoint) {
|
|
85
|
+
problems.push({
|
|
86
|
+
stage: stage.index,
|
|
87
|
+
message: `effect "${name}" runs before checkpoint "${nextCheckpoint.checkpoint}": items processed before a failure are fetched again on retry (at-least-once)`
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!checkpoints.length && stages.some(stage => Object.keys(stage.effectCalls).length)) {
|
|
94
|
+
problems.push({ stage: null, message: 'pipeline has effects but no checkpoint: a failure re-runs every effect from the start' })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return problems
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function totalEffectCalls(stages) {
|
|
101
|
+
const totals = {}
|
|
102
|
+
for (const stage of stages) {
|
|
103
|
+
for (const [name, calls] of Object.entries(stage.effectCalls)) {
|
|
104
|
+
totals[name] = name in totals && (totals[name] === null || calls === null) ? null : (totals[name] ?? 0) + calls
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return totals
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/*
|
|
111
|
+
* A static what-if for `input` records: no stage or effect is executed.
|
|
112
|
+
* Counts are upper bounds once a filtering stage has run.
|
|
113
|
+
*/
|
|
114
|
+
export function simulatePipelines(source, options = {}) {
|
|
115
|
+
const input = options.input ?? DEFAULT_INPUT
|
|
116
|
+
|
|
117
|
+
return explainPipelines(source).map(explanation => {
|
|
118
|
+
const stages = simulateStages(explanation, input)
|
|
119
|
+
return {
|
|
120
|
+
name: explanation.name,
|
|
121
|
+
input,
|
|
122
|
+
stages,
|
|
123
|
+
effectCalls: totalEffectCalls(stages),
|
|
124
|
+
checkpoints: stages.filter(stage => stage.checkpoint).length,
|
|
125
|
+
problems: findProblems(stages)
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function describeOperation(stage) {
|
|
131
|
+
const parts = [`${format(stage.items, stage.exact)} items in`]
|
|
132
|
+
if (stage.workers !== null) parts.push(`${stage.workers} workers`)
|
|
133
|
+
for (const [name, calls] of Object.entries(stage.effectCalls)) parts.push(`${format(calls, stage.exact)} calls to ${name}`)
|
|
134
|
+
if (stage.checkpoint) parts.push(`saved as "${stage.checkpoint}"`)
|
|
135
|
+
return parts.join(', ') + (stage.id ? ` @${stage.id}` : '')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function renderSimulationText(simulation) {
|
|
139
|
+
const width = Math.max(...simulation.stages.map(stage => stage.type.length))
|
|
140
|
+
const problems = simulation.problems.length
|
|
141
|
+
? simulation.problems.map(problem => ` ⚠ ${problem.stage === null ? '' : `stage ${problem.stage}: `}${problem.message}`)
|
|
142
|
+
: [' none']
|
|
143
|
+
|
|
144
|
+
return [
|
|
145
|
+
`SIMULATION ${simulation.name}`,
|
|
146
|
+
'',
|
|
147
|
+
'INPUT',
|
|
148
|
+
` ${simulation.input.toLocaleString('en-US')} records`,
|
|
149
|
+
'',
|
|
150
|
+
'OPERATIONS',
|
|
151
|
+
...simulation.stages.map(stage => ` ${stage.index} ${stage.type.padEnd(width)} ${describeOperation(stage)}`),
|
|
152
|
+
'',
|
|
153
|
+
'POTENTIAL PROBLEMS',
|
|
154
|
+
...problems
|
|
155
|
+
].join('\n')
|
|
156
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { maskNonCode } from './lexer.js'
|
|
2
|
+
|
|
3
|
+
const VARIANT = String.raw`[A-Za-z_$][\w$]*(?:\s*\([^()]*\))?`
|
|
4
|
+
const TYPE_DECLARATION = new RegExp(
|
|
5
|
+
String.raw`^([ \t]*)(export[ \t]+)?type[ \t]+([A-Za-z_$][\w$]*)[ \t]*=\s*(\|?\s*${VARIANT}(?:\s*\|\s*${VARIANT})*)`,
|
|
6
|
+
'gm'
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* `type Result = | Ok(value) | Failed(message)` declares a tagged union.
|
|
11
|
+
* Variant names must start with an uppercase letter so `match` can tell a
|
|
12
|
+
* constructor pattern such as `Ok(v)` apart from an ordinary call compared by value.
|
|
13
|
+
*/
|
|
14
|
+
export function findTypeDeclarations(source) {
|
|
15
|
+
const mask = maskNonCode(source)
|
|
16
|
+
const regex = new RegExp(TYPE_DECLARATION.source, TYPE_DECLARATION.flags)
|
|
17
|
+
const declarations = []
|
|
18
|
+
let match
|
|
19
|
+
|
|
20
|
+
while ((match = regex.exec(source))) {
|
|
21
|
+
if (mask.slice(match.index, match.index + match[0].length) !== match[0]) continue
|
|
22
|
+
|
|
23
|
+
const [text, indent, exportPart, name, body] = match
|
|
24
|
+
declarations.push({
|
|
25
|
+
name,
|
|
26
|
+
variants: parseVariants(name, body),
|
|
27
|
+
indent,
|
|
28
|
+
exported: Boolean(exportPart),
|
|
29
|
+
start: match.index,
|
|
30
|
+
end: match.index + text.length
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return declarations
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseVariants(typeName, body) {
|
|
38
|
+
const variants = body.split('|').map(part => part.trim()).filter(Boolean).map(part => {
|
|
39
|
+
const [, name, params] = part.match(/^([A-Za-z_$][\w$]*)\s*(?:\(([^()]*)\))?$/)
|
|
40
|
+
if (!/^[A-Z]/.test(name)) {
|
|
41
|
+
throw new SyntaxError(`Variant "${name}" of type "${typeName}" must start with an uppercase letter`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const fields = params?.trim() ? params.split(',').map(field => field.trim()) : []
|
|
45
|
+
const duplicateField = fields.find((field, index) => fields.indexOf(field) !== index)
|
|
46
|
+
if (duplicateField) {
|
|
47
|
+
throw new SyntaxError(`Variant "${name}" declares field "${duplicateField}" more than once`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { name, fields }
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const names = variants.map(variant => variant.name)
|
|
54
|
+
const duplicate = names.find((name, index) => names.indexOf(name) !== index)
|
|
55
|
+
if (duplicate) {
|
|
56
|
+
throw new SyntaxError(`Type "${typeName}" declares variant "${duplicate}" more than once`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return variants
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function transformTypeDeclarations(source) {
|
|
63
|
+
let result = source
|
|
64
|
+
|
|
65
|
+
for (const declaration of findTypeDeclarations(source).reverse()) {
|
|
66
|
+
const exportPart = declaration.exported ? 'export ' : ''
|
|
67
|
+
const variants = declaration.variants
|
|
68
|
+
.map(variant => `${variant.name}: ${JSON.stringify(variant.fields).replaceAll(',', ', ')}`)
|
|
69
|
+
.join(', ')
|
|
70
|
+
const names = declaration.variants.map(variant => variant.name).join(', ')
|
|
71
|
+
|
|
72
|
+
// Keeping the declaration's line count keeps later line numbers stable.
|
|
73
|
+
const lineBreaks = '\n'.repeat(source.slice(declaration.start, declaration.end).split('\n').length - 1)
|
|
74
|
+
|
|
75
|
+
const replacement =
|
|
76
|
+
`${declaration.indent}${exportPart}const ${declaration.name} = defineType(${JSON.stringify(declaration.name)}, { ${variants} }); ` +
|
|
77
|
+
`${exportPart}const { ${names} } = ${declaration.name}${lineBreaks}`
|
|
78
|
+
|
|
79
|
+
result = result.slice(0, declaration.start) + replacement + result.slice(declaration.end)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return result
|
|
83
|
+
}
|
package/src/why.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { explainPipelines } from './pipeline-explain.js'
|
|
2
|
+
|
|
3
|
+
const MATERIALIZING_STAGES = new Set(['sort', 'sortBy', 'groupBy', 'partition'])
|
|
4
|
+
const NARROWING_STAGES = new Set(['filter', 'filterAsync', 'unique', 'distinctBy', 'take', 'skip'])
|
|
5
|
+
|
|
6
|
+
function label(stage) {
|
|
7
|
+
return `stage ${stage.index} (${stage.type})`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function capitalize(text) {
|
|
11
|
+
return text[0].toUpperCase() + text.slice(1)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isEffectful(stage) {
|
|
15
|
+
return stage.effects.length > 0 || stage.effectfulFunctions.length > 0
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function uses(stage) {
|
|
19
|
+
return [
|
|
20
|
+
stage.effects.length ? `effect ${stage.effects.join(', ')}` : null,
|
|
21
|
+
stage.effectfulFunctions.length ? `effectful function ${stage.effectfulFunctions.join(', ')}` : null
|
|
22
|
+
].filter(Boolean).join(' and ')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function determinism(stages) {
|
|
26
|
+
const effectful = stages.filter(isEffectful)
|
|
27
|
+
if (!effectful.length) {
|
|
28
|
+
return {
|
|
29
|
+
question: 'Why is this pipeline deterministic?',
|
|
30
|
+
answer: 'No stage uses a declared effect or an effectful function. Plain JavaScript calls inside stages are not analyzed.'
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
question: 'Why is this pipeline not deterministic?',
|
|
35
|
+
answer: capitalize(`${effectful.map(stage => `${label(stage)} uses ${uses(stage)}`).join('; ')}.`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function laziness(stages) {
|
|
40
|
+
const blocking = stages.find(stage => MATERIALIZING_STAGES.has(stage.type))
|
|
41
|
+
if (!blocking) {
|
|
42
|
+
return {
|
|
43
|
+
question: 'Why is this pipeline lazy?',
|
|
44
|
+
answer: 'Every stage passes items on one at a time, so no stage waits for the complete input.'
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
question: 'Why is this pipeline not lazy?',
|
|
49
|
+
answer: `${capitalize(label(blocking))} needs the complete input before it can emit anything.`
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resumability(checkpoints) {
|
|
54
|
+
if (!checkpoints.length) {
|
|
55
|
+
return {
|
|
56
|
+
question: 'Why is this pipeline not resumable?',
|
|
57
|
+
answer: 'It has no checkpoint stage, so a failure restarts it from the source.'
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sentences = checkpoints.map(checkpoint => {
|
|
62
|
+
const repeated = checkpoint.index > 2 ? `repeating stages 2-${checkpoint.index - 1}` : 'reading the source again'
|
|
63
|
+
return `Checkpoint "${checkpoint.detail}" (stage ${checkpoint.index}) saves its items once they all complete; a rerun restores them instead of ${repeated}.`
|
|
64
|
+
})
|
|
65
|
+
return { question: 'Why is this pipeline resumable?', answer: sentences.join(' ') }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ordering(stages) {
|
|
69
|
+
return stages.filter(isEffectful).flatMap(stage => stages
|
|
70
|
+
.filter(narrowing => NARROWING_STAGES.has(narrowing.type) && narrowing.index < stage.index)
|
|
71
|
+
.map(narrowing => ({
|
|
72
|
+
question: `Why can't stage ${stage.index} be moved before stage ${narrowing.index}?`,
|
|
73
|
+
answer: `${capitalize(label(stage))} uses ${uses(stage)} and ${label(narrowing)} decides which items reach it: moving it earlier would also run it for items that stage ${narrowing.index} drops.`
|
|
74
|
+
})))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parallelism(stages) {
|
|
78
|
+
return stages
|
|
79
|
+
.filter(stage => stage.type === 'parallel' && stages.some(next => next.index === stage.index + 1))
|
|
80
|
+
.map(stage => {
|
|
81
|
+
const next = stages.find(candidate => candidate.index === stage.index + 1)
|
|
82
|
+
const concurrency = stage.args[0]
|
|
83
|
+
return {
|
|
84
|
+
question: `Why does stage ${stage.index} run in parallel?`,
|
|
85
|
+
answer: `parallel(${concurrency}) lets up to ${concurrency} items of ${label(next)} be processed at once; results keep their input order.`
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function replaySafety(stages, checkpoints) {
|
|
91
|
+
const beforeCheckpoints = checkpoints.flatMap(checkpoint => {
|
|
92
|
+
const effects = stages
|
|
93
|
+
.filter(stage => isEffectful(stage) && stage.index < checkpoint.index)
|
|
94
|
+
.flatMap(stage => [...stage.effects, ...stage.effectfulFunctions].map(name => ({ name, index: stage.index })))
|
|
95
|
+
if (!effects.length) return []
|
|
96
|
+
|
|
97
|
+
const subject = effects.length === 1
|
|
98
|
+
? `Effect ${effects[0].name} runs before it (stage ${effects[0].index})`
|
|
99
|
+
: `Effects ${effects.map(effect => `${effect.name} (stage ${effect.index})`).join(' and ')} run before it`
|
|
100
|
+
const pronoun = effects.length === 1 ? 'it runs' : 'they run'
|
|
101
|
+
return [{
|
|
102
|
+
question: `Why does checkpoint "${checkpoint.detail}" not guarantee replay safety?`,
|
|
103
|
+
answer: `${subject}: if a run fails before "${checkpoint.detail}" completes, ${pronoun} again on retry (at-least-once, not exactly-once).`
|
|
104
|
+
}]
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const last = checkpoints.at(-1)
|
|
108
|
+
const afterLast = last
|
|
109
|
+
? stages.filter(stage => isEffectful(stage) && stage.index > last.index).map(stage => ({
|
|
110
|
+
question: `Why can stage ${stage.index} repeat after a failure?`,
|
|
111
|
+
answer: `${capitalize(label(stage))} uses ${uses(stage)} after the last checkpoint "${last.detail}": a rerun restores the checkpoint and runs it again for every restored item.`
|
|
112
|
+
}))
|
|
113
|
+
: []
|
|
114
|
+
|
|
115
|
+
return [...beforeCheckpoints, ...afterLast]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/*
|
|
119
|
+
* Plain-language answers derived from the same static analysis as
|
|
120
|
+
* `jojo explain`, using its stage numbers (the source is stage 1).
|
|
121
|
+
*/
|
|
122
|
+
export function whyPipelines(source) {
|
|
123
|
+
return explainPipelines(source).map(explanation => {
|
|
124
|
+
const stages = explanation.stages.slice(1)
|
|
125
|
+
const checkpoints = stages.filter(stage => stage.type === 'checkpoint')
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
name: explanation.name,
|
|
129
|
+
answers: [
|
|
130
|
+
determinism(stages),
|
|
131
|
+
laziness(stages),
|
|
132
|
+
resumability(checkpoints),
|
|
133
|
+
...ordering(stages),
|
|
134
|
+
...parallelism(stages),
|
|
135
|
+
...replaySafety(stages, checkpoints)
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function renderWhyText(why) {
|
|
142
|
+
return [
|
|
143
|
+
`WHY ${why.name}`,
|
|
144
|
+
...why.answers.flatMap(({ question, answer }) => ['', question, `→ ${answer}`])
|
|
145
|
+
].join('\n')
|
|
146
|
+
}
|