okengine 0.21.0 → 0.22.0
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/AGENTS.md +4 -4
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +15 -9
- package/manifest.v1.schema.json +10 -0
- package/package.json +22 -25
- package/site/content/docs/ai/skills.mdx +1 -1
- package/site/content/docs/client/calling.mdx +27 -19
- package/site/content/docs/client/index.mdx +1 -1
- package/site/content/docs/elements/channel/email.mdx +1 -1
- package/site/content/docs/elements/channel/index.mdx +1 -1
- package/site/content/docs/elements/flow/http.mdx +4 -4
- package/site/content/docs/elements/flow/index.mdx +3 -3
- package/site/content/docs/elements/flow/meta.json +1 -1
- package/site/content/docs/elements/gate/tenancy.mdx +1 -1
- package/site/content/docs/elements/vault/rotation.mdx +15 -0
- package/site/content/docs/plugins/otp.mdx +31 -31
- package/site/content/docs/recipes/mailpit.mdx +1 -1
- package/site/content/docs/recipes/meilisearch.mdx +1 -1
- package/site/content/docs/recipes/pgdog.mdx +1 -1
- package/site/content/docs/reference/configuration.mdx +2 -2
- package/site/content/docs/reference/errors.mdx +12 -2
- package/site/content/docs/reference/fx.mdx +28 -15
- package/site/content/docs/reference/idempotency.mdx +192 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +2 -1
- package/site/content/docs/understand/meta.json +1 -1
- package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
- package/site/content/docs/understand/the-architecture.mdx +14 -12
- package/site/content/docs/understand/try-it.mdx +3 -3
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/cli/dev.test.ts +105 -2
- package/src/cli/dev.ts +37 -2
- package/src/cli/docker-cli.test.ts +1 -1
- package/src/cli/index.ts +9 -1
- package/src/cli/load-config.images.test.ts +10 -10
- package/src/cli/load-config.ts +1 -1
- package/src/cli/vault-cmd.test.ts +23 -0
- package/src/cli/vault-cmd.ts +7 -0
- package/src/client/create.ts +31 -27
- package/src/client/live.ts +44 -101
- package/src/client/sse.ts +20 -66
- package/src/client/stream.ts +25 -67
- package/src/client/transport.test.ts +225 -0
- package/src/client/transport.ts +158 -90
- package/src/client/types.ts +29 -3
- package/src/client/wire.ts +119 -0
- package/src/compiler/aot.ts +3 -32
- package/src/compiler/dynamic.ts +13 -11
- package/src/compiler/effects-infer.ts +726 -16
- package/src/compiler/extract.test.ts +37 -0
- package/src/compiler/extract.ts +113 -12
- package/src/compiler/fixtures/skyport.expected.json +24 -0
- package/src/compiler/fx-follow.test.ts +164 -0
- package/src/compiler/fx-index.ts +399 -0
- package/src/compiler/interpret.ts +45 -0
- package/src/console/server/flows-invoke.test.ts +2 -2
- package/src/console/ui-next/dist/assets/{access-page-tIbsiphz.js → access-page-Ze6ckjdd.js} +2 -2
- package/src/console/ui-next/dist/assets/agent-disclosure-Bkohc_8X.js +1 -0
- package/src/console/ui-next/dist/assets/{cache-glyph-BCC-DxKT.js → cache-glyph-rL0lHxat.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-jOmYISdc.js → call-pii-button-Deo4PP18.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-DC2xNaAb.js → collapsible-CoJ6amHf.js} +1 -1
- package/src/console/ui-next/dist/assets/{copy-inline-button-CAYD18cr.js → copy-inline-button-CWG5_ZOh.js} +1 -1
- package/src/console/ui-next/dist/assets/{detail-header-DVWNjWjg.js → detail-header-b_ZzIsmU.js} +1 -1
- package/src/console/ui-next/dist/assets/{dropdown-menu-4h2LOVXM.js → dropdown-menu-nwXEO1Ac.js} +1 -1
- package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +9 -0
- package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +1 -0
- package/src/console/ui-next/dist/assets/{explorer-empty-CJs5A-wm.js → explorer-empty-C8sSCqYT.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-gT1lsWQK.js → flows-page-D2E7BtKK.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-C2GZEJNI.js → highlighted-json-CPYBzDh0.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-BdYjcIrD.js → http-method-DTPevKmM.js} +1 -1
- package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +58 -0
- package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +4 -0
- package/src/console/ui-next/dist/assets/{replica-lag-C4QdAF7J.js → replica-lag-BM27lXld.js} +3 -3
- package/src/console/ui-next/dist/assets/{request-meta-C43DHyld.js → request-meta-BjFT7DNl.js} +1 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +41 -0
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-Dcpo4Us_.js → trace-detail-sheet-Cmitq3da.js} +2 -2
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CKJTuv43.js → tree-expand-toggle-CbDIB-7x.js} +2 -2
- package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +1 -0
- package/src/console/ui-next/dist/assets/{use-vault-list-CT4-gajj.js → use-vault-list-Cs45Czkt.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-BwZ9YjTW.js → vault-page-BOMr0go9.js} +2 -2
- package/src/console/ui-next/dist/index.html +2 -2
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +9 -0
- package/src/docker/docker.test.ts +13 -13
- package/src/docker/images-config.test.ts +12 -12
- package/src/docker/stack-id.test.ts +1 -1
- package/src/drivers/journal-postgres.ts +168 -3
- package/src/drivers/meilisearch.ts +2 -0
- package/src/drivers/vault-builtin.test.ts +10 -0
- package/src/drivers/vault-builtin.ts +13 -1
- package/src/drivers/vault-remote-bag.ts +5 -1
- package/src/drivers/vault-types.ts +6 -1
- package/src/elements/store/emit-drizzle.ts +2 -2
- package/src/elements/store/live-default.test.ts +2 -0
- package/src/elements/store/live-http.test.ts +1 -0
- package/src/elements/store/resource-list-docs.test.ts +2 -2
- package/src/elements/store/resource.test.ts +3 -3
- package/src/elements/store/schema-decl.test.ts +1 -0
- package/src/elements/store/sql-condition.ts +24 -7
- package/src/elements/store/sql-session.ts +20 -4
- package/src/elements/store/upsert-app.test.ts +1 -1
- package/src/elements/vault/audit.test.ts +137 -0
- package/src/elements/vault/audit.ts +106 -13
- package/src/elements/vault/boot-chain.ts +9 -1
- package/src/elements/vault/builtin-adapter.ts +33 -3
- package/src/i18n/catalogs/ar.ts +4 -0
- package/src/i18n/catalogs/en.ts +4 -0
- package/src/kernel/abort-scope.ts +33 -2
- package/src/kernel/app.ts +200 -20
- package/src/kernel/auto-cache.test.ts +6 -6
- package/src/kernel/boot-bind/vault.ts +1 -0
- package/src/kernel/boot.ts +19 -1
- package/src/kernel/builtin-errors.ts +8 -0
- package/src/kernel/client-descriptor.test.ts +78 -0
- package/src/kernel/client-descriptor.ts +24 -0
- package/src/kernel/concurrency.test.ts +33 -0
- package/src/kernel/effects-stamping.test.ts +2 -2
- package/src/kernel/errors-compiler.ts +20 -0
- package/src/kernel/errors-text.ts +99 -0
- package/src/kernel/errors.ts +90 -138
- package/src/kernel/external-effects.test.ts +36 -0
- package/src/kernel/flow.ts +24 -0
- package/src/kernel/fx-fetch.ts +5 -1
- package/src/kernel/fx-sql-handle.ts +305 -0
- package/src/kernel/fx.ts +42 -330
- package/src/kernel/idempotency-store.ts +578 -0
- package/src/kernel/idempotency.test.ts +556 -0
- package/src/kernel/idempotency.ts +304 -0
- package/src/kernel/index.ts +1 -0
- package/src/kernel/journal.ts +56 -0
- package/src/kernel/json-result.ts +59 -0
- package/src/kernel/project-out.ts +6 -1
- package/src/manifest/types.ts +8 -0
- package/src/okid-extended.ts +175 -0
- package/src/okid-shared.ts +103 -0
- package/src/okid.ts +30 -213
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
- package/src/plugins/otp.test.ts +65 -1
- package/src/plugins/otp.ts +59 -7
- package/src/release/absolute-regression.test.ts +63 -0
- package/src/release/build-lib.ts +9 -0
- package/src/release/http-graph.test.ts +25 -0
- package/src/release/http-graph.ts +90 -0
- package/src/release/index.ts +3 -0
- package/src/release/limits.ts +17 -2
- package/src/release/measure.ts +105 -14
- package/src/test/create-test-app.test.ts +3 -1
- package/src/test/create-test-app.ts +40 -0
- package/src/test/live-signals.test.ts +1 -0
- package/src/test/provisions.integration.test.ts +1 -0
- package/src/test/tenant-isolation.test.ts +1 -0
- package/src/console/ui-next/dist/assets/agent-disclosure-CSKumwS2.js +0 -1
- package/src/console/ui-next/dist/assets/duration-tone-CwoV56jn.js +0 -9
- package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
- package/src/console/ui-next/dist/assets/index-Cul17AcV.js +0 -63
- package/src/console/ui-next/dist/assets/observability-page-oY9vdYBk.js +0 -4
- package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-CL0D9dOq.js +0 -41
- package/src/console/ui-next/dist/assets/units-page-1PlT19ft.js +0 -1
|
@@ -73,6 +73,12 @@ export interface InferBinding {
|
|
|
73
73
|
readonly version?: number;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/** What a callee that is passed `fx` resolves to. */
|
|
77
|
+
export type FxCalleeResolution =
|
|
78
|
+
| { readonly kind: "function"; readonly fn: AstNode; readonly file?: string }
|
|
79
|
+
| { readonly kind: "intrinsic"; readonly name: "liveQuery" | "applySearchEmbedCdc" }
|
|
80
|
+
| { readonly kind: "unresolved"; readonly label: string };
|
|
81
|
+
|
|
76
82
|
/** Options for {@link inferEffects}. */
|
|
77
83
|
export interface InferEffectsOptions {
|
|
78
84
|
/** The `do` handler AST node (function / arrow). */
|
|
@@ -81,9 +87,25 @@ export interface InferEffectsOptions {
|
|
|
81
87
|
readonly bindings: ReadonlyMap<string, InferBinding>;
|
|
82
88
|
/**
|
|
83
89
|
* True when the flow options already declare an `effects` object.
|
|
84
|
-
* Required for `fx.raw` to stay cache-eligible.
|
|
90
|
+
* Required for `fx.raw` to stay cache-eligible. Opaque `fx` (a renamed
|
|
91
|
+
* parameter, an escaped chain alias, an unresolved callee) is allowed
|
|
92
|
+
* only when this is set; the declared set must still cover every effect
|
|
93
|
+
* the walk can see.
|
|
85
94
|
*/
|
|
86
95
|
readonly hasExplicitEffects: boolean;
|
|
96
|
+
/** Manifest flow name, used in OKE1900. */
|
|
97
|
+
readonly flowName?: string;
|
|
98
|
+
/**
|
|
99
|
+
* Resolve a callee in the flow's file. Absent only for unit tests that
|
|
100
|
+
* never pass `fx` into another function.
|
|
101
|
+
*/
|
|
102
|
+
/** File that contains `do`, so imported helpers resolve from there. */
|
|
103
|
+
readonly file?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Resolve a callee as seen from `file`. Absent only for unit tests that
|
|
106
|
+
* never pass `fx` into another function.
|
|
107
|
+
*/
|
|
108
|
+
readonly resolveCallee?: (callee: AstNode, file: string) => FxCalleeResolution;
|
|
87
109
|
}
|
|
88
110
|
|
|
89
111
|
/** Result of effect inference for one flow body. */
|
|
@@ -171,10 +193,15 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
|
|
|
171
193
|
const steps: string[] = [];
|
|
172
194
|
let usesRaw = false;
|
|
173
195
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
196
|
+
const opaque: string[] = [];
|
|
197
|
+
const userIdRoots: AstNode[] = [options.doNode];
|
|
198
|
+
const intrinsics: Array<{
|
|
199
|
+
name: "liveQuery" | "applySearchEmbedCdc";
|
|
200
|
+
call: CallExpression;
|
|
201
|
+
}> = [];
|
|
202
|
+
const chains = collectFxChains(options, opaque, userIdRoots, intrinsics);
|
|
177
203
|
|
|
204
|
+
for (const { call, chain } of chains) {
|
|
178
205
|
if (chain.rootMethod === "raw") {
|
|
179
206
|
usesRaw = true;
|
|
180
207
|
continue;
|
|
@@ -301,14 +328,14 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
|
|
|
301
328
|
}
|
|
302
329
|
|
|
303
330
|
if (chain.rootMethod === "store") {
|
|
304
|
-
const resolved = storeResourceFromCall(call, options.bindings);
|
|
331
|
+
const resolved = storeResourceFromCall(call, options.bindings, chain);
|
|
305
332
|
if (!resolved) continue;
|
|
306
333
|
const op = classifyStoreMethods(resolved.methods);
|
|
307
334
|
if (op === "none") continue;
|
|
308
335
|
// Skip incomplete chains (`select` before `.from`, `insert` before table) —
|
|
309
336
|
// the sibling call that carries the table arg records the real resource.
|
|
310
337
|
const leaf = resolved.methods[resolved.methods.length - 1]!;
|
|
311
|
-
const hasTable = tableFromStoreChain(call, options.bindings) !== undefined;
|
|
338
|
+
const hasTable = tableFromStoreChain(call, options.bindings, chain) !== undefined;
|
|
312
339
|
if (!hasTable && (leaf === "select" || leaf === "insert" || leaf === "update")) {
|
|
313
340
|
continue;
|
|
314
341
|
}
|
|
@@ -323,6 +350,20 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
|
|
|
323
350
|
}
|
|
324
351
|
}
|
|
325
352
|
|
|
353
|
+
for (const item of intrinsics) {
|
|
354
|
+
noteIntrinsic(item.name, item.call, options.bindings, reads, writes, embeds, (detail) => {
|
|
355
|
+
opaque.push(detail);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (opaque.length > 0 && !options.hasExplicitEffects) {
|
|
360
|
+
const flow = options.flowName ?? "flow";
|
|
361
|
+
const detail = opaque[0] ?? "hides fx from effect inference";
|
|
362
|
+
throw new Error(
|
|
363
|
+
`OKE1900: flow "${flow}" ${detail}. Name the parameter fx and keep chain aliases in the same function, or declare effects that include every effect inference can see.`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
326
367
|
const effects: Effects = {};
|
|
327
368
|
if (reads.size > 0) effects.reads = sortUnique([...reads]);
|
|
328
369
|
if (writes.size > 0) effects.writes = sortUnique([...writes]);
|
|
@@ -340,10 +381,42 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
|
|
|
340
381
|
usesRaw,
|
|
341
382
|
cacheIneligible: usesRaw && !options.hasExplicitEffects,
|
|
342
383
|
nondeterministic: asks.size > 0 || embeds.size > 0,
|
|
343
|
-
readsUserId: containsAuthUserId(
|
|
384
|
+
readsUserId: userIdRoots.some((node) => containsAuthUserId(node)),
|
|
344
385
|
};
|
|
345
386
|
}
|
|
346
387
|
|
|
388
|
+
const EFFECT_KEYS = [
|
|
389
|
+
"reads",
|
|
390
|
+
"writes",
|
|
391
|
+
"emits",
|
|
392
|
+
"sends",
|
|
393
|
+
"asks",
|
|
394
|
+
"embeds",
|
|
395
|
+
"secrets",
|
|
396
|
+
"calls",
|
|
397
|
+
"fetches",
|
|
398
|
+
] as const satisfies readonly (keyof Effects)[];
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Keys in `inferred` that `declared` does not include.
|
|
402
|
+
*
|
|
403
|
+
* An explicit `effects` object is a floor: it may add keys inference
|
|
404
|
+
* cannot see, and it must include every key the walk can see.
|
|
405
|
+
*
|
|
406
|
+
* @param declared - Author-declared effects
|
|
407
|
+
* @param inferred - Effects the walk can see
|
|
408
|
+
*/
|
|
409
|
+
export function effectsMissingFromDeclaration(declared: Effects, inferred: Effects): string[] {
|
|
410
|
+
const missing: string[] = [];
|
|
411
|
+
for (const key of EFFECT_KEYS) {
|
|
412
|
+
const have = new Set<string>(declared[key] ?? []);
|
|
413
|
+
for (const item of inferred[key] ?? []) {
|
|
414
|
+
if (!have.has(item)) missing.push(`${key} ${item}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return missing;
|
|
418
|
+
}
|
|
419
|
+
|
|
347
420
|
/**
|
|
348
421
|
* Walk an AST and collect every CallExpression.
|
|
349
422
|
*
|
|
@@ -380,6 +453,8 @@ interface FxChain {
|
|
|
380
453
|
rootMethod: string;
|
|
381
454
|
methods: string[];
|
|
382
455
|
rootCall: CallExpression;
|
|
456
|
+
/** Outermost call of an alias init, when the continuation is a later statement. */
|
|
457
|
+
aliasLeaf?: CallExpression;
|
|
383
458
|
}
|
|
384
459
|
|
|
385
460
|
/**
|
|
@@ -451,15 +526,16 @@ export function fxMemberChain(call: CallExpression): FxChain | null {
|
|
|
451
526
|
export function storeResourceFromCall(
|
|
452
527
|
call: CallExpression,
|
|
453
528
|
bindings: ReadonlyMap<string, InferBinding>,
|
|
529
|
+
known?: FxChain,
|
|
454
530
|
): { resource: ResourceRef; methods: string[] } | undefined {
|
|
455
|
-
const chain = fxMemberChain(call);
|
|
531
|
+
const chain = known ?? fxMemberChain(call);
|
|
456
532
|
if (!chain || chain.rootMethod !== "store") return undefined;
|
|
457
533
|
|
|
458
534
|
const storeArg = chain.rootCall.arguments[0];
|
|
459
535
|
const storeBinding = resolveBinding(storeArg, bindings);
|
|
460
536
|
const facet = storeBinding?.facet ?? "sql";
|
|
461
537
|
|
|
462
|
-
const table = tableFromStoreChain(call, bindings);
|
|
538
|
+
const table = tableFromStoreChain(call, bindings, chain);
|
|
463
539
|
|
|
464
540
|
if (table) {
|
|
465
541
|
return {
|
|
@@ -506,18 +582,36 @@ export function storeResourceFromCall(
|
|
|
506
582
|
function tableFromStoreChain(
|
|
507
583
|
call: CallExpression,
|
|
508
584
|
bindings: ReadonlyMap<string, InferBinding>,
|
|
585
|
+
chain?: FxChain,
|
|
586
|
+
): string | undefined {
|
|
587
|
+
const found = tableFromStoreWalk(call, bindings, chain);
|
|
588
|
+
if (found) return found;
|
|
589
|
+
if (chain?.aliasLeaf && chain.aliasLeaf !== call) {
|
|
590
|
+
return tableFromStoreWalk(chain.aliasLeaf, bindings);
|
|
591
|
+
}
|
|
592
|
+
return undefined;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function tableFromStoreWalk(
|
|
596
|
+
call: CallExpression,
|
|
597
|
+
bindings: ReadonlyMap<string, InferBinding>,
|
|
598
|
+
chain?: FxChain,
|
|
509
599
|
): string | undefined {
|
|
510
600
|
let current: AstNode | undefined = call;
|
|
511
|
-
|
|
601
|
+
const seen = new Set<AstNode>();
|
|
602
|
+
while (current && current.type === "CallExpression" && !seen.has(current)) {
|
|
603
|
+
seen.add(current);
|
|
512
604
|
const c = current as CallExpression;
|
|
513
|
-
const link = fxMemberChain(c);
|
|
514
|
-
if (!link || link.rootMethod !== "store") break;
|
|
515
|
-
const leaf = link
|
|
605
|
+
const link = chain ? undefined : fxMemberChain(c);
|
|
606
|
+
if (!chain && (!link || link.rootMethod !== "store")) break;
|
|
607
|
+
const leaf = chain ? methodNameOfCall(c) : link?.methods[link.methods.length - 1];
|
|
608
|
+
if (!leaf) break;
|
|
516
609
|
if (TABLE_ARG_METHODS.has(leaf)) {
|
|
517
610
|
// KV / files / index take a key, not a table. Treating `delete(key)` as
|
|
518
611
|
// `delete(table)` stamps `kv:key` instead of `kv:drafts` and the runtime
|
|
519
612
|
// then throws OKE1002 on the real namespace.
|
|
520
|
-
const
|
|
613
|
+
const storeArg = chain?.rootCall.arguments[0] ?? link?.rootCall.arguments[0];
|
|
614
|
+
const storeBinding = resolveBinding(storeArg, bindings);
|
|
521
615
|
if (storeBinding?.facet !== undefined && storeBinding.facet !== "sql") {
|
|
522
616
|
// keep walking — no table on this link
|
|
523
617
|
} else {
|
|
@@ -530,14 +624,24 @@ function tableFromStoreChain(
|
|
|
530
624
|
}
|
|
531
625
|
const callee = c.callee;
|
|
532
626
|
if (callee.type === "MemberExpression") {
|
|
533
|
-
|
|
534
|
-
|
|
627
|
+
const obj = (callee as AstNode & { object: AstNode }).object;
|
|
628
|
+
if (obj.type === "CallExpression") {
|
|
629
|
+
current = obj;
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
535
632
|
}
|
|
536
633
|
break;
|
|
537
634
|
}
|
|
538
635
|
return undefined;
|
|
539
636
|
}
|
|
540
637
|
|
|
638
|
+
function methodNameOfCall(call: CallExpression): string | undefined {
|
|
639
|
+
const callee = call.callee;
|
|
640
|
+
if (callee.type !== "MemberExpression") return undefined;
|
|
641
|
+
const property = (callee as AstNode & { property?: AstNode }).property;
|
|
642
|
+
return identifierName(property);
|
|
643
|
+
}
|
|
644
|
+
|
|
541
645
|
function classifyStoreMethods(methods: string[]): "read" | "write" | "both" | "none" {
|
|
542
646
|
const ops = methods.slice(1);
|
|
543
647
|
let read = false;
|
|
@@ -751,3 +855,609 @@ function containsAuthUserId(root: AstNode): boolean {
|
|
|
751
855
|
});
|
|
752
856
|
return found;
|
|
753
857
|
}
|
|
858
|
+
|
|
859
|
+
interface ChainAlias {
|
|
860
|
+
readonly methods: readonly string[];
|
|
861
|
+
readonly rootMethod: string;
|
|
862
|
+
readonly rootCall: CallExpression;
|
|
863
|
+
readonly leafCall: CallExpression;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
interface ChainHit {
|
|
867
|
+
readonly call: CallExpression;
|
|
868
|
+
readonly chain: FxChain;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const VALUE_WRAPPERS = new Set([
|
|
872
|
+
"ParenthesizedExpression",
|
|
873
|
+
"TSAsExpression",
|
|
874
|
+
"TSTypeAssertion",
|
|
875
|
+
"TSSatisfiesExpression",
|
|
876
|
+
"TSNonNullExpression",
|
|
877
|
+
"AwaitExpression",
|
|
878
|
+
]);
|
|
879
|
+
|
|
880
|
+
function collectFxChains(
|
|
881
|
+
options: InferEffectsOptions,
|
|
882
|
+
opaque: string[],
|
|
883
|
+
userIdRoots: AstNode[],
|
|
884
|
+
intrinsics: Array<{ name: "liveQuery" | "applySearchEmbedCdc"; call: CallExpression }>,
|
|
885
|
+
): ChainHit[] {
|
|
886
|
+
const hits: ChainHit[] = [];
|
|
887
|
+
const seen = new Set<AstNode>();
|
|
888
|
+
|
|
889
|
+
const follow = (
|
|
890
|
+
fn: AstNode,
|
|
891
|
+
file: string,
|
|
892
|
+
aliases: ReadonlyMap<string, ChainAlias>,
|
|
893
|
+
locals: ReadonlyMap<string, AstNode>,
|
|
894
|
+
): void => {
|
|
895
|
+
analyzeFunction(fn, "fx", aliases, locals, file);
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
const analyzeFunction = (
|
|
899
|
+
fn: AstNode,
|
|
900
|
+
door: string,
|
|
901
|
+
outerAliases: ReadonlyMap<string, ChainAlias>,
|
|
902
|
+
outerLocals: ReadonlyMap<string, AstNode>,
|
|
903
|
+
file: string,
|
|
904
|
+
): void => {
|
|
905
|
+
if (seen.has(fn)) return;
|
|
906
|
+
seen.add(fn);
|
|
907
|
+
userIdRoots.push(fn);
|
|
908
|
+
const body = (fn as AstNode & { body?: AstNode }).body;
|
|
909
|
+
if (!body) return;
|
|
910
|
+
analyzeBody(body, door, outerAliases, outerLocals, file);
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
const analyzeBody = (
|
|
914
|
+
body: AstNode,
|
|
915
|
+
door: string,
|
|
916
|
+
outerAliases: ReadonlyMap<string, ChainAlias>,
|
|
917
|
+
outerLocals: ReadonlyMap<string, AstNode>,
|
|
918
|
+
file: string,
|
|
919
|
+
): void => {
|
|
920
|
+
const aliases = new Map(outerAliases);
|
|
921
|
+
const locals = new Map(outerLocals);
|
|
922
|
+
collectDirect(body, aliases, locals);
|
|
923
|
+
checkEscapes(body, door, new Set(aliases.keys()), (detail) => {
|
|
924
|
+
opaque.push(detail);
|
|
925
|
+
});
|
|
926
|
+
for (const call of callsSkippingNested(body)) {
|
|
927
|
+
const chain = chainFromCall(call, aliases);
|
|
928
|
+
if (chain) {
|
|
929
|
+
hits.push({ call, chain });
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
noteFxPass(call, door, aliases, locals, file, opaque, intrinsics, follow);
|
|
933
|
+
}
|
|
934
|
+
for (const nested of nestedFunctions(body)) {
|
|
935
|
+
if (seen.has(nested)) continue;
|
|
936
|
+
const shadows = functionParams(nested).some((param) => paramIdentifierName(param) === door);
|
|
937
|
+
if (shadows) continue;
|
|
938
|
+
analyzeFunction(nested, door, aliases, locals, file);
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
|
|
942
|
+
const startFile = options.file ?? "";
|
|
943
|
+
if (isFunctionNode(options.doNode)) {
|
|
944
|
+
const params = functionParams(options.doNode);
|
|
945
|
+
const second = params.length >= 2 ? params[1] : undefined;
|
|
946
|
+
if (second && paramIdentifierName(second) !== "fx") {
|
|
947
|
+
opaque.push("the do callback's second parameter is not named fx");
|
|
948
|
+
}
|
|
949
|
+
analyzeFunction(options.doNode, "fx", new Map(), new Map(), startFile);
|
|
950
|
+
} else {
|
|
951
|
+
analyzeBody(options.doNode, "fx", new Map(), new Map(), startFile);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
return hits;
|
|
955
|
+
|
|
956
|
+
function noteFxPass(
|
|
957
|
+
call: CallExpression,
|
|
958
|
+
door: string,
|
|
959
|
+
aliases: ReadonlyMap<string, ChainAlias>,
|
|
960
|
+
locals: ReadonlyMap<string, AstNode>,
|
|
961
|
+
file: string,
|
|
962
|
+
reasons: string[],
|
|
963
|
+
foundIntrinsics: Array<{ name: "liveQuery" | "applySearchEmbedCdc"; call: CallExpression }>,
|
|
964
|
+
enter: (
|
|
965
|
+
fn: AstNode,
|
|
966
|
+
file: string,
|
|
967
|
+
aliases: ReadonlyMap<string, ChainAlias>,
|
|
968
|
+
locals: ReadonlyMap<string, AstNode>,
|
|
969
|
+
) => void,
|
|
970
|
+
): void {
|
|
971
|
+
const args = call.arguments ?? [];
|
|
972
|
+
for (let index = 0; index < args.length; index++) {
|
|
973
|
+
const arg = unwrapValue(args[index]);
|
|
974
|
+
if (!arg || arg.type !== "Identifier") continue;
|
|
975
|
+
const name = identifierName(arg);
|
|
976
|
+
if (!name) continue;
|
|
977
|
+
if (aliases.has(name)) {
|
|
978
|
+
reasons.push(`passes chain alias "${name}" to a call`);
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
if (name !== door) continue;
|
|
982
|
+
const resolved = resolveCallee(call.callee, locals, options, file);
|
|
983
|
+
if (resolved.kind === "function") {
|
|
984
|
+
const param = functionParams(resolved.fn)[index];
|
|
985
|
+
if (!param || paramIdentifierName(param) !== "fx") {
|
|
986
|
+
reasons.push("passes fx to a parameter that is not named fx");
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
const local = calleeLocal(call.callee, locals);
|
|
990
|
+
enter(
|
|
991
|
+
resolved.fn,
|
|
992
|
+
resolved.file ?? file,
|
|
993
|
+
local ? aliases : new Map(),
|
|
994
|
+
local ? locals : new Map(),
|
|
995
|
+
);
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
if (resolved.kind === "intrinsic") {
|
|
999
|
+
if (index !== 0) {
|
|
1000
|
+
reasons.push(`passes fx to ${resolved.name} in a position inference cannot read`);
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
foundIntrinsics.push({ name: resolved.name, call });
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
reasons.push(`passes fx to "${resolved.label}", which inference cannot resolve`);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function calleeLocal(callee: AstNode, locals: ReadonlyMap<string, AstNode>): boolean {
|
|
1012
|
+
if (callee.type !== "Identifier") return false;
|
|
1013
|
+
const name = identifierName(callee);
|
|
1014
|
+
return name !== undefined && locals.has(name);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function resolveCallee(
|
|
1018
|
+
callee: AstNode,
|
|
1019
|
+
locals: ReadonlyMap<string, AstNode>,
|
|
1020
|
+
options: InferEffectsOptions,
|
|
1021
|
+
file: string,
|
|
1022
|
+
): FxCalleeResolution {
|
|
1023
|
+
if (callee.type === "Identifier") {
|
|
1024
|
+
const name = identifierName(callee);
|
|
1025
|
+
const local = name ? locals.get(name) : undefined;
|
|
1026
|
+
if (local) return { kind: "function", fn: local, file };
|
|
1027
|
+
}
|
|
1028
|
+
if (options.resolveCallee) return options.resolveCallee(callee, file);
|
|
1029
|
+
const label = identifierName(callee) ?? "callee";
|
|
1030
|
+
return { kind: "unresolved", label };
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function noteIntrinsic(
|
|
1034
|
+
name: "liveQuery" | "applySearchEmbedCdc",
|
|
1035
|
+
call: CallExpression,
|
|
1036
|
+
bindings: ReadonlyMap<string, InferBinding>,
|
|
1037
|
+
reads: Set<ResourceRef | SignalResourceRef | AuthApiKeysResourceRef | AuthTenantsResourceRef>,
|
|
1038
|
+
writes: Set<ResourceRef | AuthApiKeysResourceRef | AuthTenantsResourceRef>,
|
|
1039
|
+
embeds: Set<EmbedRef>,
|
|
1040
|
+
opaque: (detail: string) => void,
|
|
1041
|
+
): void {
|
|
1042
|
+
if (name === "liveQuery") {
|
|
1043
|
+
const ref = sqlRefFromArg(call.arguments[1], bindings);
|
|
1044
|
+
if (!ref) {
|
|
1045
|
+
opaque("calls liveQuery with a table inference cannot resolve");
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
reads.add(ref as ResourceRef);
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
const sqlRef = stringArg(unwrapValueNode(call.arguments[5]));
|
|
1052
|
+
if (!sqlRef) {
|
|
1053
|
+
opaque("calls applySearchEmbedCdc with a sqlRef inference cannot resolve");
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
const ref = (sqlRef.includes(":") ? sqlRef : `sql:${sqlRef}`) as ResourceRef;
|
|
1057
|
+
reads.add(ref);
|
|
1058
|
+
writes.add(ref);
|
|
1059
|
+
const models = embedModelsFromColumns(call.arguments[3]);
|
|
1060
|
+
if (!models) {
|
|
1061
|
+
opaque("calls applySearchEmbedCdc with embed columns inference cannot resolve");
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
for (const model of models) embeds.add(model);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function sqlRefFromArg(
|
|
1068
|
+
arg: AstNode | undefined,
|
|
1069
|
+
bindings: ReadonlyMap<string, InferBinding>,
|
|
1070
|
+
): string | undefined {
|
|
1071
|
+
if (!arg) return undefined;
|
|
1072
|
+
const node = unwrapValue(arg);
|
|
1073
|
+
const lit = stringArg(node);
|
|
1074
|
+
if (lit) return lit.includes(":") ? lit : `sql:${lit}`;
|
|
1075
|
+
const binding = resolveBinding(node, bindings);
|
|
1076
|
+
if (binding?.kind === "table") return sqlTableRef(binding.ref);
|
|
1077
|
+
if (binding?.kind === "store") return binding.ref;
|
|
1078
|
+
return undefined;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function embedModelsFromColumns(arg: AstNode | undefined): string[] | null {
|
|
1082
|
+
if (!arg) return null;
|
|
1083
|
+
const node = unwrapValue(arg);
|
|
1084
|
+
if (!node || node.type !== "ArrayExpression") return null;
|
|
1085
|
+
const elements = (node as AstNode & { elements?: readonly (AstNode | null)[] }).elements ?? [];
|
|
1086
|
+
const models: string[] = [];
|
|
1087
|
+
for (const element of elements) {
|
|
1088
|
+
if (!element || element.type !== "ObjectExpression") return null;
|
|
1089
|
+
const model = objectStringProp(element, "model");
|
|
1090
|
+
if (model === undefined) {
|
|
1091
|
+
if (objectHasProp(element, "model")) return null;
|
|
1092
|
+
models.push("default");
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
models.push(model);
|
|
1096
|
+
}
|
|
1097
|
+
return models;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function objectStringProp(obj: AstNode, key: string): string | undefined {
|
|
1101
|
+
for (const prop of objectProps(obj)) {
|
|
1102
|
+
if (propKeyName(prop) !== key) continue;
|
|
1103
|
+
return stringArg(unwrapValue((prop as AstNode & { value?: AstNode }).value));
|
|
1104
|
+
}
|
|
1105
|
+
return undefined;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function objectHasProp(obj: AstNode, key: string): boolean {
|
|
1109
|
+
return objectProps(obj).some((prop) => propKeyName(prop) === key);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function objectProps(obj: AstNode): AstNode[] {
|
|
1113
|
+
return ((obj as AstNode & { properties?: AstNode[] }).properties ?? []).filter(
|
|
1114
|
+
(prop) => prop.type === "Property" || prop.type === "ObjectProperty",
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function propKeyName(prop: AstNode): string | undefined {
|
|
1119
|
+
const key = (prop as AstNode & { key?: AstNode }).key;
|
|
1120
|
+
return identifierName(key) ?? stringArg(key);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function collectDirect(
|
|
1124
|
+
body: AstNode,
|
|
1125
|
+
aliases: Map<string, ChainAlias>,
|
|
1126
|
+
locals: Map<string, AstNode>,
|
|
1127
|
+
): void {
|
|
1128
|
+
walkStatements(body, (node) => {
|
|
1129
|
+
if (node.type === "FunctionDeclaration") {
|
|
1130
|
+
const name = identifierName((node as AstNode & { id?: AstNode }).id);
|
|
1131
|
+
if (name) locals.set(name, node);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
if (node.type !== "VariableDeclarator") return;
|
|
1135
|
+
const name = identifierName((node as AstNode & { id?: AstNode }).id);
|
|
1136
|
+
const init = (node as AstNode & { init?: AstNode | null }).init;
|
|
1137
|
+
if (!name || !init) return;
|
|
1138
|
+
const value = unwrapValue(init);
|
|
1139
|
+
if (value && isFunctionNode(value)) {
|
|
1140
|
+
locals.set(name, value);
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
const alias = aliasFromInit(init, aliases);
|
|
1144
|
+
if (alias) aliases.set(name, alias);
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function aliasFromInit(init: AstNode, aliases: ReadonlyMap<string, ChainAlias>): ChainAlias | null {
|
|
1149
|
+
const node = unwrapValue(init);
|
|
1150
|
+
if (!node || node.type !== "CallExpression") return null;
|
|
1151
|
+
const chain = chainFromCall(node as CallExpression, aliases);
|
|
1152
|
+
if (!chain || chain.rootMethod !== "store") return null;
|
|
1153
|
+
// `const rows = await fx.store(db).select().from(table)` is the query
|
|
1154
|
+
// result. `const q = await fx.store(db)` is still the handle.
|
|
1155
|
+
if (expressionIsAwaited(init) && !isStoreHandle(chain)) return null;
|
|
1156
|
+
return {
|
|
1157
|
+
methods: chain.methods,
|
|
1158
|
+
rootMethod: chain.rootMethod,
|
|
1159
|
+
rootCall: chain.rootCall,
|
|
1160
|
+
leafCall: chain.aliasLeaf ?? (node as CallExpression),
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function isStoreHandle(chain: FxChain): boolean {
|
|
1165
|
+
return chain.rootMethod === "store" && classifyStoreMethods(chain.methods) === "none";
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function expressionIsAwaited(node: AstNode): boolean {
|
|
1169
|
+
let current: AstNode | undefined = node;
|
|
1170
|
+
for (let i = 0; i < 8 && current; i++) {
|
|
1171
|
+
if (current.type === "AwaitExpression") return true;
|
|
1172
|
+
if (!VALUE_WRAPPERS.has(current.type)) return false;
|
|
1173
|
+
current =
|
|
1174
|
+
(current as AstNode & { expression?: AstNode }).expression ??
|
|
1175
|
+
(current as AstNode & { argument?: AstNode }).argument;
|
|
1176
|
+
}
|
|
1177
|
+
return false;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function chainFromCall(
|
|
1181
|
+
call: CallExpression,
|
|
1182
|
+
aliases: ReadonlyMap<string, ChainAlias>,
|
|
1183
|
+
): FxChain | null {
|
|
1184
|
+
const direct = fxMemberChain(call);
|
|
1185
|
+
if (direct) return direct;
|
|
1186
|
+
|
|
1187
|
+
let current: CallExpression | null = call;
|
|
1188
|
+
const methods: string[] = [];
|
|
1189
|
+
while (current) {
|
|
1190
|
+
const callee = current.callee;
|
|
1191
|
+
if (callee.type !== "MemberExpression") return null;
|
|
1192
|
+
const member = callee as AstNode & {
|
|
1193
|
+
object: AstNode;
|
|
1194
|
+
property: AstNode;
|
|
1195
|
+
computed?: boolean;
|
|
1196
|
+
};
|
|
1197
|
+
if (member.computed === true || member.property.type !== "Identifier") return null;
|
|
1198
|
+
methods.unshift((member.property as Identifier).name);
|
|
1199
|
+
if (member.object.type === "Identifier") {
|
|
1200
|
+
const alias = aliases.get((member.object as Identifier).name);
|
|
1201
|
+
if (!alias) return null;
|
|
1202
|
+
return {
|
|
1203
|
+
rootMethod: alias.rootMethod,
|
|
1204
|
+
methods: [...alias.methods, ...methods],
|
|
1205
|
+
rootCall: alias.rootCall,
|
|
1206
|
+
aliasLeaf: alias.leafCall,
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
if (member.object.type === "CallExpression") {
|
|
1210
|
+
current = member.object as CallExpression;
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
return null;
|
|
1214
|
+
}
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function checkEscapes(
|
|
1219
|
+
root: AstNode,
|
|
1220
|
+
door: string,
|
|
1221
|
+
aliasNames: ReadonlySet<string>,
|
|
1222
|
+
report: (detail: string) => void,
|
|
1223
|
+
): void {
|
|
1224
|
+
const rec = (node: AstNode, parent: AstNode | undefined, inCallArg: boolean): void => {
|
|
1225
|
+
if (node !== root && isFunctionNode(node)) return;
|
|
1226
|
+
if (node.type === "Identifier") {
|
|
1227
|
+
const name = (node as Identifier).name;
|
|
1228
|
+
const isAlias = aliasNames.has(name);
|
|
1229
|
+
const isDoor = name === door;
|
|
1230
|
+
if (
|
|
1231
|
+
(isAlias || isDoor) &&
|
|
1232
|
+
!isTypePosition(parent) &&
|
|
1233
|
+
!(isDoor && inCallArg) &&
|
|
1234
|
+
!isAllowedFxUse(node, parent)
|
|
1235
|
+
) {
|
|
1236
|
+
report(describeFxEscape(name, parent, isAlias));
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
if (node.type === "CallExpression") {
|
|
1240
|
+
const call = node as CallExpression;
|
|
1241
|
+
rec(call.callee, node, false);
|
|
1242
|
+
for (const arg of call.arguments ?? []) {
|
|
1243
|
+
if (arg) rec(arg, node, true);
|
|
1244
|
+
}
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
if (VALUE_WRAPPERS.has(node.type) && inCallArg) {
|
|
1248
|
+
const inner =
|
|
1249
|
+
(node as AstNode & { expression?: AstNode }).expression ??
|
|
1250
|
+
(node as AstNode & { argument?: AstNode }).argument;
|
|
1251
|
+
if (inner) rec(inner, node, true);
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
for (const value of Object.values(node)) {
|
|
1255
|
+
if (Array.isArray(value)) {
|
|
1256
|
+
for (const item of value) {
|
|
1257
|
+
if (isAstNode(item)) rec(item, node, inCallArg);
|
|
1258
|
+
}
|
|
1259
|
+
} else if (isAstNode(value)) {
|
|
1260
|
+
rec(value, node, inCallArg);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
};
|
|
1264
|
+
rec(root, undefined, false);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function isAllowedFxUse(node: AstNode, parent: AstNode | undefined): boolean {
|
|
1268
|
+
if (!parent) return false;
|
|
1269
|
+
if (parent.type === "MemberExpression") {
|
|
1270
|
+
const member = parent as AstNode & { object?: AstNode; property?: AstNode; computed?: boolean };
|
|
1271
|
+
if (member.property === node && member.computed !== true) return true;
|
|
1272
|
+
if (member.object === node) return true;
|
|
1273
|
+
}
|
|
1274
|
+
if (parent.type === "VariableDeclarator" && (parent as AstNode & { id?: AstNode }).id === node) {
|
|
1275
|
+
return true;
|
|
1276
|
+
}
|
|
1277
|
+
if (
|
|
1278
|
+
(parent.type === "FunctionDeclaration" ||
|
|
1279
|
+
parent.type === "FunctionExpression" ||
|
|
1280
|
+
parent.type === "ArrowFunctionExpression") &&
|
|
1281
|
+
(parent as AstNode & { id?: AstNode }).id === node
|
|
1282
|
+
) {
|
|
1283
|
+
return true;
|
|
1284
|
+
}
|
|
1285
|
+
return false;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function describeFxEscape(name: string, parent: AstNode | undefined, isAlias: boolean): string {
|
|
1289
|
+
if (isAlias) {
|
|
1290
|
+
if (parent?.type === "ReturnStatement") return `returns chain alias "${name}"`;
|
|
1291
|
+
if (parent?.type === "CallExpression") return `passes chain alias "${name}" to a call`;
|
|
1292
|
+
if (
|
|
1293
|
+
parent?.type === "ArrayExpression" ||
|
|
1294
|
+
parent?.type === "ObjectExpression" ||
|
|
1295
|
+
parent?.type === "Property" ||
|
|
1296
|
+
parent?.type === "ObjectProperty"
|
|
1297
|
+
) {
|
|
1298
|
+
return `puts chain alias "${name}" in an object or array`;
|
|
1299
|
+
}
|
|
1300
|
+
if (parent?.type === "AssignmentExpression") return `reassigns chain alias "${name}"`;
|
|
1301
|
+
return `lets chain alias "${name}" escape`;
|
|
1302
|
+
}
|
|
1303
|
+
if (parent?.type === "VariableDeclarator") return "aliases fx";
|
|
1304
|
+
if (
|
|
1305
|
+
parent?.type === "ObjectPattern" ||
|
|
1306
|
+
parent?.type === "ArrayPattern" ||
|
|
1307
|
+
parent?.type === "Property" ||
|
|
1308
|
+
parent?.type === "ObjectProperty" ||
|
|
1309
|
+
parent?.type === "RestElement"
|
|
1310
|
+
) {
|
|
1311
|
+
return "destructures fx";
|
|
1312
|
+
}
|
|
1313
|
+
return "lets fx escape";
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function isTypePosition(parent: AstNode | undefined): boolean {
|
|
1317
|
+
if (!parent) return false;
|
|
1318
|
+
if (!parent.type.startsWith("TS")) return false;
|
|
1319
|
+
return !VALUE_WRAPPERS.has(parent.type);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function callsSkippingNested(body: AstNode): CallExpression[] {
|
|
1323
|
+
const out: CallExpression[] = [];
|
|
1324
|
+
const take = (node: AstNode): void => {
|
|
1325
|
+
if (node.type === "CallExpression") out.push(node as CallExpression);
|
|
1326
|
+
};
|
|
1327
|
+
take(body);
|
|
1328
|
+
walkSkipFunctions(body, take);
|
|
1329
|
+
return out;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
function nestedFunctions(body: AstNode): AstNode[] {
|
|
1333
|
+
const out: AstNode[] = [];
|
|
1334
|
+
walkSkipFunctions(body, (node) => {
|
|
1335
|
+
if (isFunctionNode(node)) out.push(node);
|
|
1336
|
+
});
|
|
1337
|
+
return out;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function walkSkipFunctions(root: AstNode, visit: (node: AstNode) => void): void {
|
|
1341
|
+
const rec = (node: AstNode): void => {
|
|
1342
|
+
for (const value of Object.values(node)) {
|
|
1343
|
+
if (Array.isArray(value)) {
|
|
1344
|
+
for (const item of value) {
|
|
1345
|
+
if (!isAstNode(item)) continue;
|
|
1346
|
+
visit(item);
|
|
1347
|
+
if (!isFunctionNode(item)) rec(item);
|
|
1348
|
+
}
|
|
1349
|
+
} else if (isAstNode(value)) {
|
|
1350
|
+
visit(value);
|
|
1351
|
+
if (!isFunctionNode(value)) rec(value);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
};
|
|
1355
|
+
rec(root);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function walkStatements(node: AstNode, visit: (node: AstNode) => void): void {
|
|
1359
|
+
if (node.type === "BlockStatement" || node.type === "Program" || node.type === "StaticBlock") {
|
|
1360
|
+
const body = (node as AstNode & { body?: AstNode[] }).body ?? [];
|
|
1361
|
+
for (const stmt of body) walkStatement(stmt, visit);
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
walkSkipFunctions(node, (child) => {
|
|
1365
|
+
if (child.type === "VariableDeclarator" || child.type === "FunctionDeclaration") visit(child);
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function walkStatement(stmt: AstNode, visit: (node: AstNode) => void): void {
|
|
1370
|
+
if (stmt.type === "FunctionDeclaration") {
|
|
1371
|
+
visit(stmt);
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
if (stmt.type === "VariableDeclaration") {
|
|
1375
|
+
const declarations = (stmt as AstNode & { declarations?: AstNode[] }).declarations ?? [];
|
|
1376
|
+
for (const decl of declarations) visit(decl);
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
if (stmt.type === "BlockStatement") {
|
|
1380
|
+
walkStatements(stmt, visit);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
if (
|
|
1384
|
+
stmt.type === "IfStatement" ||
|
|
1385
|
+
stmt.type === "ForStatement" ||
|
|
1386
|
+
stmt.type === "ForInStatement" ||
|
|
1387
|
+
stmt.type === "ForOfStatement" ||
|
|
1388
|
+
stmt.type === "WhileStatement" ||
|
|
1389
|
+
stmt.type === "DoWhileStatement" ||
|
|
1390
|
+
stmt.type === "LabeledStatement"
|
|
1391
|
+
) {
|
|
1392
|
+
const consequent = (stmt as AstNode & { consequent?: AstNode }).consequent;
|
|
1393
|
+
const alternate = (stmt as AstNode & { alternate?: AstNode }).alternate;
|
|
1394
|
+
const body = (stmt as AstNode & { body?: AstNode }).body;
|
|
1395
|
+
if (consequent) walkStatement(consequent, visit);
|
|
1396
|
+
if (alternate) walkStatement(alternate, visit);
|
|
1397
|
+
if (body && body !== consequent) walkStatement(body, visit);
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
if (stmt.type === "TryStatement") {
|
|
1401
|
+
const block = (stmt as AstNode & { block?: AstNode }).block;
|
|
1402
|
+
const handler = (stmt as AstNode & { handler?: AstNode }).handler;
|
|
1403
|
+
const finalizer = (stmt as AstNode & { finalizer?: AstNode }).finalizer;
|
|
1404
|
+
if (block) walkStatement(block, visit);
|
|
1405
|
+
const handlerBody = (handler as (AstNode & { body?: AstNode }) | undefined)?.body;
|
|
1406
|
+
if (handlerBody) walkStatement(handlerBody, visit);
|
|
1407
|
+
if (finalizer) walkStatement(finalizer, visit);
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
if (stmt.type === "SwitchStatement") {
|
|
1411
|
+
const cases = (stmt as AstNode & { cases?: AstNode[] }).cases ?? [];
|
|
1412
|
+
for (const item of cases) {
|
|
1413
|
+
const consequent = (item as AstNode & { consequent?: AstNode[] }).consequent ?? [];
|
|
1414
|
+
for (const child of consequent) walkStatement(child, visit);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function functionParams(fn: AstNode): AstNode[] {
|
|
1420
|
+
return (fn as AstNode & { params?: AstNode[] }).params ?? [];
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
function paramIdentifierName(param: AstNode): string | undefined {
|
|
1424
|
+
if (param.type === "Identifier") return identifierName(param);
|
|
1425
|
+
if (param.type === "AssignmentPattern" || param.type === "RestElement") {
|
|
1426
|
+
const inner =
|
|
1427
|
+
(param as AstNode & { left?: AstNode }).left ??
|
|
1428
|
+
(param as AstNode & { argument?: AstNode }).argument;
|
|
1429
|
+
return identifierName(inner);
|
|
1430
|
+
}
|
|
1431
|
+
return undefined;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function isFunctionNode(node: AstNode): boolean {
|
|
1435
|
+
return (
|
|
1436
|
+
node.type === "ArrowFunctionExpression" ||
|
|
1437
|
+
node.type === "FunctionExpression" ||
|
|
1438
|
+
node.type === "FunctionDeclaration"
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function unwrapValue(node: AstNode | undefined): AstNode | undefined {
|
|
1443
|
+
if (!node) return undefined;
|
|
1444
|
+
return unwrapValueNode(node);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function unwrapValueNode(node: AstNode | undefined): AstNode | undefined {
|
|
1448
|
+
if (!node) return undefined;
|
|
1449
|
+
let current = node;
|
|
1450
|
+
for (let i = 0; i < 8; i++) {
|
|
1451
|
+
if (!VALUE_WRAPPERS.has(current.type)) break;
|
|
1452
|
+
const next =
|
|
1453
|
+
(current as AstNode & { expression?: AstNode }).expression ??
|
|
1454
|
+
(current as AstNode & { argument?: AstNode }).argument;
|
|
1455
|
+
if (!next) break;
|
|
1456
|
+
current = next;
|
|
1457
|
+
}
|
|
1458
|
+
return current;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function isAstNode(value: unknown): value is AstNode {
|
|
1462
|
+
return typeof value === "object" && value !== null && typeof (value as AstNode).type === "string";
|
|
1463
|
+
}
|