@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.
@@ -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
+ }