@loomwork/compiler 0.0.0-stage → 0.1.1

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.
Files changed (59) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +62 -2
  4. package/dist/cli.d.ts +3 -0
  5. package/dist/cli.d.ts.map +1 -0
  6. package/dist/cli.js +81 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/hash.d.ts +3 -0
  9. package/dist/hash.d.ts.map +1 -0
  10. package/dist/hash.js +16 -0
  11. package/dist/hash.js.map +1 -0
  12. package/dist/importResolve.d.ts +5 -0
  13. package/dist/importResolve.d.ts.map +1 -0
  14. package/dist/importResolve.js +130 -0
  15. package/dist/importResolve.js.map +1 -0
  16. package/dist/index.d.ts +14 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +26 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/loopIdentity.d.ts +18 -0
  21. package/dist/loopIdentity.d.ts.map +1 -0
  22. package/dist/loopIdentity.js +179 -0
  23. package/dist/loopIdentity.js.map +1 -0
  24. package/dist/manifest.d.ts +98 -0
  25. package/dist/manifest.d.ts.map +1 -0
  26. package/dist/manifest.js +123 -0
  27. package/dist/manifest.js.map +1 -0
  28. package/dist/nondeterminism.d.ts +25 -0
  29. package/dist/nondeterminism.d.ts.map +1 -0
  30. package/dist/nondeterminism.js +88 -0
  31. package/dist/nondeterminism.js.map +1 -0
  32. package/dist/plugin.d.ts +3 -0
  33. package/dist/plugin.d.ts.map +1 -0
  34. package/dist/plugin.js +71 -0
  35. package/dist/plugin.js.map +1 -0
  36. package/dist/runnerReach.d.ts +136 -0
  37. package/dist/runnerReach.d.ts.map +1 -0
  38. package/dist/runnerReach.js +1809 -0
  39. package/dist/runnerReach.js.map +1 -0
  40. package/dist/transform.d.ts +6 -0
  41. package/dist/transform.d.ts.map +1 -0
  42. package/dist/transform.js +416 -0
  43. package/dist/transform.js.map +1 -0
  44. package/dist/types.d.ts +73 -0
  45. package/dist/types.d.ts.map +1 -0
  46. package/dist/types.js +3 -0
  47. package/dist/types.js.map +1 -0
  48. package/package.json +64 -3
  49. package/src/cli.ts +90 -0
  50. package/src/hash.ts +12 -0
  51. package/src/importResolve.ts +133 -0
  52. package/src/index.ts +23 -0
  53. package/src/loopIdentity.ts +210 -0
  54. package/src/manifest.ts +185 -0
  55. package/src/nondeterminism.ts +133 -0
  56. package/src/plugin.ts +74 -0
  57. package/src/runnerReach.ts +1964 -0
  58. package/src/transform.ts +523 -0
  59. package/src/types.ts +79 -0
