@flowget/graph-validation 0.1.0 → 0.3.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,12 @@ 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. Config traversal is bounded (a
46
+ stack-overflow-DoS guard); a reference nested deeper than the bound is left to
47
+ the runtime resolver's non-retryable throw rather than pre-checked.
35
48
 
36
49
  ## Install
37
50
 
@@ -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,147 @@
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. }}` / `{{ a..b }}`) is permissive HERE — resolvePath is pure
112
+ // declaration resolution. The empty segment is a grammar-level error that the
113
+ // reference walker flags upstream (`hasEmptyPathSegment` →
114
+ // TEMPLATE_EMPTY_SEGMENT, single-operand-only), mirroring the engine's
115
+ // unconditional runtime throw; resolvePath must not also mis-resolve it here.
116
+ if (pathSegments.some((s) => s === ""))
117
+ return { kind: "known" };
118
+ let level = tree.root;
119
+ let current;
120
+ for (let i = 0; i < pathSegments.length; i++) {
121
+ const seg = pathSegments[i];
122
+ if (seg === undefined)
123
+ return { kind: "known" };
124
+ const next = level.get(seg);
125
+ if (next === undefined) {
126
+ // Segment not enumerated at this level.
127
+ if (i === 0)
128
+ return { kind: "unknown-root", rootKey: seg };
129
+ // `current` is the resolved parent (a known path). If it enumerates
130
+ // children, this is an undeclared sibling → warning; otherwise it's a
131
+ // bare leaf (object/scalar/wildcard) → permissive drill-through.
132
+ if (current !== undefined && current.children.size > 0) {
133
+ return {
134
+ kind: "undeclared-child",
135
+ parentPath: pathSegments.slice(0, i).join("."),
136
+ childKey: seg,
137
+ declaredSiblings: [...current.children.keys()],
138
+ };
139
+ }
140
+ return { kind: "drill-through-leaf" };
141
+ }
142
+ current = next;
143
+ level = next.children;
144
+ }
145
+ // Consumed every segment → an exact declared path (leaf or prefix).
146
+ return { kind: "known" };
147
+ }
package/dist/index.d.ts CHANGED
@@ -13,9 +13,11 @@
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
- export { classifyPathKind, findMalformedTemplateTokens, FORBIDDEN_PATH_TOKENS, hasForbiddenPathToken, hasTemplateExpression, isReservedNamespace, isWholeStringTemplate, MAX_TEMPLATE_LENGTH, parseTemplate, parseTemplateExpression, RESERVED_NAMESPACES, templateExpressions, } from "./template-ast.js";
20
+ export { classifyPathKind, findMalformedTemplateTokens, FORBIDDEN_PATH_TOKENS, hasEmptyPathSegment, hasForbiddenPathToken, hasTemplateExpression, isReservedNamespace, isWholeStringTemplate, MAX_TEMPLATE_LENGTH, parseTemplate, parseTemplateExpression, RESERVED_NAMESPACES, templateExpressions, } from "./template-ast.js";
19
21
  export { isAssignable, isConcreteScalar, isWildcardValueType, jsValueType, } from "./value-type.js";
20
22
  export { buildReversedAdjacency, collectUpstream, collectUpstreamFrom, } from "./reachability.js";
21
23
  export { compareFieldCoherence, type CoherenceMismatch, type CoherenceMismatchKind, type SchemaFieldProjection, type SchemaProjection, } from "./coherence.js";
package/dist/index.js CHANGED
@@ -14,12 +14,16 @@
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) ───────────
20
24
  export { indexCatalog, resolveNodeOutputFields, resolveNodeTriggerPayload, } from "./catalog.js";
21
25
  // ── Template-grammar classifier → @flowget/types TemplateAst ─
22
- export { classifyPathKind, findMalformedTemplateTokens, FORBIDDEN_PATH_TOKENS, hasForbiddenPathToken, hasTemplateExpression, isReservedNamespace, isWholeStringTemplate, MAX_TEMPLATE_LENGTH, parseTemplate, parseTemplateExpression, RESERVED_NAMESPACES, templateExpressions, } from "./template-ast.js";
26
+ export { classifyPathKind, findMalformedTemplateTokens, FORBIDDEN_PATH_TOKENS, hasEmptyPathSegment, hasForbiddenPathToken, hasTemplateExpression, isReservedNamespace, isWholeStringTemplate, MAX_TEMPLATE_LENGTH, parseTemplate, parseTemplateExpression, RESERVED_NAMESPACES, templateExpressions, } from "./template-ast.js";
23
27
  // ── Coarse value-type lattice ───────────────────────────────
24
28
  export { isAssignable, isConcreteScalar, isWildcardValueType, jsValueType, } from "./value-type.js";
25
29
  // ── Reachability ────────────────────────────────────────────
package/dist/issues.d.ts CHANGED
@@ -54,8 +54,38 @@ 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";
80
+ /**
81
+ * A template path has an empty/blank segment — a leading, trailing, or
82
+ * doubled dot (`{{ trigger. }}`, `{{ steps.x.output. }}`, `{{ a..b }}`). The
83
+ * engine resolver throws `missing_path` / `missing_step` on an empty token
84
+ * UNCONDITIONALLY (input-independent), so the static verdict flags it too.
85
+ * Checked per-operand for a single-operand expression only — a `??` chain
86
+ * rescues the throw, so flagging inside a chain would be a false positive.
87
+ */
88
+ readonly TEMPLATE_EMPTY_SEGMENT: "validation/template-empty-segment";
59
89
  /** A whole-string template resolves to a value-type incompatible with the consumer field. */
60
90
  readonly TYPE_MISMATCH: "validation/type-mismatch";
61
91
  /** A required field is empty. */
package/dist/issues.js CHANGED
@@ -31,8 +31,38 @@ 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",
57
+ /**
58
+ * A template path has an empty/blank segment — a leading, trailing, or
59
+ * doubled dot (`{{ trigger. }}`, `{{ steps.x.output. }}`, `{{ a..b }}`). The
60
+ * engine resolver throws `missing_path` / `missing_step` on an empty token
61
+ * UNCONDITIONALLY (input-independent), so the static verdict flags it too.
62
+ * Checked per-operand for a single-operand expression only — a `??` chain
63
+ * rescues the throw, so flagging inside a chain would be a false positive.
64
+ */
65
+ TEMPLATE_EMPTY_SEGMENT: "validation/template-empty-segment",
36
66
  /** A whole-string template resolves to a value-type incompatible with the consumer field. */
37
67
  TYPE_MISMATCH: "validation/type-mismatch",
38
68
  /** A required field is empty. */
@@ -25,9 +25,10 @@
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
- import { hasForbiddenPathToken, hasTemplateExpression, isWholeStringTemplate, parseTemplate, templateExpressions, } from "./template-ast.js";
31
+ import { hasEmptyPathSegment, hasForbiddenPathToken, hasTemplateExpression, isWholeStringTemplate, parseTemplate, templateExpressions, } from "./template-ast.js";
31
32
  import { isAssignable, isConcreteScalar } from "./value-type.js";
32
33
  /** Bound on nested-data recursion (rows / key-value / nested objects). */
33
34
  const MAX_DATA_DEPTH = 64;
@@ -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 ?? []);
@@ -138,6 +144,22 @@ function validateTemplateString(raw, isTopLevel, site, ctx, issues) {
138
144
  }
139
145
  /** Validate one operand's producer existence + reachability + declared-output. */
140
146
  function validateOperandExistence(path, tokenRaw, site, ctx, upstream, issues) {
147
+ // An empty/blank path segment (`trigger.`, `steps.x.output.`, `a..b`) — the
148
+ // engine resolver throws `missing_path` / `missing_step` on it UNCONDITIONALLY
149
+ // (input-independent), so mirror that with a hard error. Grammar-level, like
150
+ // the forbidden-token guard, so it fires independent of catalog declarations
151
+ // and BEFORE the namespace/existence logic. Reached only for a single-operand
152
+ // expression (a `??` chain rescues the throw → flagging inside one is an FP).
153
+ if (hasEmptyPathSegment(path)) {
154
+ issues.push({
155
+ code: ISSUE_CODES.TEMPLATE_EMPTY_SEGMENT,
156
+ severity: "error",
157
+ nodeId: site.node.id,
158
+ fieldKey: site.fieldKey,
159
+ message: `Node "${site.node.id}" template "${tokenRaw}" has an empty path segment (a leading, trailing, or doubled dot) and will be rejected at runtime.`,
160
+ });
161
+ return;
162
+ }
141
163
  if (path.kind === "v1-reserved") {
142
164
  const ns = path.segments[0];
143
165
  // `workflow.*` / `input.*` are opaque execution primitives — permissive.
@@ -154,18 +176,19 @@ function validateOperandExistence(path, tokenRaw, site, ctx, upstream, issues) {
154
176
  issues.push(reachIssue);
155
177
  return;
156
178
  }
157
- // Declared-output check on the canonical envelope only.
158
- // - v2-step: `steps.<id>.output.<key>` (segments[2] === "output").
179
+ // Declared-output check on the canonical envelope only, resolving the WHOLE
180
+ // drill-in path (dotted output keys are nested paths — see `field-tree.ts`).
181
+ // - v2-step: `steps.<id>.output.<path…>` (segments[2] === "output").
159
182
  // `steps.<id>.decorators.…` and other shapes are permissive.
160
- // - v1-step: `<id>.<field>` maps to `output.<field>`.
183
+ // - v1-step: `<id>.<path…>` maps to `output.<path…>`.
161
184
  if (path.kind === "v2-step") {
162
185
  if (path.segments[2] === "output" && path.segments.length >= 4) {
163
- checkOutputKey(producerId, path.segments[3], tokenRaw, site, ctx, issues);
186
+ checkOutputPath(producerId, path.segments.slice(3), tokenRaw, site, ctx, issues);
164
187
  }
165
188
  return;
166
189
  }
167
190
  if (path.segments.length >= 2) {
168
- checkOutputKey(producerId, path.segments[1], tokenRaw, site, ctx, issues);
191
+ checkOutputPath(producerId, path.segments.slice(1), tokenRaw, site, ctx, issues);
169
192
  }
170
193
  }
171
194
  function checkProducerReachable(producerId, tokenRaw, site, ctx, upstream) {
@@ -189,9 +212,14 @@ function checkProducerReachable(producerId, tokenRaw, site, ctx, upstream) {
189
212
  }
190
213
  return null;
191
214
  }
192
- function checkOutputKey(producerId, key, tokenRaw, site, ctx, issues) {
193
- if (key === undefined || key === "")
194
- return;
215
+ /**
216
+ * Resolve a drill-in path against a producer's declared-output tree and emit
217
+ * the reference verdict: an unknown TOP-LEVEL output key is a hard error
218
+ * (unchanged pre-nesting behavior); an undeclared CHILD of a known
219
+ * nested-object output is an advisory warning (the value may still exist at
220
+ * run time); a known path / drill-through-a-leaf / no-declarations is silent.
221
+ */
222
+ function checkOutputPath(producerId, pathSegments, tokenRaw, site, ctx, issues) {
195
223
  const producer = ctx.nodeById.get(producerId);
196
224
  if (producer === undefined)
197
225
  return;
@@ -200,35 +228,96 @@ function checkOutputKey(producerId, key, tokenRaw, site, ctx, issues) {
200
228
  // step scope. Both permissive here.
201
229
  if (def === undefined || def.category === "decorator")
202
230
  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
- }
231
+ const tree = getOutputTree(producerId, ctx);
232
+ const resolution = resolvePath(tree, pathSegments);
233
+ 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
234
  }
216
235
  function validateTriggerKey(path, tokenRaw, site, ctx, issues) {
217
- const key = path.segments[1];
218
- if (key === undefined || key === "")
219
- return;
220
236
  const info = ctx.triggerInfo;
221
237
  // No trigger at all → the missing-trigger rule covers it; some trigger
222
238
  // payload unknown → permissive.
223
239
  if (!info.hasTrigger || !info.allKnown)
224
240
  return;
225
- if (!info.keys.has(key)) {
241
+ const resolution = resolvePath(info.tree, path.segments.slice(1));
242
+ 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.`);
243
+ }
244
+ /**
245
+ * Turn a {@link PathResolution} into an issue (or nothing). `unknown-root` →
246
+ * hard error via `unknownRootMessage`; `undeclared-child` → advisory warning
247
+ * via `undeclaredChildMessage`; everything else is silent.
248
+ */
249
+ function emitResolutionIssue(resolution, site, issues, unknownRootMessage, undeclaredChildMessage) {
250
+ if (resolution.kind === "unknown-root") {
226
251
  issues.push({
227
252
  code: ISSUE_CODES.TEMPLATE_UNKNOWN_OUTPUT,
228
253
  severity: "error",
229
254
  nodeId: site.node.id,
230
255
  fieldKey: site.fieldKey,
231
- message: `Node "${site.node.id}" references "${tokenRaw}", but no trigger declares a payload field "${key}".`,
256
+ message: unknownRootMessage(resolution.rootKey),
257
+ });
258
+ return;
259
+ }
260
+ if (resolution.kind === "undeclared-child") {
261
+ const childPath = `${resolution.parentPath}.${resolution.childKey}`;
262
+ const siblings = [...resolution.declaredSiblings].sort().join(", ");
263
+ issues.push({
264
+ code: ISSUE_CODES.TEMPLATE_UNDECLARED_CHILD,
265
+ severity: "warning",
266
+ nodeId: site.node.id,
267
+ fieldKey: site.fieldKey,
268
+ message: undeclaredChildMessage(childPath, siblings),
269
+ });
270
+ }
271
+ }
272
+ /** Get (building + caching once) a producer's declared-output prefix tree. */
273
+ function getOutputTree(producerId, ctx) {
274
+ let tree = ctx.outputTreeCache.get(producerId);
275
+ if (tree === undefined) {
276
+ tree = buildFieldTree(resolveProducerOutputs(producerId, ctx));
277
+ ctx.outputTreeCache.set(producerId, tree);
278
+ }
279
+ return tree;
280
+ }
281
+ /** A producer node's resolved output fields (`[]` for unknown / decorator). */
282
+ function resolveProducerOutputs(producerId, ctx) {
283
+ const producer = ctx.nodeById.get(producerId);
284
+ if (producer === undefined)
285
+ return [];
286
+ const def = ctx.index.get(producer.type);
287
+ if (def === undefined || def.category === "decorator")
288
+ return [];
289
+ return resolveNodeOutputFields(def, producer.data ?? {});
290
+ }
291
+ /**
292
+ * Emit a {@link ISSUE_CODES.FIELD_PATH_CONFLICT} error for every node whose
293
+ * declared output / trigger-payload keys contain a dotted-key contradiction (a
294
+ * key that is both a concrete scalar and a nested-object parent). Reported
295
+ * independent of any reference, because such a declaration cannot be resolved.
296
+ */
297
+ function collectDeclarationConflicts(graph, ctx, issues) {
298
+ for (const node of graph.nodes) {
299
+ const def = ctx.index.get(node.type);
300
+ if (def === undefined)
301
+ continue;
302
+ // Output surface — reuse the cached per-producer tree.
303
+ if (def.category !== "decorator") {
304
+ pushConflicts(getOutputTree(node.id, ctx), node.id, "output", issues);
305
+ }
306
+ // Trigger-payload surface — a distinct declared list from `output`.
307
+ if (def.category === "trigger") {
308
+ const payload = resolveNodeTriggerPayload(def, node.data ?? {});
309
+ pushConflicts(buildFieldTree(payload), node.id, "trigger payload", issues);
310
+ }
311
+ }
312
+ }
313
+ function pushConflicts(tree, nodeId, surface, issues) {
314
+ for (const conflict of tree.contradictions) {
315
+ issues.push({
316
+ code: ISSUE_CODES.FIELD_PATH_CONFLICT,
317
+ severity: "error",
318
+ nodeId,
319
+ fieldKey: conflict.path,
320
+ 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
321
  });
233
322
  }
234
323
  }
