@checkdigit/eslint-plugin 7.17.1 → 7.18.0-PR.143-9946

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.
Files changed (80) hide show
  1. package/dist-mjs/athena/api-locator.mjs +30 -0
  2. package/dist-mjs/athena/api-matcher.mjs +108 -0
  3. package/dist-mjs/athena/athena.mjs +328 -0
  4. package/dist-mjs/athena/column.mjs +1 -0
  5. package/dist-mjs/athena/context.mjs +21 -0
  6. package/dist-mjs/athena/index.mjs +1 -0
  7. package/dist-mjs/athena/service-table.mjs +32 -0
  8. package/dist-mjs/athena/types.mjs +1 -0
  9. package/dist-mjs/athena/visitor.mjs +258 -0
  10. package/dist-mjs/index.mjs +8 -4
  11. package/dist-mjs/no-status-code-assert.mjs +1 -1
  12. package/dist-mjs/openapi/deref-schema.mjs +14 -0
  13. package/dist-mjs/openapi/generate-schema.mjs +273 -0
  14. package/dist-mjs/openapi/service-schema-generator.mjs +147 -0
  15. package/dist-mjs/peggy/athena-peggy.mjs +20629 -0
  16. package/dist-types/athena/api-locator.d.ts +2 -0
  17. package/dist-types/athena/api-matcher.d.ts +14 -0
  18. package/dist-types/athena/athena.d.ts +6 -0
  19. package/dist-types/athena/column.d.ts +1 -0
  20. package/dist-types/athena/context.d.ts +21 -0
  21. package/dist-types/athena/index.d.ts +8 -0
  22. package/dist-types/athena/service-table.d.ts +8 -0
  23. package/dist-types/athena/types.d.ts +474 -0
  24. package/dist-types/athena/visitor.d.ts +63 -0
  25. package/dist-types/no-status-code-assert.d.ts +1 -1
  26. package/dist-types/openapi/deref-schema.d.ts +1 -0
  27. package/dist-types/openapi/generate-schema.d.ts +33 -0
  28. package/dist-types/openapi/service-schema-generator.d.ts +5 -0
  29. package/dist-types/peggy/athena-peggy.d.ts +13 -0
  30. package/package.json +1 -96
  31. package/src/athena/ATHENA.md +387 -0
  32. package/src/athena/PLAN.md +355 -0
  33. package/src/athena/api-locator.ts +39 -0
  34. package/src/athena/api-matcher.ts +169 -0
  35. package/src/athena/athena.ts +488 -0
  36. package/src/athena/column.ts +2 -0
  37. package/src/athena/context.ts +47 -0
  38. package/src/athena/index.ts +11 -0
  39. package/src/athena/service-table.ts +55 -0
  40. package/src/athena/types.ts +526 -0
  41. package/src/athena/visitor.ts +365 -0
  42. package/src/index.ts +4 -0
  43. package/src/no-side-effects.ts +1 -1
  44. package/src/no-status-code-assert.ts +2 -2
  45. package/src/openapi/deref-schema.ts +14 -0
  46. package/src/openapi/generate-schema.ts +422 -0
  47. package/src/openapi/service-schema-generator.ts +189 -0
  48. package/src/peggy/athena-chat.peggy +608 -0
  49. package/src/peggy/athena-peggy.ts +22078 -0
  50. package/src/peggy/athena.peggy +2967 -0
  51. package/src/require-service-call-response-declaration.ts +2 -2
  52. package/src/services/interchange/v1/swagger.schema.deref.json +849 -0
  53. package/src/services/interchange/v1/swagger.schema.json +473 -0
  54. package/src/services/interchange/v1/swagger.yml +414 -0
  55. package/src/services/ledger/v1/swagger.schema.deref.json +6694 -0
  56. package/src/services/ledger/v1/swagger.schema.json +1820 -0
  57. package/src/services/ledger/v1/swagger.yml +1094 -0
  58. package/src/services/link/v1/swagger.schema.deref.json +648 -0
  59. package/src/services/link/v1/swagger.schema.json +444 -0
  60. package/src/services/link/v1/swagger.yml +343 -0
  61. package/src/services/message/v1/swagger.schema.deref.json +22049 -0
  62. package/src/services/message/v1/swagger.schema.json +3470 -0
  63. package/src/services/message/v1/swagger.yml +2798 -0
  64. package/src/services/message/v2/swagger.schema.deref.json +72221 -0
  65. package/src/services/message/v2/swagger.schema.json +3558 -0
  66. package/src/services/message/v2/swagger.yml +3009 -0
  67. package/src/services/paymentCard/v1/swagger.schema.deref.json +4346 -0
  68. package/src/services/paymentCard/v1/swagger.schema.json +2181 -0
  69. package/src/services/paymentCard/v1/swagger.yml +1161 -0
  70. package/src/services/paymentCard/v2/swagger.schema.deref.json +4336 -0
  71. package/src/services/paymentCard/v2/swagger.schema.json +2155 -0
  72. package/src/services/paymentCard/v2/swagger.yml +1149 -0
  73. package/src/services/person/v1/swagger.schema.deref.json +6786 -0
  74. package/src/services/person/v1/swagger.schema.json +1445 -0
  75. package/src/services/person/v1/swagger.yml +1157 -0
  76. package/src/services/teampayApproval/v1/swagger.schema.deref.json +9898 -0
  77. package/src/services/teampayCardManagement/v1/swagger.schema.deref.json +6187 -0
  78. package/src/services/teampayClientManagement/v1/swagger.schema.deref.json +4914 -0
  79. package/src/services/teampayClientManagement/v1/swagger.schema.json +1964 -0
  80. package/src/services/teampayClientManagement/v1/swagger.yml +1376 -0
