@checkdigit/eslint-plugin 7.18.0-PR.143-3aac → 7.18.0-PR.143-1cc5

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.
@@ -47,6 +47,8 @@ export declare function isJoin(node: unknown): node is Join;
47
47
  export declare function isBaseFrom(node: unknown): node is BaseFrom;
48
48
  export declare function hasArrayIndex(node: ColumnRefItem): node is ColumnRefWithIndex;
49
49
  export declare function walk(node: unknown, visitor: VisitorMap): void;
50
+ /** Return true if the expression contains a lambda (`->`) subexpression. */
51
+ export declare function containsLambda(expr: unknown): boolean;
50
52
  /** Collect all column_ref nodes within an expression subtree. */
51
53
  export declare function extractColumnRefs(expr: unknown): ColumnRefItem[];
52
54
  /** Return the path string from the first json_extract_scalar / json_extract call found. */
package/package.json CHANGED
@@ -1 +1 @@
1
- {"name":"@checkdigit/eslint-plugin","version":"7.18.0-PR.143-3aac","description":"Check Digit eslint plugins","keywords":["eslint","eslintplugin"],"homepage":"https://github.com/checkdigit/eslint-plugin#readme","bugs":{"url":"https://github.com/checkdigit/eslint-plugin/issues"},"repository":{"type":"git","url":"https://github.com/checkdigit/eslint-plugin"},"license":"MIT","author":"Check Digit, LLC","sideEffects":false,"type":"module","exports":{".":{"types":"./dist-types/index.d.ts","import":"./dist-mjs/index.mjs","default":"./dist-mjs/index.mjs"}},"files":["src","dist-types","dist-mjs","!src/**/test/**","!src/**/*.test.ts","!src/**/*.spec.ts","!dist-types/**/test/**","!dist-types/**/*.test.d.ts","!dist-types/**/*.spec.d.ts","!dist-mjs/**/test/**","!dist-mjs/**/*.test.mjs","!dist-mjs/**/*.spec.mjs","SECURITY.md"],"scripts":{"build:dist-mjs":"rimraf dist-mjs && npx builder --type=module --sourceMap --outDir=dist-mjs && node dist-mjs/index.mjs","build:dist-types":"rimraf dist-types && npx builder --type=types --outDir=dist-types","ci:compile":"tsc --noEmit","ci:coverage":"NODE_OPTIONS=\"--disable-warning ExperimentalWarning --experimental-vm-modules\" jest --coverage=true","ci:lint":"npm run lint","ci:style":"npm run prettier","ci:test":"NODE_OPTIONS=\"--disable-warning ExperimentalWarning --experimental-vm-modules\" jest --coverage=false","lint":"eslint --max-warnings 0 .","lint:fix":"eslint --max-warnings 0 --fix .","peggy":"for file in ./src/peggy/*.peggy; do peggy \"$file\" --format es --output \"${file%.peggy}-peggy.ts\"; done","peggy-watch":"for file in ./src/peggy/*.peggy; do peggy \"$file\" --format=es --watch --output=\"${file%.peggy}-peggy.ts\"; done","prepare":"","prepublishOnly":"npm run build:dist-types && npm run build:dist-mjs","prettier":"prettier --ignore-path .gitignore --list-different .","prettier:fix":"prettier --ignore-path .gitignore --write .","test":"npm run ci:compile && npm run ci:test && npm run ci:lint && npm run ci:style"},"prettier":"@checkdigit/prettier-config","jest":{"preset":"@checkdigit/jest-config"},"dependencies":{"@apidevtools/json-schema-ref-parser":"^15.3.5","@typescript-eslint/type-utils":"^8.60.1","@typescript-eslint/utils":"^8.60.1","ajv":"^8.20.0","debug":"^4.4.3","glob":"^13.0.6","http-status-codes":"^2.3.0","js-yaml":"^4.2.0","json-pointer":"^0.6.2","jsonpath-plus":"^10.4.0","ts-api-utils":"^2.5.0"},"devDependencies":{"@checkdigit/jest-config":"^6.0.2","@checkdigit/prettier-config":"^6.1.0","@checkdigit/typescript-config":"10.0.0","@eslint/js":"^9.37.0","@types/debug":"^4.1.13","@types/eslint":"^9.6.1","@types/eslint-config-prettier":"^6.11.3","@types/js-yaml":"^4.0.9","@types/json-pointer":"^1.0.34","@typescript-eslint/parser":"^8.60.1","@typescript-eslint/rule-tester":"^8.60.1","eslint":"^9.37.0","eslint-config-prettier":"^10.1.8","eslint-import-resolver-typescript":"^4.4.5","eslint-plugin-eslint-plugin":"^6.4.0","eslint-plugin-import":"^2.32.0","eslint-plugin-no-only-tests":"^3.4.0","eslint-plugin-no-secrets":"^2.3.3","eslint-plugin-node":"^11.1.0","eslint-plugin-sonarjs":"^1.0.4","openapi-types":"^12.1.3","peggy":"^4.2.0","rimraf":"^6.1.3","typescript-eslint":"^8.60.1"},"peerDependencies":{"eslint":">=9 <10"},"engines":{"node":">=22.18"},"service":{"api":{"root":"src","endpoints":["api/v1"]}},"wallaby":{"env":{"params":{"runner":"--experimental-vm-modules"}}}}
1
+ {"name":"@checkdigit/eslint-plugin","version":"7.18.0-PR.143-1cc5","description":"Check Digit eslint plugins","keywords":["eslint","eslintplugin"],"homepage":"https://github.com/checkdigit/eslint-plugin#readme","bugs":{"url":"https://github.com/checkdigit/eslint-plugin/issues"},"repository":{"type":"git","url":"https://github.com/checkdigit/eslint-plugin"},"license":"MIT","author":"Check Digit, LLC","sideEffects":false,"type":"module","exports":{".":{"types":"./dist-types/index.d.ts","import":"./dist-mjs/index.mjs","default":"./dist-mjs/index.mjs"}},"files":["src","dist-types","dist-mjs","!src/**/test/**","!src/**/*.test.ts","!src/**/*.spec.ts","!dist-types/**/test/**","!dist-types/**/*.test.d.ts","!dist-types/**/*.spec.d.ts","!dist-mjs/**/test/**","!dist-mjs/**/*.test.mjs","!dist-mjs/**/*.spec.mjs","SECURITY.md"],"scripts":{"build:dist-mjs":"rimraf dist-mjs && npx builder --type=module --sourceMap --outDir=dist-mjs && node dist-mjs/index.mjs","build:dist-types":"rimraf dist-types && npx builder --type=types --outDir=dist-types","ci:compile":"tsc --noEmit","ci:coverage":"NODE_OPTIONS=\"--disable-warning ExperimentalWarning --experimental-vm-modules\" jest --coverage=true","ci:lint":"npm run lint","ci:style":"npm run prettier","ci:test":"NODE_OPTIONS=\"--disable-warning ExperimentalWarning --experimental-vm-modules\" jest --coverage=false","lint":"eslint --max-warnings 0 .","lint:fix":"eslint --max-warnings 0 --fix .","peggy":"for file in ./src/peggy/*.peggy; do peggy \"$file\" --format es --output \"${file%.peggy}-peggy.ts\"; done","peggy-watch":"for file in ./src/peggy/*.peggy; do peggy \"$file\" --format=es --watch --output=\"${file%.peggy}-peggy.ts\"; done","prepare":"","prepublishOnly":"npm run build:dist-types && npm run build:dist-mjs","prettier":"prettier --ignore-path .gitignore --list-different .","prettier:fix":"prettier --ignore-path .gitignore --write .","test":"npm run ci:compile && npm run ci:test && npm run ci:lint && npm run ci:style"},"prettier":"@checkdigit/prettier-config","jest":{"preset":"@checkdigit/jest-config"},"dependencies":{"@apidevtools/json-schema-ref-parser":"^15.3.5","@typescript-eslint/type-utils":"^8.60.1","@typescript-eslint/utils":"^8.60.1","ajv":"^8.20.0","debug":"^4.4.3","glob":"^13.0.6","http-status-codes":"^2.3.0","js-yaml":"^4.2.0","json-pointer":"^0.6.2","jsonpath-plus":"^10.4.0","ts-api-utils":"^2.5.0"},"devDependencies":{"@checkdigit/jest-config":"^6.0.2","@checkdigit/prettier-config":"^6.1.0","@checkdigit/typescript-config":"10.0.0","@eslint/js":"^9.37.0","@types/debug":"^4.1.13","@types/eslint":"^9.6.1","@types/eslint-config-prettier":"^6.11.3","@types/js-yaml":"^4.0.9","@types/json-pointer":"^1.0.34","@typescript-eslint/parser":"^8.60.1","@typescript-eslint/rule-tester":"^8.60.1","eslint":"^9.37.0","eslint-config-prettier":"^10.1.8","eslint-import-resolver-typescript":"^4.4.5","eslint-plugin-eslint-plugin":"^6.4.0","eslint-plugin-import":"^2.32.0","eslint-plugin-no-only-tests":"^3.4.0","eslint-plugin-no-secrets":"^2.3.3","eslint-plugin-node":"^11.1.0","eslint-plugin-sonarjs":"^1.0.4","openapi-types":"^12.1.3","peggy":"^4.2.0","rimraf":"^6.1.3","typescript-eslint":"^8.60.1"},"peerDependencies":{"eslint":">=9 <10"},"engines":{"node":">=22.18"},"service":{"api":{"root":"src","endpoints":["api/v1"]}},"wallaby":{"env":{"params":{"runner":"--experimental-vm-modules"}}}}
@@ -1,5 +1,7 @@
1
1
  // athena/athena.ts
