@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 +13 -0
- package/dist/field-tree.d.ts +110 -0
- package/dist/field-tree.js +147 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -1
- package/dist/issues.d.ts +30 -0
- package/dist/issues.js +30 -0
- package/dist/references.js +130 -30
- package/dist/template-ast.d.ts +10 -0
- package/dist/template-ast.js +12 -0
- package/dist/trigger-inputs.d.ts +66 -0
- package/dist/trigger-inputs.js +178 -0
- package/package.json +1 -1
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. */
|
package/dist/references.js
CHANGED
|
@@ -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
|
-
//
|
|
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>.<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/template-ast.d.ts
CHANGED
|
@@ -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-
|
package/dist/template-ast.js
CHANGED
|
@@ -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.
|
|
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",
|