@panagos/jojoscript 0.10.1 → 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.
@@ -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
+ }