criterionx-vscode 0.3.1
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 +21 -0
- package/README.md +139 -0
- package/dist/extension.js +354 -0
- package/language-configuration.json +37 -0
- package/package.json +108 -0
- package/snippets/criterion.json +213 -0
- package/src/extension.ts +156 -0
- package/src/validators.test.ts +340 -0
- package/src/validators.ts +322 -0
- package/syntaxes/criterion-injection.tmLanguage.json +20 -0
- package/syntaxes/criterion.tmLanguage.json +103 -0
- package/tsconfig.json +20 -0
- package/vitest.config.mts +8 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure validation functions for Criterion decision files
|
|
3
|
+
* Extracted from extension.ts for testability
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface Diagnostic {
|
|
7
|
+
range: { start: number; end: number };
|
|
8
|
+
message: string;
|
|
9
|
+
severity: "error" | "warning" | "info";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Find the end of a block starting with {
|
|
14
|
+
*/
|
|
15
|
+
export function findBlockEnd(text: string, start: number): number {
|
|
16
|
+
let depth = 0;
|
|
17
|
+
let inString = false;
|
|
18
|
+
let stringChar = "";
|
|
19
|
+
|
|
20
|
+
for (let i = start; i < text.length; i++) {
|
|
21
|
+
const char = text[i];
|
|
22
|
+
const prevChar = i > 0 ? text[i - 1] : "";
|
|
23
|
+
|
|
24
|
+
if (inString) {
|
|
25
|
+
if (char === stringChar && prevChar !== "\\") {
|
|
26
|
+
inString = false;
|
|
27
|
+
}
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
32
|
+
inString = true;
|
|
33
|
+
stringChar = char;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (char === "{") {
|
|
38
|
+
depth++;
|
|
39
|
+
} else if (char === "}") {
|
|
40
|
+
depth--;
|
|
41
|
+
if (depth === 0) {
|
|
42
|
+
return i + 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return text.length;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Find the end of an array starting with [
|
|
52
|
+
*/
|
|
53
|
+
export function findArrayEnd(text: string, start: number): number {
|
|
54
|
+
let depth = 0;
|
|
55
|
+
let inString = false;
|
|
56
|
+
let stringChar = "";
|
|
57
|
+
|
|
58
|
+
for (let i = start; i < text.length; i++) {
|
|
59
|
+
const char = text[i];
|
|
60
|
+
const prevChar = i > 0 ? text[i - 1] : "";
|
|
61
|
+
|
|
62
|
+
if (inString) {
|
|
63
|
+
if (char === stringChar && prevChar !== "\\") {
|
|
64
|
+
inString = false;
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
70
|
+
inString = true;
|
|
71
|
+
stringChar = char;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (char === "[") {
|
|
76
|
+
depth++;
|
|
77
|
+
} else if (char === "]") {
|
|
78
|
+
depth--;
|
|
79
|
+
if (depth === 0) {
|
|
80
|
+
return i;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return text.length;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check if text content is a Criterion file
|
|
90
|
+
*/
|
|
91
|
+
export function isCriterionContent(fileName: string, text: string): boolean {
|
|
92
|
+
return (
|
|
93
|
+
fileName.endsWith(".criterion.ts") ||
|
|
94
|
+
(fileName.endsWith(".ts") &&
|
|
95
|
+
(text.includes("defineDecision") || text.includes("@criterionx/core")))
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check for missing id in decision
|
|
101
|
+
*/
|
|
102
|
+
export function checkMissingId(text: string): Diagnostic[] {
|
|
103
|
+
const diagnostics: Diagnostic[] = [];
|
|
104
|
+
|
|
105
|
+
const defineDecisionRegex = /defineDecision\s*\(\s*\{/g;
|
|
106
|
+
let match;
|
|
107
|
+
|
|
108
|
+
while ((match = defineDecisionRegex.exec(text)) !== null) {
|
|
109
|
+
const startPos = match.index;
|
|
110
|
+
const blockEnd = findBlockEnd(text, startPos);
|
|
111
|
+
const block = text.slice(startPos, blockEnd);
|
|
112
|
+
|
|
113
|
+
if (!block.includes("id:") && !block.includes("id :")) {
|
|
114
|
+
diagnostics.push({
|
|
115
|
+
range: { start: startPos, end: startPos + 14 },
|
|
116
|
+
message: "Decision is missing required 'id' property",
|
|
117
|
+
severity: "error",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return diagnostics;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Check for missing version in decision
|
|
127
|
+
*/
|
|
128
|
+
export function checkMissingVersion(text: string): Diagnostic[] {
|
|
129
|
+
const diagnostics: Diagnostic[] = [];
|
|
130
|
+
|
|
131
|
+
const defineDecisionRegex = /defineDecision\s*\(\s*\{/g;
|
|
132
|
+
let match;
|
|
133
|
+
|
|
134
|
+
while ((match = defineDecisionRegex.exec(text)) !== null) {
|
|
135
|
+
const startPos = match.index;
|
|
136
|
+
const blockEnd = findBlockEnd(text, startPos);
|
|
137
|
+
const block = text.slice(startPos, blockEnd);
|
|
138
|
+
|
|
139
|
+
if (!block.includes("version:") && !block.includes("version :")) {
|
|
140
|
+
diagnostics.push({
|
|
141
|
+
range: { start: startPos, end: startPos + 14 },
|
|
142
|
+
message: "Decision is missing required 'version' property",
|
|
143
|
+
severity: "error",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return diagnostics;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check for missing schemas
|
|
153
|
+
*/
|
|
154
|
+
export function checkMissingSchemas(text: string): Diagnostic[] {
|
|
155
|
+
const diagnostics: Diagnostic[] = [];
|
|
156
|
+
|
|
157
|
+
const defineDecisionRegex = /defineDecision\s*\(\s*\{/g;
|
|
158
|
+
let match;
|
|
159
|
+
|
|
160
|
+
while ((match = defineDecisionRegex.exec(text)) !== null) {
|
|
161
|
+
const startPos = match.index;
|
|
162
|
+
const blockEnd = findBlockEnd(text, startPos);
|
|
163
|
+
const block = text.slice(startPos, blockEnd);
|
|
164
|
+
|
|
165
|
+
const schemas = ["inputSchema", "outputSchema", "profileSchema"];
|
|
166
|
+
|
|
167
|
+
for (const schema of schemas) {
|
|
168
|
+
if (!block.includes(`${schema}:`)) {
|
|
169
|
+
diagnostics.push({
|
|
170
|
+
range: { start: startPos, end: startPos + 14 },
|
|
171
|
+
message: `Decision is missing required '${schema}' property`,
|
|
172
|
+
severity: "error",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return diagnostics;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Check for empty rules array
|
|
183
|
+
*/
|
|
184
|
+
export function checkEmptyRules(text: string): Diagnostic[] {
|
|
185
|
+
const diagnostics: Diagnostic[] = [];
|
|
186
|
+
|
|
187
|
+
const emptyRulesRegex = /rules\s*:\s*\[\s*\]/g;
|
|
188
|
+
let match;
|
|
189
|
+
|
|
190
|
+
while ((match = emptyRulesRegex.exec(text)) !== null) {
|
|
191
|
+
diagnostics.push({
|
|
192
|
+
range: { start: match.index, end: match.index + match[0].length },
|
|
193
|
+
message: "Decision has no rules defined. Add at least one rule.",
|
|
194
|
+
severity: "warning",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return diagnostics;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Check for missing rule properties
|
|
203
|
+
*/
|
|
204
|
+
export function checkMissingRuleProperties(text: string): Diagnostic[] {
|
|
205
|
+
const diagnostics: Diagnostic[] = [];
|
|
206
|
+
|
|
207
|
+
const rulesRegex = /rules\s*:\s*\[/g;
|
|
208
|
+
let rulesMatch;
|
|
209
|
+
|
|
210
|
+
while ((rulesMatch = rulesRegex.exec(text)) !== null) {
|
|
211
|
+
const rulesStart = rulesMatch.index + rulesMatch[0].length;
|
|
212
|
+
const rulesEnd = findArrayEnd(text, rulesMatch.index);
|
|
213
|
+
const rulesBlock = text.slice(rulesStart, rulesEnd);
|
|
214
|
+
|
|
215
|
+
const ruleRegex = /\{\s*id\s*:/g;
|
|
216
|
+
let ruleMatch;
|
|
217
|
+
|
|
218
|
+
while ((ruleMatch = ruleRegex.exec(rulesBlock)) !== null) {
|
|
219
|
+
const ruleStart = rulesStart + ruleMatch.index;
|
|
220
|
+
const ruleEnd = findBlockEnd(text, ruleStart);
|
|
221
|
+
const ruleBlock = text.slice(ruleStart, ruleEnd);
|
|
222
|
+
|
|
223
|
+
const requiredProps = ["when", "emit", "explain"];
|
|
224
|
+
|
|
225
|
+
for (const prop of requiredProps) {
|
|
226
|
+
if (!ruleBlock.includes(`${prop}:`)) {
|
|
227
|
+
diagnostics.push({
|
|
228
|
+
range: { start: ruleStart, end: ruleStart + 1 },
|
|
229
|
+
message: `Rule is missing required '${prop}' function`,
|
|
230
|
+
severity: "error",
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return diagnostics;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Validate all checks on text content
|
|
242
|
+
*/
|
|
243
|
+
export function validateCriterionContent(text: string): Diagnostic[] {
|
|
244
|
+
return [
|
|
245
|
+
...checkMissingId(text),
|
|
246
|
+
...checkMissingVersion(text),
|
|
247
|
+
...checkMissingSchemas(text),
|
|
248
|
+
...checkEmptyRules(text),
|
|
249
|
+
...checkMissingRuleProperties(text),
|
|
250
|
+
];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Hover documentation for Criterion keywords
|
|
255
|
+
*/
|
|
256
|
+
export const hoverDocs: Record<string, string> = {
|
|
257
|
+
defineDecision:
|
|
258
|
+
"**defineDecision(config)**\n\nCreate a new Criterion decision with type inference.\n\n```typescript\ndefineDecision({\n id: string,\n version: string,\n inputSchema: ZodSchema,\n outputSchema: ZodSchema,\n profileSchema: ZodSchema,\n rules: Rule[]\n})\n```",
|
|
259
|
+
inputSchema:
|
|
260
|
+
"**inputSchema**\n\nZod schema defining the input context for this decision.\n\nExample:\n```typescript\ninputSchema: z.object({\n amount: z.number(),\n country: z.string()\n})\n```",
|
|
261
|
+
outputSchema:
|
|
262
|
+
"**outputSchema**\n\nZod schema defining the output of this decision.\n\nExample:\n```typescript\noutputSchema: z.object({\n approved: z.boolean(),\n reason: z.string()\n})\n```",
|
|
263
|
+
profileSchema:
|
|
264
|
+
"**profileSchema**\n\nZod schema defining the profile parameters.\n\nExample:\n```typescript\nprofileSchema: z.object({\n threshold: z.number(),\n blockedCountries: z.array(z.string())\n})\n```",
|
|
265
|
+
when: "**when(ctx, profile)**\n\nCondition function that returns true if this rule should match.\n\n```typescript\nwhen: (ctx, profile) => ctx.amount > profile.threshold\n```",
|
|
266
|
+
emit: "**emit(ctx, profile)**\n\nFunction that returns the output when this rule matches.\n\n```typescript\nemit: (ctx, profile) => ({\n approved: false,\n reason: 'Amount too high'\n})\n```",
|
|
267
|
+
explain:
|
|
268
|
+
"**explain(ctx, profile)**\n\nFunction that returns a human-readable explanation.\n\n```typescript\nexplain: (ctx) => `Amount ${ctx.amount} exceeded limit`\n```",
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Get hover documentation for a word
|
|
273
|
+
*/
|
|
274
|
+
export function getHoverDoc(word: string): string | undefined {
|
|
275
|
+
return hoverDocs[word];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Generate decision template content
|
|
280
|
+
*/
|
|
281
|
+
export function generateDecisionTemplate(name: string): string {
|
|
282
|
+
const pascalName = name
|
|
283
|
+
.split("-")
|
|
284
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
285
|
+
.join("");
|
|
286
|
+
const camelName = pascalName.charAt(0).toLowerCase() + pascalName.slice(1);
|
|
287
|
+
|
|
288
|
+
return `import { defineDecision } from "@criterionx/core";
|
|
289
|
+
import { z } from "zod";
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* ${pascalName} Decision
|
|
293
|
+
*/
|
|
294
|
+
export const ${camelName} = defineDecision({
|
|
295
|
+
id: "${name}",
|
|
296
|
+
version: "1.0.0",
|
|
297
|
+
|
|
298
|
+
inputSchema: z.object({
|
|
299
|
+
// TODO: Define input schema
|
|
300
|
+
value: z.string(),
|
|
301
|
+
}),
|
|
302
|
+
|
|
303
|
+
outputSchema: z.object({
|
|
304
|
+
// TODO: Define output schema
|
|
305
|
+
result: z.string(),
|
|
306
|
+
}),
|
|
307
|
+
|
|
308
|
+
profileSchema: z.object({
|
|
309
|
+
// TODO: Define profile schema
|
|
310
|
+
}),
|
|
311
|
+
|
|
312
|
+
rules: [
|
|
313
|
+
{
|
|
314
|
+
id: "default",
|
|
315
|
+
when: () => true,
|
|
316
|
+
emit: () => ({ result: "OK" }),
|
|
317
|
+
explain: () => "Default rule",
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
});
|
|
321
|
+
`;
|
|
322
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
|
|
3
|
+
"name": "Criterion Injection",
|
|
4
|
+
"scopeName": "source.criterion.injection",
|
|
5
|
+
"injectionSelector": "L:source.ts, L:source.tsx",
|
|
6
|
+
"patterns": [
|
|
7
|
+
{
|
|
8
|
+
"name": "keyword.control.criterion.injection",
|
|
9
|
+
"match": "\\b(defineDecision|createRule|createProfileRegistry)\\b"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "constant.language.status.criterion.injection",
|
|
13
|
+
"match": "\\b(OK|NO_MATCH|INVALID_INPUT|INVALID_OUTPUT)\\b"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "support.type.criterion.injection",
|
|
17
|
+
"match": "\\b(Decision|Rule|Profile|Result|Engine|RuleTrace|ResultMeta)\\b"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
|
|
3
|
+
"name": "Criterion",
|
|
4
|
+
"scopeName": "source.criterion",
|
|
5
|
+
"patterns": [
|
|
6
|
+
{ "include": "#comments" },
|
|
7
|
+
{ "include": "#strings" },
|
|
8
|
+
{ "include": "#criterion-keywords" },
|
|
9
|
+
{ "include": "#criterion-functions" },
|
|
10
|
+
{ "include": "#zod-schema" },
|
|
11
|
+
{ "include": "#rule-properties" },
|
|
12
|
+
{ "include": "source.ts" }
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"comments": {
|
|
16
|
+
"patterns": [
|
|
17
|
+
{
|
|
18
|
+
"name": "comment.line.double-slash.criterion",
|
|
19
|
+
"match": "//.*$"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"name": "comment.block.criterion",
|
|
23
|
+
"begin": "/\\*",
|
|
24
|
+
"end": "\\*/"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"strings": {
|
|
29
|
+
"patterns": [
|
|
30
|
+
{
|
|
31
|
+
"name": "string.quoted.double.criterion",
|
|
32
|
+
"begin": "\"",
|
|
33
|
+
"end": "\"",
|
|
34
|
+
"patterns": [
|
|
35
|
+
{ "name": "constant.character.escape.criterion", "match": "\\\\." }
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "string.quoted.single.criterion",
|
|
40
|
+
"begin": "'",
|
|
41
|
+
"end": "'",
|
|
42
|
+
"patterns": [
|
|
43
|
+
{ "name": "constant.character.escape.criterion", "match": "\\\\." }
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "string.template.criterion",
|
|
48
|
+
"begin": "`",
|
|
49
|
+
"end": "`",
|
|
50
|
+
"patterns": [
|
|
51
|
+
{ "name": "constant.character.escape.criterion", "match": "\\\\." },
|
|
52
|
+
{
|
|
53
|
+
"name": "meta.template.expression.criterion",
|
|
54
|
+
"begin": "\\$\\{",
|
|
55
|
+
"end": "\\}",
|
|
56
|
+
"patterns": [{ "include": "source.ts" }]
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
"criterion-keywords": {
|
|
63
|
+
"patterns": [
|
|
64
|
+
{
|
|
65
|
+
"name": "keyword.control.criterion",
|
|
66
|
+
"match": "\\b(defineDecision|createRule|createProfileRegistry)\\b"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"name": "storage.type.criterion",
|
|
70
|
+
"match": "\\b(Decision|Rule|Profile|Result|Engine)\\b"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"name": "constant.language.status.criterion",
|
|
74
|
+
"match": "\\b(OK|NO_MATCH|INVALID_INPUT|INVALID_OUTPUT)\\b"
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
},
|
|
78
|
+
"criterion-functions": {
|
|
79
|
+
"patterns": [
|
|
80
|
+
{
|
|
81
|
+
"name": "entity.name.function.criterion",
|
|
82
|
+
"match": "\\b(engine\\.run|engine\\.explain)\\b"
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
},
|
|
86
|
+
"zod-schema": {
|
|
87
|
+
"patterns": [
|
|
88
|
+
{
|
|
89
|
+
"name": "support.function.zod.criterion",
|
|
90
|
+
"match": "\\bz\\.(object|string|number|boolean|array|enum|union|literal|optional|nullable|transform|refine|default)\\b"
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
},
|
|
94
|
+
"rule-properties": {
|
|
95
|
+
"patterns": [
|
|
96
|
+
{
|
|
97
|
+
"name": "variable.other.property.criterion",
|
|
98
|
+
"match": "\\b(id|version|inputSchema|outputSchema|profileSchema|rules|meta|when|emit|explain)\\s*:"
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "CommonJS",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"moduleResolution": "node",
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"declarationMap": true,
|
|
14
|
+
"sourceMap": true,
|
|
15
|
+
"rootDir": "src",
|
|
16
|
+
"outDir": "dist"
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*"],
|
|
19
|
+
"exclude": ["node_modules", "dist"]
|
|
20
|
+
}
|