@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,21 @@
1
+ /**
2
+ * `@flowget/graph-validation` — the single source of static workflow-graph
3
+ * validation logic, shared by the builder (design-time, pre-submit) and the
4
+ * worker (host-side, pre-flight) so their verdicts are EQUAL BY CONSTRUCTION.
5
+ *
6
+ * Pure, isomorphic, zero-runtime-dependency (peer `@flowget/types` only). No
7
+ * `zod`, no `react`, no Node built-ins, no `Date` / `Math.random` — the
8
+ * identical bundle runs in the browser and host-side.
9
+ *
10
+ * Primary entry point: {@link validateGraphStatic}. The grammar classifier,
11
+ * value-type lattice, reachability walker, and coherence comparator are all
12
+ * exported standalone so consumers (and the engine, which will adopt the
13
+ * classifier) can compose them directly.
14
+ */
15
+ export { validateGraphStatic } from "./validate-graph.js";
16
+ export { ISSUE_CODES, type IssueCode, type ValidationIssue, type ValidationSeverity, } from "./issues.js";
17
+ 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";
19
+ export { isAssignable, isConcreteScalar, isWildcardValueType, jsValueType, } from "./value-type.js";
20
+ export { buildReversedAdjacency, collectUpstream, collectUpstreamFrom, } from "./reachability.js";
21
+ export { compareFieldCoherence, type CoherenceMismatch, type CoherenceMismatchKind, type SchemaFieldProjection, type SchemaProjection, } from "./coherence.js";
package/dist/index.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `@flowget/graph-validation` — the single source of static workflow-graph
3
+ * validation logic, shared by the builder (design-time, pre-submit) and the
4
+ * worker (host-side, pre-flight) so their verdicts are EQUAL BY CONSTRUCTION.
5
+ *
6
+ * Pure, isomorphic, zero-runtime-dependency (peer `@flowget/types` only). No
7
+ * `zod`, no `react`, no Node built-ins, no `Date` / `Math.random` — the
8
+ * identical bundle runs in the browser and host-side.
9
+ *
10
+ * Primary entry point: {@link validateGraphStatic}. The grammar classifier,
11
+ * value-type lattice, reachability walker, and coherence comparator are all
12
+ * exported standalone so consumers (and the engine, which will adopt the
13
+ * classifier) can compose them directly.
14
+ */
15
+ // ── Primary aggregator ──────────────────────────────────────
16
+ export { validateGraphStatic } from "./validate-graph.js";
17
+ // ── Public output contract ──────────────────────────────────
18
+ export { ISSUE_CODES, } from "./issues.js";
19
+ // ── Input interface (catalog + output resolution) ───────────
20
+ export { indexCatalog, resolveNodeOutputFields, resolveNodeTriggerPayload, } from "./catalog.js";
21
+ // ── 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";
23
+ // ── Coarse value-type lattice ───────────────────────────────
24
+ export { isAssignable, isConcreteScalar, isWildcardValueType, jsValueType, } from "./value-type.js";
25
+ // ── Reachability ────────────────────────────────────────────
26
+ export { buildReversedAdjacency, collectUpstream, collectUpstreamFrom, } from "./reachability.js";
27
+ // ── node.json ↔ schema coherence (worker boot-coherence) ────
28
+ export { compareFieldCoherence, } from "./coherence.js";
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The package's PUBLIC OUTPUT contract — {@link ValidationIssue} — plus the
3
+ * stable machine {@link ISSUE_CODES} and a deterministic id-assigning
4
+ * collector.
5
+ *
6
+ * The shape mirrors the builder's existing `ValidationIssue`
7
+ * (`id`/`severity`/`message` + optional `nodeId`/`edgeId`/`fieldKey`/`code`)
8
+ * so the builder can spread these issues straight into its
9
+ * `validationIssues` array, and the worker adopts the same shape — one
10
+ * output vocabulary across both consumers.
11
+ *
12
+ * **Recommendation (flagged for the chain review): promote
13
+ * {@link ValidationIssue} to `@flowget/types`.** It is a cross-repo output
14
+ * vocabulary consumed by builder + worker (and surfaced by the engine),
15
+ * exactly the role `@flowget/types` already fills for `TemplateAst`,
16
+ * `WorkflowGraph`, and `NodeValidationIssue`. It lives here for 0.1.0 only
17
+ * because `@flowget/types@0.5.0` is already published and out of scope for
18
+ * this ticket; the next types minor (0.6.0) should host it and this package
19
+ * should re-export it, so all three consumers converge on one type identity.
20
+ */
21
+ /** Blocking (`error`) vs advisory (`warning` / `info`). Matches the builder's `ValidationSeverity`. */
22
+ export type ValidationSeverity = "error" | "warning" | "info";
23
+ /**
24
+ * One validation finding. `nodeId` / `edgeId` / `fieldKey` scope it to a
25
+ * graph instance so a consumer's renderer resolves back to the exact node /
26
+ * edge / field. `code` is the stable machine code (see {@link ISSUE_CODES});
27
+ * `id` is a deterministic, per-run-unique key suitable for a render list.
28
+ */
29
+ export type ValidationIssue = {
30
+ /** Deterministic, unique-within-a-run key (stable for a given graph+catalog). */
31
+ id: string;
32
+ severity: ValidationSeverity;
33
+ /** Human-readable message; reference/type issues name BOTH endpoints. */
34
+ message: string;
35
+ /** Issue scoped to a specific node. */
36
+ nodeId?: string;
37
+ /** Issue scoped to a specific edge. */
38
+ edgeId?: string;
39
+ /** Config field key within a node, when the issue is field-scoped. */
40
+ fieldKey?: string;
41
+ /** Stable machine code for telemetry / translation (see {@link ISSUE_CODES}). */
42
+ code?: string;
43
+ };
44
+ /**
45
+ * Stable machine codes. Consumers switch/telemetry on these; the human
46
+ * `message` may change wording without a code change.
47
+ */
48
+ export declare const ISSUE_CODES: {
49
+ /** `WorkflowNode.type` is absent from the catalog. */
50
+ readonly UNKNOWN_NODE_TYPE: "validation/unknown-node-type";
51
+ /** A template references a node id that does not exist in the graph. */
52
+ readonly TEMPLATE_UNKNOWN_NODE: "validation/template-unknown-node";
53
+ /** A template references a node that is not an upstream ancestor of the consumer. */
54
+ readonly TEMPLATE_UNREACHABLE: "validation/template-unreachable";
55
+ /** A template references an output/payload key the producer does not declare. */
56
+ readonly TEMPLATE_UNKNOWN_OUTPUT: "validation/template-unknown-output";
57
+ /** A template path uses a forbidden prototype-chain token. */
58
+ readonly TEMPLATE_FORBIDDEN_TOKEN: "validation/template-forbidden-token";
59
+ /** A whole-string template resolves to a value-type incompatible with the consumer field. */
60
+ readonly TYPE_MISMATCH: "validation/type-mismatch";
61
+ /** A required field is empty. */
62
+ readonly FIELD_REQUIRED: "validation/field-required";
63
+ /** A literal field value's coarse type mismatches the field's declared value type. */
64
+ readonly FIELD_TYPE: "validation/field-type";
65
+ /** A `select` field value is not one of the declared options. */
66
+ readonly FIELD_ENUM: "validation/field-enum";
67
+ /** A numeric field value is below `min`. */
68
+ readonly FIELD_MIN: "validation/field-min";
69
+ /** A numeric field value is above `max`. */
70
+ readonly FIELD_MAX: "validation/field-max";
71
+ /** A string field value fails the field's `pattern`. */
72
+ readonly FIELD_PATTERN: "validation/field-pattern";
73
+ };
74
+ export type IssueCode = (typeof ISSUE_CODES)[keyof typeof ISSUE_CODES];
75
+ /** An issue before its deterministic `id` is assigned. */
76
+ export type RawIssue = Omit<ValidationIssue, "id">;
77
+ /**
78
+ * Assign each raw issue a deterministic, per-run-unique `id`. The id is a
79
+ * composite of `code` + scope + an occurrence ordinal (so two issues
80
+ * sharing a code/node/field — e.g. two dangling refs in one field — still
81
+ * get distinct ids). Order-dependent only, hence fully deterministic for a
82
+ * given `(graph, catalog)`. No `Date` / random.
83
+ */
84
+ export declare function finalizeIssues(raw: readonly RawIssue[]): ValidationIssue[];
package/dist/issues.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The package's PUBLIC OUTPUT contract — {@link ValidationIssue} — plus the
3
+ * stable machine {@link ISSUE_CODES} and a deterministic id-assigning
4
+ * collector.
5
+ *
6
+ * The shape mirrors the builder's existing `ValidationIssue`
7
+ * (`id`/`severity`/`message` + optional `nodeId`/`edgeId`/`fieldKey`/`code`)
8
+ * so the builder can spread these issues straight into its
9
+ * `validationIssues` array, and the worker adopts the same shape — one
10
+ * output vocabulary across both consumers.
11
+ *
12
+ * **Recommendation (flagged for the chain review): promote
13
+ * {@link ValidationIssue} to `@flowget/types`.** It is a cross-repo output
14
+ * vocabulary consumed by builder + worker (and surfaced by the engine),
15
+ * exactly the role `@flowget/types` already fills for `TemplateAst`,
16
+ * `WorkflowGraph`, and `NodeValidationIssue`. It lives here for 0.1.0 only
17
+ * because `@flowget/types@0.5.0` is already published and out of scope for
18
+ * this ticket; the next types minor (0.6.0) should host it and this package
19
+ * should re-export it, so all three consumers converge on one type identity.
20
+ */
21
+ /**
22
+ * Stable machine codes. Consumers switch/telemetry on these; the human
23
+ * `message` may change wording without a code change.
24
+ */
25
+ export const ISSUE_CODES = {
26
+ /** `WorkflowNode.type` is absent from the catalog. */
27
+ UNKNOWN_NODE_TYPE: "validation/unknown-node-type",
28
+ /** A template references a node id that does not exist in the graph. */
29
+ TEMPLATE_UNKNOWN_NODE: "validation/template-unknown-node",
30
+ /** A template references a node that is not an upstream ancestor of the consumer. */
31
+ TEMPLATE_UNREACHABLE: "validation/template-unreachable",
32
+ /** A template references an output/payload key the producer does not declare. */
33
+ TEMPLATE_UNKNOWN_OUTPUT: "validation/template-unknown-output",
34
+ /** A template path uses a forbidden prototype-chain token. */
35
+ TEMPLATE_FORBIDDEN_TOKEN: "validation/template-forbidden-token",
36
+ /** A whole-string template resolves to a value-type incompatible with the consumer field. */
37
+ TYPE_MISMATCH: "validation/type-mismatch",
38
+ /** A required field is empty. */
39
+ FIELD_REQUIRED: "validation/field-required",
40
+ /** A literal field value's coarse type mismatches the field's declared value type. */
41
+ FIELD_TYPE: "validation/field-type",
42
+ /** A `select` field value is not one of the declared options. */
43
+ FIELD_ENUM: "validation/field-enum",
44
+ /** A numeric field value is below `min`. */
45
+ FIELD_MIN: "validation/field-min",
46
+ /** A numeric field value is above `max`. */
47
+ FIELD_MAX: "validation/field-max",
48
+ /** A string field value fails the field's `pattern`. */
49
+ FIELD_PATTERN: "validation/field-pattern",
50
+ };
51
+ /**
52
+ * Assign each raw issue a deterministic, per-run-unique `id`. The id is a
53
+ * composite of `code` + scope + an occurrence ordinal (so two issues
54
+ * sharing a code/node/field — e.g. two dangling refs in one field — still
55
+ * get distinct ids). Order-dependent only, hence fully deterministic for a
56
+ * given `(graph, catalog)`. No `Date` / random.
57
+ */
58
+ export function finalizeIssues(raw) {
59
+ const ordinals = new Map();
60
+ return raw.map((issue) => {
61
+ const key = `${issue.code ?? ""}#${issue.nodeId ?? ""}#${issue.edgeId ?? ""}#${issue.fieldKey ?? ""}`;
62
+ const n = ordinals.get(key) ?? 0;
63
+ ordinals.set(key, n + 1);
64
+ return { ...issue, id: `${key}#${n}` };
65
+ });
66
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Graph reachability — "which nodes ran before node X". A `{{ steps.P.… }}`
3
+ * reference resolves at runtime only if P's output is already in scope, i.e.
4
+ * P is an UPSTREAM ANCESTOR of the consumer. Lifted from the builder's
5
+ * `variables.ts` `collectUpstream`, so the reachable set the validator
6
+ * computes is identical to the set the builder's variable picker walks.
7
+ *
8
+ * The reversed adjacency is built ONCE ({@link buildReversedAdjacency}) and
9
+ * reused across every {@link collectUpstreamFrom} walk. This is the hot path —
10
+ * the validator runs on every builder submit and every worker pre-flight —
11
+ * so rebuilding the map per consumer node (O(V·(V+E))) is avoided.
12
+ */
13
+ import type { WorkflowEdge } from "@flowget/types";
14
+ /**
15
+ * Reversed adjacency: node id → its DIRECT predecessor (edge `source`) ids.
16
+ * Built once from the full edge list in O(E); reused by every
17
+ * {@link collectUpstreamFrom} call over the same graph.
18
+ */
19
+ export declare function buildReversedAdjacency(edges: readonly WorkflowEdge[]): ReadonlyMap<string, string[]>;
20
+ /**
21
+ * Every node id transitively upstream of `nodeId`, walking a PREBUILT reversed
22
+ * adjacency. Iterative DFS with a visited set — handles diamonds and tolerates
23
+ * cycles. `nodeId` itself is NOT included (a node is not its own ancestor — a
24
+ * self-reference is correctly unreachable). O(V+E) per call over the prebuilt
25
+ * map.
26
+ */
27
+ export declare function collectUpstreamFrom(nodeId: string, adjacency: ReadonlyMap<string, string[]>): Set<string>;
28
+ /**
29
+ * Convenience wrapper — {@link buildReversedAdjacency} + a single
30
+ * {@link collectUpstreamFrom} walk. Kept as the stable public entry point.
31
+ *
32
+ * When resolving upstream sets for MANY nodes over the same edge list (the
33
+ * validator's own hot path), build the adjacency ONCE with
34
+ * {@link buildReversedAdjacency} and call {@link collectUpstreamFrom} per node
35
+ * instead — this wrapper rebuilds the map on every call.
36
+ */
37
+ export declare function collectUpstream(nodeId: string, edges: readonly WorkflowEdge[]): Set<string>;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Graph reachability — "which nodes ran before node X". A `{{ steps.P.… }}`
3
+ * reference resolves at runtime only if P's output is already in scope, i.e.
4
+ * P is an UPSTREAM ANCESTOR of the consumer. Lifted from the builder's
5
+ * `variables.ts` `collectUpstream`, so the reachable set the validator
6
+ * computes is identical to the set the builder's variable picker walks.
7
+ *
8
+ * The reversed adjacency is built ONCE ({@link buildReversedAdjacency}) and
9
+ * reused across every {@link collectUpstreamFrom} walk. This is the hot path —
10
+ * the validator runs on every builder submit and every worker pre-flight —
11
+ * so rebuilding the map per consumer node (O(V·(V+E))) is avoided.
12
+ */
13
+ /**
14
+ * Reversed adjacency: node id → its DIRECT predecessor (edge `source`) ids.
15
+ * Built once from the full edge list in O(E); reused by every
16
+ * {@link collectUpstreamFrom} call over the same graph.
17
+ */
18
+ export function buildReversedAdjacency(edges) {
19
+ const adjacency = new Map();
20
+ for (const e of edges) {
21
+ const bucket = adjacency.get(e.target);
22
+ if (bucket)
23
+ bucket.push(e.source);
24
+ else
25
+ adjacency.set(e.target, [e.source]);
26
+ }
27
+ return adjacency;
28
+ }
29
+ /**
30
+ * Every node id transitively upstream of `nodeId`, walking a PREBUILT reversed
31
+ * adjacency. Iterative DFS with a visited set — handles diamonds and tolerates
32
+ * cycles. `nodeId` itself is NOT included (a node is not its own ancestor — a
33
+ * self-reference is correctly unreachable). O(V+E) per call over the prebuilt
34
+ * map.
35
+ */
36
+ export function collectUpstreamFrom(nodeId, adjacency) {
37
+ const visited = new Set();
38
+ const stack = [...(adjacency.get(nodeId) ?? [])];
39
+ while (stack.length > 0) {
40
+ const id = stack.pop();
41
+ if (id === undefined || visited.has(id))
42
+ continue;
43
+ visited.add(id);
44
+ for (const up of adjacency.get(id) ?? [])
45
+ stack.push(up);
46
+ }
47
+ return visited;
48
+ }
49
+ /**
50
+ * Convenience wrapper — {@link buildReversedAdjacency} + a single
51
+ * {@link collectUpstreamFrom} walk. Kept as the stable public entry point.
52
+ *
53
+ * When resolving upstream sets for MANY nodes over the same edge list (the
54
+ * validator's own hot path), build the adjacency ONCE with
55
+ * {@link buildReversedAdjacency} and call {@link collectUpstreamFrom} per node
56
+ * instead — this wrapper rebuilds the map on every call.
57
+ */
58
+ export function collectUpstream(nodeId, edges) {
59
+ return collectUpstreamFrom(nodeId, buildReversedAdjacency(edges));
60
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Reference-existence + reachability + whole-string type-compat walker.
3
+ *
4
+ * For every non-opaque config value on every node, parse its `{{ … }}`
5
+ * templates and check each operand against the runtime resolver's rules:
6
+ *
7
+ * 1. **Existence** — a `steps.<id>` / bare `<id>` token must name a real
8
+ * graph node; a `steps.<id>.output.<key>` / `<id>.<field>` /
9
+ * `trigger.<key>` token must name a DECLARED output / trigger-payload
10
+ * field.
11
+ * 2. **Reachability** — the producer node must be an upstream ANCESTOR of
12
+ * the consumer (its output is only in scope if it ran first). A
13
+ * self-reference is correctly unreachable.
14
+ * 3. **Type-compat** — when the whole field value is a single
15
+ * `{{ … }}` token, compare the producer's declared value-type against
16
+ * the consumer field's via the coarse {@link isAssignable} lattice.
17
+ *
18
+ * Every rule is CONSERVATIVE: it fires only when the producer, its output,
19
+ * and the consumer field are all statically KNOWN and the mismatch is
20
+ * certain — permissive on unknown/undeclared/opaque, for zero false
21
+ * positives. `workflow.*` / `input.*` are opaque execution primitives
22
+ * (never flagged); `trigger.*` is checked only against declared trigger
23
+ * payloads and is NOT reachability-gated (the worker populates the reserved
24
+ * `trigger` namespace globally at runtime, independent of topology).
25
+ */
26
+ import type { WorkflowGraph } from "@flowget/types";
27
+ import { type CatalogIndex } from "./catalog.js";
28
+ import { type RawIssue } from "./issues.js";
29
+ /** Collect every reference/reachability/type-compat issue across the graph. */
30
+ export declare function collectReferenceIssues(graph: WorkflowGraph, index: CatalogIndex): RawIssue[];
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Reference-existence + reachability + whole-string type-compat walker.
3
+ *
4
+ * For every non-opaque config value on every node, parse its `{{ … }}`
5
+ * templates and check each operand against the runtime resolver's rules:
6
+ *
7
+ * 1. **Existence** — a `steps.<id>` / bare `<id>` token must name a real
8
+ * graph node; a `steps.<id>.output.<key>` / `<id>.<field>` /
9
+ * `trigger.<key>` token must name a DECLARED output / trigger-payload
10
+ * field.
11
+ * 2. **Reachability** — the producer node must be an upstream ANCESTOR of
12
+ * the consumer (its output is only in scope if it ran first). A
13
+ * self-reference is correctly unreachable.
14
+ * 3. **Type-compat** — when the whole field value is a single
15
+ * `{{ … }}` token, compare the producer's declared value-type against
16
+ * the consumer field's via the coarse {@link isAssignable} lattice.
17
+ *
18
+ * Every rule is CONSERVATIVE: it fires only when the producer, its output,
19
+ * and the consumer field are all statically KNOWN and the mismatch is
20
+ * certain — permissive on unknown/undeclared/opaque, for zero false
21
+ * positives. `workflow.*` / `input.*` are opaque execution primitives
22
+ * (never flagged); `trigger.*` is checked only against declared trigger
23
+ * payloads and is NOT reachability-gated (the worker populates the reserved
24
+ * `trigger` namespace globally at runtime, independent of topology).
25
+ */
26
+ import { resolveFieldValueType } from "@flowget/types";
27
+ import { resolveNodeOutputFields, resolveNodeTriggerPayload, } from "./catalog.js";
28
+ import { ISSUE_CODES } from "./issues.js";
29
+ import { buildReversedAdjacency, collectUpstreamFrom } from "./reachability.js";
30
+ import { hasForbiddenPathToken, hasTemplateExpression, isWholeStringTemplate, parseTemplate, templateExpressions, } from "./template-ast.js";
31
+ import { isAssignable, isConcreteScalar } from "./value-type.js";
32
+ /** Bound on nested-data recursion (rows / key-value / nested objects). */
33
+ const MAX_DATA_DEPTH = 64;
34
+ /** Collect every reference/reachability/type-compat issue across the graph. */
35
+ export function collectReferenceIssues(graph, index) {
36
+ const ctx = {
37
+ index,
38
+ nodeById: new Map(graph.nodes.map((n) => [n.id, n])),
39
+ triggerInfo: computeTriggerInfo(graph, index),
40
+ adjacency: buildReversedAdjacency(graph.edges),
41
+ upstreamCache: new Map(),
42
+ };
43
+ const issues = [];
44
+ for (const node of graph.nodes) {
45
+ const def = index.get(node.type);
46
+ const opaque = new Set(def?.opaqueFields ?? []);
47
+ const fieldsByKey = new Map((def?.fields ?? []).map((f) => [f.key, f]));
48
+ for (const [key, value] of Object.entries(node.data ?? {})) {
49
+ if (opaque.has(key))
50
+ continue;
51
+ const field = fieldsByKey.get(key);
52
+ const site = {
53
+ node,
54
+ fieldKey: key,
55
+ consumerType: field ? resolveFieldValueType(field) : undefined,
56
+ };
57
+ walkValue(value, true, 0, site, ctx, issues);
58
+ }
59
+ }
60
+ return issues;
61
+ }
62
+ /** Recursively descend config values, validating template strings at the leaves. */
63
+ function walkValue(value, isTopLevel, depth, site, ctx, issues) {
64
+ if (typeof value === "string") {
65
+ validateTemplateString(value, isTopLevel, site, ctx, issues);
66
+ return;
67
+ }
68
+ if (depth >= MAX_DATA_DEPTH)
69
+ return;
70
+ if (Array.isArray(value)) {
71
+ for (const item of value) {
72
+ walkValue(item, false, depth + 1, site, ctx, issues);
73
+ }
74
+ return;
75
+ }
76
+ if (value !== null && typeof value === "object") {
77
+ for (const child of Object.values(value)) {
78
+ walkValue(child, false, depth + 1, site, ctx, issues);
79
+ }
80
+ }
81
+ }
82
+ function validateTemplateString(raw, isTopLevel, site, ctx, issues) {
83
+ const ast = parseTemplate(raw);
84
+ if (!hasTemplateExpression(ast))
85
+ return;
86
+ const upstream = getUpstream(site.node.id, ctx);
87
+ const wholeString = isWholeStringTemplate(ast);
88
+ for (const { raw: tokenRaw, expression } of templateExpressions(ast)) {
89
+ // A `??` fallback chain RESCUES a missing operand at runtime (the engine
90
+ // swallows missing_step / missing_path and falls through to the next
91
+ // operand; only the whole chain going empty yields undefined/"" — it
92
+ // never throws). So existence / reachability / unknown-output are checked
93
+ // ONLY for a single-operand expression, where a miss genuinely throws.
94
+ // Flagging an operand inside a `??` chain would be a false positive: the
95
+ // chain resolves fine. The forbidden-path-token guard is the exception —
96
+ // the engine throws it even under `??`, so it is checked on every operand.
97
+ const singleOperand = expression.operands.length === 1;
98
+ for (const path of expression.operands) {
99
+ if (hasForbiddenPathToken(path)) {
100
+ issues.push({
101
+ code: ISSUE_CODES.TEMPLATE_FORBIDDEN_TOKEN,
102
+ severity: "error",
103
+ nodeId: site.node.id,
104
+ fieldKey: site.fieldKey,
105
+ message: `Node "${site.node.id}" template "${tokenRaw}" uses a forbidden path token (\`__proto__\` / \`constructor\` / \`prototype\`) and will be rejected at runtime.`,
106
+ });
107
+ continue;
108
+ }
109
+ if (singleOperand) {
110
+ validateOperandExistence(path, tokenRaw, site, ctx, upstream, issues);
111
+ }
112
+ }
113
+ // Type-compat: only for a whole-string, single-operand template into a
114
+ // concrete-scalar consumer field. Multi-operand `??` chains and
115
+ // interpolation are intentionally not type-checked (zero false positives).
116
+ if (wholeString &&
117
+ isTopLevel &&
118
+ site.consumerType !== undefined &&
119
+ isConcreteScalar(site.consumerType) &&
120
+ expression.operands.length === 1) {
121
+ const operand = expression.operands[0];
122
+ if (operand !== undefined) {
123
+ const producerType = producerValueType(operand, ctx, upstream);
124
+ if (producerType !== undefined &&
125
+ isConcreteScalar(producerType) &&
126
+ !isAssignable(producerType, site.consumerType)) {
127
+ issues.push({
128
+ code: ISSUE_CODES.TYPE_MISMATCH,
129
+ severity: "error",
130
+ nodeId: site.node.id,
131
+ fieldKey: site.fieldKey,
132
+ message: `Node "${site.node.id}" field "${site.fieldKey}" expects ${site.consumerType} but "${tokenRaw}" resolves to ${producerType}.`,
133
+ });
134
+ }
135
+ }
136
+ }
137
+ }
138
+ }
139
+ /** Validate one operand's producer existence + reachability + declared-output. */
140
+ function validateOperandExistence(path, tokenRaw, site, ctx, upstream, issues) {
141
+ if (path.kind === "v1-reserved") {
142
+ const ns = path.segments[0];
143
+ // `workflow.*` / `input.*` are opaque execution primitives — permissive.
144
+ if (ns === "trigger")
145
+ validateTriggerKey(path, tokenRaw, site, ctx, issues);
146
+ return;
147
+ }
148
+ // Resolve the producer node id per grammar: `steps.<id>.…` vs bare `<id>.…`.
149
+ const producerId = path.kind === "v2-step" ? path.segments[1] : path.segments[0];
150
+ if (producerId === undefined || producerId === "")
151
+ return;
152
+ const reachIssue = checkProducerReachable(producerId, tokenRaw, site, ctx, upstream);
153
+ if (reachIssue !== null) {
154
+ issues.push(reachIssue);
155
+ return;
156
+ }
157
+ // Declared-output check on the canonical envelope only.
158
+ // - v2-step: `steps.<id>.output.<key>` (segments[2] === "output").
159
+ // `steps.<id>.decorators.…` and other shapes are permissive.
160
+ // - v1-step: `<id>.<field>` maps to `output.<field>`.
161
+ if (path.kind === "v2-step") {
162
+ if (path.segments[2] === "output" && path.segments.length >= 4) {
163
+ checkOutputKey(producerId, path.segments[3], tokenRaw, site, ctx, issues);
164
+ }
165
+ return;
166
+ }
167
+ if (path.segments.length >= 2) {
168
+ checkOutputKey(producerId, path.segments[1], tokenRaw, site, ctx, issues);
169
+ }
170
+ }
171
+ function checkProducerReachable(producerId, tokenRaw, site, ctx, upstream) {
172
+ if (!ctx.nodeById.has(producerId)) {
173
+ return {
174
+ code: ISSUE_CODES.TEMPLATE_UNKNOWN_NODE,
175
+ severity: "error",
176
+ nodeId: site.node.id,
177
+ fieldKey: site.fieldKey,
178
+ message: `Node "${site.node.id}" references "${tokenRaw}" but no node "${producerId}" exists in the workflow.`,
179
+ };
180
+ }
181
+ if (producerId === site.node.id || !upstream.has(producerId)) {
182
+ return {
183
+ code: ISSUE_CODES.TEMPLATE_UNREACHABLE,
184
+ severity: "error",
185
+ nodeId: site.node.id,
186
+ fieldKey: site.fieldKey,
187
+ message: `Node "${site.node.id}" references "${tokenRaw}" but "${producerId}" is not upstream of "${site.node.id}" — its output is not available here.`,
188
+ };
189
+ }
190
+ return null;
191
+ }
192
+ function checkOutputKey(producerId, key, tokenRaw, site, ctx, issues) {
193
+ if (key === undefined || key === "")
194
+ return;
195
+ const producer = ctx.nodeById.get(producerId);
196
+ if (producer === undefined)
197
+ return;
198
+ const def = ctx.index.get(producer.type);
199
+ // Unknown node type is reported by the aggregator; decorators produce no
200
+ // step scope. Both permissive here.
201
+ if (def === undefined || def.category === "decorator")
202
+ return;
203
+ const outputs = resolveNodeOutputFields(def, producer.data ?? {});
204
+ if (outputs.length === 0)
205
+ return; // undeclared/unknown outputs → permissive
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
+ }
215
+ }
216
+ function validateTriggerKey(path, tokenRaw, site, ctx, issues) {
217
+ const key = path.segments[1];
218
+ if (key === undefined || key === "")
219
+ return;
220
+ const info = ctx.triggerInfo;
221
+ // No trigger at all → the missing-trigger rule covers it; some trigger
222
+ // payload unknown → permissive.
223
+ if (!info.hasTrigger || !info.allKnown)
224
+ return;
225
+ if (!info.keys.has(key)) {
226
+ issues.push({
227
+ code: ISSUE_CODES.TEMPLATE_UNKNOWN_OUTPUT,
228
+ severity: "error",
229
+ nodeId: site.node.id,
230
+ fieldKey: site.fieldKey,
231
+ message: `Node "${site.node.id}" references "${tokenRaw}", but no trigger declares a payload field "${key}".`,
232
+ });
233
+ }
234
+ }
235
+ /**
236
+ * The concrete value-type a fully-resolving reference produces, or
237
+ * `undefined` when it cannot be pinned down statically (drill-in, unknown
238
+ * node, unreachable, undeclared output, opaque namespace) — in which case
239
+ * type-compat is skipped.
240
+ */
241
+ function producerValueType(path, ctx, upstream) {
242
+ if (path.kind === "v1-reserved") {
243
+ if (path.segments[0] !== "trigger" || path.segments.length !== 2) {
244
+ return undefined;
245
+ }
246
+ const key = path.segments[1];
247
+ return key !== undefined ? ctx.triggerInfo.keyTypes.get(key) : undefined;
248
+ }
249
+ if (path.kind === "v2-step") {
250
+ if (path.segments.length !== 4 || path.segments[2] !== "output") {
251
+ return undefined;
252
+ }
253
+ return outputFieldType(path.segments[1], path.segments[3], ctx, upstream);
254
+ }
255
+ // v1-step: exactly `<id>.<field>`.
256
+ if (path.segments.length !== 2)
257
+ return undefined;
258
+ return outputFieldType(path.segments[0], path.segments[1], ctx, upstream);
259
+ }
260
+ function outputFieldType(producerId, key, ctx, upstream) {
261
+ if (producerId === undefined || key === undefined)
262
+ return undefined;
263
+ if (!upstream.has(producerId))
264
+ return undefined;
265
+ const producer = ctx.nodeById.get(producerId);
266
+ if (producer === undefined)
267
+ return undefined;
268
+ const def = ctx.index.get(producer.type);
269
+ if (def === undefined || def.category === "decorator")
270
+ return undefined;
271
+ const outputs = resolveNodeOutputFields(def, producer.data ?? {});
272
+ return outputs.find((f) => f.key === key)?.type;
273
+ }
274
+ function getUpstream(nodeId, ctx) {
275
+ let upstream = ctx.upstreamCache.get(nodeId);
276
+ if (upstream === undefined) {
277
+ upstream = collectUpstreamFrom(nodeId, ctx.adjacency);
278
+ ctx.upstreamCache.set(nodeId, upstream);
279
+ }
280
+ return upstream;
281
+ }
282
+ function computeTriggerInfo(graph, index) {
283
+ let hasTrigger = false;
284
+ let allKnown = true;
285
+ const keys = new Set();
286
+ // `null` marks a key whose type is ambiguous across triggers.
287
+ const keyTypes = new Map();
288
+ for (const node of graph.nodes) {
289
+ const def = index.get(node.type);
290
+ if (def?.category !== "trigger")
291
+ continue;
292
+ hasTrigger = true;
293
+ const payload = resolveNodeTriggerPayload(def, node.data ?? {});
294
+ if (payload.length === 0) {
295
+ allKnown = false;
296
+ continue;
297
+ }
298
+ for (const f of payload) {
299
+ keys.add(f.key);
300
+ if (!keyTypes.has(f.key))
301
+ keyTypes.set(f.key, f.type);
302
+ else if (keyTypes.get(f.key) !== f.type)
303
+ keyTypes.set(f.key, null);
304
+ }
305
+ }
306
+ const finalTypes = new Map();
307
+ for (const [k, v] of keyTypes) {
308
+ if (v !== null)
309
+ finalTypes.set(k, v);
310
+ }
311
+ return { hasTrigger, allKnown, keys, keyTypes: finalTypes };
312
+ }