@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,73 @@
1
+ export interface ExtractedChunk {
2
+ stepId: string;
3
+ kind: "step" | "skill" | "projection";
4
+ chunkVirtualId: string;
5
+ chunkSource: string;
6
+ sourceFile: string;
7
+ hash: string;
8
+ }
9
+ export interface SkillEntry {
10
+ id: string;
11
+ varName: string;
12
+ chunkVirtualId?: string;
13
+ hash?: string;
14
+ sourceFile: string;
15
+ }
16
+ export interface ProjectionEntry {
17
+ id: string;
18
+ varName: string;
19
+ /** The event-type keys the projection folds — read statically from the handlers object. */
20
+ eventTypes: string[];
21
+ chunkVirtualId?: string;
22
+ hash?: string;
23
+ sourceFile: string;
24
+ }
25
+ export interface FlowEntry {
26
+ id: string;
27
+ version: string;
28
+ stepIds: string[];
29
+ suspendPoints: string[];
30
+ sourceFile: string;
31
+ /**
32
+ * ADR 0061 — loop step-identity findings from `analyzeLoopIdentity` over the
33
+ * flow-declaring module (surfaced as manifest warnings and consumable by
34
+ * `@loomwork/verify` via `FlowSpec.loopIdentityCalls`).
35
+ */
36
+ loopIdentityFindings?: import("./loopIdentity").LoopStepIdentityFinding[];
37
+ /**
38
+ * Issue #82 — non-deterministic calls reachable outside a `ctx.decide`/
39
+ * `ctx.execute` boundary, from `analyzeNonDeterminism` over the flow-declaring
40
+ * module. Consumed by `@loomwork/verify` via `FlowSpec.nonDeterministicCalls`.
41
+ *
42
+ * Emitted here for the same reason `loopIdentityFindings` is: the analysis
43
+ * needs the runner SOURCE, the compiler is the only thing that has it, and
44
+ * asking an application to read its own files to feed a linter is a wiring
45
+ * step nobody performs.
46
+ */
47
+ nonDeterministicCalls?: import("./nondeterminism").NonDeterministicCall[];
48
+ /**
49
+ * ADR 0086 — runner-body reach: `ctx.run` targets and `ctx.suspend`
50
+ * conditions, plus what could not be resolved. Consumed by `@loomwork/verify` via
51
+ * `FlowSpec.effects` / `suspends` / `unresolvedReach`.
52
+ */
53
+ runnerReach?: import("./runnerReach").RunnerReach;
54
+ }
55
+ export interface StepEntry {
56
+ id: string;
57
+ varName: string;
58
+ hasHandler: boolean;
59
+ chunkVirtualId?: string;
60
+ toolId?: string;
61
+ sourceFile: string;
62
+ hash?: string;
63
+ }
64
+ export interface PluginState {
65
+ chunks: Map<string, ExtractedChunk>;
66
+ steps: Map<string, StepEntry>;
67
+ stepsByVarName: Map<string, string>;
68
+ flows: Map<string, FlowEntry>;
69
+ skills: Map<string, SkillEntry>;
70
+ skillsByVarName: Map<string, string>;
71
+ projections: Map<string, ProjectionEntry>;
72
+ }
73
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,cAAc;IAG7B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,GAAG,OAAO,GAAG,YAAY,CAAC;IACtC,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,2FAA2F;IAC3F,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,OAAO,gBAAgB,EAAE,uBAAuB,EAAE,CAAC;IAC1E;;;;;;;;;OASG;IACH,qBAAqB,CAAC,EAAE,OAAO,kBAAkB,EAAE,oBAAoB,EAAE,CAAC;IAC1E;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,eAAe,EAAE,WAAW,CAAC;CACnD;AAED,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,OAAO,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IACpC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IAC9B,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IAC9B,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IAChC,eAAe,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;CAC3C"}
package/dist/types.js ADDED
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
2
  "name": "@loomwork/compiler",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Vite plugin and CLI compiler for the Loom framework — handler$ extraction, flow graph analysis, and resumability",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "bin": {
