@flowget/graph-validation 0.1.0 → 0.2.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/README.md CHANGED
@@ -25,6 +25,13 @@ What it does:
25
25
  `??` fallback chains). ReDoS-safe on untrusted input.
26
26
  - **Reference existence + reachability** — every `{{ steps.<id>.output.<key> }}`
27
27
  token must name a real upstream node and a declared output.
28
+ - **Nested fields via dotted keys** — a `.` in an `OutputField` / trigger-payload
29
+ `key` is the path separator (matching the template grammar, which has no
30
+ bracket form). A flat list of `payment.id` / `payment.amount` reconstructs a
31
+ `payment` object so `{{ trigger.payment.amount }}` resolves at leaf
32
+ granularity. An undeclared nested child is an advisory **warning** (the
33
+ runtime payload may still carry it); a key declared as both a scalar and an
34
+ object parent is a hard error.
28
35
  - **Coarse value-type lattice** — `isAssignable(producer, consumer)` over the
29
36
  `FieldValueType` set; permissive on `unknown` / `object` / `array` so a
30
37
  "valid" verdict never lies (zero false positives).
@@ -32,6 +39,10 @@ What it does:
32
39
  `min` / `max` / `pattern`.
33
40
  - **`validateGraphStatic(graph, catalog)`** — the aggregator, returning a flat
34
41
  `ValidationIssue[]`.
42
+ - **`validateTriggerInputs(graph, catalog, input)`** — a run-input gate: flags
43
+ every `{{ trigger.<path> }}` the graph references that the run input (the
44
+ builder's test input, or the worker's real event payload) does not provide,
45
+ path-aware and false-positive-free.
35
46
 
36
47
  ## Install
