@flowget/graph-validation 0.1.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/LICENSE +105 -0
- package/README.md +71 -0
- package/dist/catalog.d.ts +64 -0
- package/dist/catalog.js +89 -0
- package/dist/coherence.d.ts +52 -0
- package/dist/coherence.js +94 -0
- package/dist/field-rules.d.ts +18 -0
- package/dist/field-rules.js +183 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +28 -0
- package/dist/issues.d.ts +84 -0
- package/dist/issues.js +66 -0
- package/dist/reachability.d.ts +37 -0
- package/dist/reachability.js +60 -0
- package/dist/references.d.ts +30 -0
- package/dist/references.js +312 -0
- package/dist/template-ast.d.ts +101 -0
- package/dist/template-ast.js +211 -0
- package/dist/validate-graph.d.ts +32 -0
- package/dist/validate-graph.js +53 -0
- package/dist/value-type.d.ts +41 -0
- package/dist/value-type.js +94 -0
- package/package.json +60 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template-grammar classifier — the single parser both the builder's
|
|
3
|
+
* design-time verdict and the worker's pre-flight verdict run, so they
|
|
4
|
+
* classify `{{ … }}` templates IDENTICALLY.
|
|
5
|
+
*
|
|
6
|
+
* Lifted faithfully from the engine's `resolve-templates` classifier
|
|
7
|
+
* (`@flowget/engine`) and cross-checked against the builder's
|
|
8
|
+
* `template-highlight` classifier. Produces the `@flowget/types`
|
|
9
|
+
* {@link TemplateAst} shapes (designed for exactly this package). The
|
|
10
|
+
* engine will later ADOPT this shared classifier, so it must stay a faithful
|
|
11
|
+
* drop-in for the runtime resolver.
|
|
12
|
+
*
|
|
13
|
+
* ## ReDoS posture (this parses UNTRUSTED template strings)
|
|
14
|
+
*
|
|
15
|
+
* Every regex here is a SINGLE quantifier over a character class with no
|
|
16
|
+
* self-overlap — provably linear, no catastrophic backtracking:
|
|
17
|
+
*
|
|
18
|
+
* - {@link TOKEN_RE} `\{\{[^{}]*\}\}` — one `*` over a negated class.
|
|
19
|
+
* - {@link PATH_RE} `^[\w.-]+$` — one `+` over a class.
|
|
20
|
+
*
|
|
21
|
+
* The `??` fallback chain is split on the LITERAL two-character string
|
|
22
|
+
* `"??"` (`String.prototype.split("??")`), NOT a regex, so there is no
|
|
23
|
+
* `\s*\?\?\s*`-style backtracking surface at all. A defensive
|
|
24
|
+
* {@link MAX_TEMPLATE_LENGTH} cap bounds pathological inputs before the
|
|
25
|
+
* scan even starts.
|
|
26
|
+
*/
|
|
27
|
+
import type { TemplateAst, TemplateExpression, TemplatePath, TemplatePathKind } from "@flowget/types";
|
|
28
|
+
/**
|
|
29
|
+
* Reserved top-level namespaces — always v1 semantics (flat walk, no
|
|
30
|
+
* `.output` envelope). Mirrors the engine resolver's `RESERVED_NAMESPACES`
|
|
31
|
+
* verbatim: `trigger` (trigger-payload), `workflow` + `input`
|
|
32
|
+
* (execution-level primitives).
|
|
33
|
+
*/
|
|
34
|
+
export declare const RESERVED_NAMESPACES: ReadonlySet<string>;
|
|
35
|
+
/** `true` iff `token` is a reserved template namespace (`trigger` / `workflow` / `input`). */
|
|
36
|
+
export declare function isReservedNamespace(token: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Path tokens refused as a prototype-chain escape guard — mirrors the
|
|
39
|
+
* engine resolver's `FORBIDDEN_PATH_TOKENS`. A template naming any of
|
|
40
|
+
* these throws `forbidden_path_token` at runtime, so the static verdict
|
|
41
|
+
* flags it too.
|
|
42
|
+
*/
|
|
43
|
+
export declare const FORBIDDEN_PATH_TOKENS: ReadonlySet<string>;
|
|
44
|
+
/** `true` iff any segment of `path` is a forbidden prototype-chain token. */
|
|
45
|
+
export declare function hasForbiddenPathToken(path: TemplatePath): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Defensive upper bound on a template-bearing string. Beyond this the
|
|
48
|
+
* string is treated as a single literal (no token scan) — a belt-and-
|
|
49
|
+
* suspenders cap on top of the already-linear regexes. Config template
|
|
50
|
+
* strings are short; this is far above any legitimate value.
|
|
51
|
+
*/
|
|
52
|
+
export declare const MAX_TEMPLATE_LENGTH = 50000;
|
|
53
|
+
/**
|
|
54
|
+
* Classify a single operand's grammar off its first dot-separated token —
|
|
55
|
+
* a syntactic property of the path text, NOT of scope contents. Mirrors
|
|
56
|
+
* the engine resolver's `classifyOperand` exactly.
|
|
57
|
+
*/
|
|
58
|
+
export declare function classifyPathKind(raw: string): TemplatePathKind;
|
|
59
|
+
/**
|
|
60
|
+
* Parse the inner text of a `{{ … }}` token (the part between the braces)
|
|
61
|
+
* into a {@link TemplateExpression}. Returns `null` when the inner text is
|
|
62
|
+
* NOT valid grammar — a `??` chain of one or more dotted-path operands.
|
|
63
|
+
*
|
|
64
|
+
* A `null` return means "not a Flowget template": the engine's resolver
|
|
65
|
+
* leaves such a token untouched (literal), so this package does too.
|
|
66
|
+
*/
|
|
67
|
+
export declare function parseTemplateExpression(inner: string): TemplateExpression | null;
|
|
68
|
+
/**
|
|
69
|
+
* Parse a template-bearing string into a {@link TemplateAst}: an ordered
|
|
70
|
+
* list of literal / expression segments whose sources concatenate back to
|
|
71
|
+
* `raw`. A string with no valid `{{ … }}` token parses to a single
|
|
72
|
+
* `literal` segment. A `{{ … }}` whose inner text is not valid grammar is
|
|
73
|
+
* folded into the surrounding literal text (engine-faithful — the runtime
|
|
74
|
+
* resolver leaves it untouched).
|
|
75
|
+
*/
|
|
76
|
+
export declare function parseTemplate(raw: string): TemplateAst;
|
|
77
|
+
/**
|
|
78
|
+
* `true` iff the whole string is exactly one `{{ … }}` expression (no
|
|
79
|
+
* surrounding literal text). This is the type-PRESERVING case: the field
|
|
80
|
+
* receives the referenced value's raw (typed) value, so
|
|
81
|
+
* {@link isAssignable} compares value-types. Any surrounding text makes it
|
|
82
|
+
* interpolation (a string result). Mirrors the engine's
|
|
83
|
+
* `WHOLE_STRING_RE` vs interpolation split.
|
|
84
|
+
*/
|
|
85
|
+
export declare function isWholeStringTemplate(ast: TemplateAst): boolean;
|
|
86
|
+
/** `true` iff the AST carries at least one `{{ … }}` expression segment. */
|
|
87
|
+
export declare function hasTemplateExpression(ast: TemplateAst): boolean;
|
|
88
|
+
/** Every parsed expression segment in `ast`, in source order. */
|
|
89
|
+
export declare function templateExpressions(ast: TemplateAst): readonly {
|
|
90
|
+
raw: string;
|
|
91
|
+
expression: TemplateExpression;
|
|
92
|
+
}[];
|
|
93
|
+
/**
|
|
94
|
+
* Raw `{{ … }}` token strings that LOOK like a template but are not valid
|
|
95
|
+
* grammar (would be passed through as literal text at runtime). Exposed
|
|
96
|
+
* for consumers that want a builder-style "looks broken" hint; deliberately
|
|
97
|
+
* NOT surfaced by {@link validateGraphStatic}, because a non-Flowget
|
|
98
|
+
* `{{ … }}` (a mail-merge template stored in a text field) is a legitimate
|
|
99
|
+
* literal — flagging it would be a false positive.
|
|
100
|
+
*/
|
|
101
|
+
export declare function findMalformedTemplateTokens(raw: string): string[];
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template-grammar classifier — the single parser both the builder's
|
|
3
|
+
* design-time verdict and the worker's pre-flight verdict run, so they
|
|
4
|
+
* classify `{{ … }}` templates IDENTICALLY.
|
|
5
|
+
*
|
|
6
|
+
* Lifted faithfully from the engine's `resolve-templates` classifier
|
|
7
|
+
* (`@flowget/engine`) and cross-checked against the builder's
|
|
8
|
+
* `template-highlight` classifier. Produces the `@flowget/types`
|
|
9
|
+
* {@link TemplateAst} shapes (designed for exactly this package). The
|
|
10
|
+
* engine will later ADOPT this shared classifier, so it must stay a faithful
|
|
11
|
+
* drop-in for the runtime resolver.
|
|
12
|
+
*
|
|
13
|
+
* ## ReDoS posture (this parses UNTRUSTED template strings)
|
|
14
|
+
*
|
|
15
|
+
* Every regex here is a SINGLE quantifier over a character class with no
|
|
16
|
+
* self-overlap — provably linear, no catastrophic backtracking:
|
|
17
|
+
*
|
|
18
|
+
* - {@link TOKEN_RE} `\{\{[^{}]*\}\}` — one `*` over a negated class.
|
|
19
|
+
* - {@link PATH_RE} `^[\w.-]+$` — one `+` over a class.
|
|
20
|
+
*
|
|
21
|
+
* The `??` fallback chain is split on the LITERAL two-character string
|
|
22
|
+
* `"??"` (`String.prototype.split("??")`), NOT a regex, so there is no
|
|
23
|
+
* `\s*\?\?\s*`-style backtracking surface at all. A defensive
|
|
24
|
+
* {@link MAX_TEMPLATE_LENGTH} cap bounds pathological inputs before the
|
|
25
|
+
* scan even starts.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Reserved top-level namespaces — always v1 semantics (flat walk, no
|
|
29
|
+
* `.output` envelope). Mirrors the engine resolver's `RESERVED_NAMESPACES`
|
|
30
|
+
* verbatim: `trigger` (trigger-payload), `workflow` + `input`
|
|
31
|
+
* (execution-level primitives).
|
|
32
|
+
*/
|
|
33
|
+
export const RESERVED_NAMESPACES = new Set([
|
|
34
|
+
"trigger",
|
|
35
|
+
"workflow",
|
|
36
|
+
"input",
|
|
37
|
+
]);
|
|
38
|
+
/** `true` iff `token` is a reserved template namespace (`trigger` / `workflow` / `input`). */
|
|
39
|
+
export function isReservedNamespace(token) {
|
|
40
|
+
return RESERVED_NAMESPACES.has(token);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Path tokens refused as a prototype-chain escape guard — mirrors the
|
|
44
|
+
* engine resolver's `FORBIDDEN_PATH_TOKENS`. A template naming any of
|
|
45
|
+
* these throws `forbidden_path_token` at runtime, so the static verdict
|
|
46
|
+
* flags it too.
|
|
47
|
+
*/
|
|
48
|
+
export const FORBIDDEN_PATH_TOKENS = new Set([
|
|
49
|
+
"__proto__",
|
|
50
|
+
"constructor",
|
|
51
|
+
"prototype",
|
|
52
|
+
]);
|
|
53
|
+
/** `true` iff any segment of `path` is a forbidden prototype-chain token. */
|
|
54
|
+
export function hasForbiddenPathToken(path) {
|
|
55
|
+
return path.segments.some((s) => FORBIDDEN_PATH_TOKENS.has(s));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Defensive upper bound on a template-bearing string. Beyond this the
|
|
59
|
+
* string is treated as a single literal (no token scan) — a belt-and-
|
|
60
|
+
* suspenders cap on top of the already-linear regexes. Config template
|
|
61
|
+
* strings are short; this is far above any legitimate value.
|
|
62
|
+
*/
|
|
63
|
+
export const MAX_TEMPLATE_LENGTH = 50_000;
|
|
64
|
+
/**
|
|
65
|
+
* Match a `{{ … }}` token. `[^{}]*` forbids interior braces so a
|
|
66
|
+
* malformed `{{ a {{ b }} }}` captures the inner `{{ b }}`, matching the
|
|
67
|
+
* builder's `TOKEN_RE` and the engine's interpolation scan. Linear.
|
|
68
|
+
*/
|
|
69
|
+
const TOKEN_RE = /\{\{[^{}]*\}\}/g;
|
|
70
|
+
/** A single dotted path operand: word chars, dot, hyphen. One quantifier — linear. */
|
|
71
|
+
const PATH_RE = /^[\w.-]+$/;
|
|
72
|
+
/**
|
|
73
|
+
* Classify a single operand's grammar off its first dot-separated token —
|
|
74
|
+
* a syntactic property of the path text, NOT of scope contents. Mirrors
|
|
75
|
+
* the engine resolver's `classifyOperand` exactly.
|
|
76
|
+
*/
|
|
77
|
+
export function classifyPathKind(raw) {
|
|
78
|
+
const first = raw.split(".")[0] ?? "";
|
|
79
|
+
if (first === "steps")
|
|
80
|
+
return "v2-step";
|
|
81
|
+
if (RESERVED_NAMESPACES.has(first))
|
|
82
|
+
return "v1-reserved";
|
|
83
|
+
return "v1-step";
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Parse the inner text of a `{{ … }}` token (the part between the braces)
|
|
87
|
+
* into a {@link TemplateExpression}. Returns `null` when the inner text is
|
|
88
|
+
* NOT valid grammar — a `??` chain of one or more dotted-path operands.
|
|
89
|
+
*
|
|
90
|
+
* A `null` return means "not a Flowget template": the engine's resolver
|
|
91
|
+
* leaves such a token untouched (literal), so this package does too.
|
|
92
|
+
*/
|
|
93
|
+
export function parseTemplateExpression(inner) {
|
|
94
|
+
const trimmed = inner.trim();
|
|
95
|
+
if (trimmed.length === 0)
|
|
96
|
+
return null;
|
|
97
|
+
// Split on the LITERAL `??` operator — no regex, no backtracking.
|
|
98
|
+
const parts = trimmed.split("??");
|
|
99
|
+
const operands = [];
|
|
100
|
+
for (const part of parts) {
|
|
101
|
+
const raw = part.trim();
|
|
102
|
+
// Every operand must be a non-empty dotted path. An empty operand
|
|
103
|
+
// (leading/trailing/doubled `??`) is malformed grammar → not a template.
|
|
104
|
+
if (!PATH_RE.test(raw))
|
|
105
|
+
return null;
|
|
106
|
+
operands.push({
|
|
107
|
+
kind: classifyPathKind(raw),
|
|
108
|
+
segments: raw.split("."),
|
|
109
|
+
raw,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
// `parts` is always length ≥ 1 and every part passed PATH_RE, so
|
|
113
|
+
// `operands` is non-empty here — but keep the guard explicit.
|
|
114
|
+
if (operands.length === 0)
|
|
115
|
+
return null;
|
|
116
|
+
return { operands };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Parse a template-bearing string into a {@link TemplateAst}: an ordered
|
|
120
|
+
* list of literal / expression segments whose sources concatenate back to
|
|
121
|
+
* `raw`. A string with no valid `{{ … }}` token parses to a single
|
|
122
|
+
* `literal` segment. A `{{ … }}` whose inner text is not valid grammar is
|
|
123
|
+
* folded into the surrounding literal text (engine-faithful — the runtime
|
|
124
|
+
* resolver leaves it untouched).
|
|
125
|
+
*/
|
|
126
|
+
export function parseTemplate(raw) {
|
|
127
|
+
if (raw.length > MAX_TEMPLATE_LENGTH) {
|
|
128
|
+
return { raw, segments: [{ kind: "literal", text: raw }] };
|
|
129
|
+
}
|
|
130
|
+
const segments = [];
|
|
131
|
+
const pushLiteral = (text) => {
|
|
132
|
+
if (text.length === 0)
|
|
133
|
+
return;
|
|
134
|
+
const prev = segments[segments.length - 1];
|
|
135
|
+
if (prev !== undefined && prev.kind === "literal") {
|
|
136
|
+
// Coalesce adjacent literals so concatenation reproduces `raw`.
|
|
137
|
+
segments[segments.length - 1] = {
|
|
138
|
+
kind: "literal",
|
|
139
|
+
text: prev.text + text,
|
|
140
|
+
};
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
segments.push({ kind: "literal", text });
|
|
144
|
+
};
|
|
145
|
+
let lastIndex = 0;
|
|
146
|
+
TOKEN_RE.lastIndex = 0;
|
|
147
|
+
let m;
|
|
148
|
+
while ((m = TOKEN_RE.exec(raw)) !== null) {
|
|
149
|
+
const tokenRaw = m[0];
|
|
150
|
+
if (m.index > lastIndex)
|
|
151
|
+
pushLiteral(raw.slice(lastIndex, m.index));
|
|
152
|
+
const expression = parseTemplateExpression(tokenRaw.slice(2, -2));
|
|
153
|
+
if (expression === null) {
|
|
154
|
+
pushLiteral(tokenRaw);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
segments.push({ kind: "expression", raw: tokenRaw, expression });
|
|
158
|
+
}
|
|
159
|
+
lastIndex = m.index + tokenRaw.length;
|
|
160
|
+
}
|
|
161
|
+
if (lastIndex < raw.length)
|
|
162
|
+
pushLiteral(raw.slice(lastIndex));
|
|
163
|
+
if (segments.length === 0)
|
|
164
|
+
segments.push({ kind: "literal", text: "" });
|
|
165
|
+
return { raw, segments };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* `true` iff the whole string is exactly one `{{ … }}` expression (no
|
|
169
|
+
* surrounding literal text). This is the type-PRESERVING case: the field
|
|
170
|
+
* receives the referenced value's raw (typed) value, so
|
|
171
|
+
* {@link isAssignable} compares value-types. Any surrounding text makes it
|
|
172
|
+
* interpolation (a string result). Mirrors the engine's
|
|
173
|
+
* `WHOLE_STRING_RE` vs interpolation split.
|
|
174
|
+
*/
|
|
175
|
+
export function isWholeStringTemplate(ast) {
|
|
176
|
+
return ast.segments.length === 1 && ast.segments[0]?.kind === "expression";
|
|
177
|
+
}
|
|
178
|
+
/** `true` iff the AST carries at least one `{{ … }}` expression segment. */
|
|
179
|
+
export function hasTemplateExpression(ast) {
|
|
180
|
+
return ast.segments.some((s) => s.kind === "expression");
|
|
181
|
+
}
|
|
182
|
+
/** Every parsed expression segment in `ast`, in source order. */
|
|
183
|
+
export function templateExpressions(ast) {
|
|
184
|
+
const out = [];
|
|
185
|
+
for (const seg of ast.segments) {
|
|
186
|
+
if (seg.kind === "expression") {
|
|
187
|
+
out.push({ raw: seg.raw, expression: seg.expression });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Raw `{{ … }}` token strings that LOOK like a template but are not valid
|
|
194
|
+
* grammar (would be passed through as literal text at runtime). Exposed
|
|
195
|
+
* for consumers that want a builder-style "looks broken" hint; deliberately
|
|
196
|
+
* NOT surfaced by {@link validateGraphStatic}, because a non-Flowget
|
|
197
|
+
* `{{ … }}` (a mail-merge template stored in a text field) is a legitimate
|
|
198
|
+
* literal — flagging it would be a false positive.
|
|
199
|
+
*/
|
|
200
|
+
export function findMalformedTemplateTokens(raw) {
|
|
201
|
+
if (raw.length > MAX_TEMPLATE_LENGTH)
|
|
202
|
+
return [];
|
|
203
|
+
const out = [];
|
|
204
|
+
TOKEN_RE.lastIndex = 0;
|
|
205
|
+
let m;
|
|
206
|
+
while ((m = TOKEN_RE.exec(raw)) !== null) {
|
|
207
|
+
if (parseTemplateExpression(m[0].slice(2, -2)) === null)
|
|
208
|
+
out.push(m[0]);
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `validateGraphStatic(graph, catalog)` — the aggregator, and the single
|
|
3
|
+
* entry point the builder (pre-submit) and worker (pre-flight) both call.
|
|
4
|
+
* Because both feed the SAME `(graph, catalog)` into the SAME pure function,
|
|
5
|
+
* their verdicts are equal BY CONSTRUCTION.
|
|
6
|
+
*
|
|
7
|
+
* Runs, in order:
|
|
8
|
+
* 1. unknown node-type (a `WorkflowNode.type` absent from the catalog),
|
|
9
|
+
* 2. reference existence + reachability + whole-string type-compat,
|
|
10
|
+
* 3. declarative field rules (required / type / enum / min / max / pattern).
|
|
11
|
+
*
|
|
12
|
+
* The result is a flat, deterministically-ordered {@link ValidationIssue}[]
|
|
13
|
+
* — same input, same output, every time (no `Date` / random). This does NOT
|
|
14
|
+
* subsume the builder's architectural invariants (missing-trigger, cycle,
|
|
15
|
+
* decorator allow-list, per-node `validate()`); those remain the builder's,
|
|
16
|
+
* and a consumer concatenates both issue lists.
|
|
17
|
+
*/
|
|
18
|
+
import type { WorkflowGraph } from "@flowget/types";
|
|
19
|
+
import { type CatalogNode } from "./catalog.js";
|
|
20
|
+
import { type ValidationIssue } from "./issues.js";
|
|
21
|
+
/**
|
|
22
|
+
* Statically validate a workflow graph against its node catalog.
|
|
23
|
+
*
|
|
24
|
+
* @param graph The `@flowget/types` `WorkflowGraph` (nodes + edges).
|
|
25
|
+
* @param catalog The node definitions — a `readonly CatalogNode[]` (a
|
|
26
|
+
* structural subset of `NodeMetadata<BuilderHints>[]`, so
|
|
27
|
+
* pass `catalog.list()` / your registry array directly).
|
|
28
|
+
* @returns Every static issue, most-structural first, with
|
|
29
|
+
* deterministic ids. Filter by `severity === "error"` for the
|
|
30
|
+
* blocking set.
|
|
31
|
+
*/
|
|
32
|
+
export declare function validateGraphStatic(graph: WorkflowGraph, catalog: readonly CatalogNode[]): ValidationIssue[];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `validateGraphStatic(graph, catalog)` — the aggregator, and the single
|
|
3
|
+
* entry point the builder (pre-submit) and worker (pre-flight) both call.
|
|
4
|
+
* Because both feed the SAME `(graph, catalog)` into the SAME pure function,
|
|
5
|
+
* their verdicts are equal BY CONSTRUCTION.
|
|
6
|
+
*
|
|
7
|
+
* Runs, in order:
|
|
8
|
+
* 1. unknown node-type (a `WorkflowNode.type` absent from the catalog),
|
|
9
|
+
* 2. reference existence + reachability + whole-string type-compat,
|
|
10
|
+
* 3. declarative field rules (required / type / enum / min / max / pattern).
|
|
11
|
+
*
|
|
12
|
+
* The result is a flat, deterministically-ordered {@link ValidationIssue}[]
|
|
13
|
+
* — same input, same output, every time (no `Date` / random). This does NOT
|
|
14
|
+
* subsume the builder's architectural invariants (missing-trigger, cycle,
|
|
15
|
+
* decorator allow-list, per-node `validate()`); those remain the builder's,
|
|
16
|
+
* and a consumer concatenates both issue lists.
|
|
17
|
+
*/
|
|
18
|
+
import { indexCatalog } from "./catalog.js";
|
|
19
|
+
import { collectFieldRuleIssues } from "./field-rules.js";
|
|
20
|
+
import { finalizeIssues, ISSUE_CODES, } from "./issues.js";
|
|
21
|
+
import { collectReferenceIssues } from "./references.js";
|
|
22
|
+
/**
|
|
23
|
+
* Statically validate a workflow graph against its node catalog.
|
|
24
|
+
*
|
|
25
|
+
* @param graph The `@flowget/types` `WorkflowGraph` (nodes + edges).
|
|
26
|
+
* @param catalog The node definitions — a `readonly CatalogNode[]` (a
|
|
27
|
+
* structural subset of `NodeMetadata<BuilderHints>[]`, so
|
|
28
|
+
* pass `catalog.list()` / your registry array directly).
|
|
29
|
+
* @returns Every static issue, most-structural first, with
|
|
30
|
+
* deterministic ids. Filter by `severity === "error"` for the
|
|
31
|
+
* blocking set.
|
|
32
|
+
*/
|
|
33
|
+
export function validateGraphStatic(graph, catalog) {
|
|
34
|
+
const index = indexCatalog(catalog);
|
|
35
|
+
const raw = [];
|
|
36
|
+
// 1. Unknown node type — a node whose `type` has no catalog entry. The
|
|
37
|
+
// engine rejects such a node before executing; surface it up front.
|
|
38
|
+
for (const node of graph.nodes) {
|
|
39
|
+
if (!index.has(node.type)) {
|
|
40
|
+
raw.push({
|
|
41
|
+
code: ISSUE_CODES.UNKNOWN_NODE_TYPE,
|
|
42
|
+
severity: "error",
|
|
43
|
+
nodeId: node.id,
|
|
44
|
+
message: `Node "${node.id}" has unknown type "${node.type}" — no such node in the catalog.`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// 2. Template references (existence + reachability + type-compat).
|
|
49
|
+
raw.push(...collectReferenceIssues(graph, index));
|
|
50
|
+
// 3. Declarative field rules.
|
|
51
|
+
raw.push(...collectFieldRuleIssues(graph, index));
|
|
52
|
+
return finalizeIssues(raw);
|
|
53
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The coarse value-type lattice — the zero-false-positive comparator the
|
|
3
|
+
* strict-validation epic is built on. Reasons ONLY over the
|
|
4
|
+
* `@flowget/types` {@link FieldValueType} set; it never sees a live schema
|
|
5
|
+
* or a refinement (`.int()`, `.positive()`, `.literal()`) — those stay
|
|
6
|
+
* enforced at runtime by the consumer's zod. The honesty boundary: a
|
|
7
|
+
* "valid" verdict here must NEVER be wrong, so the lattice is permissive
|
|
8
|
+
* wherever it cannot be certain.
|
|
9
|
+
*/
|
|
10
|
+
import type { FieldValueType } from "@flowget/types";
|
|
11
|
+
/** `true` iff `t` is a permissive wildcard (`unknown` / `object` / `array`). */
|
|
12
|
+
export declare function isWildcardValueType(t: FieldValueType): boolean;
|
|
13
|
+
/** `true` iff `t` is a concrete scalar leaf (`string` / `number` / `boolean` / `date`). */
|
|
14
|
+
export declare function isConcreteScalar(t: FieldValueType): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Position-aware assignability, WHOLE-STRING semantics — the case where a
|
|
17
|
+
* `{{ steps.x.output.y }}` token IS the entire field value, so the
|
|
18
|
+
* consumer field receives `y`'s raw (type-preserving) value.
|
|
19
|
+
*
|
|
20
|
+
* The lattice:
|
|
21
|
+
*
|
|
22
|
+
* - Either side a wildcard (`unknown` / `object` / `array`) → assignable
|
|
23
|
+
* (permissive; zero false positives).
|
|
24
|
+
* - `date` ↔ `string` → assignable ({@link isDateStringPair} — dates ARE
|
|
25
|
+
* JSON strings).
|
|
26
|
+
* - Otherwise both CONCRETE scalars → assignable iff EQUAL. Two disjoint
|
|
27
|
+
* concrete scalars (e.g. a `number` output into a `string`-expecting
|
|
28
|
+
* input) are the ONLY thing this function flags.
|
|
29
|
+
*
|
|
30
|
+
* Interpolation (a `{{ … }}` embedded in surrounding text) is NOT checked
|
|
31
|
+
* through here: the result is always a string and every coarse producer is
|
|
32
|
+
* stringifiable, so interpolation never yields a type-mismatch verdict.
|
|
33
|
+
*/
|
|
34
|
+
export declare function isAssignable(producer: FieldValueType, consumer: FieldValueType): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Coarse {@link FieldValueType} of a concrete JavaScript value — used to
|
|
37
|
+
* check a LITERAL (non-template) config value against its field's declared
|
|
38
|
+
* value type. `null` / `undefined` map to `unknown` (permissive) so an
|
|
39
|
+
* absent value is never a type mismatch (required-ness is a separate rule).
|
|
40
|
+
*/
|
|
41
|
+
export declare function jsValueType(value: unknown): FieldValueType;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The coarse value-type lattice — the zero-false-positive comparator the
|
|
3
|
+
* strict-validation epic is built on. Reasons ONLY over the
|
|
4
|
+
* `@flowget/types` {@link FieldValueType} set; it never sees a live schema
|
|
5
|
+
* or a refinement (`.int()`, `.positive()`, `.literal()`) — those stay
|
|
6
|
+
* enforced at runtime by the consumer's zod. The honesty boundary: a
|
|
7
|
+
* "valid" verdict here must NEVER be wrong, so the lattice is permissive
|
|
8
|
+
* wherever it cannot be certain.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The permissive wildcards. `unknown` is an undeclared/opaque shape;
|
|
12
|
+
* `object` / `array` are compound shapes whose element/nested types this
|
|
13
|
+
* coarse lattice cannot see (a drill-in path under an `object` resolves to
|
|
14
|
+
* an undeclared deeper type). All three are assignable to/from ANYTHING so
|
|
15
|
+
* the comparator produces zero false positives.
|
|
16
|
+
*/
|
|
17
|
+
const WILDCARD_VALUE_TYPES = new Set([
|
|
18
|
+
"unknown",
|
|
19
|
+
"object",
|
|
20
|
+
"array",
|
|
21
|
+
]);
|
|
22
|
+
/** `true` iff `t` is a permissive wildcard (`unknown` / `object` / `array`). */
|
|
23
|
+
export function isWildcardValueType(t) {
|
|
24
|
+
return WILDCARD_VALUE_TYPES.has(t);
|
|
25
|
+
}
|
|
26
|
+
/** `true` iff `t` is a concrete scalar leaf (`string` / `number` / `boolean` / `date`). */
|
|
27
|
+
export function isConcreteScalar(t) {
|
|
28
|
+
return !isWildcardValueType(t);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* `true` iff `{ a, b } === { "date", "string" }` (in either order).
|
|
32
|
+
*
|
|
33
|
+
* `date` is a SPECIALIZATION of `string`: a `date` value IS a string in JSON
|
|
34
|
+
* (the Temporal runtime serializes to JSON, so catalogs model dates with
|
|
35
|
+
* `z.string()`, never `z.date()`). So `date → string` is guaranteed
|
|
36
|
+
* assignable, and `string → date` is treated permissively — an arbitrary
|
|
37
|
+
* string MAY be a valid date, so flagging it would be a false positive. This
|
|
38
|
+
* is the "author-may-tighten" rule surfaced by the registry data-fix.
|
|
39
|
+
*/
|
|
40
|
+
function isDateStringPair(a, b) {
|
|
41
|
+
return (a === "date" && b === "string") || (a === "string" && b === "date");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Position-aware assignability, WHOLE-STRING semantics — the case where a
|
|
45
|
+
* `{{ steps.x.output.y }}` token IS the entire field value, so the
|
|
46
|
+
* consumer field receives `y`'s raw (type-preserving) value.
|
|
47
|
+
*
|
|
48
|
+
* The lattice:
|
|
49
|
+
*
|
|
50
|
+
* - Either side a wildcard (`unknown` / `object` / `array`) → assignable
|
|
51
|
+
* (permissive; zero false positives).
|
|
52
|
+
* - `date` ↔ `string` → assignable ({@link isDateStringPair} — dates ARE
|
|
53
|
+
* JSON strings).
|
|
54
|
+
* - Otherwise both CONCRETE scalars → assignable iff EQUAL. Two disjoint
|
|
55
|
+
* concrete scalars (e.g. a `number` output into a `string`-expecting
|
|
56
|
+
* input) are the ONLY thing this function flags.
|
|
57
|
+
*
|
|
58
|
+
* Interpolation (a `{{ … }}` embedded in surrounding text) is NOT checked
|
|
59
|
+
* through here: the result is always a string and every coarse producer is
|
|
60
|
+
* stringifiable, so interpolation never yields a type-mismatch verdict.
|
|
61
|
+
*/
|
|
62
|
+
export function isAssignable(producer, consumer) {
|
|
63
|
+
if (isWildcardValueType(producer) || isWildcardValueType(consumer)) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (producer === consumer)
|
|
67
|
+
return true;
|
|
68
|
+
return isDateStringPair(producer, consumer);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Coarse {@link FieldValueType} of a concrete JavaScript value — used to
|
|
72
|
+
* check a LITERAL (non-template) config value against its field's declared
|
|
73
|
+
* value type. `null` / `undefined` map to `unknown` (permissive) so an
|
|
74
|
+
* absent value is never a type mismatch (required-ness is a separate rule).
|
|
75
|
+
*/
|
|
76
|
+
export function jsValueType(value) {
|
|
77
|
+
if (value === null || value === undefined)
|
|
78
|
+
return "unknown";
|
|
79
|
+
if (Array.isArray(value))
|
|
80
|
+
return "array";
|
|
81
|
+
switch (typeof value) {
|
|
82
|
+
case "string":
|
|
83
|
+
return "string";
|
|
84
|
+
case "number":
|
|
85
|
+
return "number";
|
|
86
|
+
case "boolean":
|
|
87
|
+
return "boolean";
|
|
88
|
+
case "object":
|
|
89
|
+
return "object";
|
|
90
|
+
default:
|
|
91
|
+
// function / symbol / bigint — never a legitimate config leaf.
|
|
92
|
+
return "unknown";
|
|
93
|
+
}
|
|
94
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flowget/graph-validation",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"flowget",
|
|
7
|
+
"workflow",
|
|
8
|
+
"workflow-engine",
|
|
9
|
+
"workflow-automation",
|
|
10
|
+
"validation",
|
|
11
|
+
"graph",
|
|
12
|
+
"template",
|
|
13
|
+
"typescript"
|
|
14
|
+
],
|
|
15
|
+
"license": "FSL-1.1-ALv2",
|
|
16
|
+
"homepage": "https://docs.flowget.io",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/flowgethq/graph-validation/issues"
|
|
19
|
+
},
|
|
20
|
+
"author": "Flowget",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"README.md"
|
|
35
|
+
],
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/flowgethq/graph-validation.git"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.build.json",
|
|
45
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
|
|
46
|
+
"lint": "eslint src/",
|
|
47
|
+
"test": "vitest run"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@flowget/types": ">=0.5.0 <1.0.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@eslint/js": "^9",
|
|
54
|
+
"@flowget/types": "^0.5.0",
|
|
55
|
+
"eslint": "^9",
|
|
56
|
+
"typescript": "^5",
|
|
57
|
+
"typescript-eslint": "^8",
|
|
58
|
+
"vitest": "^2"
|
|
59
|
+
}
|
|
60
|
+
}
|