@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
package/dist/index.d.ts
ADDED
|
@@ -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";
|
package/dist/issues.d.ts
ADDED
|
@@ -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
|
+
}
|