@@ -282,8 +371,8 @@ function getUpstream(nodeId, ctx) {
282
371
  function computeTriggerInfo(graph, index) {
283
372
  let hasTrigger = false;
284
373
  let allKnown = true;
285
- const keys = new Set();
286
- // `null` marks a key whose type is ambiguous across triggers.
374
+ const mergedFields = [];
375
+ // `null` marks a TOP-LEVEL key whose type is ambiguous across triggers.
287
376
  const keyTypes = new Map();
288
377
  for (const node of graph.nodes) {
289
378
  const def = index.get(node.type);
@@ -296,7 +385,11 @@ function computeTriggerInfo(graph, index) {
296
385
  continue;
297
386
  }
298
387
  for (const f of payload) {
299
- keys.add(f.key);
388
+ mergedFields.push(f);
389
+ // Only FLAT (dot-free) keys feed the whole-string type-compat lattice —
390
+ // nested-leaf type-compat is deferred (see `producerValueType`).
391
+ if (f.key.includes("."))
392
+ continue;
300
393
  if (!keyTypes.has(f.key))
301
394
  keyTypes.set(f.key, f.type);
302
395
  else if (keyTypes.get(f.key) !== f.type)
@@ -308,5 +401,12 @@ function computeTriggerInfo(graph, index) {
308
401
  if (v !== null)
309
402
  finalTypes.set(k, v);
310
403
  }
311
- return { hasTrigger, allKnown, keys, keyTypes: finalTypes };
404
+ // The merged tree is for reference resolution only; per-node contradictions
405
+ // are reported separately (see `collectDeclarationConflicts`).
406
+ return {
407
+ hasTrigger,
408
+ allKnown,
409
+ tree: buildFieldTree(mergedFields),
410
+ keyTypes: finalTypes,
411
+ };
312
412
  }
@@ -43,6 +43,16 @@ export declare function isReservedNamespace(token: string): boolean;
43
43
  export declare const FORBIDDEN_PATH_TOKENS: ReadonlySet<string>;
44
44
  /** `true` iff any segment of `path` is a forbidden prototype-chain token. */
45
45
  export declare function hasForbiddenPathToken(path: TemplatePath): boolean;
46
+ /**
47
+ * `true` iff `path` has an empty/blank segment — a leading, trailing, or
48
+ * doubled dot (`trigger.`, `.foo`, `a..b`, `steps.x.output.`). `PATH_RE`
49
+ * (`^[\w.-]+$`) admits these, but the engine resolver throws
50
+ * `missing_path` / `missing_step` on an empty token unconditionally
51
+ * (input-independent), so a static verdict treats them as invalid. This is a
52
+ * grammar-level property (like {@link hasForbiddenPathToken}), independent of
53
+ * any catalog declaration.
54
+ */
55
+ export declare function hasEmptyPathSegment(path: TemplatePath): boolean;
46
56
  /**
47
57
  * Defensive upper bound on a template-bearing string. Beyond this the
48
58
  * string is treated as a single literal (no token scan) — a belt-and-
@@ -54,6 +54,18 @@ export const FORBIDDEN_PATH_TOKENS = new Set([
54
54
  export function hasForbiddenPathToken(path) {
55
55
  return path.segments.some((s) => FORBIDDEN_PATH_TOKENS.has(s));
56
56
  }
57
+ /**
58
+ * `true` iff `path` has an empty/blank segment — a leading, trailing, or
59
+ * doubled dot (`trigger.`, `.foo`, `a..b`, `steps.x.output.`). `PATH_RE`
60
+ * (`^[\w.-]+$`) admits these, but the engine resolver throws
61
+ * `missing_path` / `missing_step` on an empty token unconditionally
62
+ * (input-independent), so a static verdict treats them as invalid. This is a
63
+ * grammar-level property (like {@link hasForbiddenPathToken}), independent of
64
+ * any catalog declaration.
65
+ */
66
+ export function hasEmptyPathSegment(path) {
67
+ return path.segments.some((s) => s === "");
68
+ }
57
69
  /**
58
70
  * Defensive upper bound on a template-bearing string. Beyond this the
59
71
  * string is treated as a single literal (no token scan) — a belt-and-
@@ -0,0 +1,66 @@
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
+ * **Depth boundary.** Config traversal stops at {@link MAX_DATA_DEPTH} (a
37
+ * deliberate stack-overflow-DoS guard on untrusted input). A `{{ trigger.… }}`
38
+ * nested deeper than that in `node.data` is not pre-checked here; it stays
39
+ * backstopped by the runtime resolver's non-retryable `missing_path` throw.
40
+ */
41
+ import type { WorkflowGraph } from "@flowget/types";
42
+ import { type CatalogNode } from "./catalog.js";
43
+ import { type ValidationIssue } from "./issues.js";
44
+ /**
45
+ * Flag every `{{ trigger.<path> }}` the graph references that the run `input`
46
+ * does not provide.
47
+ *
48
+ * @param graph The `@flowget/types` `WorkflowGraph`.
49
+ * @param catalog The node definitions (a `readonly CatalogNode[]`, the same
50
+ * structural subset {@link validateGraphStatic} accepts) — read
51
+ * only for each node's `opaqueFields`.
52
+ * @param input The run input: the builder's test-input object, or the
53
+ * worker's real event payload. A genuinely nested object
54
+ * (`{ payment: { amount } }`), never flat dotted keys.
55
+ * @returns One `error` per referenced-but-absent trigger path, ordered
56
+ * deterministically, with stable ids.
57
+ */
58
+ export declare function validateTriggerInputs(graph: WorkflowGraph, catalog: readonly CatalogNode[], input: Readonly<Record<string, unknown>>): ValidationIssue[];
59
+ /**
60
+ * The set of trigger-payload PATHS (dotted, e.g. `"payment.amount"`) the graph
61
+ * references via a single-operand `{{ trigger.<path> }}` template — the refs
62
+ * that genuinely throw `missing_path` at run time when the path is absent from
63
+ * the payload. This is the shared home for the walk the builder previously
64
+ * re-implemented; the worker consumes it too.
65
+ */
66
+ export declare function collectReferencedTriggerPaths(graph: WorkflowGraph, catalog: readonly CatalogNode[]): ReadonlySet<string>;
@@ -0,0 +1,178 @@
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
+ * **Depth boundary.** Config traversal stops at {@link MAX_DATA_DEPTH} (a
37
+ * deliberate stack-overflow-DoS guard on untrusted input). A `{{ trigger.… }}`
38
+ * nested deeper than that in `node.data` is not pre-checked here; it stays
39
+ * backstopped by the runtime resolver's non-retryable `missing_path` throw.
40
+ */
41
+ import { indexCatalog } from "./catalog.js";
42
+ import { finalizeIssues, ISSUE_CODES, } from "./issues.js";
43
+ import { hasForbiddenPathToken, hasTemplateExpression, parseTemplate, templateExpressions, } from "./template-ast.js";
44
+ /**
45
+ * Bound on nested-config recursion — mirrors the reference walker's deliberate
46
+ * stack-overflow-DoS guard (config values arrive from untrusted input).
47
+ *
48
+ * **Parity boundary (intentional, stated):** the engine's runtime template walk
49
+ * is UNcapped, so a `{{ trigger.<path> }}` buried in `node.data` nested deeper
50
+ * than this is not collected here and its input is not pre-checked. That
51
+ * reference is backstopped by the runtime resolver's non-retryable
52
+ * `missing_path` throw — so a genuinely-missing deep input still fails the run
53
+ * safely, just at execution rather than pre-flight. Matching the engine's
54
+ * uncapped walk (removing this guard) would re-expose the DoS and is a separate
55
+ * cross-repo decision, not this gate's.
56
+ */
57
+ const MAX_DATA_DEPTH = 64;
58
+ /**
59
+ * Flag every `{{ trigger.<path> }}` the graph references that the run `input`
60
+ * does not provide.
61
+ *
62
+ * @param graph The `@flowget/types` `WorkflowGraph`.
63
+ * @param catalog The node definitions (a `readonly CatalogNode[]`, the same
64
+ * structural subset {@link validateGraphStatic} accepts) — read
65
+ * only for each node's `opaqueFields`.
66
+ * @param input The run input: the builder's test-input object, or the
67
+ * worker's real event payload. A genuinely nested object
68
+ * (`{ payment: { amount } }`), never flat dotted keys.
69
+ * @returns One `error` per referenced-but-absent trigger path, ordered
70
+ * deterministically, with stable ids.
71
+ */
72
+ export function validateTriggerInputs(graph, catalog, input) {
73
+ const referenced = collectReferencedTriggerPaths(graph, catalog);
74
+ const raw = [];
75
+ // Sorted for a deterministic, stable issue order across runs.
76
+ for (const path of [...referenced].sort()) {
77
+ if (inputHasPath(input, path.split(".")))
78
+ continue;
79
+ raw.push({
80
+ code: ISSUE_CODES.RUN_INPUT_MISSING,
81
+ severity: "error",
82
+ fieldKey: path,
83
+ message: `This workflow references \`trigger.${path}\`, but the run input doesn't provide it.`,
84
+ });
85
+ }
86
+ return finalizeIssues(raw);
87
+ }
88
+ /**
89
+ * The set of trigger-payload PATHS (dotted, e.g. `"payment.amount"`) the graph
90
+ * references via a single-operand `{{ trigger.<path> }}` template — the refs
91
+ * that genuinely throw `missing_path` at run time when the path is absent from
92
+ * the payload. This is the shared home for the walk the builder previously
93
+ * re-implemented; the worker consumes it too.
94
+ */
95
+ export function collectReferencedTriggerPaths(graph, catalog) {
96
+ const index = indexCatalog(catalog);
97
+ const paths = new Set();
98
+ for (const node of graph.nodes) {
99
+ const def = index.get(node.type);
100
+ const opaque = new Set(def?.opaqueFields ?? []);
101
+ for (const [fieldKey, value] of Object.entries(node.data ?? {})) {
102
+ if (opaque.has(fieldKey))
103
+ continue;
104
+ collectFromValue(value, 0, paths);
105
+ }
106
+ }
107
+ return paths;
108
+ }
109
+ /** Recursively descend a config value, harvesting trigger paths at string leaves. */
110
+ function collectFromValue(value, depth, paths) {
111
+ if (typeof value === "string") {
112
+ collectFromString(value, paths);
113
+ return;
114
+ }
115
+ if (depth >= MAX_DATA_DEPTH)
116
+ return;
117
+ if (Array.isArray(value)) {
118
+ for (const item of value)
119
+ collectFromValue(item, depth + 1, paths);
120
+ return;
121
+ }
122
+ if (value !== null && typeof value === "object") {
123
+ for (const child of Object.values(value)) {
124
+ collectFromValue(child, depth + 1, paths);
125
+ }
126
+ }
127
+ }
128
+ /** Harvest single-operand `{{ trigger.<path> }}` dotted paths from one string. */
129
+ function collectFromString(raw, paths) {
130
+ const ast = parseTemplate(raw);
131
+ if (!hasTemplateExpression(ast))
132
+ return;
133
+ for (const { expression } of templateExpressions(ast)) {
134
+ // A `??` chain RESCUES a missing operand at run time — only a lone operand
135
+ // can be genuinely required. Checking one inside a chain is a false positive.
136
+ if (expression.operands.length !== 1)
137
+ continue;
138
+ const operand = expression.operands[0];
139
+ if (operand === undefined)
140
+ continue;
141
+ if (operand.kind !== "v1-reserved" || operand.segments[0] !== "trigger") {
142
+ continue;
143
+ }
144
+ if (hasForbiddenPathToken(operand))
145
+ continue; // separate graph error
146
+ // The drill-in path AFTER `trigger` — the full dotted path, so nested
147
+ // leaves (`payment.amount`) are checked at leaf granularity.
148
+ const pathSegments = operand.segments.slice(1);
149
+ if (pathSegments.length === 0)
150
+ continue;
151
+ // An empty segment (a malformed leading/trailing/doubled dot,
152
+ // `{{ trigger. }}`) is NOT the run-input gate's concern — a malformed path
153
+ // names no input key, so `validateGraphStatic`'s reference walker owns
154
+ // flagging it (TEMPLATE_EMPTY_SEGMENT), and the runtime throws on it
155
+ // unconditionally regardless. Skipping keeps this gate purely about
156
+ // referenced-vs-provided.
157
+ if (pathSegments.some((s) => s === ""))
158
+ continue;
159
+ paths.add(pathSegments.join("."));
160
+ }
161
+ }
162
+ /**
163
+ * `true` iff `input` carries the whole dotted path by own-key membership at
164
+ * each level. A present-but-`null` LEAF counts as present (its value was
165
+ * supplied); a non-object encountered MID-path means the deeper segment cannot
166
+ * resolve → absent, exactly as the strict resolver would throw.
167
+ */
168
+ function inputHasPath(input, segments) {
169
+ let current = input;
170
+ for (const seg of segments) {
171
+ if (current === null || typeof current !== "object")
172
+ return false;
173
+ if (!Object.hasOwn(current, seg))
174
+ return false;
175
+ current = current[seg];
176
+ }
177
+ return true;
178
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowget/graph-validation",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",