8
+ "loom-compile": "./dist/cli.js"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "require": "./dist/index.js",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "dependencies": {
18
+ "magic-string": "^0.30.21",
19
+ "typescript": "^5.6.3"
20
+ },
21
+ "peerDependencies": {
22
+ "vite": ">=5.0.0"
23
+ },
24
+ "peerDependenciesMeta": {
25
+ "vite": {
26
+ "optional": true
27
+ }
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22.0.0",
31
+ "vite": "^5.4.10",
32
+ "vitest": "^2.1.8"
33
+ },
34
+ "license": "Apache-2.0",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/chhetrisushil/loom.git",
38
+ "directory": "tools/compiler"
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "src",
43
+ "!src/**/*.test.ts",
44
+ "!src/**/*.test.tsx",
45
+ "!src/**/__tests__",
46
+ "!src/**/__fixtures__",
47
+ "!src/**/__snapshots__",
48
+ "!src/**/__golden__",
49
+ "LICENSE",
50
+ "NOTICE"
51
+ ],
52
+ "publishConfig": {
53
+ "access": "public",
54
+ "tag": "next"
55
+ },
56
+ "engines": {
57
+ "node": ">=20.0.0"
58
+ },
59
+ "scripts": {
60
+ "build": "tsc --build",
61
+ "dev": "tsc --build --watch",
62
+ "test": "vitest run",
63
+ "test:watch": "vitest",
64
+ "clean": "rm -rf dist tsconfig.tsbuildinfo",
65
+ "typecheck:tests": "tsc -p tsconfig.test.json"
66
+ }
6
67
  }