37
48
 
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Dotted-key-as-path resolution for `OutputField` / `triggerPayload` keys.
3
+ *
4
+ * The template grammar uses `.` as its ONLY path separator — there is no
5
+ * `{{ trigger["payment.id"] }}` bracket form (see `template-ast.ts`'s
6
+ * `PATH_RE`). So a runtime object key that literally contains a `.` is
7
+ * unreachable through any template, and the only sound reading of a `.` in a
8
+ * declared field key is "nested path". This module treats it as such: a flat
9
+ * list like
10
+ *
11
+ * [ { key: "payment.id", type: "string" },
12
+ * { key: "payment.amount", type: "number" } ]
13
+ *
14
+ * reconstructs the tree
15
+ *
16
+ * payment (implied object)
17
+ * ├─ id : string
18
+ * └─ amount : number
19
+ *
20
+ * so `{{ trigger.payment.amount }}` resolves against it. This is a TOTAL,
21
+ * collision-free interpretation, not a heuristic — nothing legitimate is lost,
22
+ * so migration is zero.
23
+ *
24
+ * ## Resolution posture (zero false positives)
25
+ *
26
+ * - **Known path** — a reference that names a declared leaf OR a prefix of
27
+ * one (an intermediate object) resolves cleanly. No issue.
28
+ * - **Unknown top-level segment** — the top-level declared key set is the
29
+ * node's authoritative contract; an unknown top-level key is a hard error
30
+ * (unchanged pre-nesting behavior).
31
+ * - **Undeclared child of a known enumerated parent** — a WARNING, not an
32
+ * error: the runtime payload may legitimately carry a child the author did
33
+ * not enumerate, so blocking it could reject a run that would succeed.
34
+ * - **Drill-in past a leaf** (bare `object` / scalar / wildcard with no
35
+ * enumerated children) — permissive, no issue (unchanged).
36
+ * - **Contradiction** — a key that is both a concrete-scalar leaf and a
37
+ * path prefix is an authoring error, surfaced at the declaration.
38
+ */
39
+ import type { FieldValueType, OutputField } from "@flowget/types";
40
+ /** One node in a declared-field prefix tree. */
41
+ export interface FieldTreeNode {
42
+ /**
43
+ * The value-type declared for the field at EXACTLY this path, when a field
44
+ * key terminates here (`undefined` for a purely-implied intermediate node
45
+ * that only appears as a prefix of deeper dotted keys).
46
+ */
47
+ declaredType?: FieldValueType;
48
+ /** Child segments — non-empty iff this key appears as a non-terminal path prefix. */
49
+ children: Map<string, FieldTreeNode>;
50
+ }
51
+ /** A declared-field prefix tree plus the contradictions found building it. */
52
+ export interface FieldTree {
53
+ /** Top-level segment → node. */
54
+ root: Map<string, FieldTreeNode>;
55
+ /** Keys that are both a concrete-scalar leaf and a nested-object parent. */
56
+ contradictions: FieldPathConflict[];
57
+ }
58
+ /** A single declared key used both as a concrete-scalar leaf and a path prefix. */
59
+ export interface FieldPathConflict {
60
+ /** The dotted path of the conflicting node (e.g. `"payment"`). */
61
+ path: string;
62
+ /** The concrete-scalar type it was declared as. */
63
+ scalarType: FieldValueType;
64
+ }
65
+ /** How a reference path resolves against a {@link FieldTree}. */
66
+ export type PathResolution =
67
+ /** No declared fields at all → nothing to check (permissive). */
68
+ {
69
+ kind: "no-declarations";
70
+ }
71
+ /** Names a declared leaf or a prefix of one → valid. */
72
+ | {
73
+ kind: "known";
74
+ }
75
+ /** Drills past a leaf with no enumerated children → permissive (unchanged). */
76
+ | {
77
+ kind: "drill-through-leaf";
78
+ }
79
+ /** Unknown top-level segment against a non-empty declared set → error. */
80
+ | {
81
+ kind: "unknown-root";
82
+ rootKey: string;
83
+ }
84
+ /** Undeclared child of a known enumerated parent → warning. */
85
+ | {
86
+ kind: "undeclared-child";
87
+ parentPath: string;
88
+ childKey: string;
89
+ declaredSiblings: string[];
90
+ };
91
+ /**
92
+ * Build the prefix tree for one flat `OutputField[]` list, splitting each key
93
+ * on `.`. Non-terminal segments imply an intermediate object; the terminal
94
+ * segment carries the declared `type`.
95
+ *
96
+ * Contradictions (a concrete-scalar leaf that also has children — either order
97
+ * of declaration) are collected, deduplicated by path. A redundant-consistent
98
+ * pair (`payment: object` + `payment.id`) is NOT a contradiction: the explicit
99
+ * `object` parent is legal and keeps carrying its own group-level metadata.
100
+ */
101
+ export declare function buildFieldTree(fields: readonly OutputField[]): FieldTree;
102
+ /**
103
+ * Resolve a reference's path segments (those AFTER the `trigger` /
104
+ * `steps.<id>.output` prefix) against a declared-field tree.
105
+ *
106
+ * `pathSegments` is the drill-in path, e.g. `["payment", "amount"]` for
107
+ * `{{ trigger.payment.amount }}`. An empty `pathSegments` (a bare
108
+ * `{{ trigger }}`) resolves as `known` — the namespace itself exists.
109
+ */
110
+ export declare function resolvePath(tree: FieldTree, pathSegments: readonly string[]): PathResolution;
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Dotted-key-as-path resolution for `OutputField` / `triggerPayload` keys.
3
+ *
4
+ * The template grammar uses `.` as its ONLY path separator — there is no
5
+ * `{{ trigger["payment.id"] }}` bracket form (see `template-ast.ts`'s
6
+ * `PATH_RE`). So a runtime object key that literally contains a `.` is
7
+ * unreachable through any template, and the only sound reading of a `.` in a
8
+ * declared field key is "nested path". This module treats it as such: a flat
9
+ * list like
10
+ *
11
+ * [ { key: "payment.id", type: "string" },
12
+ * { key: "payment.amount", type: "number" } ]
13
+ *
14
+ * reconstructs the tree
15
+ *
16
+ * payment (implied object)
17
+ * ├─ id : string
18
+ * └─ amount : number
19
+ *
20
+ * so `{{ trigger.payment.amount }}` resolves against it. This is a TOTAL,
21
+ * collision-free interpretation, not a heuristic — nothing legitimate is lost,
22
+ * so migration is zero.
23
+ *
24
+ * ## Resolution posture (zero false positives)
25
+ *
26
+ * - **Known path** — a reference that names a declared leaf OR a prefix of
27
+ * one (an intermediate object) resolves cleanly. No issue.
28
+ * - **Unknown top-level segment** — the top-level declared key set is the
29
+ * node's authoritative contract; an unknown top-level key is a hard error
30
+ * (unchanged pre-nesting behavior).
31
+ * - **Undeclared child of a known enumerated parent** — a WARNING, not an
32
+ * error: the runtime payload may legitimately carry a child the author did
33
+ * not enumerate, so blocking it could reject a run that would succeed.
34
+ * - **Drill-in past a leaf** (bare `object` / scalar / wildcard with no
35
+ * enumerated children) — permissive, no issue (unchanged).
36
+ * - **Contradiction** — a key that is both a concrete-scalar leaf and a
37
+ * path prefix is an authoring error, surfaced at the declaration.
38
+ */
39
+ import { isConcreteScalar } from "./value-type.js";
40
+ /**
41
+ * Build the prefix tree for one flat `OutputField[]` list, splitting each key
42
+ * on `.`. Non-terminal segments imply an intermediate object; the terminal
43
+ * segment carries the declared `type`.
44
+ *
45
+ * Contradictions (a concrete-scalar leaf that also has children — either order
46
+ * of declaration) are collected, deduplicated by path. A redundant-consistent
47
+ * pair (`payment: object` + `payment.id`) is NOT a contradiction: the explicit
48
+ * `object` parent is legal and keeps carrying its own group-level metadata.
49
+ */
50
+ export function buildFieldTree(fields) {
51
+ const root = new Map();
52
+ const conflicts = new Map();
53
+ for (const field of fields) {
54
+ const segments = field.key.split(".");
55
+ let level = root;
56
+ const pathSoFar = [];
57
+ for (let i = 0; i < segments.length; i++) {
58
+ const seg = segments[i];
59
+ if (seg === undefined)
60
+ break;
61
+ pathSoFar.push(seg);
62
+ let node = level.get(seg);
63
+ if (node === undefined) {
64
+ node = { children: new Map() };
65
+ level.set(seg, node);
66
+ }
67
+ const isTerminal = i === segments.length - 1;
68
+ if (isTerminal) {
69
+ // A concrete-scalar terminal on a node that already has children is a
70
+ // conflict (the dotted siblings made it an object).
71
+ if (node.children.size > 0 && isConcreteScalar(field.type)) {
72
+ conflicts.set(pathSoFar.join("."), field.type);
73
+ }
74
+ // Prefer an explicit container declaration over an implied one; keep a
75
+ // scalar only if nothing container-shaped was declared here.
76
+ if (node.declaredType === undefined || isConcreteScalar(node.declaredType)) {
77
+ node.declaredType = field.type;
78
+ }
79
+ }
80
+ else {
81
+ // Intermediate segment: if this node was declared a concrete scalar
82
+ // leaf, giving it children is a conflict.
83
+ if (node.declaredType !== undefined &&
84
+ isConcreteScalar(node.declaredType)) {
85
+ conflicts.set(pathSoFar.join("."), node.declaredType);
86
+ }
87
+ level = node.children;
88
+ }
89
+ }
90
+ }
91
+ const contradictions = [];
92
+ for (const [path, scalarType] of conflicts) {
93
+ contradictions.push({ path, scalarType });
94
+ }
95
+ return { root, contradictions };
96
+ }
97
+ /**
98
+ * Resolve a reference's path segments (those AFTER the `trigger` /
99
+ * `steps.<id>.output` prefix) against a declared-field tree.
100
+ *
101
+ * `pathSegments` is the drill-in path, e.g. `["payment", "amount"]` for
102
+ * `{{ trigger.payment.amount }}`. An empty `pathSegments` (a bare
103
+ * `{{ trigger }}`) resolves as `known` — the namespace itself exists.
104
+ */
105
+ export function resolvePath(tree, pathSegments) {
106
+ if (tree.root.size === 0)
107
+ return { kind: "no-declarations" };
108
+ if (pathSegments.length === 0)
109
+ return { kind: "known" };
110
+ // A malformed drill-in with an empty segment (a trailing/interior dot, e.g.
111
+ // `{{ trigger. }}`) is permissive — the grammar admits it, but it names no
112
+ // declared key, so flagging it would be a false positive (unchanged behavior).
113
+ if (pathSegments.some((s) => s === ""))
114
+ return { kind: "known" };
115
+ let level = tree.root;
116
+ let current;
117
+ for (let i = 0; i < pathSegments.length; i++) {
118
+ const seg = pathSegments[i];
119
+ if (seg === undefined)
120
+ return { kind: "known" };
121
+ const next = level.get(seg);
122
+ if (next === undefined) {
123
+ // Segment not enumerated at this level.
124
+ if (i === 0)
125
+ return { kind: "unknown-root", rootKey: seg };
126
+ // `current` is the resolved parent (a known path). If it enumerates
127
+ // children, this is an undeclared sibling → warning; otherwise it's a
128
+ // bare leaf (object/scalar/wildcard) → permissive drill-through.
129
+ if (current !== undefined && current.children.size > 0) {
130
+ return {
131
+ kind: "undeclared-child",
132
+ parentPath: pathSegments.slice(0, i).join("."),
133
+ childKey: seg,
134
+ declaredSiblings: [...current.children.keys()],
135
+ };
136
+ }
137
+ return { kind: "drill-through-leaf" };
138
+ }
139
+ current = next;
140
+ level = next.children;
141
+ }
142
+ // Consumed every segment → an exact declared path (leaf or prefix).
143
+ return { kind: "known" };
144
+ }
package/dist/index.d.ts CHANGED
@@ -13,6 +13,8 @@
13
13
  * classifier) can compose them directly.