2
2
 
3
+ /* eslint-disable max-lines */
4
+
3
5
  /*
4
6
  * Copyright (c) 2021-2026 Check Digit, LLC
5
7
  *
@@ -14,7 +16,7 @@ import { AST_NODE_TYPES, ESLintUtils, type TSESTree } from '@typescript-eslint/u
14
16
  import type { SchemaObject } from 'ajv/dist/2020';
15
17
 
16
18
  import { parse } from '../peggy/athena-peggy.ts';
17
- import type { AST, From, Select, With } from './types';
19
+ import type { AST, BaseFrom, From, Select, With } from './types';
18
20
  import { matchApi } from './api-matcher.ts';
19
21
  import { locateApi } from './api-locator.ts';
20
22
  import {
@@ -26,6 +28,7 @@ import {
26
28
  } from './context.ts';
27
29
  import { buildServiceTables } from './service-table.ts';
28
30
  import {
31
+ containsLambda,
29
32
  extractBracketAccessorPath,
30
33
  extractColumnRefs,
31
34
  extractJsonExtractCalls,
@@ -141,6 +144,23 @@ function fromClauseItems(select: Select): From[] {
141
144
  // Pass 1 — Resolve FROM clause: service tables → ctx.tables + ctx.aliases
142
145
  // ---------------------------------------------------------------------------
143
146
 
147
+ function resolveServiceTable(select: Select, item: BaseFrom, ctx: VisitContext): void {
148
+ const { table: tableName } = item;
149
+ try {
150
+ const apiSchemas = getApiSchemas(tableName, ctx);
151
+ if (apiSchemas.length === 0) {
152
+ throw new AthenaError(ATHENA_ERROR, `service not found: "${tableName}" (no swagger schema located)`, item);
153
+ }
154
+ const operations = matchApi(select, item, apiSchemas) ?? [];
155
+ ctx.tables.set(tableName, buildServiceTables(tableName, operations));
156
+ } catch (error) {
157
+ if (error instanceof AthenaError) {
158
+ throw error;
159
+ }
160
+ throw new AthenaError(ATHENA_ERROR, error instanceof Error ? error.message : String(error), item);
161
+ }
162
+ }
163
+
144
164
  function resolveFromClause(select: Select, ctx: VisitContext): void {
145
165
  for (const item of fromClauseItems(select)) {
146
166
  if (isUnnestFrom(item)) {
@@ -153,9 +173,7 @@ function resolveFromClause(select: Select, ctx: VisitContext): void {
153
173
  ctx.aliases.set(alias, tableName);
154
174
  }
155
175
  if (!ctx.tables.has(tableName)) {
156
- const apiSchemas = getApiSchemas(tableName, ctx);
157
- const operations = matchApi(select, item, apiSchemas) ?? [];
158
- ctx.tables.set(tableName, buildServiceTables(tableName, operations));
176
+ resolveServiceTable(select, item, ctx);
159
177
  }
160
178
  continue;
161
179
  }
@@ -174,11 +192,7 @@ function resolveFromClause(select: Select, ctx: VisitContext): void {
174
192
  continue; // already resolved (CTE or duplicate)
175
193
  }
176
194
 
177
- // Service table: locate + match API schemas from disk.
178
- // matchApi throws when no operation matches, so operations is always defined here.
179
- const apiSchemas = getApiSchemas(tableName, ctx);
180
- const operations = matchApi(select, item, apiSchemas) ?? [];
181
- ctx.tables.set(tableName, buildServiceTables(tableName, operations));
195
+ resolveServiceTable(select, item, ctx);
182
196
  }
183
197
  }
184
198
 
@@ -278,6 +292,28 @@ function expandWildcard(referencedTables: ResolvedTable[], columns: Map<string,
278
292
  }
279
293
  }
280
294
 
295
+ // If every resolved column shares the same object schema, return a hint listing its top-level
296
+ // properties. Returns '' when schemas differ across matched operations or the schema isn't an object.
297
+ function schemaPropertyHint(resolvedColumns: ResolvedColumn[]): string {
298
+ if (resolvedColumns.length === 0) {
299
+ return '';
300
+ }
301
+ const first = JSON.stringify(resolvedColumns[0]?.schema);
302
+ if (!resolvedColumns.every((col) => JSON.stringify(col.schema) === first)) {
303
+ return '';
304
+ }
305
+ const schema = resolvedColumns[0]?.schema;
306
+ if (schema?.type !== 'object') {
307
+ return '';
308
+ }
309
+ const properties = schema.properties;
310
+ if (properties === undefined) {
311
+ return '';
312
+ }
313
+ const propNames = Object.keys(properties);
314
+ return propNames.length > 0 ? `; available properties: ${propNames.join(', ')}` : '';
315
+ }
316
+
281
317
  function resolveSchemaAtPath(
282
318
  colRef: string,
283
319
  propertyAccessor: string,
@@ -295,7 +331,11 @@ function resolveSchemaAtPath(
295
331
  log('extracted schemas', extractedSchemas);
296
332
 
297
333
  if (extractedSchemas.length === 0) {
298
- throw new AthenaError(ATHENA_ERROR, `property not found ${colRef} - ${propertyAccessor}`, ast);
334
+ throw new AthenaError(
335
+ ATHENA_ERROR,
336
+ `property not found ${colRef} - ${propertyAccessor}${schemaPropertyHint(resolvedColumns)}`,
337
+ ast,
338
+ );
299
339
  }
300
340
  return extractedSchemas;
301
341
  }
@@ -321,6 +361,41 @@ function navigateSchemaPath(
321
361
  // Pass 2 — Resolve SELECT columns → Map<name, ResolvedColumn[]>
322
362
  // ---------------------------------------------------------------------------
323
363
 
364
+ /** Resolve a column by name against `referencedTables`, throwing if not found. */
365
+ function lookupColumnOrThrow(
366
+ colRef: string,
367
+ ref: object,
368
+ referencedTables: ResolvedTable[],
369
+ ctx: VisitContext,
370
+ ): ResolvedColumn[] {
371
+ const resolvedColumns = referencedTables.flatMap((table) => table.columns.get(colRef) ?? []);
372
+ if (resolvedColumns.length === 0) {
373
+ const tableNames = [...ctx.tables.keys()].join(', ');
374
+ const availableCols = [...new Set(referencedTables.flatMap((table) => [...table.columns.keys()]))].join(', ');
375
+ throw new AthenaError(
376
+ ATHENA_ERROR,
377
+ `can't found column ${colRef} in tables: ${tableNames}; available columns: ${availableCols}`,
378
+ ref,
379
+ );
380
+ }
381
+ return resolvedColumns;
382
+ }
383
+
384
+ /** Check all column_refs in `ast` exist in scope. Skips lambda expressions (lambda params look like column_refs). */
385
+ function checkColumnRefsExist(ast: unknown, allTables: ResolvedTable[], ctx: VisitContext): void {
386
+ if (containsLambda(ast)) {
387
+ return;
388
+ }
389
+ for (const ref of extractColumnRefs(ast)) {
390
+ const colRef = typeof ref.column === 'string' ? ref.column : undefined;
391
+ if (colRef === undefined || colRef === '*') {
392
+ continue;
393
+ }
394
+ const tableRef = ref.table ?? undefined;
395
+ lookupColumnOrThrow(colRef, ref, tableRef !== undefined ? lookupTables(tableRef, ctx) : allTables, ctx);
396
+ }
397
+ }
398
+
324
399
  /** Validate all json_extract / json_extract_scalar paths in a complex column expression. */