@@ -0,0 +1,488 @@
1
+ // athena/athena.ts
2
+
3
+ /*
4
+ * Copyright (c) 2021-2026 Check Digit, LLC
5
+ *
6
+ * This code is licensed under the MIT license (see LICENSE.txt for details).
7
+ */
8
+
9
+ import { strict as assert } from 'node:assert';
10
+
11
+ import debug from 'debug';
12
+ import { JSONPath } from 'jsonpath-plus';
13
+ import { ESLintUtils, type TSESTree } from '@typescript-eslint/utils';
14
+ import type { SchemaObject } from 'ajv/dist/2020';
15
+
16
+ import { parse } from '../peggy/athena-peggy.ts';
17
+ import type { AST, From, Select, With } from './types';
18
+ import { matchApi } from './api-matcher.ts';
19
+ import { locateApi } from './api-locator.ts';
20
+ import {
21
+ createChildContext,
22
+ createRootContext,
23
+ type ResolvedColumn,
24
+ type ResolvedTable,
25
+ type VisitContext,
26
+ } from './context.ts';
27
+ import { buildServiceTables } from './service-table.ts';
28
+ import {
29
+ extractBracketAccessorPath,
30
+ extractColumnRefs,
31
+ extractJsonExtractCalls,
32
+ extractJsonExtractPath,
33
+ hasFunctionCalls,
34
+ isBaseFrom,
35
+ isJoin,
36
+ isUnnestFrom,
37
+ } from './visitor.ts';
38
+
39
+ export const ruleId = 'athena';
40
+
41
+ const log = debug('eslint-plugin:athena');
42
+ const createRule = ESLintUtils.RuleCreator((name) => name);
43
+
44
+ const SYNTEXT_ERROR = 'SyntextError';
45
+ const ATHENA_ERROR = 'AthenaError';
46
+
47
+ class AthenaError extends Error {
48
+ public code: string;
49
+ constructor(code: string, message: string) {
50
+ super(message);
51
+ this.code = code;
52
+ this.name = 'AthenaError';
53
+ }
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Helpers
58
+ // ---------------------------------------------------------------------------
59
+
60
+ function resolvedCol(name: string, schema: SchemaObject, ast?: object): ResolvedColumn {
61
+ return ast !== undefined ? { name, schema, ast } : { name, schema };
62
+ }
63
+
64
+ /** Look up (and cache) API schemas for a service table name. */
65
+ function getApiSchemas(serviceName: string, ctx: VisitContext) {
66
+ let schemas = ctx.apiSchemas.get(serviceName);
67
+ if (schemas === undefined) {
68
+ schemas = locateApi(serviceName);
69
+ ctx.apiSchemas.set(serviceName, schemas);
70
+ }
71
+ return schemas;
72
+ }
73
+
74
+ /** Resolve a (possibly aliased) table name to the tables registered in ctx. */
75
+ function lookupTables(nameOrAlias: string, ctx: VisitContext): ResolvedTable[] {
76
+ const canonical = ctx.aliases.get(nameOrAlias) ?? nameOrAlias;
77
+ return ctx.tables.get(canonical) ?? [];
78
+ }
79
+
80
+ /** Normalise the FROM clause into a flat array. */
81
+ function fromClauseItems(select: Select): From[] {
82
+ if (Array.isArray(select.from)) {
83
+ return select.from;
84
+ }
85
+ if (select.from !== null) {
86
+ return [select.from];
87
+ }
88
+ return [];
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Pass 1 — Resolve FROM clause: service tables → ctx.tables + ctx.aliases
93
+ // ---------------------------------------------------------------------------
94
+
95
+ function resolveFromClause(select: Select, ctx: VisitContext): void {
96
+ for (const item of fromClauseItems(select)) {
97
+ if (isUnnestFrom(item)) {
98
+ continue; // UNNEST handled separately
99
+ }
100
+
101
+ if (isJoin(item)) {
102
+ const { table: tableName, as: alias } = item;
103
+ if (alias !== null) {
104
+ ctx.aliases.set(alias, tableName);
105
+ }
106
+ if (!ctx.tables.has(tableName)) {
107
+ const apiSchemas = getApiSchemas(tableName, ctx);
108
+ const operations = matchApi(select, item, apiSchemas) ?? [];
109
+ ctx.tables.set(tableName, buildServiceTables(tableName, operations));
110
+ }
111
+ continue;
112
+ }
113
+
114
+ if (!isBaseFrom(item)) {
115
+ continue; // skip subqueries and DUAL
116
+ }
117
+
118
+ const { table: tableName, as: alias } = item;
119
+
120
+ if (alias !== null) {
121
+ ctx.aliases.set(alias, tableName);
122
+ }
123
+
124
+ if (ctx.tables.has(tableName)) {
125
+ continue; // already resolved (CTE or duplicate)
126
+ }
127
+
128
+ // Service table: locate + match API schemas from disk.
129
+ // matchApi throws when no operation matches, so operations is always defined here.
130
+ const apiSchemas = getApiSchemas(tableName, ctx);
131
+ const operations = matchApi(select, item, apiSchemas) ?? [];
132
+ ctx.tables.set(tableName, buildServiceTables(tableName, operations));
133
+ }
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // UNNEST handling (may run before or after column selection, depending on
138
+ // whether the source column is a service-table column or a computed column).
139
+ // ---------------------------------------------------------------------------
140
+
141
+ interface UnnestMapping {
142
+ fromColumn: string;
143
+ toColumn: string;
144
+ }
145
+
146
+ function extractUnnestMappings(select: Select): UnnestMapping[] {
147
+ const mappings: UnnestMapping[] = [];
148
+
149
+ for (const item of fromClauseItems(select)) {
150
+ if (!isUnnestFrom(item)) {
151
+ continue;
152
+ }
153
+
154
+ const fromColumn = typeof item.expr.column === 'string' ? item.expr.column : undefined;
155
+ assert.ok(fromColumn !== undefined, 'UNNEST expr must be a column_ref with a string column name');
156
+
157
+ // The alias is stored as a func_call node: UNNEST(col) AS t(alias)
158
+ const toColumn = item.as?.args.value[0];
159
+ const toColName =
160
+ toColumn !== undefined && typeof (toColumn as { column?: unknown }).column === 'string'
161
+ ? (toColumn as { column: string }).column
162
+ : undefined;
163
+ assert.ok(toColName !== undefined, 'UNNEST alias must be a column_ref with a string column name');
164
+
165
+ mappings.push({ fromColumn, toColumn: toColName });
166
+ }
167
+
168
+ return mappings;
169
+ }
170
+
171
+ function applyUnnestPre(mappings: UnnestMapping[], ctx: VisitContext): UnnestMapping[] {
172
+ const deferred: UnnestMapping[] = [];
173
+
174
+ for (const { fromColumn, toColumn } of mappings) {
175
+ // Find the table that owns the source column
176
+ const ownerTable = [...ctx.tables.values()].flat().find((table) => table.columns.has(fromColumn));
177
+
178
+ if (ownerTable === undefined) {
179
+ deferred.push({ fromColumn, toColumn });
180
+ continue;
181
+ }
182
+
183
+ const sourceColumns = ownerTable.columns.get(fromColumn) ?? [];
184
+ const sourceSchema = sourceColumns[0]?.schema;
185
+ assert.ok(sourceSchema?.type === 'array', `UNNEST source column '${fromColumn}' must be an array schema`);
186
+
187
+ const unnestTableName = `${ownerTable.name ?? '<anonymous>'}:<unnested>`;
188
+ ctx.tables.set(unnestTableName, [
189
+ {
190
+ name: unnestTableName,
191
+ ...(ownerTable.apiOperation !== undefined ? { apiOperation: ownerTable.apiOperation } : {}),
192
+ columns: new Map([[toColumn, [resolvedCol(toColumn, sourceSchema.items, sourceColumns[0]?.ast)]]]),
193
+ },
194
+ ]);
195
+ }
196
+
197
+ return deferred;
198
+ }
199
+
200
+ function applyUnnestPost(mappings: UnnestMapping[], columns: Map<string, ResolvedColumn[]>): void {
201
+ for (const { fromColumn, toColumn } of mappings) {
202
+ const sourceColumns = columns.get(fromColumn);
203
+ assert.ok(sourceColumns !== undefined, `column ${fromColumn} not found in selected columns`);
204
+ const sourceSchema = sourceColumns[0]?.schema;
205
+ assert.ok(sourceSchema?.type === 'array', `UNNEST source column '${fromColumn}' must be an array schema`);
206
+ columns.set(toColumn, [resolvedCol(toColumn, sourceSchema.items, sourceColumns[0]?.ast)]);
207
+ }
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Pass 2 — Resolve SELECT columns helpers
212
+ // ---------------------------------------------------------------------------
213
+
214
+ function resolveDefaultSchemaColumn(
215
+ columnAlias: string | null,
216
+ indexedName: string,
217
+ columnAST: unknown,
218
+ columns: Map<string, ResolvedColumn[]>,
219
+ ): void {
220
+ const name = columnAlias ?? indexedName;
221
+ columns.set(name, [resolvedCol(name, { type: 'string' }, columnAST as object)]);
222
+ }
223
+
224
+ function expandWildcard(referencedTables: ResolvedTable[], columns: Map<string, ResolvedColumn[]>): void {
225
+ for (const table of referencedTables) {
226
+ for (const [colName, cols] of table.columns) {
227
+ columns.set(colName, cols);
228
+ }
229
+ }
230
+ }
231
+
232
+ function resolveSchemaAtPath(
233
+ colRef: string,
234
+ propertyAccessor: string,
235
+ resolvedColumns: ResolvedColumn[],
236
+ ): SchemaObject[] {
237
+ // Double-dot handles allOf / anyOf / oneOf wrappers that may appear in the schema.
238
+ // eslint-disable-next-line prefer-named-capture-group
239
+ const adjustedPath = `$.${propertyAccessor.substring(1).replace(/(\.|\[)/gu, '..properties$1')}`;
240
+ log('adjusted path', adjustedPath);
241
+
242
+ const extractedSchemas = resolvedColumns.flatMap((col) =>
243
+ JSONPath<SchemaObject[]>({ json: col.schema, path: adjustedPath }),
244
+ );
245
+ log('extracted schemas', extractedSchemas);
246
+
247
+ if (extractedSchemas.length === 0) {
248
+ throw new AthenaError(ATHENA_ERROR, `property not found ${colRef} - ${propertyAccessor}`);
249
+ }
250
+ return extractedSchemas;
251
+ }
252
+
253
+ function navigateSchemaPath(
254
+ colRef: string,
255
+ propertyAccessor: string,
256
+ resolvedColumns: ResolvedColumn[],
257
+ colName: string,
258
+ columnAST: unknown,
259
+ columns: Map<string, ResolvedColumn[]>,
260
+ ): void {
261
+ const extractedSchemas = resolveSchemaAtPath(colRef, propertyAccessor, resolvedColumns);
262
+ columns.set(
263
+ colName,
264
+ extractedSchemas.map((schema) => resolvedCol(colName, schema, columnAST as object)),
265
+ );
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Pass 2 — Resolve SELECT columns → Map<name, ResolvedColumn[]>
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /** Validate all json_extract / json_extract_scalar paths in a complex column expression. */
273
+ function validateComplexColumnExpression(columnAST: unknown, allTables: ResolvedTable[], ctx: VisitContext): void {
274
+ for (const { ref, path } of extractJsonExtractCalls(columnAST)) {
275
+ const tableRef = ref.table ?? undefined;
276
+ const colRef = typeof ref.column === 'string' ? ref.column : undefined;
277
+ if (colRef === undefined) {
278
+ continue;
279
+ }
280
+ const referencedTables = tableRef !== undefined ? lookupTables(tableRef, ctx) : allTables;
281
+ const resolvedColumns = referencedTables.flatMap((table) => table.columns.get(colRef) ?? []);
282
+ if (resolvedColumns.length > 0) {
283
+ resolveSchemaAtPath(colRef, path, resolvedColumns); // throws if path not found
284
+ }
285
+ }
286
+ }
287
+
288
+ /** Resolve a column expression that has exactly one column_ref. */
289
+ function resolveSingleColumnRef(
290
+ columnAST: unknown,
291
+ columnAlias: string | null,
292
+ indexedName: string,
293
+ ref: NonNullable<ReturnType<typeof extractColumnRefs>[number]>,
294
+ allTables: ResolvedTable[],
295
+ ctx: VisitContext,
296
+ columns: Map<string, ResolvedColumn[]>,
297
+ ): void {
298
+ const tableRef = ref.table ?? undefined;
299
+ const colRef = typeof ref.column === 'string' ? ref.column : undefined;
300
+ assert.ok(colRef !== undefined, 'column_ref must have a string column name');
301
+
302
+ const referencedTables = tableRef !== undefined ? lookupTables(tableRef, ctx) : allTables;
303
+ assert.ok(referencedTables.length > 0, `no tables found for column reference '${colRef}'`);
304
+
305
+ if (colRef === '*') {
306
+ expandWildcard(referencedTables, columns);
307
+ return;
308
+ }
309
+
310
+ const withFunctions = hasFunctionCalls(columnAST);
311
+ const colName = columnAlias ?? (withFunctions ? indexedName : colRef);
312
+ const resolvedColumns = referencedTables.flatMap((table) => table.columns.get(colRef) ?? []);
313
+
314
+ if (resolvedColumns.length === 0) {
315
+ const tableNames = [...ctx.tables.keys()].join(', ');
316
+ throw new AthenaError(ATHENA_ERROR, `can't found column ${colRef} in tables: ${tableNames}`);
317
+ }
318
+
319
+ const propertyAccessor = extractJsonExtractPath(columnAST) ?? extractBracketAccessorPath(columnAST);
320
+ if (propertyAccessor !== undefined) {
321
+ navigateSchemaPath(colRef, propertyAccessor, resolvedColumns, colName, columnAST, columns);
322
+ return;
323
+ }
324
+
325
+ columns.set(
326
+ colName,
327
+ resolvedColumns.map((col) => resolvedCol(colName, col.schema, columnAST as object)),
328
+ );
329
+ }
330
+
331
+ function resolveSelectColumns(select: Select, ctx: VisitContext): Map<string, ResolvedColumn[]> {
332
+ const allTables = [...ctx.tables.values()].flat();
333
+ const columns = new Map<string, ResolvedColumn[]>();
334
+
335
+ for (const [index, columnAST] of select.columns.entries()) {
336
+ log('resolving column', columnAST);
337
+
338
+ const columnAlias = (columnAST as { as?: string | null }).as ?? null;
339
+ const indexedName = `_col${String(index)}`;
340
+ const columnRefs = extractColumnRefs(columnAST);
341
+
342
+ if (columnRefs.length !== 1) {
343
+ validateComplexColumnExpression(columnAST, allTables, ctx);
344
+ resolveDefaultSchemaColumn(columnAlias, indexedName, columnAST, columns);
345
+ continue;
346
+ }
347
+
348
+ const [ref] = columnRefs;
349
+ assert.ok(ref !== undefined);
350
+ resolveSingleColumnRef(columnAST, columnAlias, indexedName, ref, allTables, ctx, columns);
351
+ }
352
+
353
+ return columns;
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Top-level SELECT resolution
358
+ // ---------------------------------------------------------------------------
359
+
360
+ function checkSelect(selectAST: Select | With, ctx: VisitContext, withTableName?: string): void {
361
+ // Unwrap CTE wrapper (With → Select)
362
+ const select = 'stmt' in selectAST ? selectAST.stmt.ast : selectAST;
363
+
364
+ // Each SELECT gets a child context that inherits CTE tables from the parent.
365
+ const selectCtx = createChildContext(ctx);
366
+
367
+ // Pass 1: resolve FROM clause → populate selectCtx.tables + selectCtx.aliases
368
+ resolveFromClause(select, selectCtx);
369
+
370
+ // UNNEST pre-pass: mappings whose source is a service-table column
371
+ const unnestMappings = extractUnnestMappings(select);
372
+ const deferredUnnest = applyUnnestPre(unnestMappings, selectCtx);
373
+
374
+ // Pass 2: resolve SELECT columns
375
+ const columns = resolveSelectColumns(select, selectCtx);
376
+
377
+ // UNNEST post-pass: mappings whose source is a computed SELECT column
378
+ applyUnnestPost(deferredUnnest, columns);
379
+
380
+ log('resolved columns', [...columns.keys()]);
381
+
382
+ // UNION ALL — next SELECT in the chain
383
+ if (select._next !== undefined) {
384
+ checkSelect(select._next, ctx, withTableName);
385
+ }
386
+
387
+ // Register CTE result so subsequent SELECTs in the same WITH can reference it
388
+ if (withTableName !== undefined) {
389
+ const resolvedTable: ResolvedTable = { name: withTableName, columns };
390
+ ctx.tables.set(withTableName, [resolvedTable]);
391
+ }
392
+ }
393
+
394
+ function checkAthenaAst(ast: AST, ctx: VisitContext): void {
395
+ assert.ok(ast.type === 'select');
396
+ const select = ast;
397
+
398
+ if (select.with !== null) {
399
+ for (const withItem of select.with) {
400
+ checkSelect(withItem.stmt.ast, ctx, withItem.name.value);
401
+ }
402
+ select.with = null;
403
+ }
404
+
405
+ checkSelect(select, ctx);
406
+ }
407
+
408
+ // ---------------------------------------------------------------------------
409
+ // ESLint rule
410
+ // ---------------------------------------------------------------------------
411
+
412
+ const rule: ESLintUtils.RuleModule<typeof SYNTEXT_ERROR | typeof ATHENA_ERROR> = createRule({
413
+ name: ruleId,
414
+ meta: {
415
+ type: 'problem',
416
+ docs: {
417
+ description: 'Validate Athena SQL strings against OpenAPI schemas at lint time',
418
+ },
419
+ schema: [],
420
+ messages: {
421
+ [SYNTEXT_ERROR]: `SyntextError {{ errorMessage }}`,
422
+ [ATHENA_ERROR]: `AthenaError {{ errorMessage }}`,
423
+ },
424
+ },
425
+ defaultOptions: [],
426
+ create(context) {
427
+ function checkSql(sql: string, sqlNode: TSESTree.Node) {
428
+ if (!/^SELECT\s+/iu.test(sql) && !/^WITH\s+/iu.test(sql)) {
429
+ return;
430
+ }
431
+
432
+ let ast: AST;
433
+ try {
434
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
435
+ ({ ast } = parse(sql, { includeLocations: true }));
436
+ } catch (error) {
437
+ context.report({
438
+ node: sqlNode,
439
+ messageId: SYNTEXT_ERROR,
440
+ data: { errorMessage: JSON.stringify(error, undefined, 2) },
441
+ });
442
+ return;
443
+ }
444
+
445
+ const athenaCtx = createRootContext();
446
+ try {
447
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
448
+ checkAthenaAst(Array.isArray(ast) ? ast[0] : ast, athenaCtx);
449
+ } catch (error) {
450
+ if (error instanceof AthenaError) {
451
+ context.report({
452
+ node: sqlNode,
453
+ messageId: ATHENA_ERROR,
454
+ data: { errorMessage: error.message },
455
+ });
456
+ } else {
457
+ // eslint-disable-next-line no-console
458
+ console.error(`Failed to apply ${ruleId} rule for "${context.filename}":`, error);
459
+ context.report({
460
+ node: sqlNode,
461
+ messageId: ATHENA_ERROR,
462
+ data: {
463
+ errorMessage: error instanceof Error ? String(error) : JSON.stringify(error, undefined, 2),
464
+ },
465
+ });
466
+ }
467
+ }
468
+ }
469
+
470
+ return {
471
+ TemplateLiteral(sqlNode) {
472
+ const sql = sqlNode.quasis
473
+ .map((quasi) => quasi.value.cooked)
474
+ .join('')
475
+ .trim();
476
+ checkSql(sql, sqlNode);
477
+ },
478
+ Literal(sqlNode) {
479
+ if (typeof sqlNode.value !== 'string') {
480
+ return;
481
+ }
482
+ checkSql(sqlNode.value, sqlNode);
483
+ },
484
+ };
485
+ },
486
+ });
487
+
488
+ export default rule;
@@ -0,0 +1,2 @@
1
+ // Re-exported for external consumers that previously imported ResolvedColumn from here.
2
+ export type { ResolvedColumn } from './context';
@@ -0,0 +1,47 @@
1
+ // athena/context.ts
2
+
3
+ import type { OpenAPIV3_1 as v3 } from 'openapi-types';
4
+
5
+ import type { ApiSchemas } from '../openapi/generate-schema';
6
+ import type { MatchedOperation } from './api-matcher';
7
+
8
+ export interface ResolvedColumn {
9
+ name: string;
10
+ schema: v3.SchemaObject;
11
+ ast?: object;
12
+ }
13
+
14
+ export interface ResolvedTable {
15
+ name?: string;
16
+ columns: Map<string, ResolvedColumn[]>;
17
+ apiOperation?: MatchedOperation[];
18
+ }
19
+
20
+ export interface VisitContext {
21
+ // Tables available in this scope, keyed by name. Each name maps to an array because
22
+ // multiple API operations can match a single service table (e.g. GET + POST on same path).
23
+ tables: Map<string, ResolvedTable[]>;
24
+ // alias → canonical table name
25
+ aliases: Map<string, string>;
26
+ // API schema disk-read cache, shared across the entire query to avoid re-reading files.
27
+ apiSchemas: Map<string, ApiSchemas[]>;
28
+ parent?: VisitContext;
29
+ }
30
+
31
+ export function createRootContext(): VisitContext {
32
+ return {
33
+ tables: new Map(),
34
+ aliases: new Map(),
35
+ apiSchemas: new Map(),
36
+ };
37
+ }
38
+
39
+ // Creates a child context that inherits CTE tables registered in the parent scope.
40
+ export function createChildContext(parent: VisitContext): VisitContext {
41
+ return {
42
+ tables: new Map(parent.tables),
43
+ aliases: new Map(),
44
+ apiSchemas: parent.apiSchemas,
45
+ parent,
46
+ };
47
+ }
@@ -0,0 +1,11 @@
1
+ import type { OpenAPIV3_1 as v3 } from 'openapi-types';
2
+
3
+ export interface ServiceEndpoint {
4
+ path: string;
5
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE';
6
+ requestBody?: v3.SchemaObject;
7
+ responses: Record<string, v3.SchemaObject>;
8
+ }
9
+
10
+ // A service may expose multiple endpoints (e.g. /sample/v1, /sample/v2).
11
+ export type Service = Record<string, ServiceEndpoint>;
@@ -0,0 +1,55 @@
1
+ // athena/service-table.ts
2
+
3
+ /*
4
+ * Copyright (c) 2021-2025 Check Digit, LLC
5
+ *
6
+ * This code is licensed under the MIT license (see LICENSE.txt for details).
7
+ */
8
+
9
+ /**
10
+ * Builds a ResolvedTable for one matched API operation against an Athena service table.
11
+ * Each Athena service table exposes a fixed set of top-level columns representing the
12
+ * Kinesis stream record fields (method, url, requestbody, responsebody, etc.).
13
+ */
14
+
15
+ import type { OpenAPIV3_1 as v3 } from 'openapi-types';
16
+ import type { SchemaObject } from 'ajv/dist/2020';
17
+
18
+ import type { MatchedOperation } from './api-matcher';
19
+ import type { ResolvedColumn, ResolvedTable } from './context';
20
+
21
+ const SCHEMA_STRING: SchemaObject = { type: 'string' };
22
+ const SCHEMA_OBJECT: SchemaObject = { type: 'object' };
23
+
24
+ function col(name: string, schema: v3.SchemaObject): ResolvedColumn {
25
+ return { name, schema };
26
+ }
27
+
28
+ function bodySchema(envelope: SchemaObject, field: 'body' | 'headers'): v3.SchemaObject {
29
+ return (envelope as Record<string, Record<string, v3.SchemaObject>>)['properties']?.[field] ?? SCHEMA_OBJECT;
30
+ }
31
+
32
+ /**
33
+ * Create one ResolvedTable per matched API operation.
34
+ * Multiple operations may match (e.g. GET + POST for the same service), so the
35
+ * caller receives an array and stores all of them under the same table name.
36
+ */
37
+ export function buildServiceTables(tableName: string, operations: MatchedOperation[]): ResolvedTable[] {
38
+ return operations.map((operation) => ({
39
+ name: tableName,
40
+ apiOperation: [operation],
41
+ columns: new Map<string, ResolvedColumn[]>([
42
+ ['method', [col('method', SCHEMA_STRING)]],
43
+ ['started', [col('started', SCHEMA_STRING)]],
44
+ ['ended', [col('ended', SCHEMA_STRING)]],
45
+ ['url', [col('url', SCHEMA_STRING)]],
46
+ ['requestbody', [col('requestbody', bodySchema(operation.request, 'body'))]],
47
+ ['requestheaders', [col('requestheaders', bodySchema(operation.request, 'headers'))]],
48
+ ['responsestatus', [col('responsestatus', SCHEMA_STRING)]],
49
+ ['responsemessage', [col('responsemessage', SCHEMA_STRING)]],
50
+ ['responsetype', [col('responsetype', SCHEMA_STRING)]],
51
+ ['responsebody', [col('responsebody', bodySchema(operation.response, 'body'))]],
52
+ ['responseheaders', [col('responseheaders', bodySchema(operation.response, 'headers'))]],
53
+ ]),
54
+ }));
55
+ }