14
14
  */
15
15
  export { validateGraphStatic } from "./validate-graph.js";
16
+ export { collectReferencedTriggerPaths, validateTriggerInputs, } from "./trigger-inputs.js";
17
+ export { buildFieldTree, type FieldPathConflict, type FieldTree, type FieldTreeNode, type PathResolution, resolvePath, } from "./field-tree.js";
16
18
  export { ISSUE_CODES, type IssueCode, type ValidationIssue, type ValidationSeverity, } from "./issues.js";
17
19
  export { type CatalogIndex, type CatalogNode, indexCatalog, resolveNodeOutputFields, resolveNodeTriggerPayload, } from "./catalog.js";
18
20
  export { classifyPathKind, findMalformedTemplateTokens, FORBIDDEN_PATH_TOKENS, hasForbiddenPathToken, hasTemplateExpression, isReservedNamespace, isWholeStringTemplate, MAX_TEMPLATE_LENGTH, parseTemplate, parseTemplateExpression, RESERVED_NAMESPACES, templateExpressions, } from "./template-ast.js";
package/dist/index.js CHANGED
@@ -14,6 +14,10 @@
14
14
  */
15
15
  // ── Primary aggregator ──────────────────────────────────────
16
16
  export { validateGraphStatic } from "./validate-graph.js";
17
+ // ── Run-input gate (builder RunTestDialog + worker pre-flight) ─
18
+ export { collectReferencedTriggerPaths, validateTriggerInputs, } from "./trigger-inputs.js";
19
+ // ── Dotted-key-as-path resolution (nested OutputField / payload) ─
20
+ export { buildFieldTree, resolvePath, } from "./field-tree.js";
17
21
  // ── Public output contract ──────────────────────────────────
18
22
  export { ISSUE_CODES, } from "./issues.js";
19
23
  // ── Input interface (catalog + output resolution) ───────────
