@panagos/jojoscript 0.7.1 → 0.9.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,223 @@
1
+ import { tokenize, significantTokens, maskNonCode } from './lexer.js'
2
+
3
+ /*
4
+ * Algebraic effects / effect handlers, source-level syntax:
5
+ *
6
+ * effect fetchUser(id)
7
+ *
8
+ * handle {
9
+ * fetchUser: id => http.get(`/users/${id}`)
10
+ * } {
11
+ * users |> map(user => fetchUser(user.id))
12
+ * }
13
+ *
14
+ * Both constructs compile to plain calls into the `@panagos/jojoscript/runtime`
15
+ * effect runtime (`defineEffect`/`withHandlers`), following the same
16
+ * "regex/token transform, no real AST" philosophy as `match-parser.js` and
17
+ * `transforms.js` rather than introducing a parallel parsing architecture.
18
+ */
19
+
20
+ function text(source, tokens) {
21
+ if (!tokens.length) return ''
22
+ return source.slice(tokens[0].start, tokens[tokens.length - 1].end)
23
+ }
24
+
25
+ /*
26
+ * Only applies `regex` to matches that are real code (not inside a string,
27
+ * template literal, or comment). Mirrors `transforms.js`'s private
28
+ * `codeAwareReplace` helper; duplicated here (rather than exported from
29
+ * `transforms.js`) to keep each transform module self-contained.
30
+ */
31
+ function codeAwareReplace(source, regex, replacer) {
32
+ const mask = maskNonCode(source)
33
+
34
+ return source.replace(regex, (...args) => {
35
+ const match = args[0]
36
+ const offset = args[args.length - 2]
37
+
38
+ if (mask.slice(offset, offset + match.length) !== match) {
39
+ return match
40
+ }
41
+
42
+ return replacer(...args)
43
+ })
44
+ }
45
+
46
+ /*
47
+ * A deliberately simple, single-line declaration: `effect name(params)`,
48
+ * optionally `export`ed. Parameter names are plain identifiers (no default
49
+ * values/destructuring/nested parens) — enough to declare an effect's
50
+ * shape without needing a full expression parser.
51
+ */
52
+ const EFFECT_DECLARATION = /^([ \t]*)(export\s+)?effect\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(([^()]*)\)\s*;?[ \t]*$/gm
53
+
54
+ function splitParams(params) {
55
+ return params.trim() ? params.split(',').map(p => p.trim()).filter(Boolean) : []
56
+ }
57
+
58
+ /*
59
+ * Static scan for `effect name(params)` declarations. Used both by the
60
+ * compiler (to know which runtime imports are needed) and by pipeline
61
+ * inspection (`pipeline-ir.js`) to recognize effect names referenced from
62
+ * inside pipeline stages.
63
+ */
64
+ export function findEffectDeclarations(source) {
65
+ const mask = maskNonCode(source)
66
+ const regex = new RegExp(EFFECT_DECLARATION.source, EFFECT_DECLARATION.flags)
67
+ const results = []
68
+ let match
69
+
70
+ while ((match = regex.exec(source))) {
71
+ const offset = match.index
72
+ if (mask.slice(offset, offset + match[0].length) !== match[0]) continue
73
+
74
+ results.push({
75
+ name: match[3],
76
+ params: splitParams(match[4]),
77
+ start: offset,
78
+ end: offset + match[0].length
79
+ })
80
+ }
81
+
82
+ return results
83
+ }
84
+
85
+ export function transformEffectDeclarations(source) {
86
+ return codeAwareReplace(source, EFFECT_DECLARATION, (match, indent, exportPart, name, params) => {
87
+ const paramList = JSON.stringify(splitParams(params))
88
+ return `${indent}${exportPart || ''}const ${name} = defineEffect(${JSON.stringify(name)}, ${paramList})`
89
+ })
90
+ }
91
+
92
+ /*
93
+ * Distinguishes the `handle` keyword from ordinary identifiers named
94
+ * "handle" (e.g. `stream.handle(...)`, `const handle = ...`).
95
+ */
96
+ function isHandleKeyword(tokens, index) {
97
+ const token = tokens[index]
98
+ if (token.type !== 'identifier' || token.value !== 'handle') return false
99
+
100
+ const prev = tokens[index - 1]
101
+ if (prev && ['.', 'const', 'let', 'var', 'function', 'class'].includes(prev.value)) return false
102
+
103
+ const next = tokens[index + 1]
104
+ if (!next || next.value !== '{') return false
105
+
106
+ return true
107
+ }
108
+
109
+ function matchingBrace(tokens, openIndex) {
110
+ let depth = 0
111
+ for (let i = openIndex; i < tokens.length; i++) {
112
+ if (tokens[i].value === '{') depth++
113
+ else if (tokens[i].value === '}') {
114
+ depth--
115
+ if (depth === 0) return i
116
+ }
117
+ }
118
+ throw new SyntaxError('Unclosed `handle` block')
119
+ }
120
+
121
+ function splitTopLevelCommas(tokens) {
122
+ const result = []
123
+ let start = 0
124
+ let depth = 0
125
+
126
+ for (let i = 0; i < tokens.length; i++) {
127
+ const value = tokens[i].value
128
+ if (value === '(' || value === '[' || value === '{') depth++
129
+ else if (value === ')' || value === ']' || value === '}') depth--
130
+ else if (value === ',' && depth === 0) {
131
+ result.push(tokens.slice(start, i))
132
+ start = i + 1
133
+ }
134
+ }
135
+
136
+ if (start < tokens.length) result.push(tokens.slice(start))
137
+ return result
138
+ }
139
+
140
+ function extractHandlerNames(source, tokens) {
141
+ return splitTopLevelCommas(tokens)
142
+ .filter(group => group.length)
143
+ .map(group => {
144
+ const first = group[0]
145
+ if (first.type === 'identifier') return first.value
146
+ if (first.type === 'string') return first.value.slice(1, -1)
147
+ throw new SyntaxError(`Invalid handler entry in \`handle { ... }\`: ${text(source, group)}`)
148
+ })
149
+ }
150
+
151
+ function locateHandleBlock(tokens, index) {
152
+ const handlerOpen = index + 1
153
+ const handlerClose = matchingBrace(tokens, handlerOpen)
154
+
155
+ const bodyOpen = handlerClose + 1
156
+ if (!tokens[bodyOpen] || tokens[bodyOpen].value !== '{') {
157
+ throw new SyntaxError('`handle { ... }` must be followed by a body block: `handle { ... } { ... }`')
158
+ }
159
+ const bodyClose = matchingBrace(tokens, bodyOpen)
160
+
161
+ return { keywordIndex: index, handlerOpen, handlerClose, bodyOpen, bodyClose }
162
+ }
163
+
164
+ /*
165
+ * Static scan for every `handle { handlers } { body }` block in `source`,
166
+ * used both by the compiler and by pipeline inspection (which handler
167
+ * names are statically declared, for a best-effort "is this effect
168
+ * handled?" signal).
169
+ */
170
+ export function findHandleBlocks(source) {
171
+ const tokens = significantTokens(tokenize(source))
172
+ const blocks = []
173
+
174
+ for (let i = 0; i < tokens.length; i++) {
175
+ if (!isHandleKeyword(tokens, i)) continue
176
+
177
+ const block = locateHandleBlock(tokens, i)
178
+ blocks.push({
179
+ start: tokens[block.keywordIndex].start,
180
+ end: tokens[block.bodyClose].end,
181
+ handlerNames: extractHandlerNames(source, tokens.slice(block.handlerOpen + 1, block.handlerClose))
182
+ })
183
+ }
184
+
185
+ return blocks
186
+ }
187
+
188
+ /*
189
+ * `handle { handlers } { body }` compiles to:
190
+ *
191
+ * (await withHandlers({ handlers }, async () => { body }))
192
+ *
193
+ * Processes the rightmost (innermost, for nested `handle` blocks) match
194
+ * first on every iteration, same strategy as `transformMatchExpressions`,
195
+ * so nested handlers compile inside-out.
196
+ */
197
+ export function transformHandleBlocks(source) {
198
+ let result = source
199
+
200
+ for (let iteration = 0; iteration < 1000; iteration++) {
201
+ const tokens = significantTokens(tokenize(result))
202
+
203
+ let target = null
204
+ for (let i = tokens.length - 1; i >= 0; i--) {
205
+ if (!isHandleKeyword(tokens, i)) continue
206
+ target = locateHandleBlock(tokens, i)
207
+ break
208
+ }
209
+
210
+ if (!target) return result
211
+
212
+ const handlerText = text(result, tokens.slice(target.handlerOpen, target.handlerClose + 1))
213
+ const bodyText = text(result, tokens.slice(target.bodyOpen, target.bodyClose + 1))
214
+ const replacement = `(await withHandlers(${handlerText}, async () => ${bodyText}))`
215
+
216
+ const start = tokens[target.keywordIndex].start
217
+ const end = tokens[target.bodyClose].end
218
+
219
+ result = result.slice(0, start) + replacement + result.slice(end)
220
+ }
221
+
222
+ throw new SyntaxError('handle transformation exceeded 1000 iterations')
223
+ }
package/src/index.js CHANGED
@@ -17,3 +17,17 @@ export {
17
17
  export {
18
18
  transformTypeAnnotations
19
19
  } from './type-annotations.js'
20
+
21
+ export {
22
+ analyzePipelines,
23
+ buildGraph,
24
+ renderGraphText,
25
+ renderGraphDot
26
+ } from './pipeline-ir.js'
27
+
28
+ export {
29
+ transformEffectDeclarations,
30
+ transformHandleBlocks,
31
+ findEffectDeclarations,
32
+ findHandleBlocks
33
+ } from './effect-parser.js'
@@ -0,0 +1,27 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ /*
4
+ * A pipeline's identity must be a deterministic hash of its *normalized*
5
+ * structure (stage names + arguments, whitespace-insensitive) rather than a
6
+ * random id, so the same program hashes to the same pipeline id across
7
+ * separate process runs. This is what lets checkpoint data saved by one run
8
+ * be found again by a later run (`jojo resume`, `jojo replay`).
9
+ */
10
+ export function computePipelineId(pipeline) {
11
+ const normalized = {
12
+ expression: normalizeText(pipeline.expression),
13
+ stages: pipeline.stages.map(stage => ({
14
+ name: stage.name,
15
+ args: stage.args.map(normalizeText)
16
+ }))
17
+ }
18
+
19
+ return createHash('sha256')
20
+ .update(JSON.stringify(normalized))
21
+ .digest('hex')
22
+ .slice(0, 16)
23
+ }
24
+
25
+ function normalizeText(text) {
26
+ return text.replace(/\s+/g, ' ').trim()
27
+ }
@@ -0,0 +1,157 @@
1
+ import { findAllPipelines } from './pipeline-parser.js'
2
+ import { computePipelineId } from './pipeline-hash.js'
3
+ import { findEffectDeclarations, findHandleBlocks } from './effect-parser.js'
4
+
5
+ /*
6
+ * Best-effort, purely textual check for whether `effectName` is referenced
7
+ * anywhere in a stage's call (its name or its argument expressions), e.g.
8
+ * `map(user => fetchUser(user.id))` or a bare `saveUser` stage. This is
9
+ * static analysis over source text, not evaluation — it cannot tell
10
+ * whether the reference is actually reachable/called, only that the name
11
+ * appears with word boundaries.
12
+ */
13
+ function effectsUsedByStage(stage, effectNames) {
14
+ if (!effectNames.length) return []
15
+ const haystack = stage.args.length ? `${stage.name} ${stage.args.join(' ')}` : stage.name
16
+ return effectNames.filter(name => new RegExp(`\\b${name}\\b`).test(haystack))
17
+ }
18
+
19
+ /*
20
+ * Static, execution-free representation of a pipeline: SOURCE, one node per
21
+ * stage, RESULT, chained by `dependencies`. Used by `jojo graph` (pure
22
+ * text/DOT rendering, no execution) and correlated at runtime with
23
+ * `jojo profile`'s collected metrics via the same `node-<n>` id scheme the
24
+ * compiler uses when emitting code (see `pipeline-parser.js`'s
25
+ * `emitPipeline`).
26
+ *
27
+ * `options.effectNames`/`options.handledEffectNames` (both plain arrays of
28
+ * effect names) let callers surface effect usage per node (`jojo graph`
29
+ * passes the whole file's `effect name(...)` declarations and any
30
+ * statically-visible `handle { name: ... }` keys) — see `analyzePipelines`.
31
+ */
32
+ export function buildGraph(pipeline, pipelineId = computePipelineId(pipeline), options = {}) {
33
+ const effectNames = options.effectNames ?? []
34
+ const handledEffectNames = new Set(options.handledEffectNames ?? [])
35
+ const nodes = []
36
+
37
+ nodes.push({
38
+ id: 'node-0',
39
+ type: 'source',
40
+ label: pipeline.expression.trim(),
41
+ args: [],
42
+ dependencies: [],
43
+ metadata: {}
44
+ })
45
+
46
+ pipeline.stages.forEach((stage, index) => {
47
+ const effects = effectsUsedByStage(stage, effectNames)
48
+
49
+ nodes.push({
50
+ id: `node-${index + 1}`,
51
+ type: stage.name,
52
+ label: stage.args.length ? `${stage.name}(${stage.args.join(', ')})` : stage.name,
53
+ args: stage.args,
54
+ dependencies: [`node-${index}`],
55
+ metadata: effects.length ? { effects } : {}
56
+ })
57
+ })
58
+
59
+ const resultId = `node-${pipeline.stages.length + 1}`
60
+ nodes.push({
61
+ id: resultId,
62
+ type: 'result',
63
+ label: 'RESULT',
64
+ args: [],
65
+ dependencies: [`node-${pipeline.stages.length}`],
66
+ metadata: {}
67
+ })
68
+
69
+ /*
70
+ * Per-effect summary across the whole pipeline: name, which node(s)
71
+ * reference it, and a best-effort static "handled" signal — true only
72
+ * if a `handle { name: ... }` block naming this effect was found
73
+ * anywhere in the same file. A statically-declared handle block that is
74
+ * conditionally executed, or a handler installed dynamically at
75
+ * runtime, cannot be seen here; this is inspection, not proof.
76
+ */
77
+ const effects = nodes
78
+ .filter(node => node.metadata.effects?.length)
79
+ .flatMap(node => node.metadata.effects.map(name => ({
80
+ name,
81
+ nodeId: node.id,
82
+ staticallyHandled: handledEffectNames.has(name)
83
+ })))
84
+
85
+ return { id: pipelineId, expression: pipeline.expression.trim(), nodes, effects }
86
+ }
87
+
88
+ /*
89
+ * Parses every pipeline statement in `source` and returns their graphs,
90
+ * each with a deterministic id derived from its normalized structure. Also
91
+ * scans `source` for `effect name(...)` declarations and `handle { ... }`
92
+ * handler-map keys, so returned graphs can expose which effects each
93
+ * pipeline uses and whether a handler for them is statically visible.
94
+ */
95
+ export function analyzePipelines(source) {
96
+ const effectNames = findEffectDeclarations(source).map(declaration => declaration.name)
97
+ const handledEffectNames = findHandleBlocks(source).flatMap(block => block.handlerNames)
98
+
99
+ return findAllPipelines(source).map(pipeline =>
100
+ buildGraph(pipeline, undefined, { effectNames, handledEffectNames })
101
+ )
102
+ }
103
+
104
+ export function renderGraphText(graph) {
105
+ const lines = []
106
+
107
+ graph.nodes.forEach((node, index) => {
108
+ let label
109
+ if (node.type === 'source') {
110
+ label = `SOURCE ${node.label}`
111
+ } else if (node.type === 'result') {
112
+ label = 'RESULT'
113
+ } else {
114
+ label = node.args.length
115
+ ? `${node.type.toUpperCase()} ${node.args.join(', ')}`
116
+ : node.type.toUpperCase()
117
+ }
118
+
119
+ if (node.metadata.effects?.length) {
120
+ label += ` [effects: ${node.metadata.effects.join(', ')}]`
121
+ }
122
+
123
+ lines.push(label)
124
+
125
+ if (index < graph.nodes.length - 1) {
126
+ lines.push(' │')
127
+ lines.push(' ▼')
128
+ }
129
+ })
130
+
131
+ return lines.join('\n')
132
+ }
133
+
134
+ export function renderGraphDot(graph) {
135
+ const lines = ['digraph pipeline {']
136
+
137
+ for (const node of graph.nodes) {
138
+ let dotLabel = node.args.length
139
+ ? `${node.type}\\n${node.args.join(', ').replace(/"/g, '\\"')}`
140
+ : node.type
141
+
142
+ if (node.metadata.effects?.length) {
143
+ dotLabel += `\\n[effects: ${node.metadata.effects.join(', ')}]`
144
+ }
145
+
146
+ lines.push(` "${node.id}" [label="${dotLabel}"];`)
147
+ }
148
+
149
+ for (const node of graph.nodes) {
150
+ for (const dependency of node.dependencies) {
151
+ lines.push(` "${dependency}" -> "${node.id}";`)
152
+ }
153
+ }
154
+
155
+ lines.push('}')
156
+ return lines.join('\n')
157
+ }