@nwire/scan 0.12.1 → 0.13.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/dist/ast-extract.d.ts +71 -0
- package/dist/ast-extract.js +1024 -0
- package/dist/graph.d.ts +56 -0
- package/dist/graph.js +325 -0
- package/dist/manifest.d.ts +67 -0
- package/dist/manifest.js +127 -0
- package/dist/scan.d.ts +46 -108
- package/dist/scan.js +13 -381
- package/dist/telemetry-runs.d.ts +37 -0
- package/dist/telemetry-runs.js +87 -0
- package/dist/topology.d.ts +10 -0
- package/dist/topology.js +10 -0
- package/dist/vite-plugin.d.ts +14 -6
- package/dist/vite-plugin.js +27 -12
- package/package.json +8 -6
|
@@ -0,0 +1,1024 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static AST discovery (Phase 0) — extract `defineEvent` + `defineAction`
|
|
3
|
+
* declarations from source WITHOUT booting the app. "Scan the graph, not the
|
|
4
|
+
* runtime." Uses the TypeScript compiler API (already a `@nwire/scan` dep) to
|
|
5
|
+
* parse each file; reads name, `.public()`, `emits`, literal metadata, and
|
|
6
|
+
* source location straight off the AST node positions.
|
|
7
|
+
*
|
|
8
|
+
* Runs ALONGSIDE the jiti scanner — the equivalence harness diffs the two
|
|
9
|
+
* manifests. Anything not statically analyzable (computed name, dynamic
|
|
10
|
+
* `emits`) is recorded in `unanalyzable`, never silently dropped (plan §2,
|
|
11
|
+
* Law 2). Covers events, actions, actors, projections, queries, and
|
|
12
|
+
* workflows (including the workflow closure edge-walk and the projection /
|
|
13
|
+
* workflow event-graph edges).
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import ts from "typescript";
|
|
17
|
+
function parse(files) {
|
|
18
|
+
return files.map((file) => ({
|
|
19
|
+
file,
|
|
20
|
+
// setParentNodes = true so we can walk up to the enclosing declaration
|
|
21
|
+
// and detect `.public()` chaining.
|
|
22
|
+
sf: ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true),
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
function sourceOf(node, sf, file) {
|
|
26
|
+
const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
|
|
27
|
+
return { file, line: line + 1, column: character + 1 };
|
|
28
|
+
}
|
|
29
|
+
/** A `defineX(...)` call whose callee is the bare identifier `name`. */
|
|
30
|
+
function isDefineCall(node, name) {
|
|
31
|
+
return (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === name);
|
|
32
|
+
}
|
|
33
|
+
/** Walk every node in a source file. */
|
|
34
|
+
function walk(node, visit) {
|
|
35
|
+
visit(node);
|
|
36
|
+
node.forEachChild((c) => walk(c, visit));
|
|
37
|
+
}
|
|
38
|
+
/** `export const Foo = defineX(...)` → "Foo" (the binding the call is assigned to). */
|
|
39
|
+
function bindingName(call) {
|
|
40
|
+
let n = call;
|
|
41
|
+
while (n && !ts.isVariableDeclaration(n))
|
|
42
|
+
n = n.parent;
|
|
43
|
+
if (n && ts.isVariableDeclaration(n) && ts.isIdentifier(n.name))
|
|
44
|
+
return n.name.text;
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
/** True when the call is the receiver of a `.public()` call: `defineX(...).public()`. */
|
|
48
|
+
function isPublic(call) {
|
|
49
|
+
const parent = call.parent;
|
|
50
|
+
return (!!parent &&
|
|
51
|
+
ts.isPropertyAccessExpression(parent) &&
|
|
52
|
+
parent.name.text === "public" &&
|
|
53
|
+
ts.isCallExpression(parent.parent) &&
|
|
54
|
+
parent.parent.expression === parent);
|
|
55
|
+
}
|
|
56
|
+
function configObject(call) {
|
|
57
|
+
for (const arg of call.arguments) {
|
|
58
|
+
if (ts.isObjectLiteralExpression(arg))
|
|
59
|
+
return arg;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
function prop(obj, key) {
|
|
64
|
+
for (const m of obj.properties) {
|
|
65
|
+
if (ts.isPropertyAssignment(m) && propName(m.name) === key)
|
|
66
|
+
return m.initializer;
|
|
67
|
+
if (ts.isShorthandPropertyAssignment(m) && m.name.text === key)
|
|
68
|
+
return m.name;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
function propName(name) {
|
|
73
|
+
if (ts.isIdentifier(name) || ts.isStringLiteral(name))
|
|
74
|
+
return name.text;
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
function stringLiteral(expr) {
|
|
78
|
+
if (expr && (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)))
|
|
79
|
+
return expr.text;
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
function stringArray(expr) {
|
|
83
|
+
if (!expr || !ts.isArrayLiteralExpression(expr))
|
|
84
|
+
return undefined;
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const el of expr.elements) {
|
|
87
|
+
const s = stringLiteral(el);
|
|
88
|
+
if (s === undefined)
|
|
89
|
+
return undefined; // non-literal element → not statically resolvable
|
|
90
|
+
out.push(s);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
/** The action's `name`: positional `defineAction("name", {...})` or `{ name: "..." }`. */
|
|
95
|
+
function actionName(call, cfg) {
|
|
96
|
+
const first = call.arguments[0];
|
|
97
|
+
if (first && (ts.isStringLiteral(first) || ts.isNoSubstitutionTemplateLiteral(first)))
|
|
98
|
+
return first.text;
|
|
99
|
+
return cfg ? stringLiteral(prop(cfg, "name")) : undefined;
|
|
100
|
+
}
|
|
101
|
+
/** True if any state spec in a `states` object literal declares `final: true`. */
|
|
102
|
+
function statesHaveFinal(statesExpr) {
|
|
103
|
+
if (!statesExpr || !ts.isObjectLiteralExpression(statesExpr))
|
|
104
|
+
return false;
|
|
105
|
+
for (const m of statesExpr.properties) {
|
|
106
|
+
if (!ts.isPropertyAssignment(m) || !ts.isObjectLiteralExpression(m.initializer))
|
|
107
|
+
continue;
|
|
108
|
+
const fin = prop(m.initializer, "final");
|
|
109
|
+
if (fin && fin.kind === ts.SyntaxKind.TrueKeyword)
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
/** Enumerate state-name keys from a `states` object literal; report static gaps. */
|
|
115
|
+
function stateKeysOf(statesExpr) {
|
|
116
|
+
if (!statesExpr)
|
|
117
|
+
return { keys: [], unanalyzable: false };
|
|
118
|
+
if (!ts.isObjectLiteralExpression(statesExpr))
|
|
119
|
+
return { keys: [], unanalyzable: true };
|
|
120
|
+
const keys = [];
|
|
121
|
+
let unanalyzable = false;
|
|
122
|
+
for (const m of statesExpr.properties) {
|
|
123
|
+
if (ts.isSpreadAssignment(m)) {
|
|
124
|
+
unanalyzable = true; // `...baseStates` — can't enumerate
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const n = ts.isPropertyAssignment(m) || ts.isShorthandPropertyAssignment(m) || ts.isMethodDeclaration(m)
|
|
128
|
+
? m.name
|
|
129
|
+
: undefined;
|
|
130
|
+
if (n && (ts.isIdentifier(n) || ts.isStringLiteral(n)))
|
|
131
|
+
keys.push(n.text);
|
|
132
|
+
else
|
|
133
|
+
unanalyzable = true; // computed `[Foo.name]:` not statically resolvable
|
|
134
|
+
}
|
|
135
|
+
return { keys, unanalyzable };
|
|
136
|
+
}
|
|
137
|
+
/** Is the call a function-valued arg (arrow or function expression)? */
|
|
138
|
+
function isInlineFn(node) {
|
|
139
|
+
return !!node && (ts.isArrowFunction(node) || ts.isFunctionExpression(node));
|
|
140
|
+
}
|
|
141
|
+
/** Bare-identifier or `ctx.<verb>` callee → the verb name, else undefined. */
|
|
142
|
+
function calleeVerb(call) {
|
|
143
|
+
const e = call.expression;
|
|
144
|
+
if (ts.isIdentifier(e))
|
|
145
|
+
return e.text;
|
|
146
|
+
if (ts.isPropertyAccessExpression(e))
|
|
147
|
+
return e.name.text;
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Map the closure's `const { active, retrying } = states` binding identifiers to
|
|
152
|
+
* their state names (renamed-aware: `{ a: renamed }` → renamed → "a").
|
|
153
|
+
*/
|
|
154
|
+
function stateBindings(closure) {
|
|
155
|
+
const out = new Map();
|
|
156
|
+
walk(closure, (n) => {
|
|
157
|
+
if (!ts.isVariableDeclaration(n) || !n.initializer)
|
|
158
|
+
return;
|
|
159
|
+
if (!ts.isIdentifier(n.initializer) || n.initializer.text !== "states")
|
|
160
|
+
return;
|
|
161
|
+
if (!ts.isObjectBindingPattern(n.name))
|
|
162
|
+
return;
|
|
163
|
+
for (const el of n.name.elements) {
|
|
164
|
+
if (!ts.isIdentifier(el.name))
|
|
165
|
+
continue;
|
|
166
|
+
const stateName = el.propertyName && ts.isIdentifier(el.propertyName) ? el.propertyName.text : el.name.text;
|
|
167
|
+
out.set(el.name.text, stateName);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
/** The state a `when` reaction fn transitions to: its returned state-ref. */
|
|
173
|
+
function returnedState(fn, states) {
|
|
174
|
+
if (!isInlineFn(fn))
|
|
175
|
+
return undefined;
|
|
176
|
+
const asState = (e) => e && ts.isIdentifier(e) && states.has(e.text) ? states.get(e.text) : undefined;
|
|
177
|
+
if (!ts.isBlock(fn.body))
|
|
178
|
+
return asState(fn.body); // arrow-expression body
|
|
179
|
+
let result;
|
|
180
|
+
walk(fn.body, (n) => {
|
|
181
|
+
if (ts.isReturnStatement(n) && n.expression) {
|
|
182
|
+
const s = asState(n.expression);
|
|
183
|
+
if (s)
|
|
184
|
+
result = s; // last return wins — good enough for the graph
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Walk an actor/workflow builder closure for state→state transitions. Each
|
|
191
|
+
* `when(Event, reaction)` is an edge: `from` is the enclosing `state(() => …)`
|
|
192
|
+
* scope (or `"*"` at the closure top = always-active), `on` the event, `to` the
|
|
193
|
+
* state the reaction returns (or `from` when it stays).
|
|
194
|
+
*/
|
|
195
|
+
function extractTransitions(closure, resolveEvent) {
|
|
196
|
+
const states = stateBindings(closure);
|
|
197
|
+
const out = [];
|
|
198
|
+
walk(closure.body ?? closure, (n) => {
|
|
199
|
+
if (!ts.isCallExpression(n) || calleeVerb(n) !== "when")
|
|
200
|
+
return;
|
|
201
|
+
const on = resolveEvent(n.arguments[0]);
|
|
202
|
+
if (!on)
|
|
203
|
+
return;
|
|
204
|
+
const to = returnedState(n.arguments[1], states);
|
|
205
|
+
let from = "*";
|
|
206
|
+
let p = n.parent;
|
|
207
|
+
while (p && p !== closure) {
|
|
208
|
+
if (ts.isCallExpression(p) &&
|
|
209
|
+
ts.isIdentifier(p.expression) &&
|
|
210
|
+
states.has(p.expression.text)) {
|
|
211
|
+
from = states.get(p.expression.text);
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
p = p.parent;
|
|
215
|
+
}
|
|
216
|
+
out.push({ from, on, to: to ?? from });
|
|
217
|
+
});
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Pull the English failure message out of a predicate. Predicates are written
|
|
222
|
+
* as `(i) => cond || "message"` (logical-OR form) or as a block returning a
|
|
223
|
+
* string literal (`{ if (!ok) return "message"; return true }`). We read the
|
|
224
|
+
* static string operand of the rightmost `||`, or the first returned string
|
|
225
|
+
* literal. No static string ⇒ undefined (the rule text is still captured).
|
|
226
|
+
*/
|
|
227
|
+
function invariantMessage(pred) {
|
|
228
|
+
// `cond || "message"` — possibly chained; the message is the RHS string.
|
|
229
|
+
const fromOr = (e) => {
|
|
230
|
+
if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.BarBarToken) {
|
|
231
|
+
const rhs = stringLiteral(e.right);
|
|
232
|
+
if (rhs !== undefined)
|
|
233
|
+
return rhs;
|
|
234
|
+
return fromOr(e.left);
|
|
235
|
+
}
|
|
236
|
+
return stringLiteral(e);
|
|
237
|
+
};
|
|
238
|
+
if (isInlineFn(pred)) {
|
|
239
|
+
const body = pred.body;
|
|
240
|
+
if (ts.isBlock(body)) {
|
|
241
|
+
// First `return "message"` in the block body.
|
|
242
|
+
let msg;
|
|
243
|
+
walk(body, (n) => {
|
|
244
|
+
if (msg !== undefined)
|
|
245
|
+
return;
|
|
246
|
+
if (ts.isReturnStatement(n) && n.expression) {
|
|
247
|
+
const lit = stringLiteral(n.expression);
|
|
248
|
+
if (lit !== undefined)
|
|
249
|
+
msg = lit;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
return msg;
|
|
253
|
+
}
|
|
254
|
+
// Expression-bodied arrow: `(i) => cond || "message"`.
|
|
255
|
+
return fromOr(body);
|
|
256
|
+
}
|
|
257
|
+
// Bare expression predicate (rare) — try the OR form directly.
|
|
258
|
+
return fromOr(pred);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Walk a handler/method body for `validate(input, [preds], state?)` calls and
|
|
262
|
+
* capture each predicate as a business-rule invariant: its source text plus the
|
|
263
|
+
* English failure message when one is statically present. Predicates with no
|
|
264
|
+
* static string are still recorded (rule only).
|
|
265
|
+
*/
|
|
266
|
+
function extractInvariants(body) {
|
|
267
|
+
const out = [];
|
|
268
|
+
walk(body, (n) => {
|
|
269
|
+
if (!ts.isCallExpression(n) || calleeVerb(n) !== "validate")
|
|
270
|
+
return;
|
|
271
|
+
const predsArg = n.arguments[1];
|
|
272
|
+
if (!predsArg || !ts.isArrayLiteralExpression(predsArg))
|
|
273
|
+
return;
|
|
274
|
+
for (const el of predsArg.elements) {
|
|
275
|
+
if (ts.isSpreadElement(el))
|
|
276
|
+
continue;
|
|
277
|
+
const rule = el.getText();
|
|
278
|
+
const message = invariantMessage(el);
|
|
279
|
+
out.push(message !== undefined ? { rule, message } : { rule });
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Walk a source file for environment reads and collect the variable names:
|
|
286
|
+
* - `process.env.FOO` (member access) and `process.env["FOO"]` (element access)
|
|
287
|
+
* - `env.get("FOO")` / `env("FOO")` (config-helper style, when present)
|
|
288
|
+
* Names that aren't a static identifier/string (computed `process.env[key]`) are
|
|
289
|
+
* skipped — only statically resolvable variable names are surfaced.
|
|
290
|
+
*/
|
|
291
|
+
function extractEnvReads(sf, into) {
|
|
292
|
+
walk(sf, (n) => {
|
|
293
|
+
// process.env.FOO → PropertyAccess( PropertyAccess(process, env), FOO )
|
|
294
|
+
if (ts.isPropertyAccessExpression(n) && isProcessEnv(n.expression)) {
|
|
295
|
+
into.add(n.name.text);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
// process.env["FOO"] → ElementAccess( PropertyAccess(process, env), "FOO" )
|
|
299
|
+
if (ts.isElementAccessExpression(n) && isProcessEnv(n.expression)) {
|
|
300
|
+
const key = stringLiteral(n.argumentExpression);
|
|
301
|
+
if (key !== undefined)
|
|
302
|
+
into.add(key);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
// env.get("FOO") / env("FOO")
|
|
306
|
+
if (ts.isCallExpression(n)) {
|
|
307
|
+
const callee = n.expression;
|
|
308
|
+
const isEnvGet = ts.isPropertyAccessExpression(callee) &&
|
|
309
|
+
ts.isIdentifier(callee.expression) &&
|
|
310
|
+
callee.expression.text === "env" &&
|
|
311
|
+
callee.name.text === "get";
|
|
312
|
+
const isEnvCall = ts.isIdentifier(callee) && callee.text === "env";
|
|
313
|
+
if (isEnvGet || isEnvCall) {
|
|
314
|
+
const key = stringLiteral(n.arguments[0]);
|
|
315
|
+
if (key !== undefined)
|
|
316
|
+
into.add(key);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
/** True for the `process.env` member-access node. */
|
|
322
|
+
function isProcessEnv(node) {
|
|
323
|
+
return (ts.isPropertyAccessExpression(node) &&
|
|
324
|
+
ts.isIdentifier(node.expression) &&
|
|
325
|
+
node.expression.text === "process" &&
|
|
326
|
+
node.name.text === "env");
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Best-effort top-level config field names from a `config/*.ts` module: the keys
|
|
330
|
+
* of a `export default { … }` object literal, plus any named exports
|
|
331
|
+
* (`export const X` / `export { X }`). De-duped, declaration order preserved.
|
|
332
|
+
*/
|
|
333
|
+
function extractConfigKeys(sf) {
|
|
334
|
+
const keys = [];
|
|
335
|
+
const seen = new Set();
|
|
336
|
+
const add = (k) => {
|
|
337
|
+
if (k && !seen.has(k)) {
|
|
338
|
+
seen.add(k);
|
|
339
|
+
keys.push(k);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
// Top-level `const x = { … }` declarations, so `export default x` resolves.
|
|
343
|
+
const objByBinding = new Map();
|
|
344
|
+
for (const stmt of sf.statements) {
|
|
345
|
+
if (!ts.isVariableStatement(stmt))
|
|
346
|
+
continue;
|
|
347
|
+
for (const d of stmt.declarationList.declarations) {
|
|
348
|
+
if (ts.isIdentifier(d.name) && d.initializer) {
|
|
349
|
+
const obj = unwrapObject(d.initializer);
|
|
350
|
+
if (obj)
|
|
351
|
+
objByBinding.set(d.name.text, obj);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
walk(sf, (n) => {
|
|
356
|
+
// export default { a, b, c } / export default cfg (cfg = { … })
|
|
357
|
+
if (ts.isExportAssignment(n) && !n.isExportEquals) {
|
|
358
|
+
const obj = unwrapObject(n.expression) ??
|
|
359
|
+
(ts.isIdentifier(n.expression) ? objByBinding.get(n.expression.text) : undefined);
|
|
360
|
+
if (obj)
|
|
361
|
+
for (const m of obj.properties)
|
|
362
|
+
add(memberKey(m));
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
// export const X = … / export function X / export class X
|
|
366
|
+
if ((ts.isVariableStatement(n) || ts.isFunctionDeclaration(n) || ts.isClassDeclaration(n)) &&
|
|
367
|
+
ts.getModifiers(n)?.some((mod) => mod.kind === ts.SyntaxKind.ExportKeyword)) {
|
|
368
|
+
if (ts.isVariableStatement(n)) {
|
|
369
|
+
for (const d of n.declarationList.declarations)
|
|
370
|
+
if (ts.isIdentifier(d.name))
|
|
371
|
+
add(d.name.text);
|
|
372
|
+
}
|
|
373
|
+
else if (n.name && ts.isIdentifier(n.name)) {
|
|
374
|
+
add(n.name.text);
|
|
375
|
+
}
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
// export { X, Y as Z }
|
|
379
|
+
if (ts.isExportDeclaration(n) && n.exportClause && ts.isNamedExports(n.exportClause)) {
|
|
380
|
+
for (const el of n.exportClause.elements)
|
|
381
|
+
add(el.name.text);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
return keys;
|
|
385
|
+
}
|
|
386
|
+
/** Unwrap an object literal, looking through `satisfies` / `as` wrappers. */
|
|
387
|
+
function unwrapObject(expr) {
|
|
388
|
+
let e = expr;
|
|
389
|
+
while (ts.isAsExpression(e) || ts.isSatisfiesExpression(e))
|
|
390
|
+
e = e.expression;
|
|
391
|
+
return ts.isObjectLiteralExpression(e) ? e : undefined;
|
|
392
|
+
}
|
|
393
|
+
/** A property's key name, when it's a plain identifier/string (not computed/spread). */
|
|
394
|
+
function memberKey(m) {
|
|
395
|
+
if (ts.isSpreadAssignment(m))
|
|
396
|
+
return undefined;
|
|
397
|
+
const name = ts.isPropertyAssignment(m) || ts.isShorthandPropertyAssignment(m) || ts.isMethodDeclaration(m)
|
|
398
|
+
? m.name
|
|
399
|
+
: undefined;
|
|
400
|
+
if (name && (ts.isIdentifier(name) || ts.isStringLiteral(name)))
|
|
401
|
+
return name.text;
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
/** Walk a body for `externalCall(X)` / `ctx.externalCall(X)` → external-call names. */
|
|
405
|
+
function extractCalls(body, callNameByBinding) {
|
|
406
|
+
const out = new Set();
|
|
407
|
+
walk(body, (n) => {
|
|
408
|
+
if (!ts.isCallExpression(n) || calleeVerb(n) !== "externalCall")
|
|
409
|
+
return;
|
|
410
|
+
const arg = n.arguments[0];
|
|
411
|
+
if (arg && ts.isIdentifier(arg) && callNameByBinding.has(arg.text))
|
|
412
|
+
out.add(callNameByBinding.get(arg.text));
|
|
413
|
+
else {
|
|
414
|
+
const lit = stringLiteral(arg);
|
|
415
|
+
if (lit !== undefined)
|
|
416
|
+
out.add(lit);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
return [...out];
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Extract `defineEvent` + `defineAction` from a set of source files. Two
|
|
423
|
+
* passes: events first (so `emits` identifiers can resolve to event names),
|
|
424
|
+
* then actions.
|
|
425
|
+
*/
|
|
426
|
+
export function extractFromFiles(files, app = "") {
|
|
427
|
+
const parsed = parse(files);
|
|
428
|
+
const events = [];
|
|
429
|
+
const actions = [];
|
|
430
|
+
const actors = [];
|
|
431
|
+
const projections = [];
|
|
432
|
+
const queries = [];
|
|
433
|
+
const workflows = [];
|
|
434
|
+
const commands = [];
|
|
435
|
+
const crons = [];
|
|
436
|
+
const externalCalls = [];
|
|
437
|
+
const inboundWebhooks = [];
|
|
438
|
+
const outboxes = [];
|
|
439
|
+
const inboxes = [];
|
|
440
|
+
const resources = [];
|
|
441
|
+
const errors = [];
|
|
442
|
+
const schemas = [];
|
|
443
|
+
const unanalyzable = [];
|
|
444
|
+
const graphEvents = [];
|
|
445
|
+
const envReads = new Set();
|
|
446
|
+
// ── Env reads — every `process.env.X` / `env.get("X")` across the source. ──
|
|
447
|
+
for (const { sf } of parsed)
|
|
448
|
+
extractEnvReads(sf, envReads);
|
|
449
|
+
// binding identifier → name, for cross-resolving refs. File-set-global,
|
|
450
|
+
// keyed by local binding text (mirrors the cross-file behaviour the
|
|
451
|
+
// examples rely on: events/actions/projections are imported by binding).
|
|
452
|
+
const eventNameByBinding = new Map();
|
|
453
|
+
const actionNameByBinding = new Map();
|
|
454
|
+
const projectionNameByBinding = new Map();
|
|
455
|
+
const externalCallNameByBinding = new Map();
|
|
456
|
+
// binding identifier → defineSchema identity, for actor state resolution.
|
|
457
|
+
const schemaByBinding = new Map();
|
|
458
|
+
// ── Pass 0: schemas (states + name) for actor resolution + schema entries ──
|
|
459
|
+
for (const { file, sf } of parsed) {
|
|
460
|
+
walk(sf, (node) => {
|
|
461
|
+
if (!isDefineCall(node, "defineSchema"))
|
|
462
|
+
return;
|
|
463
|
+
const src = sourceOf(node, sf, file);
|
|
464
|
+
const cfg = configObject(node);
|
|
465
|
+
const name = cfg ? stringLiteral(prop(cfg, "name")) : undefined;
|
|
466
|
+
const statesExpr = cfg ? prop(cfg, "states") : undefined;
|
|
467
|
+
const { keys, unanalyzable: statesUnanalyzable } = stateKeysOf(statesExpr);
|
|
468
|
+
const binding = bindingName(node);
|
|
469
|
+
if (binding)
|
|
470
|
+
schemaByBinding.set(binding, {
|
|
471
|
+
name,
|
|
472
|
+
stateNames: keys,
|
|
473
|
+
statesUnanalyzable,
|
|
474
|
+
hasFinal: statesHaveFinal(statesExpr),
|
|
475
|
+
});
|
|
476
|
+
if (name)
|
|
477
|
+
schemas.push({
|
|
478
|
+
name,
|
|
479
|
+
app,
|
|
480
|
+
key: cfg ? stringLiteral(prop(cfg, "key")) : undefined,
|
|
481
|
+
states: keys,
|
|
482
|
+
source: src,
|
|
483
|
+
});
|
|
484
|
+
else
|
|
485
|
+
unanalyzable.push({
|
|
486
|
+
kind: "schema",
|
|
487
|
+
reason: "non-literal or missing schema name",
|
|
488
|
+
source: src,
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
// ── Pass 0b: leaf primitives (name + source) — commands / crons /
|
|
493
|
+
// external calls / inbound webhooks / outboxes / inboxes / resources /
|
|
494
|
+
// errors. Each is `defineX("name", …)` or `defineX({ name|code|schedule })`.
|
|
495
|
+
for (const { file, sf } of parsed) {
|
|
496
|
+
walk(sf, (node) => {
|
|
497
|
+
if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression))
|
|
498
|
+
return;
|
|
499
|
+
const callee = node.expression.text;
|
|
500
|
+
const src = sourceOf(node, sf, file);
|
|
501
|
+
const cfg = configObject(node);
|
|
502
|
+
const push = (
|
|
503
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
504
|
+
list, kind, extra = {}) => {
|
|
505
|
+
const name = actionName(node, cfg); // first string arg OR cfg.name
|
|
506
|
+
if (name)
|
|
507
|
+
list.push({ name, app, source: src, ...extra });
|
|
508
|
+
else
|
|
509
|
+
unanalyzable.push({ kind, reason: `non-literal or missing ${kind} name`, source: src });
|
|
510
|
+
};
|
|
511
|
+
switch (callee) {
|
|
512
|
+
case "defineCommand":
|
|
513
|
+
push(commands, "command");
|
|
514
|
+
break;
|
|
515
|
+
case "defineCron":
|
|
516
|
+
push(crons, "cron", { schedule: (cfg && stringLiteral(prop(cfg, "schedule"))) ?? "" });
|
|
517
|
+
break;
|
|
518
|
+
case "defineExternalCall": {
|
|
519
|
+
push(externalCalls, "externalCall");
|
|
520
|
+
const b = bindingName(node);
|
|
521
|
+
const nm = actionName(node, cfg);
|
|
522
|
+
if (b && nm)
|
|
523
|
+
externalCallNameByBinding.set(b, nm);
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
case "defineInboundWebhook":
|
|
527
|
+
push(inboundWebhooks, "inboundWebhook");
|
|
528
|
+
break;
|
|
529
|
+
case "defineOutbox":
|
|
530
|
+
push(outboxes, "outbox");
|
|
531
|
+
break;
|
|
532
|
+
case "defineInbox":
|
|
533
|
+
push(inboxes, "inbox");
|
|
534
|
+
break;
|
|
535
|
+
case "defineResource":
|
|
536
|
+
push(resources, "resource");
|
|
537
|
+
break;
|
|
538
|
+
case "defineError": {
|
|
539
|
+
const code = (cfg && stringLiteral(prop(cfg, "code"))) ?? stringLiteral(node.arguments[0]);
|
|
540
|
+
if (code)
|
|
541
|
+
errors.push({ code, app, source: src });
|
|
542
|
+
else
|
|
543
|
+
unanalyzable.push({
|
|
544
|
+
kind: "error",
|
|
545
|
+
reason: "non-literal or missing error code",
|
|
546
|
+
source: src,
|
|
547
|
+
});
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
default:
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
// ── Pass 1: events ──
|
|
556
|
+
for (const { file, sf } of parsed) {
|
|
557
|
+
walk(sf, (node) => {
|
|
558
|
+
if (!isDefineCall(node, "defineEvent"))
|
|
559
|
+
return;
|
|
560
|
+
const src = sourceOf(node, sf, file);
|
|
561
|
+
const cfg = configObject(node);
|
|
562
|
+
const name = cfg ? stringLiteral(prop(cfg, "name")) : undefined;
|
|
563
|
+
if (!name) {
|
|
564
|
+
unanalyzable.push({
|
|
565
|
+
kind: "event",
|
|
566
|
+
reason: "non-literal or missing event name",
|
|
567
|
+
source: src,
|
|
568
|
+
});
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const binding = bindingName(node);
|
|
572
|
+
if (binding)
|
|
573
|
+
eventNameByBinding.set(binding, name);
|
|
574
|
+
events.push({
|
|
575
|
+
name,
|
|
576
|
+
app,
|
|
577
|
+
public: isPublic(node),
|
|
578
|
+
description: cfg ? stringLiteral(prop(cfg, "description")) : undefined,
|
|
579
|
+
audience: (cfg && stringArray(prop(cfg, "audience"))) || undefined,
|
|
580
|
+
source: src,
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
// ── Pass 2: actions ──
|
|
585
|
+
for (const { file, sf } of parsed) {
|
|
586
|
+
walk(sf, (node) => {
|
|
587
|
+
if (!isDefineCall(node, "defineAction"))
|
|
588
|
+
return;
|
|
589
|
+
const src = sourceOf(node, sf, file);
|
|
590
|
+
const cfg = configObject(node);
|
|
591
|
+
const name = actionName(node, cfg);
|
|
592
|
+
if (!name) {
|
|
593
|
+
unanalyzable.push({
|
|
594
|
+
kind: "action",
|
|
595
|
+
reason: "non-literal or missing action name",
|
|
596
|
+
source: src,
|
|
597
|
+
});
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const actionBinding = bindingName(node);
|
|
601
|
+
if (actionBinding)
|
|
602
|
+
actionNameByBinding.set(actionBinding, name);
|
|
603
|
+
// emits: array of event references → resolve each binding to its name.
|
|
604
|
+
const emits = [];
|
|
605
|
+
const emitsExpr = cfg ? prop(cfg, "emits") : undefined;
|
|
606
|
+
if (emitsExpr) {
|
|
607
|
+
if (ts.isArrayLiteralExpression(emitsExpr)) {
|
|
608
|
+
for (const el of emitsExpr.elements) {
|
|
609
|
+
if (ts.isIdentifier(el) && eventNameByBinding.has(el.text)) {
|
|
610
|
+
emits.push(eventNameByBinding.get(el.text));
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
const lit = stringLiteral(el);
|
|
614
|
+
if (lit !== undefined)
|
|
615
|
+
emits.push(lit);
|
|
616
|
+
else
|
|
617
|
+
unanalyzable.push({
|
|
618
|
+
kind: "action",
|
|
619
|
+
reason: `action "${name}" emits a reference not statically resolvable to an event name`,
|
|
620
|
+
source: src,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
unanalyzable.push({
|
|
627
|
+
kind: "action",
|
|
628
|
+
reason: `action "${name}" has a non-array \`emits\``,
|
|
629
|
+
source: src,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const retryExpr = cfg ? prop(cfg, "retry") : undefined;
|
|
634
|
+
let retry;
|
|
635
|
+
if (retryExpr && ts.isObjectLiteralExpression(retryExpr)) {
|
|
636
|
+
const maxExpr = prop(retryExpr, "max");
|
|
637
|
+
const max = maxExpr && ts.isNumericLiteral(maxExpr) ? Number(maxExpr.text) : undefined;
|
|
638
|
+
if (max !== undefined)
|
|
639
|
+
retry = { max };
|
|
640
|
+
}
|
|
641
|
+
const handlerExpr = cfg ? prop(cfg, "handler") : undefined;
|
|
642
|
+
const calls = handlerExpr ? extractCalls(handlerExpr, externalCallNameByBinding) : [];
|
|
643
|
+
const invariants = handlerExpr ? extractInvariants(handlerExpr) : [];
|
|
644
|
+
actions.push({
|
|
645
|
+
name,
|
|
646
|
+
app,
|
|
647
|
+
description: cfg ? stringLiteral(prop(cfg, "description")) : undefined,
|
|
648
|
+
public: isPublic(node),
|
|
649
|
+
emits,
|
|
650
|
+
calls: calls.length ? calls : undefined,
|
|
651
|
+
invariants: invariants.length ? invariants : undefined,
|
|
652
|
+
hasInlineHandler: !!handlerExpr,
|
|
653
|
+
persona: cfg ? stringLiteral(prop(cfg, "persona")) : undefined,
|
|
654
|
+
journeyStep: cfg ? stringLiteral(prop(cfg, "journeyStep")) : undefined,
|
|
655
|
+
capability: cfg ? stringLiteral(prop(cfg, "capability")) : undefined,
|
|
656
|
+
retry,
|
|
657
|
+
tags: (cfg && stringArray(prop(cfg, "tags"))) || undefined,
|
|
658
|
+
source: src,
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
// ── Pass 3: projections ──
|
|
663
|
+
// After events so `listens`/`on` refs resolve. Records the projection
|
|
664
|
+
// binding so queries (pass 5) can map a projection ref → its name.
|
|
665
|
+
for (const { file, sf } of parsed) {
|
|
666
|
+
walk(sf, (node) => {
|
|
667
|
+
if (!isDefineCall(node, "defineProjection"))
|
|
668
|
+
return;
|
|
669
|
+
const src = sourceOf(node, sf, file);
|
|
670
|
+
const name = stringLiteral(node.arguments[0]);
|
|
671
|
+
if (!name) {
|
|
672
|
+
unanalyzable.push({
|
|
673
|
+
kind: "projection",
|
|
674
|
+
reason: "non-literal or missing projection name",
|
|
675
|
+
source: src,
|
|
676
|
+
});
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const binding = bindingName(node);
|
|
680
|
+
if (binding)
|
|
681
|
+
projectionNameByBinding.set(binding, name);
|
|
682
|
+
// Builder closure (arg 1) + options object (first object literal at arg ≥ 2).
|
|
683
|
+
const closure = node.arguments[1];
|
|
684
|
+
let opts;
|
|
685
|
+
for (let i = 2; i < node.arguments.length; i++) {
|
|
686
|
+
const a = node.arguments[i];
|
|
687
|
+
if (ts.isObjectLiteralExpression(a)) {
|
|
688
|
+
opts = a;
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
// listens: derived from the `when(Event, reducer)` calls in the closure.
|
|
693
|
+
const listens = [];
|
|
694
|
+
if (isInlineFn(closure)) {
|
|
695
|
+
walk(closure.body ?? closure, (n) => {
|
|
696
|
+
if (!ts.isCallExpression(n) || calleeVerb(n) !== "when")
|
|
697
|
+
return;
|
|
698
|
+
const arg = n.arguments[0];
|
|
699
|
+
if (arg && ts.isIdentifier(arg) && eventNameByBinding.has(arg.text)) {
|
|
700
|
+
listens.push(eventNameByBinding.get(arg.text));
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const lit = stringLiteral(arg);
|
|
704
|
+
if (lit !== undefined)
|
|
705
|
+
listens.push(lit);
|
|
706
|
+
else
|
|
707
|
+
unanalyzable.push({
|
|
708
|
+
kind: "projection",
|
|
709
|
+
reason: `projection "${name}" when()s an event reference not statically resolvable to a name`,
|
|
710
|
+
source: src,
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
unanalyzable.push({
|
|
716
|
+
kind: "projection",
|
|
717
|
+
reason: `projection "${name}" builder is not an inline function`,
|
|
718
|
+
source: src,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
projections.push({
|
|
722
|
+
name,
|
|
723
|
+
app,
|
|
724
|
+
description: opts ? stringLiteral(prop(opts, "description")) : undefined,
|
|
725
|
+
listens,
|
|
726
|
+
source: src,
|
|
727
|
+
});
|
|
728
|
+
for (const evName of listens)
|
|
729
|
+
graphEvents.push({ from: evName, to: name, via: "folds" });
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
// ── Pass 4: actors ──
|
|
733
|
+
// One shape: defineActor("name", (ctx) => {...}, { schema: SchemaRef, ... }).
|
|
734
|
+
// States come from the referenced schema (+ an injected `deleted` terminal
|
|
735
|
+
// when the schema declares no `final`, matching the runtime).
|
|
736
|
+
for (const { file, sf } of parsed) {
|
|
737
|
+
walk(sf, (node) => {
|
|
738
|
+
if (!isDefineCall(node, "defineActor"))
|
|
739
|
+
return;
|
|
740
|
+
const src = sourceOf(node, sf, file);
|
|
741
|
+
const [arg0, arg1, arg2] = node.arguments;
|
|
742
|
+
if (node.arguments.some((a) => ts.isSpreadElement(a))) {
|
|
743
|
+
unanalyzable.push({
|
|
744
|
+
kind: "actor",
|
|
745
|
+
reason: "defineActor called with spread or non-literal args",
|
|
746
|
+
source: src,
|
|
747
|
+
});
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const name = stringLiteral(arg0);
|
|
751
|
+
if (!name || !isInlineFn(arg1)) {
|
|
752
|
+
unanalyzable.push({
|
|
753
|
+
kind: "actor",
|
|
754
|
+
reason: "defineActor: expected (name, closure, { schema }) with a literal name + inline closure",
|
|
755
|
+
source: src,
|
|
756
|
+
});
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const opts = arg2 && ts.isObjectLiteralExpression(arg2) ? arg2 : undefined;
|
|
760
|
+
const schemaRef = opts ? prop(opts, "schema") : undefined;
|
|
761
|
+
const info = schemaRef && ts.isIdentifier(schemaRef) ? schemaByBinding.get(schemaRef.text) : undefined;
|
|
762
|
+
if (!info) {
|
|
763
|
+
unanalyzable.push({
|
|
764
|
+
kind: "actor",
|
|
765
|
+
reason: `actor "${name}" borrows states from an unresolved schema reference`,
|
|
766
|
+
source: src,
|
|
767
|
+
});
|
|
768
|
+
actors.push({ name, app, states: [], source: src });
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (info.statesUnanalyzable) {
|
|
772
|
+
unanalyzable.push({
|
|
773
|
+
kind: "actor",
|
|
774
|
+
reason: `actor "${name}" borrows states from a schema whose \`states\` is not statically analyzable`,
|
|
775
|
+
source: src,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
// Runtime injects a `deleted` terminal when the schema has no `final`.
|
|
779
|
+
const states = info.hasFinal ? info.stateNames : [...info.stateNames, "deleted"];
|
|
780
|
+
const resolveActorEvent = (e) => e && ts.isIdentifier(e) && eventNameByBinding.has(e.text)
|
|
781
|
+
? eventNameByBinding.get(e.text)
|
|
782
|
+
: stringLiteral(e);
|
|
783
|
+
const transitions = isInlineFn(arg1) ? extractTransitions(arg1, resolveActorEvent) : [];
|
|
784
|
+
const actorInvariants = isInlineFn(arg1) ? extractInvariants(arg1) : [];
|
|
785
|
+
actors.push({
|
|
786
|
+
name,
|
|
787
|
+
app,
|
|
788
|
+
states,
|
|
789
|
+
transitions: transitions.length ? transitions : undefined,
|
|
790
|
+
invariants: actorInvariants.length ? actorInvariants : undefined,
|
|
791
|
+
source: src,
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
// ── Pass 5: queries ──
|
|
796
|
+
// After projections so a projection-form query's first arg resolves.
|
|
797
|
+
for (const { file, sf } of parsed) {
|
|
798
|
+
walk(sf, (node) => {
|
|
799
|
+
if (!isDefineCall(node, "defineQuery"))
|
|
800
|
+
return;
|
|
801
|
+
const src = sourceOf(node, sf, file);
|
|
802
|
+
const cfg = configObject(node);
|
|
803
|
+
const name = cfg ? stringLiteral(prop(cfg, "name")) : undefined;
|
|
804
|
+
if (!name) {
|
|
805
|
+
unanalyzable.push({
|
|
806
|
+
kind: "query",
|
|
807
|
+
reason: "non-literal or missing query name",
|
|
808
|
+
source: src,
|
|
809
|
+
});
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
const isPublicQ = isPublic(node);
|
|
813
|
+
// Two args ⇒ projection form; the projection ref is arg[0].
|
|
814
|
+
let projection;
|
|
815
|
+
if (node.arguments.length >= 2) {
|
|
816
|
+
const ref = node.arguments[0];
|
|
817
|
+
if (ts.isIdentifier(ref) && projectionNameByBinding.has(ref.text)) {
|
|
818
|
+
projection = projectionNameByBinding.get(ref.text);
|
|
819
|
+
}
|
|
820
|
+
else {
|
|
821
|
+
unanalyzable.push({
|
|
822
|
+
kind: "query",
|
|
823
|
+
reason: `query "${name}" references a projection not statically resolvable to a name`,
|
|
824
|
+
source: src,
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
const entry = projection !== undefined
|
|
829
|
+
? { name, app, public: isPublicQ, projection, source: src }
|
|
830
|
+
: { name, app, public: isPublicQ, source: src };
|
|
831
|
+
queries.push(entry);
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
// ── Pass 6: workflows ──
|
|
835
|
+
// Closure edge-walk: when/on(Event) → subscribesTo, send/enqueue/execute(Action)
|
|
836
|
+
// → dispatches. Uses event + action binding maps for ref resolution.
|
|
837
|
+
for (const { file, sf } of parsed) {
|
|
838
|
+
walk(sf, (node) => {
|
|
839
|
+
if (!isDefineCall(node, "defineWorkflow"))
|
|
840
|
+
return;
|
|
841
|
+
const src = sourceOf(node, sf, file);
|
|
842
|
+
const name = stringLiteral(node.arguments[0]);
|
|
843
|
+
if (!name) {
|
|
844
|
+
unanalyzable.push({
|
|
845
|
+
kind: "workflow",
|
|
846
|
+
reason: "non-literal or missing workflow name",
|
|
847
|
+
source: src,
|
|
848
|
+
});
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
const closure = node.arguments[1];
|
|
852
|
+
if (!isInlineFn(closure)) {
|
|
853
|
+
unanalyzable.push({
|
|
854
|
+
kind: "workflow",
|
|
855
|
+
reason: `workflow "${name}" closure is not an inline function`,
|
|
856
|
+
source: src,
|
|
857
|
+
});
|
|
858
|
+
// Still emit the workflow entry — name + public + description are known.
|
|
859
|
+
}
|
|
860
|
+
// Options object: first object literal at arg index ≥ 2 (skip the closure).
|
|
861
|
+
let opts;
|
|
862
|
+
for (let i = 2; i < node.arguments.length; i++) {
|
|
863
|
+
const a = node.arguments[i];
|
|
864
|
+
if (ts.isObjectLiteralExpression(a)) {
|
|
865
|
+
opts = a;
|
|
866
|
+
break;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
const description = opts ? stringLiteral(prop(opts, "description")) : undefined;
|
|
870
|
+
const subscribesTo = new Set();
|
|
871
|
+
const dispatches = new Set();
|
|
872
|
+
let transitions = [];
|
|
873
|
+
let calls = [];
|
|
874
|
+
if (isInlineFn(closure)) {
|
|
875
|
+
// Local `const t = timeout("name", ...)` declarations → timer-event names.
|
|
876
|
+
const timerNameByBinding = new Map();
|
|
877
|
+
walk(closure.body ?? closure, (n) => {
|
|
878
|
+
if (!ts.isVariableDeclaration(n) || !n.initializer)
|
|
879
|
+
return;
|
|
880
|
+
if (!ts.isCallExpression(n.initializer))
|
|
881
|
+
return;
|
|
882
|
+
if (calleeVerb(n.initializer) !== "timeout")
|
|
883
|
+
return;
|
|
884
|
+
if (!ts.isIdentifier(n.name))
|
|
885
|
+
return;
|
|
886
|
+
const timerName = stringLiteral(n.initializer.arguments[0]);
|
|
887
|
+
if (timerName !== undefined)
|
|
888
|
+
timerNameByBinding.set(n.name.text, timerName);
|
|
889
|
+
});
|
|
890
|
+
const resolveEvent = (arg) => {
|
|
891
|
+
if (!arg)
|
|
892
|
+
return;
|
|
893
|
+
if (ts.isIdentifier(arg)) {
|
|
894
|
+
if (eventNameByBinding.has(arg.text)) {
|
|
895
|
+
subscribesTo.add(eventNameByBinding.get(arg.text));
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
if (timerNameByBinding.has(arg.text)) {
|
|
899
|
+
subscribesTo.add(`__nwire/timer/${name}/${timerNameByBinding.get(arg.text)}`);
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
const lit = stringLiteral(arg);
|
|
904
|
+
if (lit !== undefined) {
|
|
905
|
+
subscribesTo.add(lit);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
unanalyzable.push({
|
|
909
|
+
kind: "workflow",
|
|
910
|
+
reason: `workflow "${name}" subscribes to an event reference not statically resolvable to a name`,
|
|
911
|
+
source: src,
|
|
912
|
+
});
|
|
913
|
+
};
|
|
914
|
+
const resolveAction = (arg) => {
|
|
915
|
+
if (!arg)
|
|
916
|
+
return;
|
|
917
|
+
if (ts.isIdentifier(arg) && actionNameByBinding.has(arg.text)) {
|
|
918
|
+
dispatches.add(actionNameByBinding.get(arg.text));
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const lit = stringLiteral(arg);
|
|
922
|
+
if (lit !== undefined) {
|
|
923
|
+
dispatches.add(lit);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
unanalyzable.push({
|
|
927
|
+
kind: "workflow",
|
|
928
|
+
reason: `workflow "${name}" dispatches an action reference not statically resolvable to a name`,
|
|
929
|
+
source: src,
|
|
930
|
+
});
|
|
931
|
+
};
|
|
932
|
+
walk(closure.body ?? closure, (n) => {
|
|
933
|
+
if (!ts.isCallExpression(n))
|
|
934
|
+
return;
|
|
935
|
+
const verb = calleeVerb(n);
|
|
936
|
+
if (verb === "when" || verb === "on") {
|
|
937
|
+
resolveEvent(n.arguments[0]);
|
|
938
|
+
// `opts.dispatches` array on the per-subscription options arg.
|
|
939
|
+
const onOpts = n.arguments[2];
|
|
940
|
+
if (onOpts && ts.isObjectLiteralExpression(onOpts)) {
|
|
941
|
+
const disp = prop(onOpts, "dispatches");
|
|
942
|
+
if (disp && ts.isArrayLiteralExpression(disp))
|
|
943
|
+
for (const el of disp.elements)
|
|
944
|
+
resolveAction(el);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
else if (verb === "send" || verb === "enqueue" || verb === "execute") {
|
|
948
|
+
resolveAction(n.arguments[0]);
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
// State→state transitions (saga state machine) + external calls.
|
|
952
|
+
const resolveWfEvent = (e) => {
|
|
953
|
+
if (e && ts.isIdentifier(e)) {
|
|
954
|
+
if (eventNameByBinding.has(e.text))
|
|
955
|
+
return eventNameByBinding.get(e.text);
|
|
956
|
+
if (timerNameByBinding.has(e.text))
|
|
957
|
+
return `__nwire/timer/${name}/${timerNameByBinding.get(e.text)}`;
|
|
958
|
+
}
|
|
959
|
+
return stringLiteral(e);
|
|
960
|
+
};
|
|
961
|
+
transitions = extractTransitions(closure, resolveWfEvent);
|
|
962
|
+
calls = extractCalls(closure, externalCallNameByBinding);
|
|
963
|
+
}
|
|
964
|
+
const entry = {
|
|
965
|
+
name,
|
|
966
|
+
app,
|
|
967
|
+
public: isPublic(node),
|
|
968
|
+
description,
|
|
969
|
+
subscribesTo: [...subscribesTo],
|
|
970
|
+
dispatches: [...dispatches],
|
|
971
|
+
transitions: transitions.length ? transitions : undefined,
|
|
972
|
+
calls: calls.length ? calls : undefined,
|
|
973
|
+
source: src,
|
|
974
|
+
};
|
|
975
|
+
workflows.push(entry);
|
|
976
|
+
for (const evName of subscribesTo)
|
|
977
|
+
graphEvents.push({ from: evName, to: name, via: "subscribes" });
|
|
978
|
+
for (const actName of dispatches)
|
|
979
|
+
graphEvents.push({ from: name, to: actName, via: "dispatches" });
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
return {
|
|
983
|
+
events,
|
|
984
|
+
actions,
|
|
985
|
+
actors,
|
|
986
|
+
projections,
|
|
987
|
+
queries,
|
|
988
|
+
workflows,
|
|
989
|
+
commands,
|
|
990
|
+
crons,
|
|
991
|
+
externalCalls,
|
|
992
|
+
inboundWebhooks,
|
|
993
|
+
outboxes,
|
|
994
|
+
inboxes,
|
|
995
|
+
resources,
|
|
996
|
+
errors,
|
|
997
|
+
schemas,
|
|
998
|
+
env: [...envReads].sort(),
|
|
999
|
+
config: collectConfigModules(files),
|
|
1000
|
+
unanalyzable,
|
|
1001
|
+
graph: { events: graphEvents },
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Discover config modules: any parsed file living in a `config/` directory.
|
|
1006
|
+
* Captures each module's top-level config field names (best-effort), sorted by
|
|
1007
|
+
* file so the output is stable.
|
|
1008
|
+
*/
|
|
1009
|
+
export function collectConfigModules(files) {
|
|
1010
|
+
const out = [];
|
|
1011
|
+
for (const file of files) {
|
|
1012
|
+
if (!isConfigModule(file))
|
|
1013
|
+
continue;
|
|
1014
|
+
const sf = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true);
|
|
1015
|
+
out.push({ file, keys: extractConfigKeys(sf) });
|
|
1016
|
+
}
|
|
1017
|
+
return out.sort((a, b) => a.file.localeCompare(b.file));
|
|
1018
|
+
}
|
|
1019
|
+
/** True for a `.ts` file inside a `config/` directory (any depth, OS-agnostic). */
|
|
1020
|
+
function isConfigModule(file) {
|
|
1021
|
+
const segments = file.split(/[/\\]/);
|
|
1022
|
+
// The file's parent directory (or an ancestor) is named `config`.
|
|
1023
|
+
return segments.slice(0, -1).includes("config");
|
|
1024
|
+
}
|