-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgetFlowValidation.ts
More file actions
210 lines (181 loc) · 8.86 KB
/
Copy pathgetFlowValidation.ts
File metadata and controls
210 lines (181 loc) · 8.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
import ts, {flattenDiagnosticMessageText} from "typescript";
import {DataType, Flow, FunctionDefinition, NodeFunction} from "@code0-tech/sagittarius-graphql-types";
import {createCompilerHost, generateFlowSourceCode, ValidationResult} from "../utils";
// TypeScript diagnostic codes we may soften into warnings for union-branch references.
const TS_ARGUMENT_NOT_ASSIGNABLE = 2345; // "Argument of type X is not assignable to parameter of type Y."
const TS_PROPERTY_DOES_NOT_EXIST = 2339; // "Property 'p' does not exist on type X."
/**
* Finds the innermost AST node that fully contains the [start, end) span.
*/
const findInnermostNode = (node: ts.Node, start: number, end: number): ts.Node | undefined => {
if (node.getStart() > start || node.getEnd() < end) return undefined;
let result: ts.Node = node;
node.forEachChild((child) => {
const found = findInnermostNode(child, start, end);
if (found) result = found;
});
return result;
};
/**
* Decides whether a type error stems from a reference whose value type is a union
* where at least one branch would satisfy the expected type, but not all of them do:
*
* - a nullable reference — `TEXT | null` used for a plain TEXT parameter (the value
* might be null/undefined at runtime); or
* - a union-branch reference — `flexible: TEXT | { deep: TEXT }` used for a plain
* TEXT parameter, or drilling into `flexible.deep` which only exists on the object
* branch (the value might be the wrong branch at runtime).
*
* Both are references the schema engine offers as suggestions, so using them is a
* warning rather than a hard error — the flow stays valid. Genuine mismatches where
* no branch fits (e.g. `NUMBER | null` → TEXT) stay errors.
*/
const isSoftReferenceMismatch = (
diagnostic: ts.Diagnostic,
sourceFile: ts.SourceFile,
checker: ts.TypeChecker
): boolean => {
if (diagnostic.start === undefined || diagnostic.length === undefined) return false;
const node = findInnermostNode(sourceFile, diagnostic.start, diagnostic.start + diagnostic.length);
if (!node) return false;
// Argument not assignable: the argument's type is a union and at least one of its
// non-nullish branches is assignable to the contextually expected parameter type.
// Nullish branches are excluded so that `NUMBER | null` (no assignable base branch)
// stays a hard error, while `TEXT | null` and `TEXT | { deep: TEXT }` soften.
if (diagnostic.code === TS_ARGUMENT_NOT_ASSIGNABLE && ts.isExpression(node)) {
const argType = checker.getTypeAtLocation(node);
if (!argType.isUnion()) return false;
const expectedType = checker.getContextualType(node);
if (!expectedType) return false;
return argType.types.some((branch) =>
(branch.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) === 0 &&
checker.isTypeAssignableTo(branch, expectedType)
);
}
// Property access into a union branch: the accessed object is a union and the
// property exists on at least one of its branches.
if (diagnostic.code === TS_PROPERTY_DOES_NOT_EXIST && ts.isPropertyAccessExpression(node.parent)) {
const objectType = checker.getNonNullableType(checker.getTypeAtLocation(node.parent.expression));
if (!objectType.isUnion()) return false;
const propertyName = node.getText();
return objectType.types.some((branch) => branch.getProperty(propertyName) !== undefined);
}
return false;
};
/**
* Validates a flow by generating virtual TypeScript code and running it through the TS compiler.
*/
export const getFlowValidation = (
flow?: Flow,
functions?: FunctionDefinition[],
dataTypes?: DataType[]
): ValidationResult => {
if (!flow?.startingNodeId) {
return {
isValid: false,
returnType: "void",
diagnostics: [{
nodeId: null,
parameterIndex: null,
code: 0,
message: "You need to provide a starting node to be able to execute this flow.",
severity: "error",
}]
}
}
if (!flow.nodes?.nodes?.find(n => n?.id == flow.startingNodeId)) {
return {
isValid: false,
returnType: "void",
diagnostics: [{
nodeId: null,
parameterIndex: null,
code: 0,
message: "The starting node is not linked within the flow. Please make sure the starting node is connected to the rest of the flow.",
severity: "error",
}]
}
}
const functionIdentifiers = new Set(functions?.map(f => f.identifier));
const unreachableFunctionDiagnostics = (flow.nodes?.nodes ?? [])
.filter(n => n?.functionDefinition && !functionIdentifiers.has(n.functionDefinition.identifier))
.map(n => ({
nodeId: n!.id,
parameterIndex: null,
code: 0,
message: `The function definition "${n!.functionDefinition!.identifier}" is not reachable.`,
severity: "error" as const,
}));
if (unreachableFunctionDiagnostics.length > 0) {
return {
isValid: false,
returnType: "void",
diagnostics: unreachableFunctionDiagnostics,
}
}
// Validation keeps reference nullability (assertNonNullReferences = false) so that a
// possibly-null reference into a non-null parameter surfaces as a diagnostic, which is
// then downgraded to a warning below instead of being silently suppressed by a `!`.
const sourceCode = generateFlowSourceCode(flow, functions, dataTypes, false, false);
// 3. Virtual TypeScript Compilation
const fileName = "index.ts";
const host = createCompilerHost(fileName, sourceCode);
const sourceFile = host.getSourceFile(fileName)!;
const program = host.languageService.getProgram()!;
const checker = program.getTypeChecker();
const diagnostics = program.getSemanticDiagnostics(sourceFile);
const errors = diagnostics.map(d => {
const message = flattenDiagnosticMessageText(d.messageText, "\n");
// "Argument of type 'undefined' is not assignable to parameter of type 'number'."
// We ignore this in flow validation too because we might generate code for incomplete flows.
let nodeId: NodeFunction['id'] | undefined;
let parameterIndex: number | null = null;
if (d.start !== undefined) {
const fullText = sourceFile.getFullText();
// Search for position marker comment near the error location
// The error position is typically the start of the problematic token (e.g., "undefined")
const searchStart = Math.max(0, d.start - 300);
const searchEnd = Math.min(fullText.length, d.start);
const searchText = fullText.substring(searchStart, searchEnd);
// Find all @pos comments in the search range
const posRegex = /\/\* @pos ([^ ]+) (\d+|null) \*\//g;
let match;
let closestMatch: RegExpExecArray | null = null;
let closestCommentEnd = -1;
// Collect all matches and find the one whose end is closest to d.start
// We want the comment that is immediately before the error
while ((match = posRegex.exec(searchText)) !== null) {
const commentStart = searchStart + match.index;
const commentEnd = commentStart + match[0].length;
// Only consider comments that end before or very close to the error start
// This ensures we get the @pos comment that directly precedes the problematic argument
if (commentEnd <= d.start!) {
if (commentEnd > closestCommentEnd) {
closestCommentEnd = commentEnd;
closestMatch = match;
}
}
}
if (closestMatch) {
nodeId = closestMatch[1] === "null" ? null : closestMatch[1] as NodeFunction['id'];
parameterIndex = parseInt(closestMatch[2], 10);
}
}
// A nullable reference (`TEXT | null`) or a union-branch reference
// (`TEXT | { deep: TEXT }`) into a non-null parameter is a valid suggestion, so its
// mismatch is a warning rather than a hard error — the flow stays valid.
const severity: "error" | "warning" = isSoftReferenceMismatch(d, sourceFile, checker) ? "warning" : "error";
return {
message,
code: d.code,
severity,
nodeId,
parameterIndex: typeof parameterIndex == "number" && Number.isSafeInteger(parameterIndex) ? parameterIndex : null,
};
}).filter((e) => e !== null);
return {
isValid: !errors.some(e => e?.severity === "error"),
returnType: "void",
diagnostics: errors,
};
};