@open-predicate/open-predicate 0.6.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,1153 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * generate-filter-schema.mjs — derive a per-resource filter schema from a
4
+ * resource's JSON Schema.
5
+ *
6
+ * The published grammar (open-predicate-schema.json) shares one `Constraint`
7
+ * definition across every field, so it can say that `{"status": "Available"}`
8
+ * is well-formed but not that "Available" is outside `status`'s domain. That
9
+ * gap is why SPEC.md §2.2 exists: the domains have to be published somewhere,
10
+ * and the grammar is not able to carry them.
11
+ *
12
+ * A *generated* schema can. Given the resource's own JSON Schema, every
13
+ * queryable path is known, along with its type, format and value set — so each
14
+ * path gets its own constraint subschema, carrying only the operators that
15
+ * apply to it and only the operands it can meaningfully take. The three
16
+ * valid-but-wrong filters in README §"Exposing search to an agent" all become
17
+ * validation failures instead of empty result sets.
18
+ *
19
+ * Operator titles and descriptions are copied from the published grammar rather
20
+ * than restated here, so the prose an agent reads stays in one place.
21
+ *
22
+ * Usage:
23
+ * open-predicate-generate <resource-schema.json> [options]
24
+ * open-predicate-generate --config <open-predicate.config.json>
25
+ *
26
+ * `open-predicate-generate` is the installed name; from a clone it is
27
+ * `node tools/generate-filter-schema.mjs`, with the same arguments.
28
+ *
29
+ * What the schema describes:
30
+ * --id <uri> $id for the generated schema (recommended)
31
+ * --title <text> title for the generated schema
32
+ * --pointer <json-ptr> subschema of the input file to treat as the resource
33
+ * --max-depth <n> how far to descend into nested objects (default 3)
34
+ * --include <list> comma-separated paths; omit for "everything found"
35
+ * --exclude <list> comma-separated paths or path prefixes ending in *
36
+ *
37
+ * What the endpoint can actually serve — the filter schema is a narrowing, so
38
+ * anything declined here is rejected by validation instead of at runtime:
39
+ * --profiles <list> comma-separated; default core,strings,ranges,collections
40
+ * --operators <list> exactly these operators, intersected with --profiles
41
+ * --drop-operators <list> everything --profiles implies, minus these
42
+ * --no-shorthand drop the bare-scalar form; every constraint takes
43
+ * the object form
44
+ * --max-filter-depth <n> the deepest filter the schema accepts. 1 is a flat
45
+ * filter with no logical operators at all, 2 permits
46
+ * one $and/$or/$nor/$not level. Unbounded by default
47
+ * --limits <json|@file> maxDepth, maxClauses and maxSetLength for the
48
+ * capability document, merged over the defaults
49
+ *
50
+ * Output:
51
+ * --descriptions <mode> all | brief | none. Default brief: the operators
52
+ * whose semantics surprise people keep their prose,
53
+ * the self-evident ones keep only a title.
54
+ * --out <file> write the schema here instead of stdout
55
+ * --capabilities <file> also write a SPEC.md §2.2 capability document
56
+ * --grammar <file> path to open-predicate-schema.json
57
+ * --config <file> read these options from JSON, using the camelCase
58
+ * names of the JS API plus "resource", "out" and
59
+ * "capabilities". Relative paths in it resolve
60
+ * against its own directory, and an explicit flag
61
+ * always beats it. This is the file to check in.
62
+ * --quiet suppress warnings on stderr
63
+ *
64
+ * A property may also opt out or override in the resource schema itself:
65
+ * "x-open-predicate": false — not queryable
66
+ * "x-open-predicate": { "queryable": false } — same
67
+ * "x-open-predicate": { "operators": ["$eq"] } — exactly these operators
68
+ */
69
+
70
+ import { readFileSync, writeFileSync } from "node:fs";
71
+ import { fileURLToPath } from "node:url";
72
+ import { dirname, join, resolve } from "node:path";
73
+ import { parseArgs } from "node:util";
74
+
75
+ const here = dirname(fileURLToPath(import.meta.url));
76
+ const DEFAULT_GRAMMAR = join(here, "..", "open-predicate-schema.json");
77
+ const DEFAULT_PROFILES = ["core", "strings", "ranges", "collections"];
78
+ /** SPEC §7's RECOMMENDED defaults, published through the capability document (§2.2). */
79
+ const DEFAULT_LIMITS = { maxDepth: 10, maxClauses: 100, maxSetLength: 1000 };
80
+
81
+ /** Formats whose values are opaque tokens: substring matching on them is noise. */
82
+ const OPAQUE_FORMATS = new Set(["uuid", "uri", "iri", "email", "ipv4", "ipv6", "duration"]);
83
+ /** Formats whose lexicographic order coincides with their natural order (SPEC §5.2). */
84
+ const ORDERED_FORMATS = new Set(["date", "date-time", "time"]);
85
+ /**
86
+ * Operators whose description earns its place next to every field: each one is
87
+ * a rule a reader would otherwise get wrong. The rest carry their title only,
88
+ * because repeating "Field equals the operand" once per path is pure tokens in
89
+ * an MCP tool definition. `--descriptions all` restores them.
90
+ */
91
+ const SURPRISING = new Set([
92
+ "$and", "$or", "$nor", "$not",
93
+ "$in", "$nin", "$exists", "$isNull", "$type",
94
+ "$like", "$ilike", "$contains", "$regex", "$flags", "$search",
95
+ "$between", "$hasAll", "$size", "$some", "$every", "$unknownAs",
96
+ ]);
97
+
98
+ /** Value-domain keywords worth carrying onto an equality operand. */
99
+ const DOMAIN_KEYWORDS = [
100
+ "enum", "const", "format", "pattern", "minLength", "maxLength",
101
+ "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf",
102
+ ];
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // $ref resolution and allOf flattening
106
+ // ---------------------------------------------------------------------------
107
+
108
+ function jsonPointer(root, pointer) {
109
+ if (pointer === "" || pointer === "#") return root;
110
+ const parts = pointer.replace(/^#/, "").split("/").slice(1);
111
+ let node = root;
112
+ for (const raw of parts) {
113
+ const key = decodeURIComponent(raw).replace(/~1/g, "/").replace(/~0/g, "~");
114
+ if (node === undefined || node === null) return undefined;
115
+ node = node[key];
116
+ }
117
+ return node;
118
+ }
119
+
120
+ /**
121
+ * Follows local $refs to a concrete node. `trail` accumulates the pointers
122
+ * crossed on this branch so a recursive schema (Pet.friends -> Pet) terminates
123
+ * rather than looping: a ref already on the trail resolves to null and the
124
+ * caller drops that branch.
125
+ */
126
+ function makeDeref(root) {
127
+ return function deref(node, trail) {
128
+ let cur = node;
129
+ for (let hops = 0; cur && typeof cur === "object" && typeof cur.$ref === "string"; hops++) {
130
+ const ref = cur.$ref;
131
+ if (!ref.startsWith("#")) {
132
+ throw new Error(`only local $ref is supported; found "${ref}". Bundle the schema first.`);
133
+ }
134
+ if (hops > 32) throw new Error(`$ref chain too long at "${ref}"`);
135
+ if (trail?.has(ref)) return null;
136
+ trail?.add(ref);
137
+ const target = jsonPointer(root, ref);
138
+ if (target === undefined) throw new Error(`unresolvable $ref: "${ref}"`);
139
+ cur = target;
140
+ }
141
+ return cur;
142
+ };
143
+ }
144
+
145
+ /** Shallow-merges allOf branches so a composed resource schema still yields fields. */
146
+ function flatten(node, deref, trail) {
147
+ const resolved = deref(node, trail);
148
+ if (!resolved || typeof resolved !== "object") return resolved;
149
+ if (!Array.isArray(resolved.allOf)) return resolved;
150
+
151
+ const merged = { ...resolved };
152
+ delete merged.allOf;
153
+ for (const branch of resolved.allOf) {
154
+ const b = flatten(branch, deref, new Set(trail));
155
+ if (!b || typeof b !== "object") continue;
156
+ merged.properties = { ...(b.properties ?? {}), ...(merged.properties ?? {}) };
157
+ if (b.required) merged.required = [...new Set([...(b.required ?? []), ...(merged.required ?? [])])];
158
+ for (const k of ["type", "items", "description", "title", ...DOMAIN_KEYWORDS]) {
159
+ if (merged[k] === undefined && b[k] !== undefined) merged[k] = b[k];
160
+ }
161
+ }
162
+ return merged;
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Type inspection
167
+ // ---------------------------------------------------------------------------
168
+
169
+ function typesOf(schema) {
170
+ if (!schema || typeof schema !== "object") return [];
171
+ if (typeof schema.type === "string") return [schema.type];
172
+ if (Array.isArray(schema.type)) return [...schema.type];
173
+ // No explicit "type" — infer from whatever else is present.
174
+ const inferred = new Set();
175
+ for (const value of [].concat(schema.const ?? [], schema.enum ?? [])) {
176
+ inferred.add(value === null ? "null" : Array.isArray(value) ? "array" : typeof value === "object" ? "object" : typeof value);
177
+ }
178
+ if (schema.properties || schema.additionalProperties) inferred.add("object");
179
+ if (schema.items || schema.prefixItems) inferred.add("array");
180
+ return [...inferred];
181
+ }
182
+
183
+ /** The consts of a closed domain, whether written as `enum` or as a union of `const`s. */
184
+ function domainValues(schema) {
185
+ if (Array.isArray(schema?.enum)) return schema.enum;
186
+ if (schema?.const !== undefined) return [schema.const];
187
+ const union = schema?.oneOf ?? schema?.anyOf;
188
+ if (Array.isArray(union) && union.length && union.every((b) => b && b.const !== undefined)) {
189
+ return union.map((b) => b.const);
190
+ }
191
+ return null;
192
+ }
193
+
194
+ const SCALARS = new Set(["string", "number", "integer", "boolean"]);
195
+
196
+ function classify(schema) {
197
+ const types = typesOf(schema);
198
+ const nullable = types.includes("null");
199
+ const nonNull = types.filter((t) => t !== "null");
200
+ if (nonNull.length === 0) return { kind: "unknown", nullable, types: nonNull };
201
+ if (nonNull.length === 1 && nonNull[0] === "array") return { kind: "array", nullable, types: nonNull };
202
+ if (nonNull.length === 1 && nonNull[0] === "object") return { kind: "object", nullable, types: nonNull };
203
+ if (nonNull.every((t) => SCALARS.has(t))) return { kind: "scalar", nullable, types: nonNull };
204
+ return { kind: "mixed", nullable, types: nonNull };
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Field paths (SPEC §3.2, §3.3)
209
+ // ---------------------------------------------------------------------------
210
+
211
+ function escapeKey(key) {
212
+ const escaped = key.replace(/([.[\]\\])/g, "\\$1");
213
+ return escaped.startsWith("$") ? `$${escaped}` : escaped;
214
+ }
215
+
216
+ function globToRegExp(glob) {
217
+ const body = glob.split("*").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
218
+ return new RegExp(`^${body}$`);
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Field collection
223
+ // ---------------------------------------------------------------------------
224
+
225
+ /**
226
+ * Walks one object schema and returns the queryable paths beneath it. Nested
227
+ * objects contribute dotted paths within the *same* filter; arrays stop the
228
+ * descent and are addressed through the $some/$every quantifiers instead, which
229
+ * say which quantifier is meant instead of leaving it to a path shape (SPEC §5.8).
230
+ */
231
+ function collectFields(node, ctx, state) {
232
+ const out = [];
233
+ walk(node, "", true, state.depth, new Set(state.trail));
234
+
235
+ function walk(schemaNode, prefix, alwaysPresent, depth, trail) {
236
+ const schema = flatten(schemaNode, ctx.deref, trail);
237
+ if (!schema || typeof schema !== "object" || !schema.properties) return;
238
+ const required = new Set(schema.required ?? []);
239
+
240
+ for (const [name, rawProp] of Object.entries(schema.properties)) {
241
+ const branchTrail = new Set(trail);
242
+ const prop = flatten(rawProp, ctx.deref, branchTrail);
243
+ if (!prop || typeof prop !== "object") continue;
244
+
245
+ const ext = rawProp["x-open-predicate"] ?? prop["x-open-predicate"];
246
+ if (ext === false || ext?.queryable === false) continue;
247
+
248
+ const path = prefix + escapeKey(name);
249
+ if (ctx.opts.exclude.some((rx) => rx.test(path))) continue;
250
+
251
+ const present = alwaysPresent && required.has(name);
252
+ const info = classify(prop);
253
+
254
+ if (info.kind === "unknown") {
255
+ ctx.warn(`skipped "${path}": no discoverable type. Add "type", or "x-open-predicate": {"operators": [...]}.`);
256
+ continue;
257
+ }
258
+
259
+ if (info.kind === "object") {
260
+ // The object itself is queryable only for presence; its members carry
261
+ // the real predicates.
262
+ if (!present) out.push({ path, schema: prop, info, present, ext, kind: "object" });
263
+ if (depth + 1 <= ctx.opts.maxDepth) {
264
+ walk(prop, `${path}.`, present, depth + 1, branchTrail);
265
+ } else {
266
+ ctx.warn(`stopped at "${path}": --max-depth ${ctx.opts.maxDepth} reached.`);
267
+ }
268
+ continue;
269
+ }
270
+
271
+ if (info.kind === "array") {
272
+ const items = flatten(prop.items ?? {}, ctx.deref, branchTrail);
273
+ const itemInfo = items ? classify(items) : { kind: "unknown" };
274
+ out.push({ path, schema: prop, info, present, ext, kind: "array", items, itemInfo, trail: branchTrail, depth });
275
+ continue;
276
+ }
277
+
278
+ out.push({ path, schema: prop, info, present, ext, kind: info.kind });
279
+ }
280
+ }
281
+
282
+ return out;
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Operator selection
287
+ // ---------------------------------------------------------------------------
288
+
289
+ function operatorsFor(field, ctx) {
290
+ if (Array.isArray(field.ext?.operators)) {
291
+ return field.ext.operators.filter((op) => ctx.enabled.has(op));
292
+ }
293
+
294
+ const ops = [];
295
+ const push = (...names) => { for (const n of names) if (ctx.enabled.has(n)) ops.push(n); };
296
+ const { info, schema, kind } = field;
297
+ const format = schema.format;
298
+ const closed = domainValues(schema) !== null;
299
+
300
+ if (kind === "object") {
301
+ push("$exists");
302
+ if (info.nullable) push("$isNull");
303
+ pushUnknownAs(push, field, info);
304
+ return ops;
305
+ }
306
+
307
+ if (kind === "array") {
308
+ // An element type we could not resolve — an untyped `items`, or a cycle the
309
+ // walk cut — leaves nothing to type an operand against. Length and presence
310
+ // are all that can be offered honestly.
311
+ if (!field.itemInfo || field.itemInfo.kind === "unknown") {
312
+ ctx.warn(`"${field.path}": element type unresolved; only $size and presence are queryable.`);
313
+ push("$size");
314
+ if (!field.present) push("$exists");
315
+ if (info.nullable) push("$isNull");
316
+ push("$not");
317
+ pushUnknownAs(push, field, info);
318
+ return ops;
319
+ }
320
+ push("$eq", "$ne");
321
+ if (field.itemInfo?.kind === "object" || field.itemInfo?.kind === "scalar") push("$some", "$every");
322
+ if (field.itemInfo.kind === "scalar") push("$hasAll");
323
+ push("$size");
324
+ if (!field.present) push("$exists");
325
+ if (info.nullable) push("$isNull");
326
+ push("$not");
327
+ pushUnknownAs(push, field, info);
328
+ return ops;
329
+ }
330
+
331
+ const isString = info.types.includes("string");
332
+ const isNumeric = info.types.some((t) => t === "number" || t === "integer");
333
+ const isBoolean = info.types.length === 1 && info.types[0] === "boolean";
334
+
335
+ push("$eq", "$ne");
336
+ // $in over a two-valued domain says nothing $eq does not.
337
+ if (!isBoolean) push("$in", "$nin");
338
+
339
+ const ordered = isNumeric || (isString && ORDERED_FORMATS.has(format));
340
+ if (ordered) {
341
+ push("$gt", "$gte", "$lt", "$lte");
342
+ push("$between", "$nbetween");
343
+ }
344
+
345
+ // Pattern matching is meaningful on free text only: not on a closed domain,
346
+ // where the accepted values are already enumerated, and not on an opaque
347
+ // token like a UUID.
348
+ if (isString && !closed && !OPAQUE_FORMATS.has(format) && !ORDERED_FORMATS.has(format)) {
349
+ push("$like", "$nlike", "$ilike", "$nilike", "$startsWith", "$endsWith", "$contains");
350
+ push("$regex", "$flags");
351
+ push("$search");
352
+ }
353
+
354
+ // $type only earns its place where the type is genuinely a union.
355
+ if (info.types.length > 1) push("$type");
356
+ if (!field.present) push("$exists");
357
+ if (info.nullable) push("$isNull");
358
+ push("$not");
359
+ pushUnknownAs(push, field, info);
360
+ return ops;
361
+ }
362
+
363
+ /**
364
+ * $unknownAs only earns its place where UNKNOWN is reachable. A property that is
365
+ * required all the way up and cannot hold null never resolves to nothing, and
366
+ * the generated operand schemas make a type mismatch a validation error rather
367
+ * than an UNKNOWN — so on those fields the modifier would be a constant, exactly
368
+ * as $exists and $isNull are.
369
+ */
370
+ function pushUnknownAs(push, field, info) {
371
+ if (!field.present || info.nullable) push("$unknownAs");
372
+ }
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // Operand schemas
376
+ // ---------------------------------------------------------------------------
377
+
378
+ /**
379
+ * The operand schema for the equality family: the field's own value domain.
380
+ * Carrying `enum`, `pattern` and the numeric bounds is what turns
381
+ * {"status": "Available"} from an empty result set into a 400.
382
+ */
383
+ function strictValue(schema, info) {
384
+ const value = {};
385
+ const known = info?.types ?? [];
386
+ const types = info?.nullable ? [...known, "null"] : [...known];
387
+ const consts = domainValues(schema);
388
+ const union = schema.oneOf ?? schema.anyOf;
389
+
390
+ if (consts && Array.isArray(union) && union.every((b) => b?.const !== undefined) && union.some((b) => b.description)) {
391
+ // Per-value prose only survives as a union of consts; an enum has nowhere
392
+ // to put it. README §"Exposing search to an agent", step 2.
393
+ if (types.length) value.type = types.length === 1 ? types[0] : types;
394
+ value.anyOf = union.map((b) => ({ const: b.const, ...(b.description ? { description: b.description } : {}) }));
395
+ if (info.nullable) value.anyOf.push({ const: null });
396
+ return value;
397
+ }
398
+
399
+ if (types.length) value.type = types.length === 1 ? types[0] : types;
400
+ for (const k of DOMAIN_KEYWORDS) {
401
+ if (schema[k] !== undefined) value[k] = schema[k];
402
+ }
403
+ if (consts && info?.nullable && Array.isArray(value.enum) && !value.enum.includes(null)) {
404
+ value.enum = [...value.enum, null];
405
+ }
406
+ return value;
407
+ }
408
+
409
+ /**
410
+ * The operand schema for the ordering family. Deliberately looser than
411
+ * `strictValue`: `{"$gt": 0}` against a field whose minimum is 1 is a
412
+ * perfectly sensible predicate, so the bounds must not be carried across.
413
+ */
414
+ function looseValue(schema, info) {
415
+ const value = { type: info.types.length === 1 ? info.types[0] : info.types };
416
+ if (schema.format && ORDERED_FORMATS.has(schema.format)) value.format = schema.format;
417
+ return value;
418
+ }
419
+
420
+ function setOf(itemsRef) {
421
+ return { type: "array", minItems: 1, uniqueItems: true, items: itemsRef };
422
+ }
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // Emission
426
+ // ---------------------------------------------------------------------------
427
+
428
+ function sanitize(text) {
429
+ return text
430
+ .replace(/\[\*\]/g, "_elem")
431
+ .replace(/[^A-Za-z0-9]+/g, "_")
432
+ .replace(/^_+|_+$/g, "") || "field";
433
+ }
434
+
435
+ function defName(ctx, prefix, hint) {
436
+ const base = `${prefix}${sanitize(hint)}`;
437
+ let name = base;
438
+ for (let n = 2; ctx.defs[name] !== undefined || ctx.reserved.has(name); n++) name = `${base}${n}`;
439
+ ctx.reserved.add(name);
440
+ return name;
441
+ }
442
+
443
+ /**
444
+ * Copies the operator's own title and description out of the published grammar,
445
+ * so the prose an agent reads lives in exactly one place. `from` picks the
446
+ * right definition for the two operators that exist at both levels: $not means
447
+ * something different inside a Filter than inside a constraint object.
448
+ */
449
+ function annotate(ctx, op, schema, from = "constraint") {
450
+ const table = from === "filter" ? ctx.grammar.$defs.Filter.properties : ctx.grammar.$defs.ConstraintObject.properties;
451
+ const source = table[op] ?? ctx.grammar.$defs.ConstraintObject.properties[op] ?? ctx.grammar.$defs.Filter.properties[op];
452
+ const { title, description } = source ?? {};
453
+ const keep = ctx.opts.descriptions === "all" || (ctx.opts.descriptions === "brief" && SURPRISING.has(op));
454
+ return {
455
+ ...(title ? { title } : {}),
456
+ ...(keep && description ? { description } : {}),
457
+ ...schema,
458
+ };
459
+ }
460
+
461
+ function emitSizeDef(ctx) {
462
+ if (!ctx.defs.Size) {
463
+ ctx.reserved.add("Size");
464
+ ctx.defs.Size = structuredClone(ctx.grammar.$defs.SizeConstraint);
465
+ }
466
+ return { $ref: "#/$defs/Size" };
467
+ }
468
+
469
+ /**
470
+ * Carries the published constraint object's dependency keywords over to a
471
+ * generated one, for the triggers whose operator survived: $flags needs $regex
472
+ * beside it, and $unknownAs needs something to modify.
473
+ *
474
+ * These rules are part of what the grammar rejects, so a generated schema that
475
+ * drops one is *wider* than the grammar there — which is the one thing a
476
+ * generated schema may never be (README §"Narrowing only"). Reading them off
477
+ * the grammar rather than restating them here means a rule added to
478
+ * $defs/ConstraintObject reaches generated schemas with the version that
479
+ * introduced it.
480
+ */
481
+ function dependencyRules(ctx, props) {
482
+ const source = ctx.grammar.$defs.ConstraintObject;
483
+ // The rule is carried; the $comment justifying it is not. That prose is
484
+ // written for someone reading the grammar, and here it would be one copy per
485
+ // field, charged by the token to whoever inlines this in a tool definition.
486
+ const constraining = ({ $comment, ...rest }) => structuredClone(rest);
487
+ const rules = {};
488
+ for (const keyword of ["dependentSchemas", "dependentRequired"]) {
489
+ const kept = {};
490
+ for (const [trigger, rule] of Object.entries(source[keyword] ?? {})) {
491
+ if (trigger in props) kept[trigger] = Array.isArray(rule) ? [...rule] : constraining(rule);
492
+ }
493
+ if (Object.keys(kept).length > 0) rules[keyword] = kept;
494
+ }
495
+ return rules;
496
+ }
497
+
498
+ /** Builds the constraint-object subschema for one field, and registers it. */
499
+ function emitConstraint(ctx, field, prefix) {
500
+ const ops = operatorsFor(field, ctx);
501
+ if (ops.length === 0) return null;
502
+ // A bounded $not points at a copy of this constraint object with $not removed,
503
+ // so it needs at least one other operator to negate.
504
+ if (ctx.opts.maxFilterDepth !== undefined && ops.length === 1 && ops[0] === "$not") return null;
505
+
506
+ const name = defName(ctx, `${prefix}C_`, field.nameHint ?? field.path);
507
+ const props = {};
508
+
509
+ const hint = field.nameHint ?? field.path;
510
+ const valueRef = () => {
511
+ if (!field.valueDef) {
512
+ field.valueDef = defName(ctx, `${prefix}V_`, hint);
513
+ ctx.defs[field.valueDef] = strictValue(field.schema, field.info);
514
+ }
515
+ return { $ref: `#/$defs/${field.valueDef}` };
516
+ };
517
+ const orderedRef = () => {
518
+ if (!field.orderedDef) {
519
+ field.orderedDef = defName(ctx, `${prefix}O_`, hint);
520
+ ctx.defs[field.orderedDef] = looseValue(field.schema, field.info);
521
+ }
522
+ return { $ref: `#/$defs/${field.orderedDef}` };
523
+ };
524
+ // Unbounded, $not negates this very constraint object, so {"$not": {"$not": …}}
525
+ // nests forever. Where a depth is set that has to stop somewhere, and one
526
+ // level is the natural place: under Kleene logic ¬¬X ≡ X even for UNKNOWN, so
527
+ // a negated negation says nothing the plain constraint does not.
528
+ let negName = null;
529
+ const notRef = () => {
530
+ if (ctx.opts.maxFilterDepth === undefined) return `#/$defs/${name}`;
531
+ negName ??= defName(ctx, `${prefix}N_`, hint);
532
+ return `#/$defs/${negName}`;
533
+ };
534
+ const itemRef = () => {
535
+ if (!field.itemDef) {
536
+ field.itemDef = defName(ctx, `${prefix}I_`, hint);
537
+ ctx.defs[field.itemDef] = strictValue(field.items ?? {}, field.itemInfo);
538
+ }
539
+ return { $ref: `#/$defs/${field.itemDef}` };
540
+ };
541
+
542
+ for (const op of ops) {
543
+ switch (op) {
544
+ case "$eq": case "$ne":
545
+ props[op] = annotate(ctx, op, field.kind === "array"
546
+ ? { type: "array", items: itemRef() }
547
+ : valueRef());
548
+ break;
549
+ case "$in": case "$nin":
550
+ props[op] = annotate(ctx, op, setOf(valueRef()));
551
+ break;
552
+ case "$gt": case "$gte": case "$lt": case "$lte":
553
+ props[op] = annotate(ctx, op, orderedRef());
554
+ break;
555
+ case "$between": case "$nbetween":
556
+ props[op] = annotate(ctx, op, { type: "array", minItems: 2, maxItems: 2, items: orderedRef() });
557
+ break;
558
+ case "$hasAll":
559
+ props[op] = annotate(ctx, op, setOf(itemRef()));
560
+ break;
561
+ case "$size":
562
+ props[op] = annotate(ctx, op, emitSizeDef(ctx));
563
+ break;
564
+ case "$some": case "$every": {
565
+ // Both quantifiers take the same element condition, so the subschema is
566
+ // emitted once and referenced twice.
567
+ field.elementTarget ??= emitQuantifierTarget(ctx, field, prefix);
568
+ if (field.elementTarget) props[op] = annotate(ctx, op, field.elementTarget);
569
+ break;
570
+ }
571
+ case "$not":
572
+ props[op] = annotate(ctx, op, { $ref: notRef() });
573
+ break;
574
+ case "$flags":
575
+ props[op] = annotate(ctx, op, { type: "string", pattern: "^[ims]{0,3}$" });
576
+ break;
577
+ default: {
578
+ // Everything left takes the operand shape the grammar already gives it:
579
+ // $like and friends, $regex, $search, $exists, $isNull, $type.
580
+ const base = ctx.grammar.$defs.ConstraintObject.properties[op];
581
+ const { title, description, $ref, ...shape } = base ?? {};
582
+ props[op] = annotate(ctx, op, shape);
583
+ }
584
+ }
585
+ }
586
+
587
+ if (negName) {
588
+ // The same constraint object minus $not, so one negation is expressible and
589
+ // two are not. Reachable only through $not, whose own description explains
590
+ // negation, so the field's prose is not repeated into it.
591
+ const { $not: dropped, ...rest } = props;
592
+ ctx.defs[negName] = {
593
+ title: `${field.path}, negated`,
594
+ type: "object",
595
+ minProperties: 1,
596
+ ...dependencyRules(ctx, rest),
597
+ properties: rest,
598
+ additionalProperties: false,
599
+ };
600
+ }
601
+
602
+ const constraint = {
603
+ title: field.path,
604
+ ...(field.schema.description ? { description: field.schema.description } : {}),
605
+ type: "object",
606
+ minProperties: 1,
607
+ ...dependencyRules(ctx, props),
608
+ properties: props,
609
+ additionalProperties: false,
610
+ };
611
+
612
+ ctx.defs[name] = constraint;
613
+ field.constraintDef = name;
614
+ field.operators = ops;
615
+
616
+ // Scalars keep the shorthand: {"status": "open"} is {"status": {"$eq": "open"}}.
617
+ // --no-shorthand gives them the object-only form arrays and objects already
618
+ // have, which costs a provider nothing to accept and buys a validator that
619
+ // reports where a constraint went wrong instead of an anyOf that failed.
620
+ if (field.kind === "scalar" && ctx.opts.shorthand) {
621
+ return { anyOf: [valueRef(), { $ref: `#/$defs/${name}` }] };
622
+ }
623
+ return { $ref: `#/$defs/${name}` };
624
+ }
625
+
626
+ function emitQuantifierTarget(ctx, field, prefix) {
627
+ if (field.itemInfo?.kind === "object") {
628
+ if (field.depth + 1 > ctx.opts.maxDepth) {
629
+ ctx.warn(`"${field.path}": --max-depth reached, $some and $every omitted.`);
630
+ return null;
631
+ }
632
+ const name = defName(ctx, `${prefix}F_`, `${field.path}_elem`);
633
+ const filter = emitFilter(ctx, field.items, name, {
634
+ depth: field.depth + 1,
635
+ trail: field.trail ?? new Set(),
636
+ selfRef: `#/$defs/${name}`,
637
+ });
638
+ if (!filter) return null;
639
+ return { $ref: `#/$defs/${name}` };
640
+ }
641
+ if (field.itemInfo?.kind === "scalar") {
642
+ // An array of scalars has no member paths, so the element condition is a
643
+ // constraint object over the element value itself (SPEC §5.8).
644
+ const element = {
645
+ path: `${field.path} element`,
646
+ nameHint: `${field.path}_elem`,
647
+ schema: field.items ?? {},
648
+ info: field.itemInfo,
649
+ present: true,
650
+ kind: "scalar",
651
+ };
652
+ const ref = emitConstraint(ctx, element, prefix);
653
+ // The quantifiers take the object form only; the scalar shorthand is not
654
+ // part of their operand grammar.
655
+ return element.constraintDef ? { $ref: `#/$defs/${element.constraintDef}` } : ref;
656
+ }
657
+ return null;
658
+ }
659
+
660
+ /**
661
+ * Emits a Filter over one object schema: logical operators plus one property
662
+ * per queryable path. `additionalProperties: false` over an explicit property
663
+ * list is what makes an unknown field a validation error rather than a runtime
664
+ * `unknown-field` error.
665
+ */
666
+ function emitFilter(ctx, node, name, state) {
667
+ if (name !== "__root__") ctx.reserved.add(name);
668
+ const fields = collectFields(node, ctx, state);
669
+ if (fields.length === 0) {
670
+ ctx.warn(`no queryable fields found${name === "__root__" ? "" : ` for ${name}`}.`);
671
+ return null;
672
+ }
673
+
674
+ const prefix = name === "__root__" ? "" : `${sanitize(name)}_`;
675
+ const fieldProps = {};
676
+ const emitted = [];
677
+ for (const field of fields) {
678
+ const ref = emitConstraint(ctx, field, prefix);
679
+ if (!ref) continue;
680
+ fieldProps[field.path] = ref;
681
+ emitted.push(field);
682
+ }
683
+ if (emitted.length === 0) {
684
+ // Logical operators over no field predicates is an infinite regress with no
685
+ // base case: every instance of it is unsatisfiable.
686
+ ctx.warn(`no constraints could be emitted${name === "__root__" ? "" : ` for ${name}`}.`);
687
+ return null;
688
+ }
689
+
690
+ // Unbounded nesting is the single self-referential object it has always been.
691
+ // A bounded one has to be a chain, because JSON Schema cannot count how deep
692
+ // an instance already is: level i offers the logical operators over level
693
+ // i+1, and the last level does not offer them at all. Every level shares the
694
+ // field properties, so the cost is n copies of a map of $refs, not n copies
695
+ // of the operand schemas.
696
+ const depth = ctx.opts.maxFilterDepth ?? 1;
697
+ const hint = name === "__root__" ? "Filter" : name;
698
+ const deeper = [];
699
+ for (let i = 2; i <= depth; i++) deeper.push(defName(ctx, "", `${hint}_depth_${i}`));
700
+
701
+ const level = (i) => {
702
+ const properties = {};
703
+ const next =
704
+ ctx.opts.maxFilterDepth === undefined ? (state.selfRef ?? "#")
705
+ : i < depth ? `#/$defs/${deeper[i - 1]}`
706
+ : null;
707
+ if (next) {
708
+ for (const op of ["$and", "$or", "$nor"]) {
709
+ if (!ctx.enabled.has(op)) continue;
710
+ properties[op] = annotate(ctx, op, { type: "array", minItems: 1, items: { $ref: next } }, "filter");
711
+ }
712
+ if (ctx.enabled.has("$not")) properties.$not = annotate(ctx, "$not", { $ref: next }, "filter");
713
+ }
714
+ return {
715
+ type: "object",
716
+ minProperties: 1,
717
+ properties: { ...properties, ...fieldProps },
718
+ additionalProperties: false,
719
+ };
720
+ };
721
+
722
+ for (let i = 2; i <= depth; i++) ctx.defs[deeper[i - 2]] = level(i);
723
+ const filter = level(1);
724
+
725
+ if (name === "__root__") {
726
+ ctx.rootFilter = filter;
727
+ ctx.rootFields = emitted;
728
+ // The deeper levels repeat the root's field properties, so --include has to
729
+ // reach them too — otherwise a path pruned from the root walks back in
730
+ // under an $and.
731
+ ctx.rootLevels = deeper.map((level) => ctx.defs[level]);
732
+ } else {
733
+ ctx.defs[name] = filter;
734
+ }
735
+ return filter;
736
+ }
737
+
738
+ // ---------------------------------------------------------------------------
739
+ // Public API
740
+ // ---------------------------------------------------------------------------
741
+
742
+ const SHORTHAND_RULE =
743
+ "A bare scalar is equality: {\"status\": \"open\"} is {\"status\": {\"$eq\": \"open\"}}.";
744
+
745
+ const SILENT_RULES = [
746
+ "Sibling members are combined with implicit AND, at every level.",
747
+ SHORTHAND_RULE,
748
+ "Comparisons use three-valued logic: $ne and $not do NOT match records where the field is null or absent. To include those records, add \"$unknownAs\": true to the same constraint.",
749
+ "$in compares the whole field value; it is not array membership. To say something about the elements of an array, quantify: {\"tags\": {\"$some\": {\"$in\": [\"a\"]}}}.",
750
+ ];
751
+
752
+ export function generateFilterSchema(resource, options = {}) {
753
+ const ctx = prepare(resource, options);
754
+ emitFilter(ctx, ctx.resource, "__root__", { depth: 0, trail: new Set() });
755
+ if (!ctx.rootFilter) throw new Error("no queryable fields — nothing to generate");
756
+ if (ctx.opts.include) pruneToIncluded(ctx);
757
+
758
+ const title = options.title ?? `${ctx.resource.title ?? "Resource"} — filter`;
759
+ const schema = {
760
+ $schema: "https://json-schema.org/draft/2020-12/schema",
761
+ ...(options.id ? { $id: options.id } : {}),
762
+ title,
763
+ description: [
764
+ `A filter over ${ctx.resource.title ?? "this resource"}, in OpenPredicate.`,
765
+ ...SILENT_RULES.filter((rule) => ctx.opts.shorthand || rule !== SHORTHAND_RULE),
766
+ ].join(" "),
767
+ $comment:
768
+ `Generated by tools/generate-filter-schema.mjs from ${ctx.resource.$id ?? options.source ?? "a resource schema"} ` +
769
+ `against ${ctx.grammar.$id}. Profiles: ${ctx.opts.profiles.join(", ")}` +
770
+ `${ctx.opts.excluded.length ? ` (minus ${ctx.opts.excluded.join(", ")})` : ""}. Do not edit by hand.`,
771
+ ...ctx.rootFilter,
772
+ $defs: ctx.defs,
773
+ };
774
+ return { schema, capabilities: buildCapabilities(ctx, options), warnings: ctx.warnings };
775
+ }
776
+
777
+ export function generateCapabilities(resource, options = {}) {
778
+ return generateFilterSchema(resource, options).capabilities;
779
+ }
780
+
781
+ /**
782
+ * --include is applied after the walk rather than during it, so a nested path
783
+ * like "shelter.city" can be named without also naming its parent.
784
+ */
785
+ function pruneToIncluded(ctx) {
786
+ const keep = new Set(ctx.opts.include);
787
+ for (const filter of [ctx.rootFilter, ...ctx.rootLevels]) {
788
+ for (const path of Object.keys(filter.properties)) {
789
+ if (!path.startsWith("$") && !keep.has(path)) delete filter.properties[path];
790
+ }
791
+ }
792
+ for (const path of ctx.opts.include) {
793
+ if (!(path in ctx.rootFilter.properties)) ctx.warn(`--include names "${path}", which was not found.`);
794
+ }
795
+ ctx.rootFields = ctx.rootFields.filter((f) => keep.has(f.path));
796
+ }
797
+
798
+ function buildCapabilities(ctx, options) {
799
+ const fields = {};
800
+ for (const field of ctx.rootFields ?? []) {
801
+ const entry = { operators: field.operators };
802
+ const types = field.info.types;
803
+ if (types.length === 1) entry.type = types[0];
804
+ else if (types.length > 1) entry.type = types;
805
+ if (field.schema.format) entry.format = field.schema.format;
806
+ const values = domainValues(field.schema);
807
+ if (values) entry.values = values;
808
+ if (field.kind === "array" && field.items) {
809
+ const itemValues = domainValues(field.items);
810
+ if (itemValues) entry.itemValues = itemValues;
811
+ }
812
+ if (field.schema.description) entry.description = field.schema.description;
813
+ if (field.info.nullable) entry.nullable = true;
814
+ fields[field.path] = entry;
815
+ }
816
+ return {
817
+ queryLanguage: ctx.grammar.$id,
818
+ ...(options.id ? { filterSchema: options.id } : {}),
819
+ profiles: ctx.opts.complete,
820
+ fields,
821
+ limits: ctx.opts.limits,
822
+ };
823
+ }
824
+
825
+ /**
826
+ * The operator set the generated schema will offer: profiles first, because
827
+ * they are the coarse unit a server advertises, then the operator-level refinement
828
+ * for the cases a profile boundary does not fit — a backend with LIKE but no
829
+ * POSITION supports $like and not $contains, and a key-value store cannot
830
+ * implement $exists at all.
831
+ *
832
+ * Narrowing the set is always safe: the emitted schema accepts fewer filters
833
+ * than the grammar, never more. What it costs is the profile *claim*, which is
834
+ * why the demotion is warned about here and reflected in the capability
835
+ * document rather than silently kept (SPEC §2.1).
836
+ */
837
+ function selectOperators(grammar, profiles, options, warn) {
838
+ const byProfile = grammar["x-profiles"];
839
+ const defined = new Set(Object.values(byProfile).flat());
840
+ const profileOf = (op) => Object.keys(byProfile).find((p) => byProfile[p].includes(op));
841
+
842
+ const check = (names, flag) => {
843
+ for (const op of names) {
844
+ if (!defined.has(op)) {
845
+ throw new Error(`unknown operator "${op}" in ${flag}; the grammar defines ${[...defined].join(", ")}`);
846
+ }
847
+ }
848
+ };
849
+ check(options.operators ?? [], "--operators");
850
+ check(options.dropOperators ?? [], "--drop-operators");
851
+
852
+ let enabled = new Set(profiles.flatMap((p) => byProfile[p]));
853
+
854
+ if (options.operators) {
855
+ for (const op of options.operators) {
856
+ if (!enabled.has(op)) {
857
+ warn(`--operators names "${op}", which the "${profileOf(op)}" profile supplies; add it to --profiles for the operator to take effect.`);
858
+ }
859
+ }
860
+ enabled = new Set(options.operators.filter((op) => enabled.has(op)));
861
+ }
862
+ for (const op of options.dropOperators ?? []) enabled.delete(op);
863
+
864
+ // A kept operator whose dependency was dropped would be emitted with a
865
+ // `dependentRequired` rule naming a member `additionalProperties: false`
866
+ // forbids — present in the schema and impossible to use. The rule is read off
867
+ // the grammar (today: $flags needs $regex) so a dependency added later is
868
+ // handled by construction rather than by a second list here.
869
+ for (const [trigger, requires] of Object.entries(grammar.$defs.ConstraintObject.dependentRequired ?? {})) {
870
+ if (!enabled.has(trigger)) continue;
871
+ const missing = requires.filter((op) => !enabled.has(op));
872
+ if (missing.length === 0) continue;
873
+ enabled.delete(trigger);
874
+ warn(`"${trigger}" requires ${missing.join(", ")} beside it, so it is excluded too.`);
875
+ }
876
+
877
+ if (enabled.size === 0) throw new Error("the selection leaves no operators enabled");
878
+
879
+ // Which profiles survive as *claims*. SPEC §2.1: a profile other than core is
880
+ // implemented in full or not at all, so a profile missing an operator is no
881
+ // longer on offer — the per-field `operators` lists carry what is.
882
+ const requested = new Set(profiles.flatMap((p) => byProfile[p]));
883
+ const excluded = [...requested].filter((op) => !enabled.has(op));
884
+ const complete = profiles.filter((p) => byProfile[p].every((op) => enabled.has(op)));
885
+ for (const p of profiles) {
886
+ if (complete.includes(p)) continue;
887
+ const missing = byProfile[p].filter((op) => !enabled.has(op));
888
+ warn(
889
+ p === "core"
890
+ ? `the "core" profile is incomplete (${missing.join(", ")} excluded). SPEC §2.1 requires core in full, so this endpoint is not a conforming implementation; the capability document will not claim core.`
891
+ : `profile "${p}" is not offered in full (${missing.join(", ")} excluded), so it is omitted from the capability document's profiles (SPEC §2.1).`,
892
+ );
893
+ }
894
+
895
+ return { enabled, complete, excluded };
896
+ }
897
+
898
+ /**
899
+ * SPEC §7's limits are a claim published through the capability document, not
900
+ * something the schema enforces — except `maxDepth`, which `--max-filter-depth`
901
+ * does enforce. Where both are given they should agree, so a disagreement is
902
+ * worth saying out loud.
903
+ */
904
+ function resolveLimits(options, warn) {
905
+ const given = options.limits ?? {};
906
+ for (const [key, value] of Object.entries(given)) {
907
+ if (!(key in DEFAULT_LIMITS)) {
908
+ throw new Error(`unknown limit "${key}"; --limits takes ${Object.keys(DEFAULT_LIMITS).join(", ")}`);
909
+ }
910
+ if (!Number.isInteger(value) || value < 1) {
911
+ throw new Error(`limit "${key}" must be a positive integer, got ${JSON.stringify(value)}`);
912
+ }
913
+ }
914
+ const limits = { ...DEFAULT_LIMITS, ...given };
915
+ if (options.maxFilterDepth !== undefined) {
916
+ if (given.maxDepth === undefined) limits.maxDepth = options.maxFilterDepth;
917
+ else if (given.maxDepth !== options.maxFilterDepth) {
918
+ warn(`--limits maxDepth is ${given.maxDepth} but the schema refuses logical nesting past ${options.maxFilterDepth}; the published limit is the looser claim.`);
919
+ }
920
+ }
921
+ return limits;
922
+ }
923
+
924
+ function prepare(resource, options) {
925
+ const grammar = options.grammar ?? JSON.parse(readFileSync(options.grammarPath ?? DEFAULT_GRAMMAR, "utf8"));
926
+ const profiles = options.profiles ?? DEFAULT_PROFILES;
927
+
928
+ const modes = ["all", "brief", "none"];
929
+ if (options.descriptions && !modes.includes(options.descriptions)) {
930
+ throw new Error(`--descriptions must be one of ${modes.join(", ")}`);
931
+ }
932
+
933
+ const known = Object.keys(grammar["x-profiles"]);
934
+ for (const p of profiles) {
935
+ if (!known.includes(p)) throw new Error(`unknown profile "${p}"; the grammar defines ${known.join(", ")}`);
936
+ }
937
+ if (!profiles.includes("core")) throw new Error(`the "core" profile is mandatory (SPEC §2.1)`);
938
+
939
+ if (options.maxFilterDepth !== undefined
940
+ && (!Number.isInteger(options.maxFilterDepth) || options.maxFilterDepth < 1)) {
941
+ throw new Error(`--max-filter-depth must be an integer of 1 or more, got ${JSON.stringify(options.maxFilterDepth)}`);
942
+ }
943
+
944
+ const warnings = [];
945
+ const warn = (m) => warnings.push(m);
946
+ const { enabled, complete, excluded } = selectOperators(grammar, profiles, options, warn);
947
+ const limits = resolveLimits(options, warn);
948
+
949
+ const root = resource;
950
+ const deref = makeDeref(root);
951
+ const entry = options.pointer ? jsonPointer(root, options.pointer) : root;
952
+ if (entry === undefined) throw new Error(`--pointer "${options.pointer}" does not resolve`);
953
+ const resolved = flatten(entry, deref, new Set());
954
+
955
+ return {
956
+ grammar,
957
+ deref,
958
+ enabled,
959
+ defs: {},
960
+ reserved: new Set(["Size"]),
961
+ warnings,
962
+ warn,
963
+ resource: resolved,
964
+ opts: {
965
+ profiles,
966
+ complete,
967
+ excluded,
968
+ limits,
969
+ maxDepth: options.maxDepth ?? 3,
970
+ maxFilterDepth: options.maxFilterDepth,
971
+ shorthand: options.shorthand !== false,
972
+ descriptions: options.descriptions ?? "brief",
973
+ include: options.include ?? null,
974
+ exclude: (options.exclude ?? []).map(globToRegExp),
975
+ },
976
+ };
977
+ }
978
+
979
+ // ---------------------------------------------------------------------------
980
+ // CLI
981
+ // ---------------------------------------------------------------------------
982
+
983
+ const FLAGS = {
984
+ id: { type: "string" },
985
+ title: { type: "string" },
986
+ profiles: { type: "string" },
987
+ operators: { type: "string" },
988
+ "drop-operators": { type: "string" },
989
+ "no-shorthand": { type: "boolean" },
990
+ "max-filter-depth": { type: "string" },
991
+ limits: { type: "string" },
992
+ pointer: { type: "string" },
993
+ "max-depth": { type: "string" },
994
+ include: { type: "string" },
995
+ exclude: { type: "string" },
996
+ capabilities: { type: "string" },
997
+ descriptions: { type: "string" },
998
+ grammar: { type: "string" },
999
+ out: { type: "string" },
1000
+ config: { type: "string" },
1001
+ quiet: { type: "boolean" },
1002
+ help: { type: "boolean", short: "h" },
1003
+ };
1004
+
1005
+ /** Config keys that name a file, and so resolve against the config's own directory. */
1006
+ const CONFIG_PATHS = new Set(["resource", "out", "capabilities", "grammar"]);
1007
+
1008
+ /** The config file's vocabulary: the JS API's option names, plus where things go. */
1009
+ const CONFIG_KEYS = [
1010
+ "resource", "id", "title", "profiles", "operators", "dropOperators", "shorthand",
1011
+ "maxFilterDepth", "limits", "pointer", "maxDepth", "include", "exclude",
1012
+ "descriptions", "out", "capabilities", "grammar", "quiet",
1013
+ ];
1014
+
1015
+ /**
1016
+ * Merges a config file with the command line into one options object.
1017
+ *
1018
+ * The config file is the artifact a provider checks in beside their resource
1019
+ * schema and regenerates from, so the capability selection lives in version
1020
+ * control rather than in whoever's shell history. A flag given explicitly wins
1021
+ * over it, which is what makes a one-off `--include` on top of a checked-in
1022
+ * config work.
1023
+ *
1024
+ * Exported so the merge can be tested without spawning a process.
1025
+ */
1026
+ export function resolveOptions(values, positionals, cwd = process.cwd()) {
1027
+ if (positionals.length > 1) {
1028
+ throw new Error(`expected one resource schema, got ${positionals.length}: ${positionals.join(", ")}`);
1029
+ }
1030
+
1031
+ let config = {};
1032
+ let base = cwd;
1033
+ if (values.config !== undefined) {
1034
+ const file = resolve(cwd, values.config);
1035
+ base = dirname(file);
1036
+ config = JSON.parse(readFileSync(file, "utf8"));
1037
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
1038
+ throw new Error(`${values.config} must contain a JSON object`);
1039
+ }
1040
+ // Refused rather than ignored, for the same reason parseArgs refuses an
1041
+ // unknown flag: a misspelled key is a selection that silently did not apply.
1042
+ const unknown = Object.keys(config).filter((k) => !CONFIG_KEYS.includes(k));
1043
+ if (unknown.length > 0) {
1044
+ throw new Error(
1045
+ `unknown key${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")} in ` +
1046
+ `${values.config}; it takes ${CONFIG_KEYS.join(", ")}`,
1047
+ );
1048
+ }
1049
+ }
1050
+
1051
+ // Paths in a config file are relative to the config file. Paths on the command
1052
+ // line are relative to the cwd, and are left as written so that a generated
1053
+ // $comment names the schema the way the reader would.
1054
+ const configValue = (key) => (CONFIG_PATHS.has(key) ? resolve(base, config[key]) : config[key]);
1055
+ const pick = (flag, key) =>
1056
+ values[flag] !== undefined ? values[flag]
1057
+ : config[key] !== undefined ? configValue(key)
1058
+ : undefined;
1059
+
1060
+ const list = (v) => {
1061
+ if (typeof v !== "string") return v;
1062
+ const items = v.split(",").map((s) => s.trim()).filter(Boolean);
1063
+ return items.length > 0 ? items : undefined;
1064
+ };
1065
+ const integer = (v, flag, min) => {
1066
+ if (v === undefined) return undefined;
1067
+ const n = Number(v);
1068
+ if (!Number.isInteger(n) || n < min) {
1069
+ throw new Error(`${flag} must be an integer of ${min} or more, got ${JSON.stringify(v)}`);
1070
+ }
1071
+ return n;
1072
+ };
1073
+ const limits = () => {
1074
+ const raw = values.limits;
1075
+ if (raw === undefined) return config.limits;
1076
+ const text = raw.startsWith("@") ? readFileSync(resolve(cwd, raw.slice(1)), "utf8") : raw;
1077
+ try {
1078
+ return JSON.parse(text);
1079
+ } catch (error) {
1080
+ throw new Error(`--limits is not valid JSON: ${error.message}`);
1081
+ }
1082
+ };
1083
+
1084
+ const source = positionals[0] ?? (config.resource !== undefined ? resolve(base, config.resource) : undefined);
1085
+
1086
+ return {
1087
+ source,
1088
+ out: pick("out", "out"),
1089
+ capabilities: pick("capabilities", "capabilities"),
1090
+ quiet: values.quiet ?? config.quiet ?? false,
1091
+ options: {
1092
+ id: pick("id", "id"),
1093
+ title: pick("title", "title"),
1094
+ source,
1095
+ profiles: list(pick("profiles", "profiles")),
1096
+ operators: list(pick("operators", "operators")),
1097
+ dropOperators: list(pick("drop-operators", "dropOperators")),
1098
+ shorthand: values["no-shorthand"] ? false : config.shorthand,
1099
+ maxFilterDepth: integer(pick("max-filter-depth", "maxFilterDepth"), "--max-filter-depth", 1),
1100
+ limits: limits(),
1101
+ pointer: pick("pointer", "pointer"),
1102
+ maxDepth: integer(pick("max-depth", "maxDepth"), "--max-depth", 0),
1103
+ descriptions: pick("descriptions", "descriptions"),
1104
+ include: list(pick("include", "include")),
1105
+ exclude: list(pick("exclude", "exclude")),
1106
+ grammarPath: pick("grammar", "grammar"),
1107
+ },
1108
+ };
1109
+ }
1110
+
1111
+ function usage() {
1112
+ // The header comment is the help text; keeping one copy avoids the usual drift.
1113
+ return readFileSync(fileURLToPath(import.meta.url), "utf8")
1114
+ .split("*/")[0]
1115
+ .replace(/^#!.*\n/, "")
1116
+ .replace(/^\/\*\*?\n?/, "")
1117
+ .replace(/^ \*\/?/gm, "")
1118
+ .replace(/^ /gm, "")
1119
+ .trimEnd();
1120
+ }
1121
+
1122
+ function main(argv) {
1123
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: FLAGS });
1124
+
1125
+ if (values.help) {
1126
+ process.stdout.write(`${usage()}\n`);
1127
+ process.exit(0);
1128
+ }
1129
+
1130
+ const resolved = resolveOptions(values, positionals);
1131
+ if (!resolved.source) {
1132
+ process.stdout.write(`${usage()}\n`);
1133
+ process.exit(1);
1134
+ }
1135
+
1136
+ const resource = JSON.parse(readFileSync(resolved.source, "utf8"));
1137
+ const { schema, capabilities, warnings } = generateFilterSchema(resource, resolved.options);
1138
+
1139
+ const json = `${JSON.stringify(schema, null, 2)}\n`;
1140
+ if (resolved.out) writeFileSync(resolved.out, json);
1141
+ else process.stdout.write(json);
1142
+
1143
+ if (resolved.capabilities) {
1144
+ writeFileSync(resolved.capabilities, `${JSON.stringify(capabilities, null, 2)}\n`);
1145
+ }
1146
+ if (!resolved.quiet) {
1147
+ for (const w of warnings) process.stderr.write(`warning: ${w}\n`);
1148
+ }
1149
+ }
1150
+
1151
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
1152
+ main(process.argv.slice(2));
1153
+ }