@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.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +62 -2
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +81 -0
- package/dist/cli.js.map +1 -0
- package/dist/hash.d.ts +3 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +16 -0
- package/dist/hash.js.map +1 -0
- package/dist/importResolve.d.ts +5 -0
- package/dist/importResolve.d.ts.map +1 -0
- package/dist/importResolve.js +130 -0
- package/dist/importResolve.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/loopIdentity.d.ts +18 -0
- package/dist/loopIdentity.d.ts.map +1 -0
- package/dist/loopIdentity.js +179 -0
- package/dist/loopIdentity.js.map +1 -0
- package/dist/manifest.d.ts +98 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +123 -0
- package/dist/manifest.js.map +1 -0
- package/dist/nondeterminism.d.ts +25 -0
- package/dist/nondeterminism.d.ts.map +1 -0
- package/dist/nondeterminism.js +88 -0
- package/dist/nondeterminism.js.map +1 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +71 -0
- package/dist/plugin.js.map +1 -0
- package/dist/runnerReach.d.ts +136 -0
- package/dist/runnerReach.d.ts.map +1 -0
- package/dist/runnerReach.js +1809 -0
- package/dist/runnerReach.js.map +1 -0
- package/dist/transform.d.ts +6 -0
- package/dist/transform.d.ts.map +1 -0
- package/dist/transform.js +416 -0
- package/dist/transform.js.map +1 -0
- package/dist/types.d.ts +73 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +64 -3
- package/src/cli.ts +90 -0
- package/src/hash.ts +12 -0
- package/src/importResolve.ts +133 -0
- package/src/index.ts +23 -0
- package/src/loopIdentity.ts +210 -0
- package/src/manifest.ts +185 -0
- package/src/nondeterminism.ts +133 -0
- package/src/plugin.ts +74 -0
- package/src/runnerReach.ts +1964 -0
- package/src/transform.ts +523 -0
- package/src/types.ts +79 -0
package/dist/types.d.ts
ADDED
|
@@ -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 @@
|
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|