325
400
  function validateComplexColumnExpression(columnAST: unknown, allTables: ResolvedTable[], ctx: VisitContext): void {
326
401
  for (const { ref, path, fnNode } of extractJsonExtractCalls(columnAST)) {
@@ -361,12 +436,7 @@ function resolveSingleColumnRef(
361
436
 
362
437
  const withFunctions = hasFunctionCalls(columnAST);
363
438
  const colName = columnAlias ?? (withFunctions ? indexedName : colRef);
364
- const resolvedColumns = referencedTables.flatMap((table) => table.columns.get(colRef) ?? []);
365
-
366
- if (resolvedColumns.length === 0) {
367
- const tableNames = [...ctx.tables.keys()].join(', ');
368
- throw new AthenaError(ATHENA_ERROR, `can't found column ${colRef} in tables: ${tableNames}`, columnAST as object);
369
- }
439
+ const resolvedColumns = lookupColumnOrThrow(colRef, ref, referencedTables, ctx);
370
440
 
371
441
  const propertyAccessor = extractJsonExtractPath(columnAST) ?? extractBracketAccessorPath(columnAST);
372
442
  if (propertyAccessor !== undefined) {
@@ -392,6 +462,7 @@ function resolveSelectColumns(select: Select, ctx: VisitContext): Map<string, Re
392
462
  const columnRefs = extractColumnRefs(columnAST);
393
463
 
394
464
  if (columnRefs.length !== 1) {
465
+ checkColumnRefsExist(columnAST, allTables, ctx);
395
466
  validateComplexColumnExpression(columnAST, allTables, ctx);
396
467
  resolveDefaultSchemaColumn(columnAlias, indexedName, columnAST, columns);
397
468
  continue;
@@ -499,21 +570,33 @@ const rule: ESLintUtils.RuleModule<typeof SYNTEXT_ERROR | typeof ATHENA_ERROR> =
499
570
  return;
500
571
  }
501
572
 
573
+ const sqlMapping = buildSqlMapping(sqlNode);
502
574
  let ast: AST;
503
575
  try {
504
576
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
505
577
  ({ ast } = parse(sql, { includeLocations: true }));
506
578
  } catch (error) {
507
- log('error checking Athena AST', { error, sql });
508
- context.report({
509
- node: sqlNode,
510
- messageId: SYNTEXT_ERROR,
511
- data: { errorMessage: JSON.stringify(error, undefined, 2), sql },
512
- });
579
+ log('error parsing Athena SQL', { error, sql });
580
+ const pegLoc = (error as { location?: { start: { offset: number }; end: { offset: number } } }).location;
581
+ if (pegLoc !== undefined) {
582
+ const sourceText = context.sourceCode.getText();
583
+ context.report({
584
+ loc: {
585
+ start: offsetToLoc(sourceText, sqlOffsetToSource(pegLoc.start.offset, sqlMapping)),
586
+ end: offsetToLoc(sourceText, sqlOffsetToSource(pegLoc.end.offset, sqlMapping)),
587
+ },
588
+ messageId: SYNTEXT_ERROR,
589
+ data: { errorMessage: (error as Error).message },
590
+ });
591
+ } else {
592
+ context.report({
593
+ node: sqlNode,
594
+ messageId: SYNTEXT_ERROR,
595
+ data: { errorMessage: (error as Error).message },
596
+ });
597
+ }
513
598
  return;
514
599
  }
515
-
516
- const sqlMapping = buildSqlMapping(sqlNode);
517
600
  const athenaCtx = createRootContext();
518
601
  try {
519
602
  // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
@@ -573,3 +656,5 @@ const rule: ESLintUtils.RuleModule<typeof SYNTEXT_ERROR | typeof ATHENA_ERROR> =
573
656
  });
574
657
 
575
658
  export default rule;
659
+
660
+ /* eslint-enable max-lines */
@@ -264,6 +264,19 @@ export function walk(node: unknown, visitor: VisitorMap): void {
264
264
  // Convenience extractors — replace ad-hoc JSONPath queries.
265
265
  // -------------------------------------------------------------------
266
266
 
267
+ /** Return true if the expression contains a lambda (`->`) subexpression. */
268
+ export function containsLambda(expr: unknown): boolean {
269
+ let found = false;
270
+ walkExpr(expr, {
271
+ visitBinary(node) {
272
+ if (node.operator === '->') {
273
+ found = true;
274
+ }
275
+ },
276
+ });
277
+ return found;
278
+ }
279
+
267
280
  /** Collect all column_ref nodes within an expression subtree. */
268
281
  export function extractColumnRefs(expr: unknown): ColumnRefItem[] {
269
282
  const refs: ColumnRefItem[] = [];
@@ -1553,12 +1553,13 @@ function peg$parse(input: any, options: any): any {
1553
1553
  var peg$f107 = function (t, alias) {
1554
1554
  if (t.type === 'var') {
1555
1555
  t.as = alias;
1556
- return t;
1556
+ return { ...t, ...getLocationObject() };
1557
1557
  } else {
1558
1558
  return {
1559
1559
  db: t.db,
1560
1560
  table: t.table,
1561
1561
  as: alias,
1562
+ ...getLocationObject(),
1562
1563
  };
1563
1564
  }
1564
1565
  };
@@ -2035,6 +2036,7 @@ function peg$parse(input: any, options: any): any {
2035
2036
  table: tbl[3],
2036
2037
  column: col[3],
2037
2038
  collate: ce && ce[1],
2039
+ ...getLocationObject(),
2038
2040
  };
2039
2041
  };
2040
2042
  var peg$f187 = function (tbl, col, ce) {
@@ -2044,6 +2046,7 @@ function peg$parse(input: any, options: any): any {
2044
2046
  table: tbl,
2045
2047
  column: col,
2046
2048
  collate: ce && ce[1],
2049
+ ...getLocationObject(),
2047
2050
  };
2048
2051
  };
2049
2052
  var peg$f188 = function (col, ce) {
@@ -2053,6 +2056,7 @@ function peg$parse(input: any, options: any): any {
2053
2056
  table: null,
2054
2057
  column: col,
2055
2058
  collate: ce && ce[1],
2059
+ ...getLocationObject(),
2056
2060
  };
2057
2061
  };
2058
2062
  var peg$f189 = function (head, tail) {
@@ -1239,12 +1239,13 @@ table_base
1239
1239
  / t:table_name __ alias:alias_clause? {
1240
1240
  if (t.type === 'var') {
1241
1241
  t.as = alias;
1242
- return t;
1242
+ return { ...t, ...getLocationObject() };
1243
1243
  } else {
1244
1244
  return {
1245
1245
  db: t.db,
1246
1246
  table: t.table,
1247
- as: alias
1247
+ as: alias,
1248
+ ...getLocationObject(),
1248
1249
  };
1249
1250
  }
1250
1251
  }
@@ -1906,6 +1907,7 @@ column_ref
1906
1907
  table: tbl[3],
1907
1908
  column: col[3],
1908
1909
  collate: ce && ce[1],
1910
+ ...getLocationObject(),
1909
1911
  };
1910
1912
  }
1911
1913
  / tbl:ident __ DOT __ col:column ce:(__ collate_expr)? {
@@ -1915,6 +1917,7 @@ column_ref
1915
1917
  table: tbl,
1916
1918
  column: col,
1917
1919
  collate: ce && ce[1],
1920
+ ...getLocationObject(),
1918
1921
  };
1919
1922
  }
1920
1923
  / col:column ce:(__ collate_expr)? {
@@ -1924,6 +1927,7 @@ column_ref
1924
1927
  table: null,
1925
1928
  column: col,
1926
1929
  collate: ce && ce[1],
1930
+ ...getLocationObject(),
1927
1931
  };
1928
1932
  }
1929
1933