package/src/cli.ts ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { buildManifest, printWarnings } from "./manifest";
5
+ import { transformFile } from "./transform";
6
+ import type { PluginState } from "./types";
7
+
8
+ function walk(dir: string, out: string[] = []): string[] {
9
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
10
+ const full = path.join(dir, entry.name);
11
+ if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== "dist") {
12
+ walk(full, out);
13
+ } else if (entry.isFile() && /\.[jt]sx?$/.test(entry.name)) {
14
+ out.push(full);
15
+ }
16
+ }
17
+ return out;
18
+ }
19
+
20
+ const USAGE = "usage: loom-compile [srcDir=src] [outDir=dist-loom]";
21
+
22
+ async function main(): Promise<void> {
23
+ const [, , srcDir = "src", outDir = "dist-loom"] = process.argv;
24
+ if (srcDir === "--help" || srcDir === "-h") {
25
+ console.log(USAGE);
26
+ return;
27
+ }
28
+ // Positional arguments are DIRECTORIES. A flag reaching `readdirSync` surfaces as an ENOENT
29
+ // naming a path the user never typed, which reads like a broken install rather than a
30
+ // mistyped command.
31
+ if (srcDir.startsWith("-") || outDir.startsWith("-")) {
32
+ console.error(
33
+ `[loom-compiler] unknown option "${srcDir.startsWith("-") ? srcDir : outDir}"\n${USAGE}`
34
+ );
35
+ process.exitCode = 1;
36
+ return;
37
+ }
38
+ if (!fs.existsSync(srcDir)) {
39
+ console.error(`[loom-compiler] no such directory: ${srcDir}\n${USAGE}`);
40
+ process.exitCode = 1;
41
+ return;
42
+ }
43
+
44
+ const state: PluginState = {
45
+ chunks: new Map(),
46
+ steps: new Map(),
47
+ stepsByVarName: new Map(),
48
+ flows: new Map(),
49
+ skills: new Map(),
50
+ skillsByVarName: new Map(),
51
+ projections: new Map(),
52
+ };
53
+
54
+ const files = walk(path.resolve(srcDir));
55
+ for (const file of files) {
56
+ const code = fs.readFileSync(file, "utf-8");
57
+ const result = transformFile(code, file, state);
58
+ if (result) {
59
+ const rel = path.relative(path.resolve(srcDir), file);
60
+ const dest = path.join(outDir, rel);
61
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
62
+ fs.writeFileSync(dest, result.code, "utf-8");
63
+ if (result.map) {
64
+ fs.writeFileSync(`${dest}.map`, result.map, "utf-8");
65
+ }
66
+ }
67
+ }
68
+
69
+ for (const [virtualId, chunk] of state.chunks) {
70
+ const fileName = `${virtualId.replace("\0loom:chunk:", "chunks/")}.js`;
71
+ const dest = path.join(outDir, fileName);
72
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
73
+ fs.writeFileSync(dest, chunk.chunkSource, "utf-8");
74
+ }
75
+
76
+ const manifest = buildManifest(state, outDir);
77
+ printWarnings(manifest);
78
+ fs.writeFileSync(
79
+ path.join(outDir, "loom-manifest.json"),
80
+ JSON.stringify(manifest, null, 2),
81
+ "utf-8"
82
+ );
83
+
84
+ console.log(`[loom-compiler] Done. Processed ${files.length} source files.`);
85
+ }
86
+
87
+ main().catch((err) => {
88
+ console.error("[loom-compiler] Fatal:", err);
89
+ process.exit(1);
90
+ });
package/src/hash.ts ADDED
@@ -0,0 +1,12 @@
1
+ export function fnv1a32(input: string): number {
2
+ let hash = 0x811c9dc5;
3
+ for (let i = 0; i < input.length; i++) {
4
+ hash ^= input.charCodeAt(i);
5
+ hash = Math.imul(hash, 0x01000193) >>> 0;
6
+ }
7
+ return hash;
8
+ }
9
+
10
+ export function shortHash(input: string): string {
11
+ return fnv1a32(input).toString(16).padStart(8, "0").slice(0, 8);
12
+ }
@@ -0,0 +1,133 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import ts from "typescript";
4
+ import { type ImportedBinding, inferStepFactory, stringLiteralValue } from "./runnerReach";
5
+
6
+ // ── L37: follow a RELATIVE import to the constant / step factory it names ─────
7
+ //
8
+ // `runnerReach` reads one parsed module. A flow that writes `llmStep$(id, LLM_RESHAPE)` imports both
9
+ // the factory and the constant, so on one module they are unresolvable. This reads the imported
10
+ // module (relative specifiers only: `./x`, `../x`, with `.ts`/`.tsx`/`/index.ts` and `.js`->`.ts`
11
+ // mapping) and reports what its top-level declarations SHOW:
12
+ // - `export const NAME = "literal"` (or an expression-free template / `as const`) -> a value;
13
+ // - `export function f(id, toolId) { return step$({ toolId, … }) }` (or an arrow) -> a factory.
14
+ // Not followed, so they stay unresolved: bare/aliased specifiers (`@x/y`, tsconfig paths),
15
+ // re-exports (`export { X } from`, `export *`), namespace imports, a constant defined by another
16
+ // constant or call. Never a guess: a miss is the same "unresolved" the single-module analysis gives.
17
+
18
+ interface ParsedModule {
19
+ mtimeMs: number;
20
+ consts: Map<string, string>;
21
+ /** exported name -> declaration node (function or arrow/function-expression initializer) */
22
+ fns: Map<string, ts.Node>;
23
+ exportedConsts: Set<string>;
24
+ }
25
+
26
+ const cache = new Map<string, ParsedModule>();
27
+
28
+ function resolveFile(from: string, spec: string): string | undefined {
29
+ if (!spec.startsWith("./") && !spec.startsWith("../")) return undefined;
30
+ const base = path.resolve(path.dirname(from), spec);
31
+ const stem = base.replace(/\.(m|c)?js$/, "");
32
+ const candidates = [
33
+ base,
34
+ `${base}.ts`,
35
+ `${base}.tsx`,
36
+ path.join(base, "index.ts"),
37
+ `${stem}.ts`,
38
+ `${stem}.tsx`,
39
+ ];
40
+ for (const c of candidates) {
41
+ try {
42
+ if (fs.statSync(c).isFile() && /\.tsx?$/.test(c)) return c;
43
+ } catch {
44
+ // not there
45
+ }
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ function parseModule(file: string): ParsedModule | undefined {
51
+ let mtimeMs: number;
52
+ try {
53
+ mtimeMs = fs.statSync(file).mtimeMs;
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ const hit = cache.get(file);
58
+ if (hit !== undefined && hit.mtimeMs === mtimeMs) return hit;
59
+ let text: string;
60
+ try {
61
+ text = fs.readFileSync(file, "utf8");
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true);
66
+ const consts = new Map<string, string>();
67
+ const fns = new Map<string, ts.Node>();
68
+ const exportedConsts = new Set<string>();
69
+ const exported = (n: ts.Node): boolean =>
70
+ ts.canHaveModifiers(n) &&
71
+ (ts.getModifiers(n) ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
72
+ for (const st of sf.statements) {
73
+ if (ts.isVariableStatement(st)) {
74
+ const isConst = (st.declarationList.flags & ts.NodeFlags.Const) !== 0;
75
+ for (const d of st.declarationList.declarations) {
76
+ if (!ts.isIdentifier(d.name) || d.initializer === undefined || !isConst) continue;
77
+ const v = stringLiteralValue(d.initializer);
78
+ if (v !== undefined) {
79
+ consts.set(d.name.text, v);
80
+ if (exported(st)) exportedConsts.add(d.name.text);
81
+ } else if (
82
+ exported(st) &&
83
+ (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer))
84
+ ) {
85
+ fns.set(d.name.text, d.initializer);
86
+ }
87
+ }
88
+ } else if (ts.isFunctionDeclaration(st) && st.name !== undefined && exported(st)) {
89
+ fns.set(st.name.text, st);
90
+ }
91
+ }
92
+ const parsed = { mtimeMs, consts, fns, exportedConsts };
93
+ cache.set(file, parsed);
94
+ return parsed;
95
+ }
96
+
97
+ /** Build the `importResolver` for one module (`undefined` when it imports nothing relative). */
98
+ export function createImportResolver(
99
+ sourceFile: ts.SourceFile,
100
+ absolutePath: string
101
+ ): ((localName: string) => ImportedBinding | undefined) | undefined {
102
+ const imports = new Map<string, { file: string; name: string }>();
103
+ for (const st of sourceFile.statements) {
104
+ if (!ts.isImportDeclaration(st) || !ts.isStringLiteral(st.moduleSpecifier)) continue;
105
+ const clause = st.importClause;
106
+ if (clause === undefined || clause.isTypeOnly) continue;
107
+ const file = resolveFile(absolutePath, st.moduleSpecifier.text);
108
+ if (file === undefined) continue;
109
+ const bindings = clause.namedBindings;
110
+ if (bindings !== undefined && ts.isNamedImports(bindings)) {
111
+ for (const el of bindings.elements) {
112
+ if (el.isTypeOnly) continue;
113
+ imports.set(el.name.text, { file, name: (el.propertyName ?? el.name).text });
114
+ }
115
+ }
116
+ }
117
+ if (imports.size === 0) return undefined;
118
+ return (localName) => {
119
+ const imp = imports.get(localName);
120
+ if (imp === undefined) return undefined;
121
+ const mod = parseModule(imp.file);
122
+ if (mod === undefined) return undefined;
123
+ if (mod.exportedConsts.has(imp.name)) return { value: mod.consts.get(imp.name) as string };
124
+ const fn = mod.fns.get(imp.name);
125
+ if (fn === undefined) return undefined;
126
+ const factory = inferStepFactory(fn, (e) => {
127
+ const lit = stringLiteralValue(e);
128
+ if (lit !== undefined) return lit;
129
+ return ts.isIdentifier(e) ? mod.consts.get(e.text) : undefined;
130
+ });
131
+ return factory === undefined ? undefined : { factory };
132
+ };
133
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ export { loomPlugin } from "./plugin";
2
+ export { buildManifest, printWarnings, COMPILER_MANIFEST_VERSION } from "./manifest";
3
+ export { transformFile } from "./transform";
4
+ export { shortHash, fnv1a32 } from "./hash";
5
+ export { analyzeNonDeterminism, analyzeNode } from "./nondeterminism";
6
+ export type { NonDeterministicCall } from "./nondeterminism";
7
+ export { analyzeLoopIdentity, analyzeLoopIdentityNode } from "./loopIdentity";
8
+ export type { LoopStepIdentityFinding } from "./loopIdentity";
9
+ export { analyzeReachNode, analyzeRunnerReach } from "./runnerReach";
10
+ export { KNOWN_STEP_FACTORIES } from "./runnerReach";
11
+ export type {
12
+ AnalyzeReachOptions,
13
+ ImportedBinding,
14
+ ReachEscape,
15
+ ReachEscapeArg,
16
+ StepFactoryShape,
17
+ RunnerReach,
18
+ RunnerStepRef,
19
+ RunnerSuspend,
20
+ UnresolvedReach,
21
+ } from "./runnerReach";
22
+ export type { LoomManifest, ManifestFlow } from "./manifest";
23
+ export type { ExtractedChunk, FlowEntry, PluginState, StepEntry } from "./types";
@@ -0,0 +1,210 @@
1
+ import ts from "typescript";
2
+
3
+ // ── Loop step-identity static analysis (DE-5, ADR 0061) ──────────────────────
4
+ //
5
+ // Frames are keyed by step id: `ctx.run(step, input)` inside a loop that cannot
6
+ // vary `step.id` per iteration is served iteration 0's memoized output for every
7
+ // subsequent iteration — silent replay corruption. The identity rule (ADR 0061
8
+ // D1): a step invoked more than once with different intent must mint a
9
+ // deterministic distinct id (`${id}#${index}`).
10
+ //
11
+ // This pass flags `*.run(...)` / `*.execute(...)` calls inside loop bodies whose
12
+ // first argument either IS a static step reference (identifier/property access —
13
+ // the id cannot vary) or is an expression whose subtree references NO loop-scoped
14
+ // binding (the id cannot depend on the iteration). Anything unresolvable stays
15
+ // quiet — false negatives are acceptable; false positives kill lints.
16
+ //
17
+ // Like `nondeterminism.ts`, this analyzes a SOURCE REGION (the flow runner /
18
+ // orchestration body). Step handler$ bodies are memoized by the kernel and are
19
+ // not the target. Output feeds `@loomwork/verify`'s determinism rule via
20
+ // `FlowSpec.loopIdentityCalls`.
21
+
22
+ export interface LoopStepIdentityFinding {
23
+ /** The invoking method: `run` or `execute`. */
24
+ callee: string;
25
+ /** The innermost enclosing loop construct. */
26
+ loopKind: string;
27
+ reason: "static-step-in-loop" | "id-not-derived-from-loop-variable";
28
+ location?: string;
29
+ }
30
+
31
+ const CALLBACK_LOOPS = new Set(["map", "forEach", "flatMap"]);
32
+
33
+ function isRunOrExecuteCall(node: ts.Node): node is ts.CallExpression {
34
+ if (!ts.isCallExpression(node)) return false;
35
+ const e = node.expression;
36
+ return (
37
+ ts.isPropertyAccessExpression(e) &&
38
+ ts.isIdentifier(e.name) &&
39
+ (e.name.text === "run" || e.name.text === "execute")
40
+ );
41
+ }
42
+
43
+ /** Collect binding names a loop scope introduces or mutates (iteration-varying names). */
44
+ function collectLoopBindings(loop: ts.Node, body: ts.Node): Set<string> {
45
+ const names = new Set<string>();
46
+
47
+ const addDeclarationList = (list: ts.ForInitializer | undefined): void => {
48
+ if (list !== undefined && ts.isVariableDeclarationList(list)) {
49
+ for (const d of list.declarations) collectBoundNames(d.name, names);
50
+ }
51
+ };
52
+ if (ts.isForStatement(loop)) addDeclarationList(loop.initializer);
53
+ if (ts.isForOfStatement(loop) || ts.isForInStatement(loop)) addDeclarationList(loop.initializer);
54
+
55
+ // Counters mutated inside the body (`i++`, `i += 1`, `i = i + 1`) vary per
56
+ // iteration even when declared outside the loop (the `while` idiom).
57
+ const scanMutations = (node: ts.Node): void => {
58
+ if (
59
+ (ts.isPostfixUnaryExpression(node) || ts.isPrefixUnaryExpression(node)) &&
60
+ (node.operator === ts.SyntaxKind.PlusPlusToken ||
61
+ node.operator === ts.SyntaxKind.MinusMinusToken) &&
62
+ ts.isIdentifier(node.operand)
63
+ ) {
64
+ names.add(node.operand.text);
65
+ } else if (
66
+ ts.isBinaryExpression(node) &&
67
+ node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
68
+ node.operatorToken.kind <= ts.SyntaxKind.LastAssignment &&
69
+ ts.isIdentifier(node.left)
70
+ ) {
71
+ names.add(node.left.text);
72
+ }
73
+ ts.forEachChild(node, scanMutations);
74
+ };
75
+ scanMutations(body);
76
+ return names;
77
+ }
78
+
79
+ /** Add every identifier bound by a (possibly destructuring) binding name. */
80
+ function collectBoundNames(name: ts.BindingName, into: Set<string>): void {
81
+ if (ts.isIdentifier(name)) {
82
+ into.add(name.text);
83
+ return;
84
+ }
85
+ for (const element of name.elements) {
86
+ if (ts.isBindingElement(element)) collectBoundNames(element.name, into);
87
+ }
88
+ }
89
+
90
+ /** True if the subtree references any of the given names as a VALUE (not a property name). */
91
+ function referencesAny(node: ts.Node, names: ReadonlySet<string>): boolean {
92
+ let found = false;
93
+ const visit = (n: ts.Node): void => {
94
+ if (found) return;
95
+ if (ts.isIdentifier(n) && names.has(n.text)) {
96
+ const parent = n.parent;
97
+ // `obj.i` — the `.i` name position is not a reference to the loop variable.
98
+ const isPropertyNamePosition =
99
+ (ts.isPropertyAccessExpression(parent) && parent.name === n) ||
100
+ (ts.isPropertyAssignment(parent) && parent.name === n);
101
+ if (!isPropertyNamePosition) {
102
+ found = true;
103
+ return;
104
+ }
105
+ }
106
+ ts.forEachChild(n, visit);
107
+ };
108
+ visit(node);
109
+ return found;
110
+ }
111
+
112
+ /** A `.map`/`.forEach`/`.flatMap` call whose first argument is a function — treat as a loop. */
113
+ function callbackLoop(
114
+ node: ts.Node
115
+ ): { body: ts.Node; params: Set<string>; kind: string } | undefined {
116
+ if (!ts.isCallExpression(node)) return undefined;
117
+ const e = node.expression;
118
+ if (!ts.isPropertyAccessExpression(e) || !ts.isIdentifier(e.name)) return undefined;
119
+ if (!CALLBACK_LOOPS.has(e.name.text)) return undefined;
120
+ const cb = node.arguments[0];
121
+ if (cb === undefined || (!ts.isArrowFunction(cb) && !ts.isFunctionExpression(cb))) {
122
+ return undefined;
123
+ }
124
+ const params = new Set<string>();
125
+ for (const p of cb.parameters) collectBoundNames(p.name, params);
126
+ return { body: cb.body, params, kind: `.${e.name.text}()` };
127
+ }
128
+
129
+ /** Analyze a parsed node (a flow runner / orchestration body) for loop-identity hazards. */
130
+ export function analyzeLoopIdentityNode(
131
+ root: ts.Node,
132
+ location?: string
133
+ ): LoopStepIdentityFinding[] {
134
+ const findings: LoopStepIdentityFinding[] = [];
135
+ // Stack of active loop scopes; bindings accumulate (an id derived from ANY
136
+ // enclosing loop's variable is accepted — the nested-loop case is documented
137
+ // as out of scope for the heuristic).
138
+ const loopStack: Array<{ kind: string; bindings: Set<string> }> = [];
139
+
140
+ const classify = (call: ts.CallExpression): void => {
141
+ if (loopStack.length === 0) return;
142
+ const step = call.arguments[0];
143
+ if (step === undefined) return;
144
+ const callee = (call.expression as ts.PropertyAccessExpression).name.text;
145
+ const loopKind = loopStack[loopStack.length - 1]!.kind;
146
+
147
+ if (ts.isIdentifier(step) || ts.isPropertyAccessExpression(step)) {
148
+ // A static StepDefinition reference — its id is fixed across iterations.
149
+ findings.push({
150
+ callee,
151
+ loopKind,
152
+ reason: "static-step-in-loop",
153
+ ...(location !== undefined && { location }),
154
+ });
155
+ return;
156
+ }
157
+ if (ts.isCallExpression(step) || ts.isObjectLiteralExpression(step)) {
158
+ const union = new Set<string>();
159
+ for (const scope of loopStack) for (const n of scope.bindings) union.add(n);
160
+ if (!referencesAny(step, union)) {
161
+ findings.push({
162
+ callee,
163
+ loopKind,
164
+ reason: "id-not-derived-from-loop-variable",
165
+ ...(location !== undefined && { location }),
166
+ });
167
+ }
168
+ return;
169
+ }
170
+ // Anything else (conditional, array index, helper-derived) — stay quiet.
171
+ };
172
+
173
+ const visit = (node: ts.Node): void => {
174
+ if (isRunOrExecuteCall(node)) classify(node);
175
+
176
+ if (ts.isIterationStatement(node, false)) {
177
+ const body = node.statement;
178
+ loopStack.push({
179
+ kind: ts.SyntaxKind[node.kind],
180
+ bindings: collectLoopBindings(node, body),
181
+ });
182
+ ts.forEachChild(node, visit);
183
+ loopStack.pop();
184
+ return;
185
+ }
186
+ const cb = callbackLoop(node);
187
+ if (cb !== undefined) {
188
+ // Visit the receiver/arguments outside the loop scope, then the callback
189
+ // body inside it.
190
+ loopStack.push({ kind: cb.kind, bindings: cb.params });
191
+ visit(cb.body);
192
+ loopStack.pop();
193
+ return;
194
+ }
195
+ ts.forEachChild(node, visit);
196
+ };
197
+
198
+ visit(root);
199
+ return findings;
200
+ }
201
+
202
+ /**
203
+ * Analyze a source string (a flow runner / orchestration body) for `ctx.run`/
204
+ * `ctx.execute` calls inside loops whose step id cannot vary per iteration
205
+ * (ADR 0061). Feed the result to `@loomwork/verify` via `FlowSpec.loopIdentityCalls`.
206
+ */
207
+ export function analyzeLoopIdentity(source: string, location?: string): LoopStepIdentityFinding[] {
208
+ const sf = ts.createSourceFile("loopid.ts", source, ts.ScriptTarget.ESNext, true);
209
+ return analyzeLoopIdentityNode(sf, location);
210
+ }