@m6d/cortex-cli 0.13.0 → 0.15.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.
@@ -25,11 +25,22 @@ export const datasetColumnRefSchema = z.string().regex(DATASET_COLUMN_REF_PATTER
25
25
  export const DATASET_COLUMN_TYPES = ["string", "number", "boolean", "date"] as const;
26
26
  export type DatasetColumnType = (typeof DATASET_COLUMN_TYPES)[number];
27
27
 
28
- export const datasetColumnSchema = z.object({
29
- name: datasetIdentifierSchema,
30
- type: z.enum(DATASET_COLUMN_TYPES),
31
- description: z.string().max(200).default(""),
32
- });
28
+ /**
29
+ * One column. `values`, on a string column, lists every value it can hold:
30
+ * the agent reads them, and a query comparing the column to anything else is
31
+ * refused before it runs.
32
+ */
33
+ export const datasetColumnSchema = z
34
+ .object({
35
+ name: datasetIdentifierSchema,
36
+ type: z.enum(DATASET_COLUMN_TYPES),
37
+ description: z.string().max(200).default(""),
38
+ values: z.array(z.string().min(1).max(200)).min(1).max(100).optional(),
39
+ })
40
+ .refine((column) => column.values === undefined || column.type === "string", {
41
+ message: "only a string column lists its values",
42
+ path: ["values"],
43
+ });
33
44
  export type DatasetColumn = z.infer<typeof datasetColumnSchema>;
34
45
 
35
46
  /**
@@ -396,10 +407,59 @@ export function resolveDatasetColumn(catalog: DatasetCatalog, from: string, ref:
396
407
  dataset = target;
397
408
  }
398
409
  const column = dataset.columns.find((candidate) => candidate.name === columnName);
399
- if (!column) return missing(`"${dataset.name}" has no column "${columnName}"`);
410
+ if (!column) return missing(unknownColumnError(dataset, columnName));
400
411
  return { ok: true as const, column, path: segments.join(".") };
401
412
  }
402
413
 
414
+ /**
415
+ * Why `dataset` has no column `name`, with the columns it has and up to
416
+ * three closest ones: names containing it or contained in it (ignoring
417
+ * case, and only for names longer than two characters), else names two
418
+ * edits or fewer away. A query that guessed `name` reads `departments has
419
+ * no column "name". Closest: nameAr, nameEn. Columns: id, nameAr, ...`
420
+ */
421
+ export function unknownColumnError(dataset: DatasetDefinition, name: string) {
422
+ const columns = dataset.columns.map((column) => column.name);
423
+ const wanted = name.toLowerCase();
424
+ const containing = columns.filter((column) => {
425
+ const lower = column.toLowerCase();
426
+ return (
427
+ (wanted.length > 2 && lower.includes(wanted)) ||
428
+ (lower.length > 2 && wanted.includes(lower))
429
+ );
430
+ });
431
+ const closest = (
432
+ containing.length > 0
433
+ ? containing
434
+ : // Lengths more than two apart are more than two edits apart, so a
435
+ // name of any length costs only its own columns' comparisons.
436
+ columns.filter(
437
+ (column) =>
438
+ Math.abs(column.length - wanted.length) <= 2 &&
439
+ editDistance(column.toLowerCase(), wanted) <= 2,
440
+ )
441
+ ).slice(0, 3);
442
+ const hint = closest.length > 0 ? ` Closest: ${closest.join(", ")}.` : "";
443
+ return `${dataset.name} has no column "${name}".${hint} Columns: ${columns.join(", ")}.`;
444
+ }
445
+
446
+ /** Levenshtein distance: the fewest single-character edits from `a` to `b`. */
447
+ function editDistance(a: string, b: string) {
448
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
449
+ for (let i = 1; i <= a.length; i++) {
450
+ const current = [i];
451
+ for (let j = 1; j <= b.length; j++) {
452
+ current[j] = Math.min(
453
+ previous[j]! + 1,
454
+ current[j - 1]! + 1,
455
+ previous[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
456
+ );
457
+ }
458
+ previous = current;
459
+ }
460
+ return previous[b.length]!;
461
+ }
462
+
403
463
  function refError(kind: string, ref: string, reason: string) {
404
464
  return `${kind} "${ref}": ${reason}`;
405
465
  }
@@ -654,6 +714,19 @@ export function validateDatasetQuery(catalog: DatasetCatalog, query: DatasetQuer
654
714
  } satisfies QueryCheck;
655
715
 
656
716
  for (const filter of query.filter) checkFilter(check, filter);
717
+ const row = checkAnsweredRow(check, catalog, query);
718
+ checkFinishedRow(check, query, row);
719
+ checkExpansion(check, query, catalog.get(query.from)?.columns.length ?? 0);
720
+ return issues;
721
+ }
722
+
723
+ /**
724
+ * The row a query answers, checked as it is built: on a grouped query its
725
+ * keys and aggregate aliases (never one name for both, and no select), on a
726
+ * plain one its select aliases; each with the names that are a column as it
727
+ * is, which keep what the column says.
728
+ */
729
+ function checkAnsweredRow(check: QueryCheck, catalog: DatasetCatalog, query: DatasetQuery) {
657
730
  const groupKeys = checkGroupBy(check, query.groupBy);
658
731
  const aliases = checkAggregates(check, query.aggregate);
659
732
  for (const alias of aliases.keys()) {
@@ -671,12 +744,17 @@ export function validateDatasetQuery(catalog: DatasetCatalog, query: DatasetQuer
671
744
  );
672
745
  }
673
746
  const selectAliases = checkSelect(check, query.select ?? []);
674
- const row = grouped
675
- ? { names: new Map([...groupKeys, ...aliases]), kind: "grouped" as const }
676
- : { names: selectAliases, kind: "plain" as const };
677
- checkFinishedRow(check, query, row);
678
- checkExpansion(check, query, catalog.get(query.from)?.columns.length ?? 0);
679
- return issues;
747
+ return grouped
748
+ ? {
749
+ names: new Map([...groupKeys, ...aliases]),
750
+ kind: "grouped" as const,
751
+ columns: passedColumns(catalog, query, query.groupBy),
752
+ }
753
+ : {
754
+ names: selectAliases,
755
+ kind: "plain" as const,
756
+ columns: passedColumns(catalog, query, query.select ?? []),
757
+ };
680
758
  }
681
759
 
682
760
  /** How large any one expression a host translates may grow once every alias it names is written out in place. */
@@ -856,7 +934,7 @@ function filterCost(filter: DatasetFilter, expanded: ReadonlyMap<string, number>
856
934
  function checkFinishedRow(
857
935
  check: QueryCheck,
858
936
  query: DatasetQuery,
859
- row: { names: Map<string, DatasetColumnType>; kind: "grouped" | "plain" },
937
+ row: RowNames & { names: Map<string, DatasetColumnType> },
860
938
  ) {
861
939
  const rowCheck = rowScope(check, row);
862
940
  // On a plain row a selected column is a name too; a window or calculation may not take it.
@@ -881,7 +959,7 @@ function rowScope(check: QueryCheck, row: RowNames) {
881
959
  issue: (message: string) => check.issue(message),
882
960
  columnOf(kind, ref) {
883
961
  const type = row.names.get(ref);
884
- if (type) return { name: ref, type, description: "" };
962
+ if (type) return row.columns.get(ref) ?? { name: ref, type, description: "" };
885
963
  if (row.kind === "plain") return check.columnOf(kind, ref);
886
964
  check.issue(
887
965
  refError(
@@ -1203,10 +1281,31 @@ function checkFilter(check: QueryCheck, filter: DatasetFilter, kind = "filter")
1203
1281
  ? columnValueIssue(check, kind, type, filter.op, filter.value.col)
1204
1282
  : (filterValueIssue(filter) ??
1205
1283
  filterValueTypeIssue(type, filter) ??
1206
- truncatedDateIssue(filter));
1284
+ truncatedDateIssue(filter) ??
1285
+ listedValueIssue(check, kind, filter));
1207
1286
  if (issue) check.issue(refError(kind, shown, issue));
1208
1287
  }
1209
1288
 
1289
+ const LISTED_VALUE_OPS = new Set<DatasetFilterOp>(["=", "!=", "in", "notIn"]);
1290
+
1291
+ /**
1292
+ * A column that lists its values is only compared with one of them: any
1293
+ * other literal would match no row, or every row, and the host would answer
1294
+ * as if the data said so.
1295
+ */
1296
+ function listedValueIssue(check: QueryCheck, kind: string, filter: DatasetCondition) {
1297
+ if (typeof filter.col !== "string" || !LISTED_VALUE_OPS.has(filter.op)) return undefined;
1298
+ if (filter.value === undefined || isColumnValue(filter.value)) return undefined;
1299
+ const listed = check.columnOf(kind, filter.col)?.values;
1300
+ if (!listed) return undefined;
1301
+ const values = Array.isArray(filter.value) ? filter.value : [filter.value];
1302
+ // A number or a boolean is compared as the text the host turns it into.
1303
+ const odd = values.find((value) => !listed.includes(String(value)));
1304
+ return odd === undefined
1305
+ ? undefined
1306
+ : `${JSON.stringify(odd)} is not one of its values: ${listed.join(", ")}`;
1307
+ }
1308
+
1210
1309
  /** A truncated date is a whole day, so a literal against it is one too ("2024-03-01"); a time or a bare month would never match. */
1211
1310
  function truncatedDateIssue(filter: DatasetCondition) {
1212
1311
  const truncated =
@@ -1314,7 +1413,31 @@ function aggregateType(
1314
1413
  }
1315
1414
 
1316
1415
  /** What sort and rank may name: on a grouped query a group key or an alias; on a plain one a select alias or any reachable column. */
1317
- type RowNames = { names: ReadonlyMap<string, DatasetColumnType>; kind: "grouped" | "plain" };
1416
+ type RowNames = {
1417
+ names: ReadonlyMap<string, DatasetColumnType>;
1418
+ kind: "grouped" | "plain";
1419
+ /** The names that are a column as it is (group keys, select entries), so what the column says (its values) still holds. */
1420
+ columns: ReadonlyMap<string, DatasetColumn>;
1421
+ };
1422
+
1423
+ /**
1424
+ * The row names that are a column as it is, each with the column: a bare
1425
+ * column (`status`), or one under an alias (`{ expr: "status", as: "state" }`).
1426
+ */
1427
+ function passedColumns(
1428
+ catalog: DatasetCatalog,
1429
+ query: DatasetQuery,
1430
+ entries: ReadonlyArray<string | { expr: DatasetOperand; as: string }>,
1431
+ ) {
1432
+ return new Map(
1433
+ entries.flatMap((entry) => {
1434
+ const [name, ref] = typeof entry === "string" ? [entry, entry] : [entry.as, entry.expr];
1435
+ if (typeof ref !== "string") return [];
1436
+ const resolved = resolveDatasetColumn(catalog, query.from, ref);
1437
+ return resolved.ok ? [[name, resolved.column] as const] : [];
1438
+ }),
1439
+ );
1440
+ }
1318
1441
 
1319
1442
  function checkSort(check: QueryCheck, sort: DatasetQuery["sort"], kind: string, row: RowNames) {
1320
1443
  for (const { col } of sort) checkRowName(check, kind, col, row);
@@ -7,19 +7,22 @@ const REDACTION_MARKER = "[REDACTED]";
7
7
  * itself and, after a leading scheme word (`Bearer xyz`, `Basic abc`), the
8
8
  * bare secret the scheme merely labels; each of those raw as in a header,
9
9
  * percent-encoded as in a path slot (`encodeURIComponent`) or a query value
10
- * (`URLSearchParams`, which spells a space as `+`), and JSON-escaped as inside
11
- * a request body. The scrubber replaces longer forms first.
10
+ * (`URLSearchParams`, which spells a space as `+`), JSON-escaped as inside
11
+ * a request body, and JSON-escaped then percent-encoded as a form field sent
12
+ * as JSON text is. The scrubber replaces longer forms first.
12
13
  */
13
14
  export function credentialForms(value: string) {
14
15
  const bare = /^[A-Za-z][\w.-]* +(\S.*)$/.exec(value)?.[1];
16
+ const percentEncoded = (form: string) => [
17
+ encodeURIComponent(form),
18
+ new URLSearchParams({ v: form }).toString().slice(2),
19
+ ];
15
20
  return [
16
21
  ...new Set(
17
- (bare ? [value, bare] : [value]).flatMap((form) => [
18
- form,
19
- encodeURIComponent(form),
20
- new URLSearchParams({ v: form }).toString().slice(2),
21
- JSON.stringify(form).slice(1, -1),
22
- ]),
22
+ (bare ? [value, bare] : [value]).flatMap((form) => {
23
+ const json = JSON.stringify(form).slice(1, -1);
24
+ return [form, ...percentEncoded(form), json, ...percentEncoded(json)];
25
+ }),
23
26
  ),
24
27
  ];
25
28
  }
@@ -24,3 +24,24 @@ export function escapePointerToken(token: string) {
24
24
  export function unescapePointerToken(token: string) {
25
25
  return token.replaceAll("~1", "/").replaceAll("~0", "~");
26
26
  }
27
+
28
+ /** An RFC 6901 array index: digits with no leading zero. */
29
+ export const ARRAY_INDEX = /^(0|[1-9]\d*)$/;
30
+
31
+ /** RFC 6901 lookup; `undefined` when the pointer names nothing (an inherited key, or an array index not written canonically, included). */
32
+ export function readJsonPointer(value: unknown, pointer: string) {
33
+ if (pointer === "") return value;
34
+ let current: unknown = value;
35
+ for (const raw of pointer.slice(1).split("/")) {
36
+ const key = unescapePointerToken(raw);
37
+ if (Array.isArray(current)) {
38
+ if (!ARRAY_INDEX.test(key)) return undefined;
39
+ current = current[Number(key)];
40
+ } else if (isRecord(current) && Object.hasOwn(current, key)) {
41
+ current = current[key];
42
+ } else {
43
+ return undefined;
44
+ }
45
+ }
46
+ return current;
47
+ }