package/dist/issues.d.ts CHANGED
@@ -54,6 +54,27 @@ export declare const ISSUE_CODES: {
54
54
  readonly TEMPLATE_UNREACHABLE: "validation/template-unreachable";
55
55
  /** A template references an output/payload key the producer does not declare. */
56
56
  readonly TEMPLATE_UNKNOWN_OUTPUT: "validation/template-unknown-output";
57
+ /**
58
+ * WARNING (advisory, non-blocking). A template drills into a child of a
59
+ * KNOWN nested-object field whose child set is enumerated (via dotted
60
+ * sibling keys) but does not list this child. The runtime payload MAY still
61
+ * carry it, so this is a likely-typo hint, not a hard error — see the
62
+ * zero-false-positive posture for nested drill-in.
63
+ */
64
+ readonly TEMPLATE_UNDECLARED_CHILD: "validation/template-undeclared-child";
65
+ /**
66
+ * A single declared field key is BOTH a concrete scalar leaf AND a
67
+ * nested-object parent (a dotted sibling implies it holds children). E.g.
68
+ * `payment: string` alongside `payment.id: string`. Unresolvable — the
69
+ * dotted-key-as-path grammar cannot honor both. Reject at the declaration.
70
+ */
71
+ readonly FIELD_PATH_CONFLICT: "validation/field-path-conflict";
72
+ /**
73
+ * A `{{ trigger.<path> }}` the graph REFERENCES is absent from the run
74
+ * input (test input at builder Run, real event payload at worker pre-flight).
75
+ * The strict resolver throws `missing_path` on it at run time.
76
+ */
77
+ readonly RUN_INPUT_MISSING: "validation/run-input-missing";
57
78
  /** A template path uses a forbidden prototype-chain token. */
58
79
  readonly TEMPLATE_FORBIDDEN_TOKEN: "validation/template-forbidden-token";
59
80
  /** A whole-string template resolves to a value-type incompatible with the consumer field. */
package/dist/issues.js CHANGED
@@ -31,6 +31,27 @@ export const ISSUE_CODES = {
31
31
  TEMPLATE_UNREACHABLE: "validation/template-unreachable",
32
32
  /** A template references an output/payload key the producer does not declare. */
33
33
  TEMPLATE_UNKNOWN_OUTPUT: "validation/template-unknown-output",
34
+ /**
35
+ * WARNING (advisory, non-blocking). A template drills into a child of a
36
+ * KNOWN nested-object field whose child set is enumerated (via dotted
37
+ * sibling keys) but does not list this child. The runtime payload MAY still
38
+ * carry it, so this is a likely-typo hint, not a hard error — see the
39
+ * zero-false-positive posture for nested drill-in.
40
+ */
41
+ TEMPLATE_UNDECLARED_CHILD: "validation/template-undeclared-child",
42
+ /**
43
+ * A single declared field key is BOTH a concrete scalar leaf AND a
44
+ * nested-object parent (a dotted sibling implies it holds children). E.g.
45
+ * `payment: string` alongside `payment.id: string`. Unresolvable — the
46
+ * dotted-key-as-path grammar cannot honor both. Reject at the declaration.
47
+ */
48
+ FIELD_PATH_CONFLICT: "validation/field-path-conflict",
49
+ /**
50
+ * A `{{ trigger.<path> }}` the graph REFERENCES is absent from the run
51
+ * input (test input at builder Run, real event payload at worker pre-flight).
52
+ * The strict resolver throws `missing_path` on it at run time.
53
+ */
54
+ RUN_INPUT_MISSING: "validation/run-input-missing",
34
55
  /** A template path uses a forbidden prototype-chain token. */
35
56
  TEMPLATE_FORBIDDEN_TOKEN: "validation/template-forbidden-token",
36
57
  /** A whole-string template resolves to a value-type incompatible with the consumer field. */
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { resolveFieldValueType } from "@flowget/types";
27
27
  import { resolveNodeOutputFields, resolveNodeTriggerPayload, } from "./catalog.js";
28
+ import { buildFieldTree, resolvePath, } from "./field-tree.js";
28
29
  import { ISSUE_CODES } from "./issues.js";
29
30
  import { buildReversedAdjacency, collectUpstreamFrom } from "./reachability.js";
30
31
  import { hasForbiddenPathToken, hasTemplateExpression, isWholeStringTemplate, parseTemplate, templateExpressions, } from "./template-ast.js";
@@ -39,8 +40,13 @@ export function collectReferenceIssues(graph, index) {
39
40
  triggerInfo: computeTriggerInfo(graph, index),
40
41
  adjacency: buildReversedAdjacency(graph.edges),
41
42
  upstreamCache: new Map(),
43
+ outputTreeCache: new Map(),
42
44
  };
43
45
  const issues = [];
46
+ // 0. Declaration conflicts (dotted-key contradictions) — surfaced for EVERY
47
+ // node that declares them, independent of whether anything references the
48
+ // field, since a contradictory declaration cannot be resolved at all.
49
+ collectDeclarationConflicts(graph, ctx, issues);
44
50
  for (const node of graph.nodes) {
45
51
  const def = index.get(node.type);
46
52
  const opaque = new Set(def?.opaqueFields ?? []);
@@ -154,18 +160,19 @@ function validateOperandExistence(path, tokenRaw, site, ctx, upstream, issues) {
154
160
  issues.push(reachIssue);
155
161
  return;
156
162
  }
157
- // Declared-output check on the canonical envelope only.
158
- // - v2-step: `steps.<id>.output.<key>` (segments[2] === "output").
163
+ // Declared-output check on the canonical envelope only, resolving the WHOLE
164
+ // drill-in path (dotted output keys are nested paths — see `field-tree.ts`).
165
+ // - v2-step: `steps.<id>.output.<path…>` (segments[2] === "output").
159
166
  // `steps.<id>.decorators.…` and other shapes are permissive.
160
- // - v1-step: `<id>.<field>` maps to `output.<field>`.
167
+ // - v1-step: `<id>.<path…>` maps to `output.<path…>`.
161
168
  if (path.kind === "v2-step") {
162
169
  if (path.segments[2] === "output" && path.segments.length >= 4) {
163
- checkOutputKey(producerId, path.segments[3], tokenRaw, site, ctx, issues);
170
+ checkOutputPath(producerId, path.segments.slice(3), tokenRaw, site, ctx, issues);
164
171
  }
165
172
  return;
166
173
  }
167
174
  if (path.segments.length >= 2) {
168
- checkOutputKey(producerId, path.segments[1], tokenRaw, site, ctx, issues);
175
+ checkOutputPath(producerId, path.segments.slice(1), tokenRaw, site, ctx, issues);
169
176
  }
170
177
  }
171
178
  function checkProducerReachable(producerId, tokenRaw, site, ctx, upstream) {
@@ -189,9 +196,14 @@ function checkProducerReachable(producerId, tokenRaw, site, ctx, upstream) {
189
196
  }
190
197
  return null;
191
198
  }
192
- function checkOutputKey(producerId, key, tokenRaw, site, ctx, issues) {
193
- if (key === undefined || key === "")
194
- return;
199
+ /**
200
+ * Resolve a drill-in path against a producer's declared-output tree and emit
201
+ * the reference verdict: an unknown TOP-LEVEL output key is a hard error
202
+ * (unchanged pre-nesting behavior); an undeclared CHILD of a known
203
+ * nested-object output is an advisory warning (the value may still exist at
204
+ * run time); a known path / drill-through-a-leaf / no-declarations is silent.
205
+ */
206
+ function checkOutputPath(producerId, pathSegments, tokenRaw, site, ctx, issues) {
195
207
  const producer = ctx.nodeById.get(producerId);
196
208
  if (producer === undefined)
197
209
  return;
@@ -200,35 +212,96 @@ function checkOutputKey(producerId, key, tokenRaw, site, ctx, issues) {
200
212
  // step scope. Both permissive here.
201
213
  if (def === undefined || def.category === "decorator")
202
214
  return;
203
- const outputs = resolveNodeOutputFields(def, producer.data ?? {});
204
- if (outputs.length === 0)
205
- return; // undeclared/unknown outputs permissive
206
- if (!outputs.some((f) => f.key === key)) {
207
- issues.push({
208
- code: ISSUE_CODES.TEMPLATE_UNKNOWN_OUTPUT,
209
- severity: "error",
210
- nodeId: site.node.id,
211
- fieldKey: site.fieldKey,
212
- message: `Node "${site.node.id}" references "${tokenRaw}", but "${producerId}" declares no output "${key}".`,
213
- });
214
- }
215
+ const tree = getOutputTree(producerId, ctx);
216
+ const resolution = resolvePath(tree, pathSegments);
217
+ emitResolutionIssue(resolution, site, issues, (topKey) => `Node "${site.node.id}" references "${tokenRaw}", but "${producerId}" declares no output "${topKey}".`, (childPath, siblings) => `Node "${site.node.id}" references "${tokenRaw}", but "${producerId}" output declares no "${childPath}" (declared: ${siblings}). It may still resolve if the value carries it.`);
215
218
  }
216
219
  function validateTriggerKey(path, tokenRaw, site, ctx, issues) {
217
- const key = path.segments[1];
218
- if (key === undefined || key === "")
219
- return;
220
220
  const info = ctx.triggerInfo;
221
221
  // No trigger at all → the missing-trigger rule covers it; some trigger
222
222
  // payload unknown → permissive.
223
223
  if (!info.hasTrigger || !info.allKnown)
224
224
  return;
225
- if (!info.keys.has(key)) {
225
+ const resolution = resolvePath(info.tree, path.segments.slice(1));
226
+ emitResolutionIssue(resolution, site, issues, (topKey) => `Node "${site.node.id}" references "${tokenRaw}", but no trigger declares a payload field "${topKey}".`, (childPath, siblings) => `Node "${site.node.id}" references "${tokenRaw}", but no trigger payload declares "${childPath}" (declared: ${siblings}). It may still resolve if the event payload carries it.`);
227
+ }
228
+ /**
229
+ * Turn a {@link PathResolution} into an issue (or nothing). `unknown-root` →
230
+ * hard error via `unknownRootMessage`; `undeclared-child` → advisory warning
231
+ * via `undeclaredChildMessage`; everything else is silent.
232
+ */
233
+ function emitResolutionIssue(resolution, site, issues, unknownRootMessage, undeclaredChildMessage) {
234
+ if (resolution.kind === "unknown-root") {
226
235
  issues.push({
227
236
  code: ISSUE_CODES.TEMPLATE_UNKNOWN_OUTPUT,
228
237
  severity: "error",
229
238
  nodeId: site.node.id,
230
239
  fieldKey: site.fieldKey,
231
- message: `Node "${site.node.id}" references "${tokenRaw}", but no trigger declares a payload field "${key}".`,
240
+ message: unknownRootMessage(resolution.rootKey),
241
+ });
242
+ return;
243
+ }
244
+ if (resolution.kind === "undeclared-child") {
245
+ const childPath = `${resolution.parentPath}.${resolution.childKey}`;
246
+ const siblings = [...resolution.declaredSiblings].sort().join(", ");
247
+ issues.push({
248
+ code: ISSUE_CODES.TEMPLATE_UNDECLARED_CHILD,
249
+ severity: "warning",
250
+ nodeId: site.node.id,
251
+ fieldKey: site.fieldKey,
252
+ message: undeclaredChildMessage(childPath, siblings),
253
+ });
254
+ }
255
+ }
256
+ /** Get (building + caching once) a producer's declared-output prefix tree. */
257
+ function getOutputTree(producerId, ctx) {
258
+ let tree = ctx.outputTreeCache.get(producerId);
259
+ if (tree === undefined) {
260
+ tree = buildFieldTree(resolveProducerOutputs(producerId, ctx));
261
+ ctx.outputTreeCache.set(producerId, tree);
262
+ }
263
+ return tree;
264
+ }
265
+ /** A producer node's resolved output fields (`[]` for unknown / decorator). */
266
+ function resolveProducerOutputs(producerId, ctx) {
267
+ const producer = ctx.nodeById.get(producerId);
268
+ if (producer === undefined)
269
+ return [];
270
+ const def = ctx.index.get(producer.type);
271
+ if (def === undefined || def.category === "decorator")
272
+ return [];
273
+ return resolveNodeOutputFields(def, producer.data ?? {});
274
+ }
275
+ /**
276
+ * Emit a {@link ISSUE_CODES.FIELD_PATH_CONFLICT} error for every node whose
277
+ * declared output / trigger-payload keys contain a dotted-key contradiction (a
278
+ * key that is both a concrete scalar and a nested-object parent). Reported
279
+ * independent of any reference, because such a declaration cannot be resolved.
280
+ */
281
+ function collectDeclarationConflicts(graph, ctx, issues) {
282
+ for (const node of graph.nodes) {
283
+ const def = ctx.index.get(node.type);
284
+ if (def === undefined)
285
+ continue;
286
+ // Output surface — reuse the cached per-producer tree.
287
+ if (def.category !== "decorator") {
288
+ pushConflicts(getOutputTree(node.id, ctx), node.id, "output", issues);
289
+ }
290
+ // Trigger-payload surface — a distinct declared list from `output`.
291
+ if (def.category === "trigger") {
292
+ const payload = resolveNodeTriggerPayload(def, node.data ?? {});
293
+ pushConflicts(buildFieldTree(payload), node.id, "trigger payload", issues);
294
+ }
295
+ }
296
+ }
297
+ function pushConflicts(tree, nodeId, surface, issues) {
298
+ for (const conflict of tree.contradictions) {
299
+ issues.push({
300
+ code: ISSUE_CODES.FIELD_PATH_CONFLICT,
301
+ severity: "error",
302
+ nodeId,
303
+ fieldKey: conflict.path,
304
+ message: `Node "${nodeId}" declares ${surface} "${conflict.path}" as ${conflict.scalarType}, but a dotted sibling key uses it as a nested object — a field key cannot be both a ${conflict.scalarType} and an object.`,
232
305
  });
233
306
  }
234
307
  }
@@ -282,8 +355,8 @@ function getUpstream(nodeId, ctx) {
282
355
  function computeTriggerInfo(graph, index) {
283
356
  let hasTrigger = false;
284
357
  let allKnown = true;
285
- const keys = new Set();
286
- // `null` marks a key whose type is ambiguous across triggers.
358
+ const mergedFields = [];
359
+ // `null` marks a TOP-LEVEL key whose type is ambiguous across triggers.
287
360
  const keyTypes = new Map();
288
361
  for (const node of graph.nodes) {
289
362
  const def = index.get(node.type);
@@ -296,7 +369,11 @@ function computeTriggerInfo(graph, index) {
296
369
  continue;
297
370
  }
298
371
  for (const f of payload) {
299
- keys.add(f.key);
372
+ mergedFields.push(f);
373
+ // Only FLAT (dot-free) keys feed the whole-string type-compat lattice —
374
+ // nested-leaf type-compat is deferred (see `producerValueType`).
375
+ if (f.key.includes("."))
376
+ continue;
300
377
  if (!keyTypes.has(f.key))
301
378
  keyTypes.set(f.key, f.type);
302
379
  else if (keyTypes.get(f.key) !== f.type)
@@ -308,5 +385,12 @@ function computeTriggerInfo(graph, index) {
308
385
  if (v !== null)
309
386
  finalTypes.set(k, v);
310
387
  }
311
- return { hasTrigger, allKnown, keys, keyTypes: finalTypes };
388
+ // The merged tree is for reference resolution only; per-node contradictions
389
+ // are reported separately (see `collectDeclarationConflicts`).
390
+ return {
391
+ hasTrigger,
392
+ allKnown,
393
+ tree: buildFieldTree(mergedFields),
394
+ keyTypes: finalTypes,
395
+ };
312
396
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Run-input validation — the SHARED, path-aware "does the run input carry every
3
+ * `{{ trigger.<path> }}` the graph references?" gate.
4
+ *
5
+ * Both consumers feed the SAME graph + catalog + input into
6
+ * {@link validateTriggerInputs}, so their verdict is equal by construction:
7
+ *
8
+ * - the **builder** passes the `<RunTestDialog>` test-input JSON;
9
+ * - the **worker** passes the real event payload at pre-flight.
10
+ *
11
+ * Under the strict runtime resolver a `{{ trigger.<path> }}` whose value is
12
+ * absent from the payload throws a nonRetryable `missing_path` — so catching it
13
+ * here refuses the run up front instead of failing mid-execution.
14
+ *
15
+ * ## Zero false positives (matches the reference walker's discipline)
16
+ *
17
+ * - **Reference-driven** — only a field the graph actually references is
18
+ * checked. An unreferenced declared payload field is harmless and never
19
+ * flagged.
20
+ * - **Single-operand only** — a `{{ a ?? b }}` fallback chain RESCUES a
21
+ * missing operand at run time (the engine falls through), so only a lone
22
+ * `{{ trigger.<path> }}` operand can be "required".
23
+ * - **Opaque fields skipped** — never template-resolved by the engine.
24
+ * - **Forbidden tokens skipped** — a `__proto__` / `constructor` / `prototype`
25
+ * path is a separate graph error, not a missing-input.
26
+ * - **Own-key presence, path-aware** — presence is decided by `Object.hasOwn`
27
+ * at EACH segment of the dotted path (`payment.amount` checks
28
+ * `input.payment` then `.amount`). A provided-but-`null` value is present
29
+ * (not flagged); a path that cannot descend (a non-object mid-path) is
30
+ * missing, exactly as the resolver would throw.
31
+ *
32
+ * This is `input`-shape-agnostic: it never inspects declared payload types, so
33
+ * the identical function serves a seeded test object and a live event payload.
34
+ * (Leaf type-compat on nested paths is deferred — this gate is existence only.)
35
+ */
36
+ import type { WorkflowGraph } from "@flowget/types";
37
+ import { type CatalogNode } from "./catalog.js";
38
+ import { type ValidationIssue } from "./issues.js";
39
+ /**
40
+ * Flag every `{{ trigger.<path> }}` the graph references that the run `input`
41
+ * does not provide.
42
+ *
43
+ * @param graph The `@flowget/types` `WorkflowGraph`.
44
+ * @param catalog The node definitions (a `readonly CatalogNode[]`, the same
45
+ * structural subset {@link validateGraphStatic} accepts) — read
46
+ * only for each node's `opaqueFields`.
47
+ * @param input The run input: the builder's test-input object, or the
48
+ * worker's real event payload. A genuinely nested object
49
+ * (`{ payment: { amount } }`), never flat dotted keys.
50
+ * @returns One `error` per referenced-but-absent trigger path, ordered
51
+ * deterministically, with stable ids.
52
+ */
53
+ export declare function validateTriggerInputs(graph: WorkflowGraph, catalog: readonly CatalogNode[], input: Readonly<Record<string, unknown>>): ValidationIssue[];
54
+ /**
55
+ * The set of trigger-payload PATHS (dotted, e.g. `"payment.amount"`) the graph
56
+ * references via a single-operand `{{ trigger.<path> }}` template — the refs
57
+ * that genuinely throw `missing_path` at run time when the path is absent from
58
+ * the payload. This is the shared home for the walk the builder previously
59
+ * re-implemented; the worker consumes it too.
60
+ */
61
+ export declare function collectReferencedTriggerPaths(graph: WorkflowGraph, catalog: readonly CatalogNode[]): ReadonlySet<string>;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Run-input validation — the SHARED, path-aware "does the run input carry every
3
+ * `{{ trigger.<path> }}` the graph references?" gate.
4
+ *
5
+ * Both consumers feed the SAME graph + catalog + input into
6
+ * {@link validateTriggerInputs}, so their verdict is equal by construction:
7
+ *
8
+ * - the **builder** passes the `<RunTestDialog>` test-input JSON;
9
+ * - the **worker** passes the real event payload at pre-flight.
10
+ *
11
+ * Under the strict runtime resolver a `{{ trigger.<path> }}` whose value is
12
+ * absent from the payload throws a nonRetryable `missing_path` — so catching it
13
+ * here refuses the run up front instead of failing mid-execution.
14
+ *
15
+ * ## Zero false positives (matches the reference walker's discipline)
16
+ *
17
+ * - **Reference-driven** — only a field the graph actually references is
18
+ * checked. An unreferenced declared payload field is harmless and never
19
+ * flagged.
20
+ * - **Single-operand only** — a `{{ a ?? b }}` fallback chain RESCUES a
21
+ * missing operand at run time (the engine falls through), so only a lone
22
+ * `{{ trigger.<path> }}` operand can be "required".
23
+ * - **Opaque fields skipped** — never template-resolved by the engine.
24
+ * - **Forbidden tokens skipped** — a `__proto__` / `constructor` / `prototype`
25
+ * path is a separate graph error, not a missing-input.
26
+ * - **Own-key presence, path-aware** — presence is decided by `Object.hasOwn`
27
+ * at EACH segment of the dotted path (`payment.amount` checks
28
+ * `input.payment` then `.amount`). A provided-but-`null` value is present
29
+ * (not flagged); a path that cannot descend (a non-object mid-path) is
30
+ * missing, exactly as the resolver would throw.
31
+ *
32
+ * This is `input`-shape-agnostic: it never inspects declared payload types, so
33
+ * the identical function serves a seeded test object and a live event payload.
34
+ * (Leaf type-compat on nested paths is deferred — this gate is existence only.)
35
+ */
36
+ import { indexCatalog } from "./catalog.js";
37
+ import { finalizeIssues, ISSUE_CODES, } from "./issues.js";
38
+ import { hasForbiddenPathToken, hasTemplateExpression, parseTemplate, templateExpressions, } from "./template-ast.js";
39
+ /** Bound on nested-config recursion — mirrors the reference walker. */
40
+ const MAX_DATA_DEPTH = 64;
41
+ /**
42
+ * Flag every `{{ trigger.<path> }}` the graph references that the run `input`
43
+ * does not provide.
44
+ *
45
+ * @param graph The `@flowget/types` `WorkflowGraph`.
46
+ * @param catalog The node definitions (a `readonly CatalogNode[]`, the same
47
+ * structural subset {@link validateGraphStatic} accepts) — read
48
+ * only for each node's `opaqueFields`.
49
+ * @param input The run input: the builder's test-input object, or the
50
+ * worker's real event payload. A genuinely nested object
51
+ * (`{ payment: { amount } }`), never flat dotted keys.
52
+ * @returns One `error` per referenced-but-absent trigger path, ordered
53
+ * deterministically, with stable ids.
54
+ */
55
+ export function validateTriggerInputs(graph, catalog, input) {
56
+ const referenced = collectReferencedTriggerPaths(graph, catalog);
57
+ const raw = [];
58
+ // Sorted for a deterministic, stable issue order across runs.
59
+ for (const path of [...referenced].sort()) {
60
+ if (inputHasPath(input, path.split(".")))
61
+ continue;
62
+ raw.push({
63
+ code: ISSUE_CODES.RUN_INPUT_MISSING,
64
+ severity: "error",
65
+ fieldKey: path,
66
+ message: `This workflow references \`trigger.${path}\`, but the run input doesn't provide it.`,
67
+ });
68
+ }
69
+ return finalizeIssues(raw);
70
+ }
71
+ /**
72
+ * The set of trigger-payload PATHS (dotted, e.g. `"payment.amount"`) the graph
73
+ * references via a single-operand `{{ trigger.<path> }}` template — the refs
74
+ * that genuinely throw `missing_path` at run time when the path is absent from
75
+ * the payload. This is the shared home for the walk the builder previously
76
+ * re-implemented; the worker consumes it too.
77
+ */
78
+ export function collectReferencedTriggerPaths(graph, catalog) {
79
+ const index = indexCatalog(catalog);
80
+ const paths = new Set();
81
+ for (const node of graph.nodes) {
82
+ const def = index.get(node.type);
83
+ const opaque = new Set(def?.opaqueFields ?? []);
84
+ for (const [fieldKey, value] of Object.entries(node.data ?? {})) {
85
+ if (opaque.has(fieldKey))
86
+ continue;
87
+ collectFromValue(value, 0, paths);
88
+ }
89
+ }
90
+ return paths;
91
+ }
92
+ /** Recursively descend a config value, harvesting trigger paths at string leaves. */
93
+ function collectFromValue(value, depth, paths) {
94
+ if (typeof value === "string") {
95
+ collectFromString(value, paths);
96
+ return;
97
+ }
98
+ if (depth >= MAX_DATA_DEPTH)
99
+ return;
100
+ if (Array.isArray(value)) {
101
+ for (const item of value)
102
+ collectFromValue(item, depth + 1, paths);
103
+ return;
104
+ }
105
+ if (value !== null && typeof value === "object") {
106
+ for (const child of Object.values(value)) {
107
+ collectFromValue(child, depth + 1, paths);
108
+ }
109
+ }
110
+ }
111
+ /** Harvest single-operand `{{ trigger.<path> }}` dotted paths from one string. */
112
+ function collectFromString(raw, paths) {
113
+ const ast = parseTemplate(raw);
114
+ if (!hasTemplateExpression(ast))
115
+ return;
116
+ for (const { expression } of templateExpressions(ast)) {
117
+ // A `??` chain RESCUES a missing operand at run time — only a lone operand
118
+ // can be genuinely required. Checking one inside a chain is a false positive.
119
+ if (expression.operands.length !== 1)
120
+ continue;
121
+ const operand = expression.operands[0];
122
+ if (operand === undefined)
123
+ continue;
124
+ if (operand.kind !== "v1-reserved" || operand.segments[0] !== "trigger") {
125
+ continue;
126
+ }
127
+ if (hasForbiddenPathToken(operand))
128
+ continue; // separate graph error
129
+ // The drill-in path AFTER `trigger` — the full dotted path, so nested
130
+ // leaves (`payment.amount`) are checked at leaf granularity.
131
+ const pathSegments = operand.segments.slice(1);
132
+ if (pathSegments.length === 0)
133
+ continue;
134
+ if (pathSegments.some((s) => s === ""))
135
+ continue;
136
+ paths.add(pathSegments.join("."));
137
+ }
138
+ }
139
+ /**
140
+ * `true` iff `input` carries the whole dotted path by own-key membership at
141
+ * each level. A present-but-`null` LEAF counts as present (its value was
142
+ * supplied); a non-object encountered MID-path means the deeper segment cannot
143
+ * resolve → absent, exactly as the strict resolver would throw.
144
+ */
145
+ function inputHasPath(input, segments) {
146
+ let current = input;
147
+ for (const seg of segments) {
148
+ if (current === null || typeof current !== "object")
149
+ return false;
150
+ if (!Object.hasOwn(current, seg))
151
+ return false;
152
+ current = current[seg];
153
+ }
154
+ return true;
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowget/graph-validation",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Isomorphic, zero-runtime-dependency static validator for Flowget workflow graphs — the single source of validation logic so the builder's pre-submit verdict and the worker's pre-flight verdict are equal by construction. Template-grammar classifier, reference/reachability walker, coarse value-type lattice, and declarative field rules.",
5
5
  "keywords": [
6
6
  "flowget",