@xano/xanoscript-language-server 12.17.0 → 12.19.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,76 @@
1
+ /**
2
+ * Markdown for a check and for one of its params, rendered from the catalogue entry as served.
3
+ * Nothing is written here that the catalogue does not say.
4
+ */
5
+
6
+ const code = (value) => "`" + (value === "" ? '""' : value) + "`";
7
+
8
+ /** The value set a param (or its list items) is closed to, if any. */
9
+ export function paramValues(spec) {
10
+ return spec?.values ?? spec?.items?.values ?? null;
11
+ }
12
+
13
+ /** One line under a check: `name` (type, required). */
14
+ function paramLine(name, spec) {
15
+ const flags = [spec?.type ?? "string"];
16
+ if (spec?.required) flags.push("required");
17
+ return `- ${code(name)} (${flags.join(", ")})`;
18
+ }
19
+
20
+ /**
21
+ * @param {Object} check a catalogue item
22
+ * @param {{scopeFields?: Set<string>}} [catalogue] leaves the shared scope fields out of the list
23
+ */
24
+ export function checkMarkdown(check, catalogue) {
25
+ const lines = [`**${check.label || check.id}**`, "", code(check.id)];
26
+ if (check.description) lines.push("", check.description);
27
+ const facts = [];
28
+ if (check.category) facts.push(`Category: ${check.category}`);
29
+ if (check.object_kinds?.length) {
30
+ facts.push(`Inspects: ${check.object_kinds.join(", ")}`);
31
+ }
32
+ if (facts.length) lines.push("", facts.join(" · "));
33
+
34
+ const scope = catalogue?.scopeFields ?? new Set();
35
+ const own = Object.entries(check.params ?? {}).filter(([name]) => !scope.has(name));
36
+ if (own.length) {
37
+ lines.push("", "Params:", ...own.map(([name, spec]) => paramLine(name, spec)));
38
+ }
39
+ if (check.requires_one_of?.length) {
40
+ lines.push("", `Set at least one of ${check.requires_one_of.map(code).join(", ")}.`);
41
+ }
42
+ const applicable = (check.scope_fields ?? []).map((field) => field.name);
43
+ if (applicable.length) {
44
+ lines.push("", `Scope fields: ${applicable.map(code).join(", ")}.`);
45
+ }
46
+ if (check.fix_hint) lines.push("", `Fix: ${check.fix_hint}`);
47
+ return lines.join("\n");
48
+ }
49
+
50
+ /**
51
+ * @param {string} checkId
52
+ * @param {string} path the param name, or `name.field` for a field of an object param
53
+ * @param {Object} spec its catalogue schema
54
+ */
55
+ export function paramMarkdown(checkId, path, spec) {
56
+ const flags = [spec?.type ?? "string"];
57
+ if (spec?.required) flags.push("required");
58
+ const lines = [`**${path}** (${flags.join(", ")}) · ${code(checkId)}`];
59
+ if (spec?.description) lines.push("", spec.description);
60
+ const values = paramValues(spec);
61
+ if (values?.length) lines.push("", `One of: ${values.map(code).join(", ")}.`);
62
+ if (spec?.properties) {
63
+ lines.push("", `Fields: ${Object.keys(spec.properties).map(code).join(", ")}.`);
64
+ }
65
+ if (spec?.requires_one_of?.length) {
66
+ lines.push("", `Set at least one of ${spec.requires_one_of.map(code).join(", ")}.`);
67
+ }
68
+ if (spec?.minimum !== undefined) lines.push("", `At least ${spec.minimum}.`);
69
+ if (spec && "default" in spec) {
70
+ lines.push("", `Default: ${code(JSON.stringify(spec.default))}`);
71
+ }
72
+ if (spec && "example" in spec) {
73
+ lines.push("", `Example: ${code(JSON.stringify(spec.example))}`);
74
+ }
75
+ return lines.join("\n");
76
+ }
@@ -0,0 +1,227 @@
1
+ import { tokenMatcher } from "chevrotain";
2
+ import {
3
+ ColonToken,
4
+ CommaToken,
5
+ LCurly,
6
+ LSquare,
7
+ Minus,
8
+ RCurly,
9
+ RSquare,
10
+ } from "../../lexer/control.js";
11
+ import {
12
+ FloatLiteral,
13
+ IntegerLiteral,
14
+ SingleQuotedStringLiteral,
15
+ StringLiteral,
16
+ } from "../../lexer/literal.js";
17
+ import { MultiLineStringToken } from "../../lexer/multiline.js";
18
+ import {
19
+ DotToken,
20
+ FalseToken,
21
+ Identifier,
22
+ NewlineToken,
23
+ NullToken,
24
+ TrueToken,
25
+ } from "../../lexer/tokens.js";
26
+
27
+ /**
28
+ * A rule's `params` as values, each with the token it was written at, so a problem the catalogue
29
+ * finds can be pointed at in the source.
30
+ *
31
+ * The parser has already accepted the tokens as a constant (the schema machinery's
32
+ * `[constant]`); this only reads them back. Anything that is not a plain literal (a variable, a
33
+ * filter, an expression) comes back as `{kind: "expression"}`: the platform refuses those with
34
+ * "Policy parameters must be literal values."
35
+ *
36
+ * @typedef {{kind: "object", token: Object, entries: {key: string, keyToken: Object, value: PolicyValue}[]}
37
+ * | {kind: "array", token: Object, items: PolicyValue[]}
38
+ * | {kind: "string"|"number"|"boolean"|"null", token: Object, value: any}
39
+ * | {kind: "expression", token: Object}} PolicyValue
40
+ */
41
+
42
+ /** Whether a token is of a type (or one of its categories). */
43
+ export const isA = (token, type) => !!token && tokenMatcher(token, type);
44
+
45
+ /** A catalogue entry's own property, never an inherited one (`constructor`, `toString`). */
46
+ export const own = (object, key) => object && Object.hasOwn(object, key) ? object[key] : undefined;
47
+
48
+ /** Native Parser::stripQuotes: double quotes use C escapes, single quotes only \\ and \'. */
49
+ export function policyStringValue(image) {
50
+ if (image.startsWith('"""') || image.startsWith("'''")) {
51
+ return image.slice(3, -3).replace(/^\r?\n/, "").replace(/\r?\n[ ]*$/, "");
52
+ }
53
+ if (image.startsWith('"')) {
54
+ const bytes = [];
55
+ const encoder = new TextEncoder();
56
+ const controls = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11 };
57
+ for (const match of image.slice(1, -1).matchAll(/\\([0-7]{1,3}|x[\da-fA-F]{1,2}|[\s\S])|([^\\]+)/g)) {
58
+ const escape = match[1];
59
+ if (escape && /^[0-7]/.test(escape)) bytes.push(parseInt(escape, 8) & 255);
60
+ else if (escape && /^x[\da-fA-F]/.test(escape)) bytes.push(parseInt(escape.slice(1), 16));
61
+ else if (escape && Object.hasOwn(controls, escape)) bytes.push(controls[escape]);
62
+ else for (const byte of encoder.encode(escape ?? match[2])) bytes.push(byte);
63
+ }
64
+ return new TextDecoder().decode(new Uint8Array(bytes));
65
+ }
66
+ return image.slice(1, -1).replace(/\\([\\'])/g, "$1");
67
+ }
68
+
69
+ const unquote = (token) => policyStringValue(token.image);
70
+
71
+ const isString = (token) =>
72
+ isA(token, StringLiteral) ||
73
+ isA(token, SingleQuotedStringLiteral) ||
74
+ isA(token, MultiLineStringToken);
75
+
76
+ const isNumber = (token) =>
77
+ isA(token, IntegerLiteral) || isA(token, FloatLiteral);
78
+
79
+ /** A token that ends a value inside an object or array, or the value itself. */
80
+ const isBoundary = (token) =>
81
+ !token ||
82
+ isA(token, CommaToken) ||
83
+ isA(token, NewlineToken) ||
84
+ isA(token, RCurly) ||
85
+ isA(token, RSquare);
86
+
87
+ /**
88
+ * Read one value from `tokens[from..to]` (inclusive).
89
+ * @param {Object[]} tokens
90
+ * @param {number} from
91
+ * @param {number} to
92
+ * @returns {PolicyValue|null}
93
+ */
94
+ export function readPolicyLiteral(tokens, from, to) {
95
+ let i = from;
96
+ const peek = (ahead = 0) => (i + ahead <= to ? tokens[i + ahead] : null);
97
+
98
+ const skipLayout = () => {
99
+ while (isA(peek(), NewlineToken) || isA(peek(), CommaToken)) i++;
100
+ };
101
+
102
+ /** Skip the rest of a non-literal value, up to the separator that ends it. */
103
+ const expression = (token) => {
104
+ let depth = 0;
105
+ while (peek()) {
106
+ const t = peek();
107
+ if (isA(t, LCurly) || isA(t, LSquare)) depth++;
108
+ else if (isA(t, RCurly) || isA(t, RSquare)) {
109
+ if (depth === 0) break;
110
+ depth--;
111
+ } else if (depth === 0 && (isA(t, CommaToken) || isA(t, NewlineToken)))
112
+ break;
113
+ i++;
114
+ }
115
+ return { kind: "expression", token };
116
+ };
117
+
118
+ /** A scalar is literal only when nothing (a filter, an operator) follows it. */
119
+ const scalar = (node) => (isBoundary(peek()) ? node : expression(node.token));
120
+
121
+ const value = () => {
122
+ const token = peek();
123
+ if (!token) return null;
124
+
125
+ if (isA(token, LCurly)) {
126
+ i++;
127
+ const entries = [];
128
+ for (;;) {
129
+ skipLayout();
130
+ const keyToken = peek();
131
+ if (!keyToken) break;
132
+ if (isA(keyToken, RCurly)) {
133
+ i++;
134
+ break;
135
+ }
136
+ let key;
137
+ if (isString(keyToken)) {
138
+ key = unquote(keyToken);
139
+ i++;
140
+ } else if (isA(keyToken, Identifier)) {
141
+ key = keyToken.image;
142
+ i++;
143
+ while (isA(peek(), DotToken) && isA(peek(1), Identifier)) {
144
+ key += "." + peek(1).image;
145
+ i += 2;
146
+ }
147
+ } else {
148
+ return expression(token);
149
+ }
150
+ if (isA(peek(), ColonToken)) i++;
151
+ entries.push({ key, keyToken, value: value() });
152
+ }
153
+ return scalar({ kind: "object", token, entries });
154
+ }
155
+
156
+ if (isA(token, LSquare)) {
157
+ i++;
158
+ const items = [];
159
+ for (;;) {
160
+ skipLayout();
161
+ const next = peek();
162
+ if (!next) break;
163
+ if (isA(next, RSquare)) {
164
+ i++;
165
+ break;
166
+ }
167
+ const before = i;
168
+ items.push(value());
169
+ if (i === before) i++; // never loop on a token nothing can read
170
+ }
171
+ return scalar({ kind: "array", token, items });
172
+ }
173
+
174
+ if (isString(token)) {
175
+ i++;
176
+ return scalar({ kind: "string", token, value: unquote(token) });
177
+ }
178
+ if (isA(token, Minus) && isNumber(peek(1))) {
179
+ const number = peek(1);
180
+ i += 2;
181
+ return scalar({ kind: "number", token, value: -Number(number.image) });
182
+ }
183
+ if (isNumber(token)) {
184
+ i++;
185
+ return scalar({ kind: "number", token, value: Number(token.image) });
186
+ }
187
+ if (isA(token, TrueToken) || isA(token, FalseToken)) {
188
+ i++;
189
+ return scalar({
190
+ kind: "boolean",
191
+ token,
192
+ value: isA(token, TrueToken),
193
+ });
194
+ }
195
+ if (isA(token, NullToken)) {
196
+ i++;
197
+ return scalar({ kind: "null", token, value: null });
198
+ }
199
+ return expression(token);
200
+ };
201
+
202
+ return value();
203
+ }
204
+
205
+ /**
206
+ * The plain JavaScript value of a node (objects and arrays included), or undefined for an
207
+ * expression.
208
+ * @param {PolicyValue|null} node
209
+ */
210
+ export function policyLiteralValue(node) {
211
+ if (!node) return undefined;
212
+ switch (node.kind) {
213
+ case "object": {
214
+ const out = Object.create(null);
215
+ for (const entry of node.entries) {
216
+ out[entry.key] = policyLiteralValue(entry.value);
217
+ }
218
+ return out;
219
+ }
220
+ case "array":
221
+ return node.items.map(policyLiteralValue);
222
+ case "expression":
223
+ return undefined;
224
+ default:
225
+ return node.value;
226
+ }
227
+ }
@@ -0,0 +1,323 @@
1
+ import { policyLiteralValue } from "./policyLiteral.js";
2
+
3
+ /**
4
+ * One rule's check id and params against the check catalogue.
5
+ *
6
+ * Everything here is read from the catalogue the host loaded (see `catalogue.js`): the check
7
+ * ids, each check's params with their types, closed value sets, nested fields, `required`,
8
+ * `requires_one_of`, `minimum` and `non_empty`, and the scope fields that apply to it. Nothing
9
+ * names a check. The findings are worded like the platform's refusals (`Registry::validateParams`,
10
+ * `coerce`, `unknownCheck`) without their `rule[i] ("KEY.Rn"): params:` prefix, since a diagnostic
11
+ * already points at the place; they are a preview, and the platform's own answer on save decides.
12
+ * Where the platform stops at the first refusal, this reports every problem, one per param.
13
+ *
14
+ * Two bounds are the platform's own constants, not the catalogue's: a string or string[] param
15
+ * holds at most `STRING_MAX_LENGTH` characters, and a name longer than `NEAREST_MAX_LENGTH` bytes
16
+ * gets no near miss.
17
+ *
18
+ * Check-specific rules the catalogue does not publish (`Check::validate`, `authoredRefusal`,
19
+ * renamed ids and params, statement wildcards) are left to the platform.
20
+ *
21
+ * Whether a value is a literal at all, and duplicate names, are the block's shape, not the
22
+ * catalogue's: the parser reports those (policy_parser.js) and this skips such values.
23
+ */
24
+
25
+ /** `Registry::STRING_MAX_LENGTH`: the longest string a string or string[] param may hold, in characters. */
26
+ const STRING_MAX_LENGTH = 1024;
27
+
28
+ /** `Registry::NEAREST_MAX_LENGTH`: the longest name, in bytes, a near miss is looked for. */
29
+ const NEAREST_MAX_LENGTH = 128;
30
+
31
+ /** Edit distance, as PHP's `levenshtein` (insert, delete and replace all cost 1). */
32
+ function levenshtein(a, b) {
33
+ if (a === b) return 0;
34
+ if (!a.length) return b.length;
35
+ if (!b.length) return a.length;
36
+ let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
37
+ for (let i = 1; i <= a.length; i++) {
38
+ const current = [i];
39
+ for (let j = 1; j <= b.length; j++) {
40
+ current[j] = Math.min(
41
+ previous[j] + 1,
42
+ current[j - 1] + 1,
43
+ previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
44
+ );
45
+ }
46
+ previous = current;
47
+ }
48
+ return previous[b.length];
49
+ }
50
+
51
+ /**
52
+ * The closest names to a misspelling, nearest first, at most two (`Registry::nearest`): within
53
+ * three edits, or one containing the other. A name longer than `NEAREST_MAX_LENGTH` bytes gets
54
+ * none, since edit distance costs the product of the two lengths per candidate.
55
+ * @param {string} wanted
56
+ * @param {string[]} candidates
57
+ * @returns {string[]}
58
+ */
59
+ export function nearestNames(wanted, candidates) {
60
+ wanted = String(wanted).trim();
61
+ if (wanted === "" || new TextEncoder().encode(wanted).length > NEAREST_MAX_LENGTH) return [];
62
+ wanted = wanted.toLowerCase();
63
+ const scored = [];
64
+ for (const candidate of candidates) {
65
+ const lower = candidate.toLowerCase();
66
+ const distance = levenshtein(lower, wanted);
67
+ if (distance > 3 && !lower.includes(wanted) && !wanted.includes(lower)) {
68
+ continue;
69
+ }
70
+ scored.push([candidate, distance]);
71
+ }
72
+ scored.sort((a, b) => a[1] - b[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
73
+ return scored.slice(0, 2).map(([name]) => name);
74
+ }
75
+
76
+ /** "Did you mean …? " naming the nearest candidates to a misspelling, or "" when none is near. */
77
+ export function didYouMean(name, candidates) {
78
+ const near = nearestNames(name, candidates);
79
+ return near.length ? `Did you mean ${near.map((n) => `"${n}"`).join(" or ")}? ` : "";
80
+ }
81
+
82
+ /** `Registry::provided`: set when not null/false, not an empty or blank string, not an empty list or object. */
83
+ function provided(value) {
84
+ if (value === null || value === undefined || value === false) return false;
85
+ if (typeof value === "string") return value.trim() !== "";
86
+ if (Array.isArray(value)) return value.length > 0;
87
+ if (typeof value === "object") return Object.keys(value).length > 0;
88
+ return true;
89
+ }
90
+
91
+ /**
92
+ * A param's value as the platform validates it (`Registry::validateParams`): one left out, or written
93
+ * as `null` when its type is not `any`, is its default.
94
+ */
95
+ const inForce = (literal, schema, name) =>
96
+ Object.hasOwn(literal, name) && (literal[name] !== null || schema[name]?.type === "any")
97
+ ? literal[name]
98
+ : schema[name]?.default;
99
+
100
+ const isEmptyList = (node) =>
101
+ (node.kind === "array" && node.items.length === 0) ||
102
+ (node.kind === "object" && node.entries.length === 0);
103
+
104
+ /** The sentence for a check id that is not in the catalogue. */
105
+ export function unknownCheckMessage(id, ids) {
106
+ return `"${id}" is not a policy check. ${didYouMean(id, ids)}`.trim();
107
+ }
108
+
109
+ /**
110
+ * `Registry::coerce`: whether a value has its param's declared type and values.
111
+ * @returns {{token: Object, message: string}|null}
112
+ */
113
+ function coerce(node, spec, checkId, name) {
114
+ const type = String(spec?.type ?? "string");
115
+ const fail = (what, token = node.token) => ({
116
+ token,
117
+ message: `param "${name}" of check "${checkId}" must ${what}.`,
118
+ });
119
+
120
+ switch (type) {
121
+ case "integer": {
122
+ const whole = node.kind === "number" && Number.isFinite(node.value) && Math.floor(node.value) === node.value;
123
+ const minimum = spec.minimum;
124
+ if (!whole || (minimum !== undefined && node.value < minimum)) {
125
+ return fail(`be a whole number${minimum !== undefined ? ` of at least ${minimum}` : ""}`);
126
+ }
127
+ return null;
128
+ }
129
+ case "number":
130
+ return node.kind === "number" && Number.isFinite(node.value) ? null : fail("be a finite number");
131
+ case "object":
132
+ // PHP reads an empty list and an empty object alike, so `[]` passes as an object.
133
+ return node.kind === "object" || isEmptyList(node) ? null : fail("be an object");
134
+ case "array":
135
+ return node.kind === "array" || isEmptyList(node) ? null : fail("be an array");
136
+ case "any":
137
+ return null;
138
+ case "string[]": {
139
+ if (!(node.kind === "array" || isEmptyList(node))) {
140
+ return fail("be a list of strings");
141
+ }
142
+ const values = spec.items?.values ?? spec.values ?? [];
143
+ for (const item of node.items ?? []) {
144
+ if (!item || item.kind !== "string") {
145
+ return fail("be a list of strings", item?.token);
146
+ }
147
+ if (values.length && !values.includes(item.value)) {
148
+ return fail(`contain only: ${values.join(", ")}`, item.token);
149
+ }
150
+ if (item.value.trim() === "") {
151
+ return fail("not contain empty strings", item.token);
152
+ }
153
+ if ([...item.value].length > STRING_MAX_LENGTH) {
154
+ return fail(`list strings of at most ${STRING_MAX_LENGTH} characters`, item.token);
155
+ }
156
+ }
157
+ return null;
158
+ }
159
+ case "bool":
160
+ return node.kind === "boolean" ? null : fail("be a boolean");
161
+ default: {
162
+ if (node.kind !== "string") return fail("be a string");
163
+ if ([...node.value].length > STRING_MAX_LENGTH) {
164
+ return fail(`be at most ${STRING_MAX_LENGTH} characters`);
165
+ }
166
+ const values = spec?.values ?? [];
167
+ if (values.length && !values.includes(node.value)) {
168
+ const named = values.filter((value) => String(value) !== "");
169
+ return fail(`be one of: ${named.join(", ")}${named.length !== values.length ? " (or omit it)" : ""}`);
170
+ }
171
+ return null;
172
+ }
173
+ }
174
+ }
175
+
176
+ /** An object param's declared fields, or null when it has none or its keys are the author's own (`<placeholder>`). */
177
+ function closedProperties(spec) {
178
+ const properties = spec?.properties;
179
+ if (spec?.type !== "object" || !properties || typeof properties !== "object") return null;
180
+ const keys = Object.keys(properties);
181
+ if (keys.length === 0 || keys.some((key) => key.startsWith("<"))) return null;
182
+ return properties;
183
+ }
184
+
185
+ /**
186
+ * Why a set scope field cannot change what a rule of this check selects, or null when it can,
187
+ * read from the check's published `scope_fields` (the same reading as Studio's rule form).
188
+ */
189
+ function inapplicableScope(check, name, literal) {
190
+ const field = (check.scope_fields ?? []).find((item) => item?.name === name);
191
+ if (!field) {
192
+ return `"${name}" does not apply to check "${check.id}", so it cannot change what this rule selects.`;
193
+ }
194
+ for (const [param, condition] of Object.entries(field.applies_when ?? {})) {
195
+ const value = inForce(literal, check.params ?? {}, param);
196
+ if (condition === "non_empty") {
197
+ if (!provided(value)) {
198
+ return `"${name}" applies to check "${check.id}" only when ${param} is set.`;
199
+ }
200
+ } else if (Array.isArray(condition) && !condition.includes(String(value ?? ""))) {
201
+ return `"${name}" applies to check "${check.id}" only when ${param} is ${condition.join(" or ")}.`;
202
+ }
203
+ }
204
+ return null;
205
+ }
206
+
207
+ /**
208
+ * @param {{checkId: string|null, checkToken: Object|null, paramsToken: Object|null,
209
+ * params: import('./policyLiteral.js').PolicyValue|null, ruleToken: Object}} rule
210
+ * @param {ReturnType<import('./catalogue.js').getPolicyCatalogue>} catalogue
211
+ * @returns {{token: Object, message: string, advisory?: boolean}[]} the problems, one per param;
212
+ * `advisory` marks a finding that mirrors platform logic beyond the published schema (a scope
213
+ * field's applicability, an object param's "one of" fields), which a host shows as a warning
214
+ */
215
+ export function validatePolicyRule(rule, catalogue) {
216
+ const out = [];
217
+ const report = (token, message) => out.push({ token, message });
218
+ const advise = (token, message) => out.push({ token, message, advisory: true });
219
+
220
+ if (!catalogue?.checks?.size || rule.checkId === null) return out;
221
+
222
+ const check = catalogue.checks.get(rule.checkId);
223
+ if (!check) {
224
+ report(rule.checkToken ?? rule.ruleToken, unknownCheckMessage(rule.checkId, catalogue.ids));
225
+ return out;
226
+ }
227
+
228
+ const id = check.id;
229
+ const schema = check.params ?? {};
230
+ const anchor = rule.paramsToken ?? rule.checkToken ?? rule.ruleToken;
231
+ const params = rule.params?.kind === "null" || (rule.params?.kind === "array" && !rule.params.items.length)
232
+ ? null : rule.params;
233
+
234
+ // Not an object (the parser has said so): nothing to hold against the check.
235
+ if (params && params.kind !== "object") return out;
236
+
237
+ // Unknown names, in the order they are written; a repeated name counts once.
238
+ const given = new Map();
239
+ for (const entry of params?.entries ?? []) {
240
+ if (given.has(entry.key)) continue;
241
+ given.set(entry.key, entry);
242
+ if (!Object.hasOwn(schema, entry.key)) {
243
+ const names = Object.keys(schema);
244
+ report(
245
+ entry.keyToken,
246
+ `unknown param "${entry.key}" for check "${id}". ${didYouMean(entry.key, names)}Accepted params: ${names.join(", ") || "none"}.`
247
+ );
248
+ }
249
+ }
250
+
251
+ const literal = policyLiteralValue(params) ?? {};
252
+
253
+ for (const [name, spec] of Object.entries(schema)) {
254
+ const entry = given.get(name);
255
+ const node = entry?.value;
256
+ const has = !!node && node.kind !== "expression" && (node.kind !== "null" || spec.type === "any");
257
+ if (!has) {
258
+ if (spec.required && (!node || node.kind === "null")) {
259
+ report(anchor, `check "${id}" requires param "${name}".`);
260
+ }
261
+ continue;
262
+ }
263
+
264
+ const problem = coerce(node, spec, id, name);
265
+ if (problem) {
266
+ report(problem.token, problem.message);
267
+ continue;
268
+ }
269
+
270
+ if ((spec.required || spec.non_empty) && ((node.kind === "string" && node.value.trim() === "") || isEmptyList(node))) {
271
+ report(node.token, `check "${id}": ${name} must not be empty.`);
272
+ continue;
273
+ }
274
+
275
+ const properties = closedProperties(spec);
276
+ if (properties && node.kind === "object" && node.entries.length) {
277
+ const seen = new Set();
278
+ for (const field of node.entries) {
279
+ seen.add(field.key);
280
+ const property = Object.hasOwn(properties, field.key) ? properties[field.key] : null;
281
+ if (!property) {
282
+ report(field.keyToken, `check "${id}": Unknown ${name} field: ${field.key}`);
283
+ continue;
284
+ }
285
+ if (!field.value || field.value.kind === "expression") continue;
286
+ const nested = coerce(field.value, property, id, `${name}.${field.key}`);
287
+ if (nested) {
288
+ report(nested.token, nested.message);
289
+ } else if (property.type === "string" && field.value.value.trim() === "") {
290
+ report(field.value.token, `check "${id}": ${name}.${field.key} must not be empty.`);
291
+ }
292
+ }
293
+ for (const [key, property] of Object.entries(properties)) {
294
+ if (property.required && !seen.has(key)) {
295
+ report(entry.keyToken, `check "${id}": ${name}.${key} is required.`);
296
+ }
297
+ }
298
+ const oneOf = spec.requires_one_of ?? [];
299
+ const value = literal[name] ?? {};
300
+ if (oneOf.length && !oneOf.some((key) => provided(value[key]))) {
301
+ advise(entry.keyToken, `check "${id}": ${name} requires ${oneOf.map((key) => `${name}.${key}`).join(" or ")}.`);
302
+ }
303
+ }
304
+ }
305
+
306
+ // "One of A or B", with each param's default in force where it is not set.
307
+ const oneOf = check.requires_one_of ?? [];
308
+ if (oneOf.length) {
309
+ if (!oneOf.some((name) => provided(inForce(literal, schema, name)))) {
310
+ report(anchor, `check "${id}" requires ${oneOf.join(" or ")}.`);
311
+ }
312
+ }
313
+
314
+ // A scope field set on a check it cannot narrow (the platform refuses it on save).
315
+ for (const [name, entry] of given) {
316
+ if (catalogue.scopeFields?.has(name) && name in schema && provided(literal[name])) {
317
+ const why = inapplicableScope(check, name, literal);
318
+ if (why) advise(entry.keyToken, why);
319
+ }
320
+ }
321
+
322
+ return out;
323
+ }