Skip to content

Commit cbc00f3

Browse files
committed
feat(lint): read every entrance the stack declares (A′); stand down at the runtime gate (S)
Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <noreply@anthropic.com>
1 parent fab45fc commit cbc00f3

4 files changed

Lines changed: 401 additions & 23 deletions

File tree

‎packages/lint/src/flow-cel-root-scope.ts‎

Lines changed: 217 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@
4242
* - **record fields**: the trigger record's fields are flattened to top-level
4343
* names. Every field of EVERY object the stack declares counts, with the
4444
* registry-injected columns — not only the trigger object's — because the run
45-
* flattens whichever record its entrance hands it: a flow action passes the
46-
* action's own record to any flow it starts, a `subflow` child inherits its
47-
* parent's record, and a `map` child gets each record item. A field the stack
48-
* declares anywhere is therefore a root this door cannot prove unbound.
45+
* flattens whichever record its entrance hands it (the entrances below). A
46+
* field the stack declares anywhere is therefore a root this door cannot
47+
* prove unbound. An object-less action that launches the flow hands an empty
48+
* record carrying at most the row `id` it was given, so `id` is bound there.
4949
*
5050
* ## When the door cannot prove a root unbound, it stands down
5151
*
@@ -62,24 +62,68 @@
6262
* - a `screen` with no input contract — an `object-form` screen, or one that
6363
* declares no fields — because `refuseInvalidScreenInput` checks a resume bag
6464
* only against a declared field list, so a contract-less screen folds any bag;
65-
* - a trigger object the stack does not declare (a platform object, another
66-
* package's): its row's keys are not in hand.
65+
* - an ENTRANCE that hands the flow a record whose keys are not in hand
66+
* ({@link flowCelEntrances}, the rule below).
6767
*
68-
* ⚠️ The boundary that remains: a record of an object this stack does not
69-
* declare, handed in by a flow action, a parent `subflow` or a `map` item, can
70-
* carry a key the reader has never seen. Measured over the example apps and
71-
* hotcrm, no flow reads a bare root that is not its own binding, an engine root
72-
* or a field of its trigger object.
68+
* ## The rule for records: every entrance the stack declares is read
69+
*
70+
* The run flattens whichever record its entrance hands it (`seedRunVariables`
71+
* reads `context.record` and nothing else), so the record half of the bound set
72+
* holds only while every entrance that can hand this flow a record names an
73+
* object the stack declares. {@link flowCelEntrances} reads each entrance from
74+
* the stack's own declarations and opens the flow when one does not:
75+
*
76+
* - **a record trigger** — the start node's `config.objectName` (the trigger
77+
* object a record-change run's row belongs to) names an undeclared object;
78+
* - **a time-relative sweep** — the start node's `config.timeRelative.object`
79+
* (`TimeRelativeTriggerSchema`; its own key, independent of `objectName`, and
80+
* the object whose rows the sweep hands each run) is undeclared;
81+
* - **the inbound hook** — the flow's trigger kind is `api`
82+
* (`resolveFlowTriggerKind`: `type: 'api'` or `config.triggerType: 'api'`), so
83+
* the hook hands the request body in as the record (`trigger-api`), keys and
84+
* all;
85+
* - **an action** — an `ActionSchema` entry with `type: 'flow'` and
86+
* `target: <this flow>` in `actions[]` (its `objectName`) or in
87+
* `objects[].actions[]` (its `objectName`, else the owning object) names an
88+
* undeclared object: `dispatchFlowAction` hands the action's own record to
89+
* whatever flow it targets;
90+
* - **a `map` node** — `MapConfigSchema`'s `flowName` names this flow and its
91+
* `itemObject` is undeclared or absent: each id-bearing item becomes the
92+
* child's record, and with no `itemObject` the items' object is not in hand;
93+
* - **a parent that is itself open** — a `subflow` node (`SubflowConfigSchema`'s
94+
* `flowName`) or a `map` node names this flow inside a flow that is open: the
95+
* child spreads its parent's context, record included. Read to a fixpoint.
96+
*
97+
* The API run door hands no record (measured): both REST trigger routes and the
98+
* declarative endpoint build their context with `buildAutomationContext`, which
99+
* carries `params` and `recordId` but no `record`, and the engine never loads a
100+
* record from `object` + `recordId`. A schedule run hands none either.
101+
*
102+
* ## The runtime publish gate stands down (#22636 is the carrier)
103+
*
104+
* The same pass runs at the runtime publish gate on a flow write, and there the
105+
* per-write snapshot carries the written flow alone beside `objects` /
106+
* `permissions` / `books` / `datasets` (`buildRuntimeWriteSnapshotSet`): no
107+
* action and no other flow, so no entrance is visible and a parent's `subflow` or
108+
* `map` node could hand this flow any record. A false 422 on the only door a
109+
* Studio tenant has is worse than the gap, so this one judgment stands down there
110+
* ({@link perWriteSnapshotEntrance}); every other expression verdict at that door
111+
* is unchanged, and `objectstack validate` judges under the rule above. #22636
112+
* widens that snapshot to carry flows and actions; when it lands, this
113+
* stand-down goes.
73114
*/
74115

75116
import { firstUndeclaredReference, parseCelToAst, SCOPE_ROOTS } from '@objectstack/formula';
76117
import {
77118
APPROVAL_NODE_TYPE,
78119
APPROVAL_REVISE_NODE_TYPE,
120+
collectFlowGraphs,
79121
LOOP_NODE_TYPE,
80122
PARALLEL_NODE_TYPE,
123+
resolveFlowTriggerKind,
81124
TRY_CATCH_NODE_TYPE,
82125
} from '@objectstack/spec/automation';
126+
import type { FlowEdgeParsed, FlowNodeParsed } from '@objectstack/spec/automation';
83127

84128
import { collectFlowVariableNames, type FlowGraphLike, type FlowVariableHost } from './flow-variable-scope.js';
85129
import { nearestName, recordsOf } from './object-graph.js';
@@ -167,24 +211,42 @@ export interface UnboundFlowCelRoot {
167211
readonly members: readonly string[];
168212
}
169213

214+
/**
215+
* What the flow's entrances contribute to its scope ({@link flowCelEntrances}):
216+
* the reason one of them hands it a record whose keys are not in hand, and any
217+
* name an entrance binds that no object field covers.
218+
*/
219+
export interface FlowCelEntrance {
220+
/** Set ⇒ the flow is not judged; names the entrance. */
221+
readonly openedBy?: string;
222+
/** Names an entrance binds beyond the objects' fields (an object-less action's `id`). */
223+
readonly bound?: readonly string[];
224+
}
225+
170226
/**
171227
* The bound set of one flow, or the reason it cannot be proved.
172228
*
173229
* @param graphs every graph of the flow (`collectFlowGraphs`), so a region's
174230
* nodes count — one variable map serves the whole run.
175-
* @param triggerObject the start node's `objectName`, when it names one.
231+
* @param triggerObject the start node's `objectName`, when it names one — its
232+
* fields lead the did-you-mean order.
176233
* @param fieldIndex object name → its field names, injected columns included.
234+
* @param entrance what the flow's entrances contribute: from
235+
* {@link flowCelEntrances} at the build door, {@link perWriteSnapshotEntrance}
236+
* at the runtime publish gate. Its reason takes precedence over a node's.
177237
*/
178238
export function flowCelRootScope(
179239
flow: FlowCelRootHost,
180240
graphs: readonly FlowGraphLike[],
181241
triggerObject: string | undefined,
182242
fieldIndex: ReadonlyMap<string, readonly string[]>,
243+
entrance: FlowCelEntrance = {},
183244
): FlowCelRootScope {
184245
const bound = new Set<string>(triggerObject !== undefined ? fieldIndex.get(triggerObject) ?? [] : []);
185246
for (const name of collectFlowVariableNames(flow, graphs)) bound.add(name);
186247
for (const name of ENGINE_BOUND_ROOTS) bound.add(name);
187-
let openedBy: string | undefined;
248+
for (const name of entrance.bound ?? []) bound.add(name);
249+
let openedBy: string | undefined = entrance.openedBy;
188250
const open = (reason: string): void => { openedBy ??= reason; };
189251

190252
for (const graph of graphs) {
@@ -216,14 +278,153 @@ export function flowCelRootScope(
216278
}
217279
}
218280

219-
if (triggerObject !== undefined && !fieldIndex.has(triggerObject)) {
220-
open(`trigger object '${triggerObject}' is not declared in this stack, so its fields are not in hand`);
221-
}
222281
for (const names of fieldIndex.values()) for (const name of names) bound.add(name);
223282

224283
return openedBy === undefined ? { bound, provable: true } : { bound, provable: false, openedBy };
225284
}
226285

286+
/** The stack slice {@link flowCelEntrances} reads. */
287+
export interface FlowCelEntranceHost {
288+
readonly flows?: unknown;
289+
readonly actions?: unknown;
290+
readonly objects?: unknown;
291+
}
292+
293+
function configOf(node: AnyRec): AnyRec {
294+
return node.config && typeof node.config === 'object' && !Array.isArray(node.config) ? (node.config as AnyRec) : {};
295+
}
296+
297+
function nameOf(value: unknown): string | undefined {
298+
return typeof value === 'string' && value ? value : undefined;
299+
}
300+
301+
/**
302+
* Every flow of the stack whose entrances open it, or bind a name beyond the
303+
* objects' fields — read from the stack's own declarations (the module's rule
304+
* for records). A flow absent from the map is judged as its own text reads.
305+
*
306+
* Built once per stack, because an entrance lives OUTSIDE the flow it feeds (an
307+
* action, another flow's `subflow` / `map` node) and a parent's openness passes
308+
* to its children: the parent edges are read to a fixpoint. A child named by a
309+
* node but not declared in this stack is another package's flow and is not
310+
* judged here.
311+
*
312+
* @param fieldIndex object name → field names; an object is "declared" when it
313+
* is a key here, the same universe the bound set reads.
314+
*/
315+
export function flowCelEntrances(
316+
stack: FlowCelEntranceHost,
317+
fieldIndex: ReadonlyMap<string, readonly string[]>,
318+
): ReadonlyMap<string, FlowCelEntrance> {
319+
const flows = recordsOf(stack.flows);
320+
const flowNames = new Set(flows.map((flow) => nameOf(flow.name)).filter((name): name is string => !!name));
321+
const openedBy = new Map<string, string>();
322+
const bound = new Map<string, string[]>();
323+
const open = (flow: string, reason: string): void => {
324+
if (flowNames.has(flow) && !openedBy.has(flow)) openedBy.set(flow, reason);
325+
};
326+
const undeclared = (object: string): boolean => !fieldIndex.has(object);
327+
328+
// The flow's own trigger: a record trigger, a time-relative sweep, the inbound hook.
329+
for (const flow of flows) {
330+
const name = nameOf(flow.name);
331+
if (!name) continue;
332+
const start = recordsOf(flow.nodes).find((node) => node.type === 'start');
333+
const config = start ? configOf(start) : {};
334+
const triggerObject = nameOf(config.objectName);
335+
if (triggerObject && undeclared(triggerObject)) {
336+
open(name, `its trigger object '${triggerObject}' (start config.objectName) is not declared in this stack, so the record's keys are not in hand`);
337+
}
338+
const sweep = config.timeRelative && typeof config.timeRelative === 'object' ? (config.timeRelative as AnyRec) : undefined;
339+
const sweepObject = sweep ? nameOf(sweep.object) : undefined;
340+
if (sweepObject && undeclared(sweepObject)) {
341+
open(name, `its time-relative sweep's object '${sweepObject}' (start config.timeRelative.object) is not declared in this stack, so the record's keys are not in hand`);
342+
}
343+
if (resolveFlowTriggerKind(flow) === 'api') {
344+
open(name, 'its trigger kind is api, so the inbound hook hands the request body in as the record, whatever its keys');
345+
}
346+
}
347+
348+
// Actions that launch a flow: `actions[]`, and each object's own `actions[]`.
349+
const launches: Array<{ action: AnyRec; object: string | undefined }> = [];
350+
for (const action of recordsOf(stack.actions)) launches.push({ action, object: nameOf(action.objectName) });
351+
for (const object of recordsOf(stack.objects)) {
352+
for (const action of recordsOf(object.actions)) {
353+
launches.push({ action, object: nameOf(action.objectName) ?? nameOf(object.name) });
354+
}
355+
}
356+
for (const { action, object } of launches) {
357+
const target = action.type === 'flow' ? nameOf(action.target) : undefined;
358+
if (!target || !flowNames.has(target)) continue;
359+
if (object === undefined) {
360+
// Object-less: the action hands an empty record, carrying at most the row id it was given.
361+
bound.set(target, [...(bound.get(target) ?? []), 'id']);
362+
} else if (undeclared(object)) {
363+
open(target, `action '${nameOf(action.name) ?? '?'}' launches it on '${object}' (the action's objectName), which this stack does not declare`);
364+
}
365+
}
366+
367+
// Parent edges: a `subflow` or `map` node naming a flow of this stack.
368+
const edges: Array<{ parent: string; child: string; kind: 'subflow' | 'map'; node: string }> = [];
369+
for (const flow of flows) {
370+
const parent = nameOf(flow.name);
371+
if (!parent) continue;
372+
const graphs = collectFlowGraphs({
373+
...flow,
374+
nodes: recordsOf(flow.nodes) as unknown as FlowNodeParsed[],
375+
edges: recordsOf(flow.edges) as unknown as FlowEdgeParsed[],
376+
});
377+
for (const graph of graphs) {
378+
for (const node of recordsOf(graph.nodes)) {
379+
if (node.type !== 'subflow' && node.type !== 'map') continue;
380+
const config = configOf(node);
381+
const child = nameOf(config.flowName);
382+
if (!child || !flowNames.has(child)) continue;
383+
const nodeId = nameOf(node.id) ?? '?';
384+
edges.push({ parent, child, kind: node.type, node: nodeId });
385+
if (node.type === 'map') {
386+
const itemObject = nameOf(config.itemObject);
387+
if (!itemObject) {
388+
open(child, `map node '${nodeId}' in flow '${parent}' feeds it items and declares no config.itemObject, so the items' object is not in hand`);
389+
} else if (undeclared(itemObject)) {
390+
open(child, `map node '${nodeId}' in flow '${parent}' feeds it items of '${itemObject}' (config.itemObject), which this stack does not declare`);
391+
}
392+
}
393+
}
394+
}
395+
}
396+
for (let changed = true; changed;) {
397+
changed = false;
398+
for (const edge of edges) {
399+
if (!openedBy.has(edge.parent) || openedBy.has(edge.child)) continue;
400+
open(edge.child, `${edge.kind} node '${edge.node}' in flow '${edge.parent}' hands it that flow's record, and '${edge.parent}' is itself open`);
401+
changed = true;
402+
}
403+
}
404+
405+
const out = new Map<string, FlowCelEntrance>();
406+
for (const name of flowNames) {
407+
const reason = openedBy.get(name);
408+
const names = bound.get(name);
409+
if (reason === undefined && names === undefined) continue;
410+
out.set(name, { ...(reason !== undefined ? { openedBy: reason } : {}), ...(names !== undefined ? { bound: names } : {}) });
411+
}
412+
return out;
413+
}
414+
415+
/**
416+
* The runtime publish gate's entrance: none visible. Its per-write snapshot
417+
* carries the written flow alone (module note), so this one judgment stands down
418+
* there until #22636 widens the snapshot.
419+
*/
420+
export function perWriteSnapshotEntrance(): FlowCelEntrance {
421+
return {
422+
openedBy:
423+
'the runtime publish gate judges a flow write against a per-write snapshot that carries no action and no '
424+
+ 'other flow, so no entrance that can hand this flow a record is visible',
425+
};
426+
}
427+
227428
/** cel-js's comprehension macros: the receiver calls that bind their first argument in the rest. */
228429
const COMPREHENSION_MACROS: ReadonlySet<string> = new Set(['all', 'exists', 'exists_one', 'map', 'filter']);
229430

0 commit comments

Comments
 (0)