@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,185 @@
1
+ import * as path from "node:path";
2
+ import type { PluginState, StepEntry } from "./types";
3
+
4
+ /** The compiler manifest schema version (not semver — a monotonic integer string). */
5
+ export const COMPILER_MANIFEST_VERSION = "1";
6
+
7
+ export interface ManifestFlow {
8
+ version: string;
9
+ steps: string[];
10
+ chunkMap: Record<string, string>;
11
+ suspendPoints: string[];
12
+ warnings: string[];
13
+ /**
14
+ * ADR 0061 — structured loop step-identity findings for this flow's module
15
+ * (also rendered into `warnings`). Feed to `@loomwork/verify` via
16
+ * `FlowSpec.loopIdentityCalls`; additive manifest field.
17
+ */
18
+ loopIdentity?: Array<{ callee: string; loopKind: string; reason: string; location?: string }>;
19
+ /**
20
+ * Issue #82 — non-deterministic calls in this flow's module, and whether each
21
+ * sits inside a `ctx.decide`/`ctx.execute` boundary. Feed to `@loomwork/verify`
22
+ * via `FlowSpec.nonDeterministicCalls`; additive manifest field.
23
+ */
24
+ nonDeterministic?: Array<{ callee: string; insideDecide: boolean; location?: string }>;
25
+ /**
26
+ * ADR 0086 — runner-body reach: resolved `ctx.run` targets, `ctx.suspend`
27
+ * conditions, and the targets analysis could NOT resolve. Feeds
28
+ * `FlowSpec.effects` / `suspends` / `unresolvedReach`, which is what lets
29
+ * capability-compat, effect-class and suspension-safety see past the flow's
30
+ * DECLARED steps — and state their own coverage when they cannot.
31
+ */
32
+ runnerReach?: {
33
+ steps: Array<{ stepId?: string; toolId: string; viaDecider?: boolean }>;
34
+ unresolved: Array<{ expr: string; kind: "step" | "suspend"; location?: string }>;
35
+ suspends: Array<{
36
+ id?: string;
37
+ correlationKey?: string;
38
+ timed?: boolean;
39
+ wakeAt?: boolean;
40
+ }>;
41
+ /** L32 — `ctx` (or `ctx.decide`) handed to out-of-module callees; resolved against `verify.reach`. */
42
+ escapes?: Array<{
43
+ callee: string;
44
+ root?: string;
45
+ via: "ctx" | "decide";
46
+ member?: string;
47
+ /** L34 — the hand-off's arguments that resolve to a step, by position. */
48
+ args?: Array<{ index: number; step: { stepId?: string; toolId: string } }>;
49
+ location?: string;
50
+ }>;
51
+ };
52
+ /**
53
+ * The source module that declared this flow (relative to the project root).
54
+ * Enables tooling and `@loomwork/app`'s manifest cross-check to know where a flow
55
+ * lives. Registration itself stays a static import (see `registerFlowsFrom`) —
56
+ * this is metadata, never a dynamic-import target, so wiring stays
57
+ * deterministic/replay-safe.
58
+ */
59
+ module: string;
60
+ }
61
+
62
+ // A skill's lazy-loaded instruction body — the progressive-disclosure analog of
63
+ // a flow's per-step chunkMap. `chunk` is empty when the skill was authored with
64
+ // inline instructions (no instructions$ marker for the compiler to extract).
65
+ export interface ManifestSkill {
66
+ chunk: string;
67
+ }
68
+
69
+ // A projection's lazy-loaded fold map. `eventTypes` are the handled event-type keys
70
+ // (the runtime gates on-demand loading against them WITHOUT importing the chunk);
71
+ // `chunk` is empty when the handlers weren't an inline object literal to extract.
72
+ export interface ManifestProjection {
73
+ eventTypes: string[];
74
+ chunk: string;
75
+ }
76
+
77
+ export interface LoomManifest {
78
+ version: "1";
79
+ flows: Record<string, ManifestFlow>;
80
+ skills: Record<string, ManifestSkill>;
81
+ projections: Record<string, ManifestProjection>;
82
+ /** Deduplicated, sorted list of source modules that declare flows. */
83
+ flowModules: string[];
84
+ }
85
+
86
+ function relModule(sourceFile: string, root: string): string {
87
+ if (!sourceFile) return sourceFile;
88
+ const rel = path.isAbsolute(sourceFile) ? path.relative(root, sourceFile) : sourceFile;
89
+ return rel.split(path.sep).join("/");
90
+ }
91
+
92
+ export function buildManifest(
93
+ state: PluginState,
94
+ outDir = ".",
95
+ root: string = process.cwd()
96
+ ): LoomManifest {
97
+ const flows: Record<string, ManifestFlow> = {};
98
+ const skills: Record<string, ManifestSkill> = {};
99
+ const flowModuleSet = new Set<string>();
100
+
101
+ for (const [flowId, flow] of state.flows) {
102
+ const chunkMap: Record<string, string> = {};
103
+ const warnings: string[] = [];
104
+
105
+ for (const stepId of flow.stepIds) {
106
+ const step: StepEntry | undefined = state.steps.get(stepId);
107
+ if (!step) {
108
+ warnings.push(
109
+ `Step "${stepId}" is referenced in flow "${flowId}" but not found in analysis.`
110
+ );
111
+ continue;
112
+ }
113
+ if (step.chunkVirtualId !== undefined) {
114
+ const fileName = `chunks/${stepId}_${step.hash}.js`;
115
+ chunkMap[stepId] = `${outDir}/${fileName}`;
116
+ } else if (!step.toolId) {
117
+ warnings.push(
118
+ `Step "${stepId}" in flow "${flowId}" has neither a handler$ nor a toolId — it must be registered manually. (Steps minted dynamically in the runner body — the interpreter pattern — should instead bind a static toolId to a registered effect and derive ids as \`\${id}#\${index}\`; see ADR 0061.)`
119
+ );
120
+ }
121
+ }
122
+
123
+ // ADR 0061 — surface loop step-identity findings as warnings (rendered) AND
124
+ // structurally (for @loomwork/verify's determinism rule via loopIdentityCalls).
125
+ for (const f of flow.loopIdentityFindings ?? []) {
126
+ warnings.push(
127
+ `Flow "${flowId}": ctx.${f.callee} inside ${f.loopKind} uses a step id that cannot vary per iteration (${f.reason}) — frames are keyed by id, so later iterations replay iteration 0's output. Mint \`\${id}#\${index}\` (ADR 0061).`
128
+ );
129
+ }
130
+
131
+ const module = relModule(flow.sourceFile, root);
132
+ if (module) flowModuleSet.add(module);
133
+
134
+ flows[flowId] = {
135
+ version: flow.version,
136
+ steps: flow.stepIds,
137
+ chunkMap,
138
+ suspendPoints: flow.suspendPoints,
139
+ warnings,
140
+ module,
141
+ ...(flow.loopIdentityFindings !== undefined &&
142
+ flow.loopIdentityFindings.length > 0 && { loopIdentity: flow.loopIdentityFindings }),
143
+ ...(flow.nonDeterministicCalls !== undefined &&
144
+ flow.nonDeterministicCalls.length > 0 && { nonDeterministic: flow.nonDeterministicCalls }),
145
+ ...(flow.runnerReach !== undefined && { runnerReach: flow.runnerReach }),
146
+ };
147
+ }
148
+
149
+ for (const [skillId, skill] of state.skills) {
150
+ skills[skillId] = {
151
+ chunk: skill.chunkVirtualId ? `${outDir}/chunks/${skillId}_${skill.hash}.js` : "",
152
+ };
153
+ }
154
+
155
+ const projections: Record<string, ManifestProjection> = {};
156
+ for (const [projectionId, projection] of state.projections) {
157
+ projections[projectionId] = {
158
+ eventTypes: projection.eventTypes,
159
+ chunk: projection.chunkVirtualId
160
+ ? `${outDir}/chunks/${projectionId}_${projection.hash}.js`
161
+ : "",
162
+ };
163
+ }
164
+
165
+ return {
166
+ version: COMPILER_MANIFEST_VERSION,
167
+ flows,
168
+ skills,
169
+ projections,
170
+ flowModules: [...flowModuleSet].sort(),
171
+ };
172
+ }
173
+
174
+ export function printWarnings(manifest: LoomManifest): void {
175
+ for (const [flowId, flow] of Object.entries(manifest.flows)) {
176
+ for (const warning of flow.warnings) {
177
+ console.warn(`[loom-compiler] ⚠ ${warning}`);
178
+ }
179
+ if (flow.warnings.length === 0) {
180
+ console.info(
181
+ `[loom-compiler] ✓ flow "${flowId}" — ${flow.steps.length} step(s), ${Object.keys(flow.chunkMap).length} lazy chunk(s)`
182
+ );
183
+ }
184
+ }
185
+ }
@@ -0,0 +1,133 @@
1
+ import ts from "typescript";
2
+
3
+ // ── Non-determinism static analysis (issue #82) ──────────────────────────────
4
+ //
5
+ // Detect calls whose result varies run-to-run — Date.now / Math.random /
6
+ // crypto.randomUUID / performance.now / bare fetch / new Date() — and whether each is
7
+ // inside a `ctx.decide(...)` / `ctx.execute(...)` boundary (where the result is logged and
8
+ // served on replay, hence safe). A call reachable OUTSIDE such a boundary in code that
9
+ // re-executes on replay (the flow runner / orchestration body) diverges the fold (P2).
10
+ //
11
+ // IMPORTANT: this analyzes a SOURCE REGION you give it. Step `handler$` bodies are memoized
12
+ // (their STEP_COMPLETED output is logged and served on replay), so they are NOT the target —
13
+ // feed this the flow runner / orchestration source. Output is shaped to `@loomwork/verify`'s
14
+ // `NonDeterministicCall` and can be fed to its determinism rule via
15
+ // `resolveVerifyInput({ nonDeterministicByFlow })`.
16
+
17
+ export interface NonDeterministicCall {
18
+ callee: string;
19
+ /** True if the call is inside a `ctx.decide`/`ctx.execute` call (its result is logged). */
20
+ insideDecide: boolean;
21
+ location?: string;
22
+ }
23
+
24
+ /** Member calls whose result is non-deterministic (object.method form). */
25
+ const NON_DET_MEMBER_CALLS = new Set([
26
+ "Date.now",
27
+ "Math.random",
28
+ "crypto.randomUUID",
29
+ "crypto.getRandomValues",
30
+ "performance.now",
31
+ ]);
32
+ /** Bare (identifier) calls that are non-deterministic / side-effectful. */
33
+ const NON_DET_BARE_CALLS = new Set(["fetch"]);
34
+
35
+ function calleeName(node: ts.CallExpression): string | undefined {
36
+ const e = node.expression;
37
+ if (ts.isIdentifier(e)) return e.text;
38
+ if (
39
+ ts.isPropertyAccessExpression(e) &&
40
+ ts.isIdentifier(e.name) &&
41
+ ts.isIdentifier(e.expression)
42
+ ) {
43
+ return `${e.expression.text}.${e.name.text}`;
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ /** A `ctx.decide(...)`/`ctx.execute(...)`-shaped call (any receiver, method name decide/execute). */
49
+ function isDecideBoundary(node: ts.Node): boolean {
50
+ if (!ts.isCallExpression(node)) return false;
51
+ const e = node.expression;
52
+ return (
53
+ ts.isPropertyAccessExpression(e) &&
54
+ ts.isIdentifier(e.name) &&
55
+ (e.name.text === "decide" || e.name.text === "execute")
56
+ );
57
+ }
58
+
59
+ /**
60
+ * A `handler$(...)` call — a MEMOIZED region. Its result is written to
61
+ * STEP_COMPLETED and served from the log on replay, so `Math.random()` inside
62
+ * one is safe by construction; the same call in a runner diverges the fold.
63
+ */
64
+ function isMemoizedBoundary(node: ts.Node): boolean {
65
+ return (
66
+ ts.isCallExpression(node) &&
67
+ ts.isIdentifier(node.expression) &&
68
+ node.expression.text === "handler$"
69
+ );
70
+ }
71
+
72
+ export interface AnalyzeOptions {
73
+ /**
74
+ * Skip `handler$(...)` bodies. Required when analyzing a whole MODULE rather
75
+ * than an isolated runner: a flow module contains both, and reporting a
76
+ * handler's `Math.random` is a false positive — which is precisely the
77
+ * distinction this file's header describes, and precisely what a caller
78
+ * feeding it whole modules will otherwise get wrong.
79
+ */
80
+ skipMemoized?: boolean;
81
+ }
82
+
83
+ /** Analyze a parsed node (a function/body) for non-deterministic calls. */
84
+ export function analyzeNode(
85
+ root: ts.Node,
86
+ location?: string,
87
+ options: AnalyzeOptions = {}
88
+ ): NonDeterministicCall[] {
89
+ const calls: NonDeterministicCall[] = [];
90
+ const ancestors: ts.Node[] = [];
91
+
92
+ function record(callee: string): void {
93
+ const insideDecide = ancestors.some(isDecideBoundary);
94
+ calls.push({ callee, insideDecide, ...(location !== undefined && { location }) });
95
+ }
96
+
97
+ function visit(node: ts.Node): void {
98
+ if (options.skipMemoized === true && isMemoizedBoundary(node)) return;
99
+ ancestors.push(node);
100
+ if (ts.isCallExpression(node)) {
101
+ const name = calleeName(node);
102
+ if (name !== undefined && (NON_DET_MEMBER_CALLS.has(name) || NON_DET_BARE_CALLS.has(name))) {
103
+ record(name);
104
+ }
105
+ } else if (
106
+ ts.isNewExpression(node) &&
107
+ ts.isIdentifier(node.expression) &&
108
+ node.expression.text === "Date" &&
109
+ (node.arguments === undefined || node.arguments.length === 0)
110
+ ) {
111
+ // `new Date()` with no args reads the current time.
112
+ record("new Date");
113
+ }
114
+ ts.forEachChild(node, visit);
115
+ ancestors.pop();
116
+ }
117
+
118
+ visit(root);
119
+ return calls;
120
+ }
121
+
122
+ /**
123
+ * Analyze a source string (e.g. a flow runner / orchestration body) for non-deterministic
124
+ * calls. Feed the result to `@loomwork/verify` via `resolveVerifyInput({ nonDeterministicByFlow })`.
125
+ */
126
+ export function analyzeNonDeterminism(
127
+ source: string,
128
+ location?: string,
129
+ options: AnalyzeOptions = {}
130
+ ): NonDeterministicCall[] {
131
+ const sf = ts.createSourceFile("nondet.ts", source, ts.ScriptTarget.ESNext, true);
132
+ return analyzeNode(sf, location, options);
133
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,74 @@
1
+ import path from "node:path";
2
+ import ts from "typescript";
3
+ import type { Plugin, ResolvedConfig } from "vite";
4
+ import { buildManifest, printWarnings } from "./manifest";
5
+ import { transformFile } from "./transform";
6
+ import type { PluginState } from "./types";
7
+
8
+ const VIRTUAL_PREFIX = "\0loom:chunk:";
9
+
10
+ export function loomPlugin(): Plugin {
11
+ const state: PluginState = {
12
+ chunks: new Map(),
13
+ steps: new Map(),
14
+ stepsByVarName: new Map(),
15
+ flows: new Map(),
16
+ skills: new Map(),
17
+ skillsByVarName: new Map(),
18
+ projections: new Map(),
19
+ };
20
+
21
+ let resolvedConfig: ResolvedConfig;
22
+
23
+ return {
24
+ name: "loom",
25
+ enforce: "pre",
26
+
27
+ configResolved(config) {
28
+ resolvedConfig = config;
29
+ },
30
+
31
+ resolveId(id) {
32
+ if (id.startsWith(VIRTUAL_PREFIX) || id.startsWith("\0loom:")) {
33
+ return id;
34
+ }
35
+ return undefined;
36
+ },
37
+
38
+ load(id) {
39
+ if (id.startsWith(VIRTUAL_PREFIX)) {
40
+ const chunk = state.chunks.get(id);
41
+ if (chunk) {
42
+ // Transpile TS → JS so Vite/Rollup can parse the chunk (no .ts extension on virtual ids)
43
+ const result = ts.transpileModule(chunk.chunkSource, {
44
+ compilerOptions: {
45
+ target: ts.ScriptTarget.ESNext,
46
+ module: ts.ModuleKind.ESNext,
47
+ },
48
+ });
49
+ return { code: result.outputText };
50
+ }
51
+ }
52
+ return undefined;
53
+ },
54
+
55
+ transform(code, id) {
56
+ if (!/\.[jt]sx?$/.test(id) || id.includes("node_modules")) return undefined;
57
+ const result = transformFile(code, id, state);
58
+ if (!result) return undefined;
59
+ return { code: result.code, map: result.map };
60
+ },
61
+
62
+ generateBundle() {
63
+ const outDir = resolvedConfig?.build?.outDir ?? "dist";
64
+ const manifest = buildManifest(state, path.relative(process.cwd(), outDir));
65
+ printWarnings(manifest);
66
+
67
+ this.emitFile({
68
+ type: "asset",
69
+ fileName: "loom-manifest.json",
70
+ source: JSON.stringify(manifest, null, 2),
71
+ });
72
+ },
73
+ };
74
+ }