@@ -0,0 +1,523 @@
1
+ import MagicString from "magic-string";
2
+ import ts from "typescript";
3
+ import { shortHash } from "./hash";
4
+ import { createImportResolver } from "./importResolve";
5
+ import { type LoopStepIdentityFinding, analyzeLoopIdentityNode } from "./loopIdentity";
6
+ import { type NonDeterministicCall, analyzeNode } from "./nondeterminism";
7
+ import { type RunnerReach, analyzeReachRegion } from "./runnerReach";
8
+ import type {
9
+ ExtractedChunk,
10
+ FlowEntry,
11
+ PluginState,
12
+ ProjectionEntry,
13
+ SkillEntry,
14
+ StepEntry,
15
+ } from "./types";
16
+
17
+ const VIRTUAL_PREFIX = "\0loom:chunk:";
18
+
19
+ export function transformFile(
20
+ sourceText: string,
21
+ absolutePath: string,
22
+ pluginState: PluginState
23
+ ): { code: string; map: string } | null {
24
+ if (
25
+ !sourceText.includes("step$") &&
26
+ !sourceText.includes("flow$") &&
27
+ !sourceText.includes("skill$") &&
28
+ !sourceText.includes("projection$")
29
+ ) {
30
+ return null;
31
+ }
32
+
33
+ const sourceFile = ts.createSourceFile(absolutePath, sourceText, ts.ScriptTarget.ESNext, true);
34
+
35
+ const ms = new MagicString(sourceText);
36
+ const importLines = collectImportLines(sourceFile, sourceText);
37
+ const localSteps: StepEntry[] = [];
38
+ let modified = false;
39
+
40
+ ts.forEachChild(sourceFile, function visit(node: ts.Node): void {
41
+ if (tryProcessStep(node, absolutePath, importLines, ms, localSteps, pluginState)) {
42
+ modified = true;
43
+ }
44
+ if (tryProcessSkill(node, absolutePath, importLines, ms, pluginState)) {
45
+ modified = true;
46
+ }
47
+ if (tryProcessProjection(node, absolutePath, importLines, ms, pluginState)) {
48
+ modified = true;
49
+ }
50
+ ts.forEachChild(node, visit);
51
+ });
52
+
53
+ collectFlows(sourceFile, sourceText, localSteps, pluginState, absolutePath);
54
+
55
+ if (!modified) return null;
56
+ return {
57
+ code: ms.toString(),
58
+ map: ms.generateMap({ hires: true, source: absolutePath }).toString(),
59
+ };
60
+ }
61
+
62
+ function tryProcessStep(
63
+ node: ts.Node,
64
+ absolutePath: string,
65
+ importLines: string[],
66
+ ms: MagicString,
67
+ localSteps: StepEntry[],
68
+ pluginState: PluginState
69
+ ): boolean {
70
+ // Match a `step$({...})` call WHEREVER it appears (the walk visits every node): a direct
71
+ // variable initializer `const Foo = step$(...)` — from which we capture the var name so a
72
+ // flow's `steps: [Foo]` array can resolve it — OR inline inside that array. Same robustness
73
+ // as flow$ detection; the old variable-only gate silently skipped inline/wrapped steps.
74
+ if (!(ts.isCallExpression(node) && isCallTo(node, "step$") && node.arguments.length > 0)) {
75
+ return false;
76
+ }
77
+ const callNode = node;
78
+ const varName = declaredVarName(node); // undefined for an inline step$(...)
79
+
80
+ const argObj = callNode.arguments[0];
81
+ if (!argObj || !ts.isObjectLiteralExpression(argObj)) return false;
82
+
83
+ const idProp = findProperty(argObj, "id");
84
+ const handlerProp = findProperty(argObj, "handler");
85
+ const toolIdProp = findProperty(argObj, "toolId");
86
+
87
+ const stepId = idProp ? getStringLiteral(idProp.initializer) : undefined;
88
+ if (!stepId) return false;
89
+
90
+ const toolId = toolIdProp ? getStringLiteral(toolIdProp.initializer) : undefined;
91
+
92
+ const stepEntry: StepEntry = {
93
+ id: stepId,
94
+ varName: varName ?? stepId,
95
+ hasHandler: false,
96
+ ...(toolId !== undefined && { toolId }),
97
+ sourceFile: absolutePath,
98
+ };
99
+
100
+ if (
101
+ handlerProp &&
102
+ ts.isPropertyAssignment(handlerProp) &&
103
+ ts.isCallExpression(handlerProp.initializer) &&
104
+ isCallTo(handlerProp.initializer, "handler$") &&
105
+ handlerProp.initializer.arguments.length > 0
106
+ ) {
107
+ const fn = handlerProp.initializer.arguments[0]!;
108
+ const fnText = sourceText(ms, fn);
109
+ const hash = shortHash(absolutePath + stepId);
110
+ const chunkVirtualId = `${VIRTUAL_PREFIX}${stepId}_${hash}`;
111
+ const chunkSrc = buildChunkSource(importLines, fnText);
112
+
113
+ const chunk: ExtractedChunk = {
114
+ stepId,
115
+ kind: "step",
116
+ chunkVirtualId,
117
+ chunkSource: chunkSrc,
118
+ sourceFile: absolutePath,
119
+ hash,
120
+ };
121
+ pluginState.chunks.set(chunkVirtualId, chunk);
122
+
123
+ ms.overwrite(
124
+ handlerProp.initializer.getStart(),
125
+ handlerProp.initializer.getEnd(),
126
+ `() => import("${chunkVirtualId}")`
127
+ );
128
+
129
+ stepEntry.hasHandler = true;
130
+ stepEntry.chunkVirtualId = chunkVirtualId;
131
+ stepEntry.hash = hash;
132
+ }
133
+
134
+ if (varName) pluginState.stepsByVarName.set(varName, stepId);
135
+ pluginState.steps.set(stepId, stepEntry);
136
+ localSteps.push(stepEntry);
137
+
138
+ return stepEntry.hasHandler;
139
+ }
140
+
141
+ // Match: const Foo = skill$({ ..., instructions: instructions$("...") })
142
+ // and extract the instruction body into its own lazy chunk, exactly as
143
+ // tryProcessStep does for handler$ — so only selected skills load their text.
144
+ function tryProcessSkill(
145
+ node: ts.Node,
146
+ absolutePath: string,
147
+ importLines: string[],
148
+ ms: MagicString,
149
+ pluginState: PluginState
150
+ ): boolean {
151
+ // Detect `skill$({...})` wherever it appears (direct var initializer or nested, e.g. inside
152
+ // an agent's skill list) — same robustness as step$/flow$.
153
+ if (!(ts.isCallExpression(node) && isCallTo(node, "skill$") && node.arguments.length > 0)) {
154
+ return false;
155
+ }
156
+ const callNode = node;
157
+ const varName = declaredVarName(node);
158
+
159
+ const argObj = callNode.arguments[0];
160
+ if (!argObj || !ts.isObjectLiteralExpression(argObj)) return false;
161
+
162
+ const idProp = findProperty(argObj, "id");
163
+ const instructionsProp = findProperty(argObj, "instructions");
164
+ const skillId = idProp ? getStringLiteral(idProp.initializer) : undefined;
165
+ if (!skillId) return false;
166
+
167
+ const skillEntry: SkillEntry = {
168
+ id: skillId,
169
+ varName: varName ?? skillId,
170
+ sourceFile: absolutePath,
171
+ };
172
+
173
+ if (
174
+ instructionsProp &&
175
+ ts.isPropertyAssignment(instructionsProp) &&
176
+ ts.isCallExpression(instructionsProp.initializer) &&
177
+ isCallTo(instructionsProp.initializer, "instructions$") &&
178
+ instructionsProp.initializer.arguments.length > 0
179
+ ) {
180
+ // The body is a string/template literal; extract its exact source and emit
181
+ // it as the chunk's default export.
182
+ const bodyText = sourceText(ms, instructionsProp.initializer.arguments[0]!);
183
+ const hash = shortHash(absolutePath + skillId);
184
+ const chunkVirtualId = `${VIRTUAL_PREFIX}${skillId}_${hash}`;
185
+ const chunkSrc = buildChunkSource(importLines, bodyText);
186
+
187
+ pluginState.chunks.set(chunkVirtualId, {
188
+ stepId: skillId,
189
+ kind: "skill",
190
+ chunkVirtualId,
191
+ chunkSource: chunkSrc,
192
+ sourceFile: absolutePath,
193
+ hash,
194
+ });
195
+
196
+ ms.overwrite(
197
+ instructionsProp.initializer.getStart(),
198
+ instructionsProp.initializer.getEnd(),
199
+ `() => import("${chunkVirtualId}")`
200
+ );
201
+
202
+ skillEntry.chunkVirtualId = chunkVirtualId;
203
+ skillEntry.hash = hash;
204
+ }
205
+
206
+ if (varName) pluginState.skillsByVarName.set(varName, skillId);
207
+ pluginState.skills.set(skillId, skillEntry);
208
+
209
+ return skillEntry.chunkVirtualId !== undefined;
210
+ }
211
+
212
+ // Match: const Foo = projection$({ ..., handlers: { EVENT_A: (s,e)=>…, EVENT_B: … } })
213
+ // and extract the whole `handlers` object literal into its own lazy chunk, recording the
214
+ // event-type KEYS statically so the runtime can gate loading on a matching event without
215
+ // importing the chunk. The handlers value is rewritten to a `ProjectionHandlersRef`
216
+ // ({ _loomProjectionHandlersRef, eventTypes, load: () => import(chunk) }). Same
217
+ // lazy-boundary treatment as handler$/instructions$, but for a whole fold map.
218
+ function tryProcessProjection(
219
+ node: ts.Node,
220
+ absolutePath: string,
221
+ importLines: string[],
222
+ ms: MagicString,
223
+ pluginState: PluginState
224
+ ): boolean {
225
+ if (!(ts.isCallExpression(node) && isCallTo(node, "projection$") && node.arguments.length > 0)) {
226
+ return false;
227
+ }
228
+ const argObj = node.arguments[0];
229
+ if (!argObj || !ts.isObjectLiteralExpression(argObj)) return false;
230
+
231
+ const idProp = findProperty(argObj, "id");
232
+ const handlersProp = findProperty(argObj, "handlers");
233
+ const projectionId = idProp ? getStringLiteral(idProp.initializer) : undefined;
234
+ if (!projectionId) return false;
235
+
236
+ const projectionEntry: ProjectionEntry = {
237
+ id: projectionId,
238
+ varName: declaredVarName(node) ?? projectionId,
239
+ eventTypes: [],
240
+ sourceFile: absolutePath,
241
+ };
242
+
243
+ // Only an inline object-literal handlers map is extractable — an already-lazy ref or a
244
+ // referenced identifier is left as-is (still correct, just not code-split here).
245
+ if (
246
+ handlersProp &&
247
+ ts.isPropertyAssignment(handlersProp) &&
248
+ ts.isObjectLiteralExpression(handlersProp.initializer)
249
+ ) {
250
+ const handlersObj = handlersProp.initializer;
251
+ // The event types are the property names of the fold map — read them WITHOUT evaluating.
252
+ // Every property must be a statically-named entry (a `PropertyAssignment` or method): a
253
+ // computed key would leave `eventTypes` incomplete (the runtime would never gate/load for
254
+ // that event), and a shorthand/spread would reference names absent from the extracted chunk
255
+ // (a broken import). If any property isn't statically named, we bail out of extraction and
256
+ // leave the handlers inline+eager — correct, just not code-split.
257
+ const eventTypes: string[] = [];
258
+ let extractable = true;
259
+ for (const prop of handlersObj.properties) {
260
+ const name = prop.name;
261
+ const named =
262
+ (ts.isPropertyAssignment(prop) || ts.isMethodDeclaration(prop)) &&
263
+ name !== undefined &&
264
+ (ts.isIdentifier(name) || ts.isStringLiteral(name));
265
+ if (!named) {
266
+ extractable = false;
267
+ break;
268
+ }
269
+ eventTypes.push((name as ts.Identifier | ts.StringLiteral).text);
270
+ }
271
+ if (!extractable) {
272
+ // Leave the projection eager (handlers inline); record it with no chunk.
273
+ pluginState.projections.set(projectionId, projectionEntry);
274
+ return false;
275
+ }
276
+ projectionEntry.eventTypes = eventTypes;
277
+
278
+ const objText = sourceText(ms, handlersObj);
279
+ const hash = shortHash(absolutePath + projectionId);
280
+ const chunkVirtualId = `${VIRTUAL_PREFIX}${projectionId}_${hash}`;
281
+
282
+ pluginState.chunks.set(chunkVirtualId, {
283
+ stepId: projectionId,
284
+ kind: "projection",
285
+ chunkVirtualId,
286
+ chunkSource: buildChunkSource(importLines, objText),
287
+ sourceFile: absolutePath,
288
+ hash,
289
+ } satisfies ExtractedChunk);
290
+
291
+ // Rewrite the handlers value to the lazy ref. `eventTypes` is inlined so the runtime
292
+ // gates on it without loading the chunk.
293
+ ms.overwrite(
294
+ handlersObj.getStart(),
295
+ handlersObj.getEnd(),
296
+ `{ _loomProjectionHandlersRef: true, eventTypes: ${JSON.stringify(eventTypes)}, load: () => import("${chunkVirtualId}") }`
297
+ );
298
+
299
+ projectionEntry.chunkVirtualId = chunkVirtualId;
300
+ projectionEntry.hash = hash;
301
+ }
302
+
303
+ pluginState.projections.set(projectionId, projectionEntry);
304
+ return projectionEntry.chunkVirtualId !== undefined;
305
+ }
306
+
307
+ function collectFlows(
308
+ sourceFile: ts.SourceFile,
309
+ _sourceText: string,
310
+ _localSteps: StepEntry[],
311
+ pluginState: PluginState,
312
+ absolutePath: string
313
+ ): void {
314
+ // ADR 0061 — lint the module for loop-scoped ctx.run/execute whose step id
315
+ // cannot vary per iteration (silent replay corruption). Computed lazily, once
316
+ // per flow-bearing module; findings attach to every flow declared in it.
317
+ let loopFindings: LoopStepIdentityFinding[] | undefined;
318
+ const lintLoopIdentity = (): LoopStepIdentityFinding[] => {
319
+ loopFindings ??= analyzeLoopIdentityNode(sourceFile);
320
+ return loopFindings;
321
+ };
322
+ // The other two source-region analyses, memoized the same way: each needs the
323
+ // runner SOURCE, and the compiler is the only thing that has it. Producing
324
+ // them here is what turns the determinism, capability-compat, effect-class and
325
+ // suspension-safety rules from hand-wired into on-by-default.
326
+ let nonDetCalls: NonDeterministicCall[] | undefined;
327
+ const lintNonDeterminism = (): NonDeterministicCall[] => {
328
+ // Whole MODULE, so memoized handler$ bodies must be excluded: a step
329
+ // handler's Math.random is logged and served on replay, and reporting it
330
+ // would be a false positive on correct code — the fastest way to get a
331
+ // linter switched off.
332
+ nonDetCalls ??= analyzeNode(sourceFile, undefined, { skipMemoized: true });
333
+ return nonDetCalls;
334
+ };
335
+ const importResolver = createImportResolver(sourceFile, absolutePath);
336
+ let region: { reach: RunnerReach; boundRunners: number } | undefined;
337
+ const analyzeRegion = (): { reach: RunnerReach; boundRunners: number } => {
338
+ region ??= analyzeReachRegion(
339
+ sourceFile,
340
+ undefined,
341
+ importResolver !== undefined ? { importResolver } : {}
342
+ );
343
+ return region;
344
+ };
345
+ /** Flows this module declares whose reach came back EMPTY (see the pure-runner rule below). */
346
+ const emptyReach: FlowEntry[] = [];
347
+ let flowsInModule = 0;
348
+
349
+ ts.forEachChild(sourceFile, function visit(node: ts.Node): void {
350
+ // Match a `flow$(...)` call WHEREVER it appears — not only as a direct variable
351
+ // initializer (`const f = flow$(...)`), but also nested inside the idiomatic
352
+ // `RegisteredFlow` shape `const f = { definition: flow$(...), runner } as RegisteredFlow`.
353
+ // The walk recurses into every child (below), so matching the call node directly catches
354
+ // both; the previous variable-initializer-only check missed the object-wrapped form and
355
+ // emitted an empty `flows` map for real apps.
356
+ if (ts.isCallExpression(node) && isCallTo(node, "flow$") && node.arguments.length > 0) {
357
+ const argObj = node.arguments[0];
358
+ if (argObj && ts.isObjectLiteralExpression(argObj)) {
359
+ const idProp = findProperty(argObj, "id");
360
+ const versionProp = findProperty(argObj, "version");
361
+ const stepsProp = findProperty(argObj, "steps");
362
+
363
+ const flowId = idProp ? getStringLiteral(idProp.initializer) : undefined;
364
+ const version = versionProp ? getStringLiteral(versionProp.initializer) : "0.0.0";
365
+ if (!flowId) return;
366
+
367
+ const stepIds: string[] = [];
368
+ if (
369
+ stepsProp &&
370
+ ts.isPropertyAssignment(stepsProp) &&
371
+ ts.isArrayLiteralExpression(stepsProp.initializer)
372
+ ) {
373
+ for (const el of stepsProp.initializer.elements) {
374
+ if (ts.isIdentifier(el)) {
375
+ // A referenced step variable: `steps: [assessStep, …]`.
376
+ const resolvedId = pluginState.stepsByVarName.get(el.text);
377
+ if (resolvedId) stepIds.push(resolvedId);
378
+ } else if (
379
+ ts.isCallExpression(el) &&
380
+ isCallTo(el, "step$") &&
381
+ el.arguments.length > 0
382
+ ) {
383
+ // An inline step: `steps: [step$({ id: "…" }), …]` — read its id directly.
384
+ const arg = el.arguments[0];
385
+ const idProp =
386
+ arg && ts.isObjectLiteralExpression(arg) ? findProperty(arg, "id") : undefined;
387
+ const inlineId = idProp ? getStringLiteral(idProp.initializer) : undefined;
388
+ if (inlineId) stepIds.push(inlineId);
389
+ }
390
+ }
391
+ }
392
+
393
+ // Heuristic: detect suspend points from runner source text
394
+ const suspendPoints = detectSuspendPoints(_sourceText, stepIds, pluginState);
395
+
396
+ const findings = lintLoopIdentity();
397
+ const nonDet = lintNonDeterminism();
398
+ const flowReach = analyzeRegion().reach;
399
+ // Any reach fact keeps the analysis — including a ctx hand-off alone (L32 `escapes`); a
400
+ // flow whose only reach is `planner.compose(x, ctx)` must not read as "not analysed".
401
+ const hasReach =
402
+ flowReach.steps.length > 0 ||
403
+ flowReach.unresolved.length > 0 ||
404
+ flowReach.suspends.length > 0 ||
405
+ (flowReach.escapes?.length ?? 0) > 0;
406
+ const entry: FlowEntry = {
407
+ id: flowId,
408
+ version: version ?? "0.0.0",
409
+ stepIds,
410
+ suspendPoints,
411
+ sourceFile: absolutePath,
412
+ ...(findings.length > 0 && { loopIdentityFindings: findings }),
413
+ ...(nonDet.length > 0 && { nonDeterministicCalls: nonDet }),
414
+ ...(hasReach && { runnerReach: flowReach }),
415
+ };
416
+ flowsInModule++;
417
+ if (!hasReach) emptyReach.push(entry);
418
+ pluginState.flows.set(flowId, entry);
419
+ }
420
+ }
421
+
422
+ ts.forEachChild(node, visit);
423
+ });
424
+
425
+ // A PURE runner — one that never touches ctx (`async (_ctx, input) => reduce(input)`) — reaches
426
+ // nothing, and an empty reach used to be dropped, so verify reported the flow as "no runner-body
427
+ // analysis" although its body was read. An empty reach is only an ANALYSED empty when a followed
428
+ // runner is BOUND to this flow: in the same object literal / class as its `flow$` call, or passed
429
+ // to `registerFlow(def, …)` with `def` naming it. A runner that is merely somewhere in the module
430
+ // (an unrelated object property, class field, nested function, another flow's registration) does
431
+ // not count, and a runner the analysis cannot follow is `unresolved`, never silently skipped. The
432
+ // reach is module-wide, so with several flows in one module the binding is ambiguous and the
433
+ // flow stays unclaimed (still reported as not analysed) rather than read as clean.
434
+ if (
435
+ flowsInModule === 1 &&
436
+ emptyReach.length === 1 &&
437
+ region !== undefined &&
438
+ region.boundRunners > 0
439
+ ) {
440
+ const [entry] = emptyReach;
441
+ if (entry !== undefined) entry.runnerReach = region.reach;
442
+ }
443
+ }
444
+
445
+ function detectSuspendPoints(
446
+ sourceText: string,
447
+ stepIds: string[],
448
+ pluginState: PluginState
449
+ ): string[] {
450
+ // Heuristic: a step is a suspend point if the source file contains ctx.suspend(
451
+ // near a ctx.run(StepVar) reference and the step has kind "human" or HumanEffect.
452
+ // V1: mark any step whose effect entry has no handler and no toolId — assume human.
453
+ // A more precise analysis requires full AST traversal of runner bodies.
454
+ const suspendPoints: string[] = [];
455
+ if (sourceText.includes("ctx.suspend(") || sourceText.includes("HumanEffect")) {
456
+ for (const id of stepIds) {
457
+ const entry = pluginState.steps.get(id);
458
+ if (entry && !entry.hasHandler && !entry.toolId) {
459
+ suspendPoints.push(id);
460
+ }
461
+ }
462
+ }
463
+ return suspendPoints;
464
+ }
465
+
466
+ // ── Helpers ──────────────────────────────────────────────────────────────────
467
+
468
+ function collectImportLines(sourceFile: ts.SourceFile, text: string): string[] {
469
+ const lines: string[] = [];
470
+ for (const statement of sourceFile.statements) {
471
+ if (ts.isImportDeclaration(statement)) {
472
+ // Skip type-only imports — they're erased at runtime and Rollup can't parse them
473
+ const raw = text.slice(statement.getStart(), statement.getEnd());
474
+ if (raw.startsWith("import type ")) continue;
475
+ lines.push(text.slice(statement.getStart(), statement.getEnd()));
476
+ }
477
+ }
478
+ return lines;
479
+ }
480
+
481
+ function buildChunkSource(importLines: string[], fnText: string): string {
482
+ return [
483
+ "// @loomwork/compiler — generated chunk (do not edit)",
484
+ ...importLines,
485
+ "",
486
+ `export default ${fnText};`,
487
+ "",
488
+ ].join("\n");
489
+ }
490
+
491
+ function isCallTo(node: ts.CallExpression, name: string): boolean {
492
+ return ts.isIdentifier(node.expression) && node.expression.text === name;
493
+ }
494
+
495
+ /** The variable name a call initializes (`const Foo = call(...)`), or undefined if it's inline. */
496
+ function declaredVarName(call: ts.CallExpression): string | undefined {
497
+ const parent = call.parent;
498
+ return parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)
499
+ ? parent.name.text
500
+ : undefined;
501
+ }
502
+
503
+ function findProperty(
504
+ obj: ts.ObjectLiteralExpression,
505
+ name: string
506
+ ): ts.PropertyAssignment | undefined {
507
+ for (const prop of obj.properties) {
508
+ if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name) && prop.name.text === name) {
509
+ return prop;
510
+ }
511
+ }
512
+ return undefined;
513
+ }
514
+
515
+ function getStringLiteral(node: ts.Node | undefined): string | undefined {
516
+ if (!node) return undefined;
517
+ if (ts.isStringLiteral(node)) return node.text;
518
+ return undefined;
519
+ }
520
+
521
+ function sourceText(ms: MagicString, node: ts.Node): string {
522
+ return ms.original.slice(node.getStart(), node.getEnd());
523
+ }
package/src/types.ts ADDED
@@ -0,0 +1,79 @@
1
+ export interface ExtractedChunk {
2
+ // The owning construct's id — a step id (kind "step"), skill id ("skill"), or
3
+ // projection id ("projection").
4
+ stepId: string;
5
+ kind: "step" | "skill" | "projection";
6
+ chunkVirtualId: string;
7
+ chunkSource: string;
8
+ sourceFile: string;
9
+ hash: string;
10
+ }
11
+
12
+ export interface SkillEntry {
13
+ id: string;
14
+ varName: string;
15
+ chunkVirtualId?: string;
16
+ hash?: string;
17
+ sourceFile: string;
18
+ }
19
+
20
+ export interface ProjectionEntry {
21
+ id: string;
22
+ varName: string;
23
+ /** The event-type keys the projection folds — read statically from the handlers object. */
24
+ eventTypes: string[];
25
+ chunkVirtualId?: string;
26
+ hash?: string;
27
+ sourceFile: string;
28
+ }
29
+
30
+ export interface FlowEntry {
31
+ id: string;
32
+ version: string;
33
+ stepIds: string[];
34
+ suspendPoints: string[];
35
+ sourceFile: string;
36
+ /**
37
+ * ADR 0061 — loop step-identity findings from `analyzeLoopIdentity` over the
38
+ * flow-declaring module (surfaced as manifest warnings and consumable by
39
+ * `@loomwork/verify` via `FlowSpec.loopIdentityCalls`).
40
+ */
41
+ loopIdentityFindings?: import("./loopIdentity").LoopStepIdentityFinding[];
42
+ /**
43
+ * Issue #82 — non-deterministic calls reachable outside a `ctx.decide`/
44
+ * `ctx.execute` boundary, from `analyzeNonDeterminism` over the flow-declaring
45
+ * module. Consumed by `@loomwork/verify` via `FlowSpec.nonDeterministicCalls`.
46
+ *
47
+ * Emitted here for the same reason `loopIdentityFindings` is: the analysis
48
+ * needs the runner SOURCE, the compiler is the only thing that has it, and
49
+ * asking an application to read its own files to feed a linter is a wiring
50
+ * step nobody performs.
51
+ */
52
+ nonDeterministicCalls?: import("./nondeterminism").NonDeterministicCall[];
53
+ /**
54
+ * ADR 0086 — runner-body reach: `ctx.run` targets and `ctx.suspend`
55
+ * conditions, plus what could not be resolved. Consumed by `@loomwork/verify` via
56
+ * `FlowSpec.effects` / `suspends` / `unresolvedReach`.
57
+ */
58
+ runnerReach?: import("./runnerReach").RunnerReach;
59
+ }
60
+
61
+ export interface StepEntry {
62
+ id: string;
63
+ varName: string;
64
+ hasHandler: boolean;
65
+ chunkVirtualId?: string;
66
+ toolId?: string;
67
+ sourceFile: string;
68
+ hash?: string;
69
+ }
70
+
71
+ export interface PluginState {
72
+ chunks: Map<string, ExtractedChunk>;
73
+ steps: Map<string, StepEntry>;
74
+ stepsByVarName: Map<string, string>;
75
+ flows: Map<string, FlowEntry>;
76
+ skills: Map<string, SkillEntry>;
77
+ skillsByVarName: Map<string, string>;
78
+ projections: Map<string, ProjectionEntry>;
79
+ }