@flowget/graph-validation 0.2.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 +3 -1
- package/dist/field-tree.js +5 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/issues.d.ts +9 -0
- package/dist/issues.js +9 -0
- package/dist/references.js +17 -1
- package/dist/template-ast.d.ts +10 -0
- package/dist/template-ast.js +12 -0
- package/dist/trigger-inputs.d.ts +5 -0
- package/dist/trigger-inputs.js +24 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,7 +42,9 @@ What it does:
|
|
|
42
42
|
- **`validateTriggerInputs(graph, catalog, input)`** — a run-input gate: flags
|
|
43
43
|
every `{{ trigger.<path> }}` the graph references that the run input (the
|
|
44
44
|
builder's test input, or the worker's real event payload) does not provide,
|
|
45
|
-
path-aware and false-positive-free.
|
|
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.
|
|
46
48
|
|
|
47
49
|
## Install
|
|
48
50
|
|
package/dist/field-tree.js
CHANGED
|
@@ -108,8 +108,11 @@ export function resolvePath(tree, pathSegments) {
|
|
|
108
108
|
if (pathSegments.length === 0)
|
|
109
109
|
return { kind: "known" };
|
|
110
110
|
// A malformed drill-in with an empty segment (a trailing/interior dot, e.g.
|
|
111
|
-
// `{{ trigger. }}`
|
|
112
|
-
//
|
|
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.
|
|
113
116
|
if (pathSegments.some((s) => s === ""))
|
|
114
117
|
return { kind: "known" };
|
|
115
118
|
let level = tree.root;
|
package/dist/index.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export { collectReferencedTriggerPaths, validateTriggerInputs, } from "./trigger
|
|
|
17
17
|
export { buildFieldTree, type FieldPathConflict, type FieldTree, type FieldTreeNode, type PathResolution, resolvePath, } from "./field-tree.js";
|
|
18
18
|
export { ISSUE_CODES, type IssueCode, type ValidationIssue, type ValidationSeverity, } from "./issues.js";
|
|
19
19
|
export { type CatalogIndex, type CatalogNode, indexCatalog, resolveNodeOutputFields, resolveNodeTriggerPayload, } from "./catalog.js";
|
|
20
|
-
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";
|
|
21
21
|
export { isAssignable, isConcreteScalar, isWildcardValueType, jsValueType, } from "./value-type.js";
|
|
22
22
|
export { buildReversedAdjacency, collectUpstream, collectUpstreamFrom, } from "./reachability.js";
|
|
23
23
|
export { compareFieldCoherence, type CoherenceMismatch, type CoherenceMismatchKind, type SchemaFieldProjection, type SchemaProjection, } from "./coherence.js";
|
package/dist/index.js
CHANGED
|
@@ -23,7 +23,7 @@ export { ISSUE_CODES, } from "./issues.js";
|
|
|
23
23
|
// ── Input interface (catalog + output resolution) ───────────
|
|
24
24
|
export { indexCatalog, resolveNodeOutputFields, resolveNodeTriggerPayload, } from "./catalog.js";
|
|
25
25
|
// ── Template-grammar classifier → @flowget/types TemplateAst ─
|
|
26
|
-
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";
|
|
27
27
|
// ── Coarse value-type lattice ───────────────────────────────
|
|
28
28
|
export { isAssignable, isConcreteScalar, isWildcardValueType, jsValueType, } from "./value-type.js";
|
|
29
29
|
// ── Reachability ────────────────────────────────────────────
|
package/dist/issues.d.ts
CHANGED
|
@@ -77,6 +77,15 @@ export declare const ISSUE_CODES: {
|
|
|
77
77
|
readonly RUN_INPUT_MISSING: "validation/run-input-missing";
|
|
78
78
|
/** A template path uses a forbidden prototype-chain token. */
|
|
79
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";
|
|
80
89
|
/** A whole-string template resolves to a value-type incompatible with the consumer field. */
|
|
81
90
|
readonly TYPE_MISMATCH: "validation/type-mismatch";
|
|
82
91
|
/** A required field is empty. */
|
package/dist/issues.js
CHANGED
|
@@ -54,6 +54,15 @@ export const ISSUE_CODES = {
|
|
|
54
54
|
RUN_INPUT_MISSING: "validation/run-input-missing",
|
|
55
55
|
/** A template path uses a forbidden prototype-chain token. */
|
|
56
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",
|
|
57
66
|
/** A whole-string template resolves to a value-type incompatible with the consumer field. */
|
|
58
67
|
TYPE_MISMATCH: "validation/type-mismatch",
|
|
59
68
|
/** A required field is empty. */
|
package/dist/references.js
CHANGED
|
@@ -28,7 +28,7 @@ import { resolveNodeOutputFields, resolveNodeTriggerPayload, } from "./catalog.j
|
|
|
28
28
|
import { buildFieldTree, resolvePath, } from "./field-tree.js";
|
|
29
29
|
import { ISSUE_CODES } from "./issues.js";
|
|
30
30
|
import { buildReversedAdjacency, collectUpstreamFrom } from "./reachability.js";
|
|
31
|
-
import { hasForbiddenPathToken, hasTemplateExpression, isWholeStringTemplate, parseTemplate, templateExpressions, } from "./template-ast.js";
|
|
31
|
+
import { hasEmptyPathSegment, hasForbiddenPathToken, hasTemplateExpression, isWholeStringTemplate, parseTemplate, templateExpressions, } from "./template-ast.js";
|
|
32
32
|
import { isAssignable, isConcreteScalar } from "./value-type.js";
|
|
33
33
|
/** Bound on nested-data recursion (rows / key-value / nested objects). */
|
|
34
34
|
const MAX_DATA_DEPTH = 64;
|
|
@@ -144,6 +144,22 @@ function validateTemplateString(raw, isTopLevel, site, ctx, issues) {
|
|
|
144
144
|
}
|
|
145
145
|
/** Validate one operand's producer existence + reachability + declared-output. */
|
|
146
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
|
+
}
|
|
147
163
|
if (path.kind === "v1-reserved") {
|
|
148
164
|
const ns = path.segments[0];
|
|
149
165
|
// `workflow.*` / `input.*` are opaque execution primitives — permissive.
|
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-
|
package/dist/trigger-inputs.d.ts
CHANGED
|
@@ -32,6 +32,11 @@
|
|
|
32
32
|
* This is `input`-shape-agnostic: it never inspects declared payload types, so
|
|
33
33
|
* the identical function serves a seeded test object and a live event payload.
|
|
34
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.
|
|
35
40
|
*/
|
|
36
41
|
import type { WorkflowGraph } from "@flowget/types";
|
|
37
42
|
import { type CatalogNode } from "./catalog.js";
|
package/dist/trigger-inputs.js
CHANGED
|
@@ -32,11 +32,28 @@
|
|
|
32
32
|
* This is `input`-shape-agnostic: it never inspects declared payload types, so
|
|
33
33
|
* the identical function serves a seeded test object and a live event payload.
|
|
34
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.
|
|
35
40
|
*/
|
|
36
41
|
import { indexCatalog } from "./catalog.js";
|
|
37
42
|
import { finalizeIssues, ISSUE_CODES, } from "./issues.js";
|
|
38
43
|
import { hasForbiddenPathToken, hasTemplateExpression, parseTemplate, templateExpressions, } from "./template-ast.js";
|
|
39
|
-
/**
|
|
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
|
+
*/
|
|
40
57
|
const MAX_DATA_DEPTH = 64;
|
|
41
58
|
/**
|
|
42
59
|
* Flag every `{{ trigger.<path> }}` the graph references that the run `input`
|
|
@@ -131,6 +148,12 @@ function collectFromString(raw, paths) {
|
|
|
131
148
|
const pathSegments = operand.segments.slice(1);
|
|
132
149
|
if (pathSegments.length === 0)
|
|
133
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.
|
|
134
157
|
if (pathSegments.some((s) => s === ""))
|
|
135
158
|
continue;
|
|
136
159
|
paths.add(pathSegments.join("."));
|
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",
|