@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 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
 
@@ -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. }}`) is permissive the grammar admits it, but it names no
112
- // declared key, so flagging it would be a false positive (unchanged behavior).
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. */
@@ -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.
@@ -43,6 +43,16 @@ export declare function isReservedNamespace(token: string): boolean;
43
43
  export declare const FORBIDDEN_PATH_TOKENS: ReadonlySet<string>;
44
44
  /** `true` iff any segment of `path` is a forbidden prototype-chain token. */
45
45
  export declare function hasForbiddenPathToken(path: TemplatePath): boolean;
46
+ /**
47
+ * `true` iff `path` has an empty/blank segment — a leading, trailing, or
48
+ * doubled dot (`trigger.`, `.foo`, `a..b`, `steps.x.output.`). `PATH_RE`
49
+ * (`^[\w.-]+$`) admits these, but the engine resolver throws
50
+ * `missing_path` / `missing_step` on an empty token unconditionally
51
+ * (input-independent), so a static verdict treats them as invalid. This is a
52
+ * grammar-level property (like {@link hasForbiddenPathToken}), independent of
53
+ * any catalog declaration.
54
+ */
55
+ export declare function hasEmptyPathSegment(path: TemplatePath): boolean;
46
56
  /**
47
57
  * Defensive upper bound on a template-bearing string. Beyond this the
48
58
  * string is treated as a single literal (no token scan) — a belt-and-
@@ -54,6 +54,18 @@ export const FORBIDDEN_PATH_TOKENS = new Set([
54
54
  export function hasForbiddenPathToken(path) {
55
55
  return path.segments.some((s) => FORBIDDEN_PATH_TOKENS.has(s));
56
56
  }
57
+ /**
58
+ * `true` iff `path` has an empty/blank segment — a leading, trailing, or
59
+ * doubled dot (`trigger.`, `.foo`, `a..b`, `steps.x.output.`). `PATH_RE`
60
+ * (`^[\w.-]+$`) admits these, but the engine resolver throws
61
+ * `missing_path` / `missing_step` on an empty token unconditionally
62
+ * (input-independent), so a static verdict treats them as invalid. This is a
63
+ * grammar-level property (like {@link hasForbiddenPathToken}), independent of
64
+ * any catalog declaration.
65
+ */
66
+ export function hasEmptyPathSegment(path) {
67
+ return path.segments.some((s) => s === "");
68
+ }
57
69
  /**
58
70
  * Defensive upper bound on a template-bearing string. Beyond this the
59
71
  * string is treated as a single literal (no token scan) — a belt-and-
@@ -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";
@@ -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
- /** Bound on nested-config recursion — mirrors the reference walker. */
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.2.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",