code-auditor-mcp 3.4.12 → 3.4.13

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 (245) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/dist/analyzers/__verify_self_probe.d.ts +2 -0
  3. package/dist/analyzers/__verify_self_probe.d.ts.map +1 -0
  4. package/dist/analyzers/__verify_self_probe.js +5 -0
  5. package/dist/analyzers/__verify_self_probe.js.map +1 -0
  6. package/dist/analyzers/analyzerUtils.d.ts +34 -1
  7. package/dist/analyzers/analyzerUtils.d.ts.map +1 -1
  8. package/dist/analyzers/analyzerUtils.js +61 -22
  9. package/dist/analyzers/analyzerUtils.js.map +1 -1
  10. package/dist/analyzers/cross-language/APIContractAnalyzer.d.ts +18 -23
  11. package/dist/analyzers/cross-language/APIContractAnalyzer.d.ts.map +1 -1
  12. package/dist/analyzers/cross-language/APIContractAnalyzer.js +198 -186
  13. package/dist/analyzers/cross-language/APIContractAnalyzer.js.map +1 -1
  14. package/dist/analyzers/cross-language/DependencyGraphBuilder.d.ts +78 -55
  15. package/dist/analyzers/cross-language/DependencyGraphBuilder.d.ts.map +1 -1
  16. package/dist/analyzers/cross-language/DependencyGraphBuilder.js +244 -243
  17. package/dist/analyzers/cross-language/DependencyGraphBuilder.js.map +1 -1
  18. package/dist/analyzers/cross-language/SchemaValidator.d.ts +27 -37
  19. package/dist/analyzers/cross-language/SchemaValidator.d.ts.map +1 -1
  20. package/dist/analyzers/cross-language/SchemaValidator.js +314 -275
  21. package/dist/analyzers/cross-language/SchemaValidator.js.map +1 -1
  22. package/dist/analyzers/crossDomain/CrossDomainAnalyzer.d.ts +17 -46
  23. package/dist/analyzers/crossDomain/CrossDomainAnalyzer.d.ts.map +1 -1
  24. package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js +620 -523
  25. package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js.map +1 -1
  26. package/dist/analyzers/documentationAnalyzer.d.ts +7 -2
  27. package/dist/analyzers/documentationAnalyzer.d.ts.map +1 -1
  28. package/dist/analyzers/documentationAnalyzer.js +259 -198
  29. package/dist/analyzers/documentationAnalyzer.js.map +1 -1
  30. package/dist/analyzers/invariantsAnalyzer.d.ts.map +1 -1
  31. package/dist/analyzers/invariantsAnalyzer.js +57 -50
  32. package/dist/analyzers/invariantsAnalyzer.js.map +1 -1
  33. package/dist/analyzers/orm/adapterRegistry.d.ts +10 -0
  34. package/dist/analyzers/orm/adapterRegistry.d.ts.map +1 -1
  35. package/dist/analyzers/orm/adapterRegistry.js +10 -0
  36. package/dist/analyzers/orm/adapterRegistry.js.map +1 -1
  37. package/dist/analyzers/orm/drizzleAdapter.d.ts +22 -0
  38. package/dist/analyzers/orm/drizzleAdapter.d.ts.map +1 -1
  39. package/dist/analyzers/orm/drizzleAdapter.js +42 -44
  40. package/dist/analyzers/orm/drizzleAdapter.js.map +1 -1
  41. package/dist/analyzers/orm/prismaAdapter.d.ts +30 -0
  42. package/dist/analyzers/orm/prismaAdapter.d.ts.map +1 -1
  43. package/dist/analyzers/orm/prismaAdapter.js +30 -0
  44. package/dist/analyzers/orm/prismaAdapter.js.map +1 -1
  45. package/dist/analyzers/provenance.d.ts +62 -2
  46. package/dist/analyzers/provenance.d.ts.map +1 -1
  47. package/dist/analyzers/provenance.js +443 -349
  48. package/dist/analyzers/provenance.js.map +1 -1
  49. package/dist/analyzers/reactAnalyzer.d.ts +11 -0
  50. package/dist/analyzers/reactAnalyzer.d.ts.map +1 -1
  51. package/dist/analyzers/reactAnalyzer.js +188 -125
  52. package/dist/analyzers/reactAnalyzer.js.map +1 -1
  53. package/dist/analyzers/ruleRegistry.d.ts +110 -1
  54. package/dist/analyzers/ruleRegistry.d.ts.map +1 -1
  55. package/dist/analyzers/ruleRegistry.js +1714 -124
  56. package/dist/analyzers/ruleRegistry.js.map +1 -1
  57. package/dist/analyzers/ruleTiming.d.ts +54 -0
  58. package/dist/analyzers/ruleTiming.d.ts.map +1 -0
  59. package/dist/analyzers/ruleTiming.js +71 -0
  60. package/dist/analyzers/ruleTiming.js.map +1 -0
  61. package/dist/analyzers/universal/UniversalConventionsAnalyzer.d.ts +13 -0
  62. package/dist/analyzers/universal/UniversalConventionsAnalyzer.d.ts.map +1 -1
  63. package/dist/analyzers/universal/UniversalConventionsAnalyzer.js +362 -283
  64. package/dist/analyzers/universal/UniversalConventionsAnalyzer.js.map +1 -1
  65. package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts +26 -68
  66. package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts.map +1 -1
  67. package/dist/analyzers/universal/UniversalDRYAnalyzer.js +430 -391
  68. package/dist/analyzers/universal/UniversalDRYAnalyzer.js.map +1 -1
  69. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts +17 -202
  70. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts.map +1 -1
  71. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js +1073 -1121
  72. package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js.map +1 -1
  73. package/dist/analyzers/universal/UniversalDocumentationAnalyzer.d.ts +3 -49
  74. package/dist/analyzers/universal/UniversalDocumentationAnalyzer.d.ts.map +1 -1
  75. package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js +414 -340
  76. package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js.map +1 -1
  77. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts +48 -5
  78. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts.map +1 -1
  79. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js +262 -134
  80. package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js.map +1 -1
  81. package/dist/analyzers/universal/UniversalSchemaAnalyzer.d.ts +23 -214
  82. package/dist/analyzers/universal/UniversalSchemaAnalyzer.d.ts.map +1 -1
  83. package/dist/analyzers/universal/UniversalSchemaAnalyzer.js +139 -1521
  84. package/dist/analyzers/universal/UniversalSchemaAnalyzer.js.map +1 -1
  85. package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts +78 -79
  86. package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts.map +1 -1
  87. package/dist/analyzers/universal/UniversalStylesAnalyzer.js +613 -480
  88. package/dist/analyzers/universal/UniversalStylesAnalyzer.js.map +1 -1
  89. package/dist/analyzers/universal/schema/codeAnalysis.d.ts +307 -0
  90. package/dist/analyzers/universal/schema/codeAnalysis.d.ts.map +1 -0
  91. package/dist/analyzers/universal/schema/codeAnalysis.js +965 -0
  92. package/dist/analyzers/universal/schema/codeAnalysis.js.map +1 -0
  93. package/dist/analyzers/universal/schema/config.d.ts +31 -0
  94. package/dist/analyzers/universal/schema/config.d.ts.map +1 -0
  95. package/dist/analyzers/universal/schema/config.js +57 -0
  96. package/dist/analyzers/universal/schema/config.js.map +1 -0
  97. package/dist/analyzers/universal/schema/discovery.d.ts +167 -0
  98. package/dist/analyzers/universal/schema/discovery.d.ts.map +1 -0
  99. package/dist/analyzers/universal/schema/discovery.js +616 -0
  100. package/dist/analyzers/universal/schema/discovery.js.map +1 -0
  101. package/dist/analyzers/universal/schema/jsonSchema.d.ts +27 -0
  102. package/dist/analyzers/universal/schema/jsonSchema.d.ts.map +1 -0
  103. package/dist/analyzers/universal/schema/jsonSchema.js +368 -0
  104. package/dist/analyzers/universal/schema/jsonSchema.js.map +1 -0
  105. package/dist/analyzers/universal/schema/migrations.d.ts +90 -0
  106. package/dist/analyzers/universal/schema/migrations.d.ts.map +1 -0
  107. package/dist/analyzers/universal/schema/migrations.js +238 -0
  108. package/dist/analyzers/universal/schema/migrations.js.map +1 -0
  109. package/dist/analyzers/universal/schema/types.d.ts +131 -0
  110. package/dist/analyzers/universal/schema/types.d.ts.map +1 -0
  111. package/dist/analyzers/universal/schema/types.js +10 -0
  112. package/dist/analyzers/universal/schema/types.js.map +1 -0
  113. package/dist/analyzers/universal/schema/violations.d.ts +44 -0
  114. package/dist/analyzers/universal/schema/violations.d.ts.map +1 -0
  115. package/dist/analyzers/universal/schema/violations.js +54 -0
  116. package/dist/analyzers/universal/schema/violations.js.map +1 -0
  117. package/dist/auditRunner.d.ts.map +1 -1
  118. package/dist/auditRunner.js +89 -13
  119. package/dist/auditRunner.js.map +1 -1
  120. package/dist/cli.js +315 -40
  121. package/dist/cli.js.map +1 -1
  122. package/dist/config/configLoader.d.ts.map +1 -1
  123. package/dist/config/configLoader.js +9 -7
  124. package/dist/config/configLoader.js.map +1 -1
  125. package/dist/config/defaults.d.ts +12 -3
  126. package/dist/config/defaults.d.ts.map +1 -1
  127. package/dist/config/defaults.js +15 -5
  128. package/dist/config/defaults.js.map +1 -1
  129. package/dist/config/effectiveConfig.d.ts +75 -0
  130. package/dist/config/effectiveConfig.d.ts.map +1 -0
  131. package/dist/config/effectiveConfig.js +213 -0
  132. package/dist/config/effectiveConfig.js.map +1 -0
  133. package/dist/config/pathProfiles.d.ts +7 -4
  134. package/dist/config/pathProfiles.d.ts.map +1 -1
  135. package/dist/config/pathProfiles.js +8 -10
  136. package/dist/config/pathProfiles.js.map +1 -1
  137. package/dist/config/thresholdRationales.d.ts +45 -0
  138. package/dist/config/thresholdRationales.d.ts.map +1 -0
  139. package/dist/config/thresholdRationales.js +67 -0
  140. package/dist/config/thresholdRationales.js.map +1 -0
  141. package/dist/conventions/conventionMiner.d.ts.map +1 -1
  142. package/dist/conventions/conventionMiner.js +6 -2
  143. package/dist/conventions/conventionMiner.js.map +1 -1
  144. package/dist/enforcement/diffGate.d.ts +55 -0
  145. package/dist/enforcement/diffGate.d.ts.map +1 -0
  146. package/dist/enforcement/diffGate.js +136 -0
  147. package/dist/enforcement/diffGate.js.map +1 -0
  148. package/dist/enforcement/gate.d.ts +36 -0
  149. package/dist/enforcement/gate.d.ts.map +1 -0
  150. package/dist/enforcement/gate.js +72 -0
  151. package/dist/enforcement/gate.js.map +1 -0
  152. package/dist/enforcement/suppressions.d.ts +41 -0
  153. package/dist/enforcement/suppressions.d.ts.map +1 -0
  154. package/dist/enforcement/suppressions.js +170 -0
  155. package/dist/enforcement/suppressions.js.map +1 -0
  156. package/dist/fingerprint.d.ts.map +1 -1
  157. package/dist/fingerprint.js +4 -1
  158. package/dist/fingerprint.js.map +1 -1
  159. package/dist/invariants/ruleEngine.d.ts +3 -4
  160. package/dist/invariants/ruleEngine.d.ts.map +1 -1
  161. package/dist/invariants/ruleEngine.js +2 -2
  162. package/dist/invariants/ruleEngine.js.map +1 -1
  163. package/dist/languages/LanguageOrchestrator.d.ts +14 -0
  164. package/dist/languages/LanguageOrchestrator.d.ts.map +1 -1
  165. package/dist/languages/LanguageOrchestrator.js +93 -55
  166. package/dist/languages/LanguageOrchestrator.js.map +1 -1
  167. package/dist/languages/LanguageRegistry.d.ts +8 -0
  168. package/dist/languages/LanguageRegistry.d.ts.map +1 -1
  169. package/dist/languages/LanguageRegistry.js +8 -0
  170. package/dist/languages/LanguageRegistry.js.map +1 -1
  171. package/dist/languages/RuntimeManager.d.ts +82 -60
  172. package/dist/languages/RuntimeManager.d.ts.map +1 -1
  173. package/dist/languages/RuntimeManager.js +404 -349
  174. package/dist/languages/RuntimeManager.js.map +1 -1
  175. package/dist/languages/UniversalAnalyzer.d.ts +51 -6
  176. package/dist/languages/UniversalAnalyzer.d.ts.map +1 -1
  177. package/dist/languages/UniversalAnalyzer.js +127 -88
  178. package/dist/languages/UniversalAnalyzer.js.map +1 -1
  179. package/dist/languages/adapterBridge.d.ts +36 -0
  180. package/dist/languages/adapterBridge.d.ts.map +1 -1
  181. package/dist/languages/adapterBridge.js +89 -35
  182. package/dist/languages/adapterBridge.js.map +1 -1
  183. package/dist/languages/go/GoAdapter.d.ts +66 -23
  184. package/dist/languages/go/GoAdapter.d.ts.map +1 -1
  185. package/dist/languages/go/GoAdapter.js +553 -474
  186. package/dist/languages/go/GoAdapter.js.map +1 -1
  187. package/dist/languages/tree-sitter/TreeSitterCssAdapter.d.ts +33 -4
  188. package/dist/languages/tree-sitter/TreeSitterCssAdapter.d.ts.map +1 -1
  189. package/dist/languages/tree-sitter/TreeSitterCssAdapter.js +101 -66
  190. package/dist/languages/tree-sitter/TreeSitterCssAdapter.js.map +1 -1
  191. package/dist/languages/tree-sitter/converter.d.ts +18 -0
  192. package/dist/languages/tree-sitter/converter.d.ts.map +1 -1
  193. package/dist/languages/tree-sitter/converter.js +18 -0
  194. package/dist/languages/tree-sitter/converter.js.map +1 -1
  195. package/dist/languages/tree-sitter/parser.d.ts +39 -2
  196. package/dist/languages/tree-sitter/parser.d.ts.map +1 -1
  197. package/dist/languages/tree-sitter/parser.js +93 -12
  198. package/dist/languages/tree-sitter/parser.js.map +1 -1
  199. package/dist/languages/types.d.ts +67 -2
  200. package/dist/languages/types.d.ts.map +1 -1
  201. package/dist/languages/typescript/TreeSitterTypeScriptAdapter.d.ts +253 -72
  202. package/dist/languages/typescript/TreeSitterTypeScriptAdapter.d.ts.map +1 -1
  203. package/dist/languages/typescript/TreeSitterTypeScriptAdapter.js +1419 -923
  204. package/dist/languages/typescript/TreeSitterTypeScriptAdapter.js.map +1 -1
  205. package/dist/mcp-tools-shared.d.ts.map +1 -1
  206. package/dist/mcp-tools-shared.js.map +1 -1
  207. package/dist/pipeline.d.ts +13 -5
  208. package/dist/pipeline.d.ts.map +1 -1
  209. package/dist/pipeline.js +381 -200
  210. package/dist/pipeline.js.map +1 -1
  211. package/dist/pipelineAdapters.d.ts +3 -8
  212. package/dist/pipelineAdapters.d.ts.map +1 -1
  213. package/dist/pipelineAdapters.js +149 -94
  214. package/dist/pipelineAdapters.js.map +1 -1
  215. package/dist/presets/presets.d.ts +54 -0
  216. package/dist/presets/presets.d.ts.map +1 -0
  217. package/dist/presets/presets.js +181 -0
  218. package/dist/presets/presets.js.map +1 -0
  219. package/dist/reporting/sarifReportGenerator.d.ts.map +1 -1
  220. package/dist/reporting/sarifReportGenerator.js +0 -2
  221. package/dist/reporting/sarifReportGenerator.js.map +1 -1
  222. package/dist/ruleAliases.d.ts +67 -0
  223. package/dist/ruleAliases.d.ts.map +1 -0
  224. package/dist/ruleAliases.js +79 -0
  225. package/dist/ruleAliases.js.map +1 -0
  226. package/dist/styles/cssAstExtractor.d.ts.map +1 -1
  227. package/dist/styles/cssAstExtractor.js +78 -18
  228. package/dist/styles/cssAstExtractor.js.map +1 -1
  229. package/dist/styles/styleIndexer.js +10 -1
  230. package/dist/styles/styleIndexer.js.map +1 -1
  231. package/dist/styles/tailwindConfigLoader.d.ts +8 -6
  232. package/dist/styles/tailwindConfigLoader.d.ts.map +1 -1
  233. package/dist/styles/tailwindConfigLoader.js +61 -15
  234. package/dist/styles/tailwindConfigLoader.js.map +1 -1
  235. package/dist/styles/tailwindUtilityExpander.d.ts.map +1 -1
  236. package/dist/styles/tailwindUtilityExpander.js +13 -1
  237. package/dist/styles/tailwindUtilityExpander.js.map +1 -1
  238. package/dist/types.d.ts +181 -1
  239. package/dist/types.d.ts.map +1 -1
  240. package/dist/types.js +8 -0
  241. package/dist/types.js.map +1 -1
  242. package/package.json +5 -3
  243. package/plugin/hooks/hooks.json +1 -1
  244. package/plugin/scripts/hook-audit.sh +25 -7
  245. package/plugin/skills/code-auditor/SKILL.md +5 -5
@@ -8,173 +8,49 @@
8
8
  * R2.5: Legacy scan-all-strings path DELETED.
9
9
  * R7: schema/unknown-table severity is "suggestion".
10
10
  */
11
- import fs from 'fs/promises';
12
- import path from 'path';
11
+ import { readFileSync } from 'node:fs';
13
12
  import { UniversalAnalyzer } from '../../languages/UniversalAnalyzer.js';
14
- import picomatch from 'picomatch';
15
- import { buildProvenanceContext, isDBProvenanced, DB_CALL_METHODS, } from '../provenance.js';
16
- import { OrmAdapterRegistry } from '../orm/index.js';
13
+ import { withRuleTiming } from '../ruleTiming.js';
14
+ import { buildProvenanceContext, } from '../provenance.js';
17
15
  import { makeVisitorStatus } from '../../pipeline.js';
16
+ // Spec 34 — schema analyzer split (Step 0 reconciliation): shared types,
17
+ // SQL-context constants, and migration/DDL helpers now live in schema/
18
+ // submodules. Imported here for the class + standalone JSON-schema functions,
19
+ // then re-exported to preserve this file's public surface (pipelineAdapters.ts
20
+ // and UniversalDataAccessAnalyzer.ts import from this module).
21
+ import { parseMigrationOps, extractReExports, sqlFileHasDdl, extractMigrationOpsFromFile, } from './schema/migrations.js';
22
+ import { DB_RECEIVER_NAMES, DB_CALL_METHOD_NAMES, DB_BINDING_NAMES, DB_WRAPPER_NAMES, SQL_TAG_NAMES, DEFAULT_SCHEMA_CONFIG, } from './schema/config.js';
23
+ import { findTableReferences, checkMissingReferences, checkNamingConventions, checkQueryPatterns, checkSQLInjection, findClosestNodeAt, findEnclosingFunctionName, } from './schema/codeAnalysis.js';
24
+ import { discoverTablesFromMigrations, discoverTablesFromWrangler, discoverTablesFromSchemaFiles, discoverTablesFromOrmSchemas, passesFileGate, } from './schema/discovery.js';
25
+ export { parseMigrationOps, extractReExports, sqlFileHasDdl, extractMigrationOpsFromFile, DB_RECEIVER_NAMES, DB_CALL_METHOD_NAMES, DB_BINDING_NAMES, DB_WRAPPER_NAMES, SQL_TAG_NAMES, DEFAULT_SCHEMA_CONFIG, };
18
26
  /**
19
- * Single-source constants for SQL context detection.
20
- *
21
- * These are the ground-truth defaults. DEFAULT_SCHEMA_CONFIG references them,
22
- * and every inline fallback dereferences them directly — so ?? narrowing works
23
- * (TypeScript infers `string[]`, not `string[] | undefined` from the optional
24
- * SchemaAnalyzerConfig fields).
25
- *
26
- * Trimmed to D1/Workers DB patterns only (4 receivers, 6 methods).
27
- * Broader entries like 'connection'/'client'/'query'/'get'/'each' matched
28
- * non-DB code (WebSocket, Map, jQuery, vector stores), causing phantom
29
- * cross-domain lifecycle violations. See CHANGELOG 3.4.9 accuracy fix.
27
+ * Universal schema analyzer.
30
28
  */
31
- export const DB_RECEIVER_NAMES = ['db', 'database', 'sql', 'stmt'];
32
- export const DB_CALL_METHOD_NAMES = ['exec', 'prepare', 'batch', 'run', 'all', 'first'];
33
- export const DB_BINDING_NAMES = ['env.DB'];
34
- export const DB_WRAPPER_NAMES = ['d1Query', 'd1Exec'];
35
- export const SQL_TAG_NAMES = ['sql', 'db'];
36
- export const DEFAULT_SCHEMA_CONFIG = {
37
- enableTableUsageTracking: true,
38
- checkMissingReferences: true,
39
- checkNamingConventions: true,
40
- detectUnusedTables: false,
41
- validateQueryPatterns: true,
42
- maxQueriesPerFunction: 5,
43
- requiredSchemas: [],
44
- schemas: [],
45
- validateJsonSchemas: true,
46
- jsonSchemaVersion: 'draft-07',
47
- allowedJsonTypes: ['string', 'number', 'integer', 'boolean', 'array', 'object', 'null'],
48
- schemaFilePatterns: ['*.schema.json', '*-schema.json'],
49
- dataFilePatterns: ['*.data.json', '*.example.json'],
50
- strictMode: false,
51
- allowAdditionalProperties: true,
52
- // Spec-17 R2 defaults
53
- sqlTagNames: [...SQL_TAG_NAMES],
54
- dbReceiverNames: [...DB_RECEIVER_NAMES],
55
- dbCallMethods: [...DB_CALL_METHOD_NAMES],
56
- dbBindingNames: [...DB_BINDING_NAMES],
57
- dbWrapperNames: [...DB_WRAPPER_NAMES],
58
- fileGateGlobs: ['**/*.sql', '**/migrations/**'],
59
- schemaFiles: [],
60
- };
61
29
  export class UniversalSchemaAnalyzer extends UniversalAnalyzer {
62
30
  name = 'schema';
63
31
  description = 'Analyzes code against database schemas and validates JSON schemas';
64
32
  category = 'database';
65
- // Track references across files
66
- tableReferences = new Map();
67
33
  // Spec 25 B4 — Queue schema records for the pipeline to write after stage 2.
68
34
  // Was: direct CodeIndexDB.getInstance() call in recordTableUsage.
69
35
  _pendingSchemaRecords = { clearFiles: [], usages: [] };
70
- projectRoot;
71
- /**
72
- * Strip SQL identifier delimiters: backticks or double-quotes.
73
- */
74
- stripIdentifier(name) {
75
- if ((name.startsWith('`') && name.endsWith('`')) ||
76
- (name.startsWith('"') && name.endsWith('"'))) {
77
- return name.slice(1, -1);
78
- }
79
- return name;
80
- }
81
- /**
82
- * Parse a migration SQL source and apply stateful CREATE/DROP/RENAME
83
- * operations to the given table set in migration order.
84
- */
85
- processMigrationSource(source, tables) {
86
- // Sequential state machine: apply CREATE/DROP/RENAME in statement order
87
- // within each migration file. Fixes the rename-replay bug where CREATE
88
- // after RENAME in the same file was silently deleted by the old three-pass
89
- // approach (all CREATE then all DROP then all RENAME).
90
- const ddlRe = /(CREATE)\s+(?:VIRTUAL\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(`[^`]+`|"[^"]+"|\w+)|(DROP)\s+TABLE\s+(?:IF\s+EXISTS\s+)?(`[^`]+`|"[^"]+"|\w+)|(ALTER)\s+TABLE\s+(`[^`]+`|"[^"]+"|\w+)\s+RENAME\s+TO\s+(`[^`]+`|"[^"]+"|\w+)/gi;
91
- let match;
92
- while ((match = ddlRe.exec(source)) !== null) {
93
- const op = match[1] || match[3] || match[5];
94
- if (op === 'CREATE') {
95
- tables.add(this.stripIdentifier(match[2]));
96
- }
97
- else if (op === 'DROP') {
98
- tables.delete(this.stripIdentifier(match[4]));
99
- }
100
- else if (op === 'ALTER') {
101
- tables.delete(this.stripIdentifier(match[6]));
102
- tables.add(this.stripIdentifier(match[7]));
103
- }
104
- }
105
- }
106
36
  /**
107
37
  * Standalone analyze() override for backward compatibility with direct analyzer
108
38
  * calls (e.g., tests and non-pipeline audit paths). All production analysis now
109
39
  * flows through the pipeline visitors, but this method is preserved so tests
110
40
  * that call analyzer.analyze([file], config) continue to work.
41
+ * @param config
42
+ * @param files
43
+ * @returns
111
44
  */
112
45
  async analyze(files, config) {
113
46
  const jsonFiles = files.filter(f => f.endsWith('.json'));
114
47
  const codeFiles = files.filter(f => !f.endsWith('.json'));
115
48
  // Auto-discover known tables when no schemas are configured.
116
- const schemas = config.schemas;
117
- const projectRoot = config.projectRoot || process.cwd();
118
- this.projectRoot = projectRoot;
119
- if (!schemas || schemas.length === 0) {
120
- const fromWrangler = await this._discoverTablesFromWrangler(projectRoot);
121
- const schemaFiles = config.schemaFiles;
122
- const fromSchemaFiles = schemaFiles && schemaFiles.length > 0
123
- ? await this._discoverTablesFromSchemaFiles(schemaFiles, projectRoot)
124
- : new Set();
125
- const fromMigrations = await this._discoverTablesFromMigrations(projectRoot, config);
126
- const fromOrm = await this._discoverTablesFromOrmSchemas(codeFiles);
127
- const discovered = new Set([
128
- ...fromWrangler,
129
- ...fromSchemaFiles,
130
- ...fromMigrations,
131
- ...fromOrm,
132
- ]);
133
- if (discovered.size > 0) {
134
- config = {
135
- ...config,
136
- schemas: [{
137
- name: 'auto-discovered',
138
- tables: [...discovered].map(name => ({ name, columns: [] })),
139
- }],
140
- };
141
- }
142
- }
143
- const codeResult = codeFiles.length > 0 ? await super.analyze(codeFiles, config) : {
144
- violations: [],
145
- executionTime: 0,
146
- status: makeVisitorStatus(0),
147
- analyzerName: this.name,
148
- errors: [],
149
- filesProcessed: 0,
150
- };
151
- // Adapt JSON handling to the pipeline-style analyzeJsonSchemas(Map) signature.
152
- let jsonResult = {
153
- violations: [],
154
- executionTime: 0,
155
- status: makeVisitorStatus(0),
156
- analyzerName: this.name,
157
- errors: [],
158
- filesProcessed: 0,
159
- };
160
- if (jsonFiles.length > 0) {
161
- const jsonContents = new Map();
162
- for (const file of jsonFiles) {
163
- try {
164
- const raw = await fs.readFile(file, 'utf8');
165
- let parsed = null;
166
- try {
167
- parsed = JSON.parse(raw);
168
- }
169
- catch { /* not valid JSON */ }
170
- jsonContents.set(file, { parsed, raw });
171
- }
172
- catch {
173
- // Skip unreadable files
174
- }
175
- }
176
- jsonResult = this.analyzeJsonSchemas(jsonContents, config);
177
- }
49
+ config = await resolveSchemasViaAutoDiscovery(config, codeFiles);
50
+ const codeResult = codeFiles.length > 0
51
+ ? await super.analyze(codeFiles, config)
52
+ : emptySchemaResult(this.name);
53
+ const jsonResult = analyzeJsonFiles(jsonFiles, config, this.name);
178
54
  return {
179
55
  violations: [...codeResult.violations, ...jsonResult.violations],
180
56
  executionTime: (codeResult.executionTime || 0) + (jsonResult.executionTime || 0),
@@ -190,396 +66,32 @@ export class UniversalSchemaAnalyzer extends UniversalAnalyzer {
190
66
  async analyzeAST(ast, adapter, config, sourceCode) {
191
67
  const violations = [];
192
68
  const finalConfig = { ...DEFAULT_SCHEMA_CONFIG, ...config };
193
- // Spec 21: Build provenance context for this file (R1 provenance-primary detection)
194
- const detectionMode = config.detection?.mode ?? 'hybrid';
195
- const provenanceContext = buildProvenanceContext(ast, adapter, sourceCode, {
196
- mode: detectionMode,
197
- dbReceiverNames: finalConfig.dbReceiverNames ?? DEFAULT_SCHEMA_CONFIG.dbReceiverNames,
198
- dbBindingNames: finalConfig.dbBindingNames ?? DEFAULT_SCHEMA_CONFIG.dbBindingNames,
199
- dbCallMethods: finalConfig.dbCallMethods ?? DEFAULT_SCHEMA_CONFIG.dbCallMethods,
200
- dbWrapperNames: finalConfig.dbWrapperNames ?? DEFAULT_SCHEMA_CONFIG.dbWrapperNames,
201
- });
69
+ const provenanceContext = buildSchemaProvenanceContext(ast, adapter, sourceCode, finalConfig);
202
70
  // R2.2 — File gate: only analyze files with DB context (Spec 21: provenance-based)
203
- if (!this.passesFileGate(ast.filePath, sourceCode, finalConfig, provenanceContext)) {
71
+ if (!passesFileGate(ast.filePath, sourceCode, finalConfig, provenanceContext)) {
204
72
  return violations;
205
73
  }
206
- // Get available schemas
207
74
  const schemas = finalConfig.schemas || [];
208
- const allTables = new Set();
209
- for (const schema of schemas) {
210
- for (const table of schema.tables) {
211
- allTables.add(table.name);
212
- }
213
- }
75
+ const allTables = collectAllTableNames(schemas);
214
76
  if (finalConfig.requiredSchemas && finalConfig.requiredSchemas.length > 0 && schemas.length === 0) {
215
- violations.push(this.createViolation(ast.filePath, { line: 1, column: 1 }, 'No database schemas loaded for analysis', 'warning', 'missing-schemas', undefined, 'top-level:missing-schemas'));
77
+ violations.push(this.createViolation(ast.filePath, { line: 1, column: 1 }, 'No database schemas loaded for analysis', { severity: 'warning', rule: 'missing-schemas', symbol: 'top-level:missing-schemas' }));
216
78
  return violations;
217
79
  }
218
80
  // R2.1 — AST-based table reference extraction (replaces legacy regex scan-all-strings)
219
- // Spec 21: Uses provenance context for DB-call pattern detection
220
- const tableRefs = this.findTableReferences(ast, adapter, sourceCode, finalConfig, provenanceContext, allTables);
81
+ const tableRefs = findTableReferences(ast, adapter, sourceCode, { config: finalConfig, provenanceContext, allTables });
221
82
  // Spec 15 R1 — Record schema usage for cross-domain lifecycle analysis.
222
- // Idempotent per-file: clear stale entries before inserting fresh references.
223
83
  if (finalConfig.enableTableUsageTracking) {
224
84
  this.recordTableUsage(ast, adapter, ast.filePath, tableRefs);
225
85
  }
226
- // Check for missing table references — R2.4: Levenshtein suggestions
227
- if (finalConfig.checkMissingReferences) {
228
- // Spec 24 Item 4 Part B — 10:1 fail-open ratio.
229
- const unknownRefs = tableRefs.filter(ref => !allTables.has(ref.table) && !this.isSystemTable(ref.table));
230
- const knownCount = allTables.size;
231
- const unknownCount = unknownRefs.length;
232
- if (knownCount === 0 || unknownCount / Math.max(knownCount, 1) > 10) {
233
- const displayRatio = knownCount === 0 ? '∞' : (unknownCount / Math.max(knownCount, 1)).toFixed(1);
234
- // Silently skip — in direct test mode we don't emit console warnings
235
- }
236
- else {
237
- for (const ref of unknownRefs) {
238
- const suggestions = this.getNearestTableSuggestions(ref.table, allTables, 2);
239
- const msg = suggestions.length > 0
240
- ? `Reference to unknown table '${ref.table}' (${ref.type}). Did you mean: ${suggestions.join(', ')}?`
241
- : `Reference to unknown table '${ref.table}' (${ref.type})`;
242
- violations.push(this.createViolation(ast.filePath, ref.location, msg, 'suggestion', 'unknown-table', undefined, ref.table));
243
- }
244
- }
245
- }
246
- // Check naming conventions
247
- if (finalConfig.checkNamingConventions) {
248
- violations.push(...this.checkNamingConventions(tableRefs, ast.filePath));
249
- }
250
- // Check query patterns
251
- if (finalConfig.validateQueryPatterns) {
252
- violations.push(...this.checkQueryPatterns(ast, adapter, sourceCode, finalConfig));
253
- }
254
- // Check for SQL injection patterns
255
- violations.push(...this.checkSQLInjection(ast, adapter, sourceCode));
256
- return violations;
257
- }
258
- // ---------------------------------------------------------------------------
259
- // Auto-discovery helpers — used by the standalone analyze() override and
260
- // the pre-pipeline discovery phase in auditRunner.
261
- // ---------------------------------------------------------------------------
262
- /**
263
- * Walk project root recursively, returning files matching any of the given
264
- * picomatch globs. Skips node_modules and dot-directories.
265
- */
266
- async _walkFiles(root, globs) {
267
- const results = [];
268
- async function walk(dir) {
269
- let names;
270
- try {
271
- names = await fs.readdir(dir);
272
- }
273
- catch {
274
- return; // Skip unreadable directories
275
- }
276
- for (const name of names) {
277
- const fullPath = path.join(dir, name);
278
- // Skip node_modules and dot-directories
279
- if (name === 'node_modules' || name.startsWith('.'))
280
- continue;
281
- let stat;
282
- try {
283
- stat = await fs.stat(fullPath);
284
- }
285
- catch {
286
- continue; // Skip unstatable
287
- }
288
- if (stat.isDirectory()) {
289
- await walk(fullPath);
290
- }
291
- else if (stat.isFile()) {
292
- const relative = path.relative(root, fullPath);
293
- const matched = globs.some(g => picomatch.isMatch(relative, g));
294
- if (matched) {
295
- results.push(fullPath);
296
- }
297
- }
298
- }
299
- }
300
- await walk(root);
301
- return results;
302
- }
303
- async _discoverTablesFromMigrations(projectRoot, config) {
304
- const tables = new Set();
305
- const gateGlobs = config.fileGateGlobs ?? ['**/*.sql', '**/migrations/**'];
306
- const walkedFiles = await this._walkFiles(projectRoot, gateGlobs);
307
- walkedFiles.sort();
308
- for (const file of walkedFiles) {
309
- try {
310
- const source = await fs.readFile(file, 'utf8');
311
- this.processMigrationSource(source, tables);
312
- }
313
- catch {
314
- // Skip unreadable files
315
- }
316
- }
317
- return tables;
318
- }
319
- async _discoverTablesFromWrangler(projectRoot) {
320
- const tables = new Set();
321
- const wranglerPath = path.join(projectRoot, 'wrangler.toml');
322
- let wranglerContent;
323
- try {
324
- wranglerContent = await fs.readFile(wranglerPath, 'utf8');
325
- }
326
- catch {
327
- return tables; // No wrangler.toml
328
- }
329
- const migrationDirs = [];
330
- let inD1Block = false;
331
- for (const line of wranglerContent.split('\n')) {
332
- const trimmed = line.trim();
333
- if (/^\[\[d1_databases\]\]/i.test(trimmed)) {
334
- inD1Block = true;
335
- continue;
336
- }
337
- if (inD1Block && trimmed.startsWith('[')) {
338
- inD1Block = false;
339
- continue;
340
- }
341
- if (inD1Block) {
342
- const m = trimmed.match(/^migrations_dir\s*=\s*['"](.+?)['"]/);
343
- if (m) {
344
- migrationDirs.push(m[1]);
345
- }
346
- }
347
- }
348
- for (const migDir of migrationDirs) {
349
- const absDir = path.resolve(projectRoot, migDir);
350
- let entries;
351
- try {
352
- const dirents = await fs.readdir(absDir, { withFileTypes: true });
353
- entries = dirents
354
- .filter(e => e.isFile() && e.name.endsWith('.sql'))
355
- .map(e => e.name)
356
- .sort();
357
- }
358
- catch {
359
- continue;
360
- }
361
- for (const entry of entries) {
362
- const filePath = path.join(absDir, entry);
363
- try {
364
- const source = await fs.readFile(filePath, 'utf8');
365
- this.processMigrationSource(source, tables);
366
- }
367
- catch {
368
- // Skip unreadable files
369
- }
370
- }
371
- }
372
- return tables;
373
- }
374
- async _discoverTablesFromSchemaFiles(schemaFiles, projectRoot) {
375
- const tables = new Set();
376
- for (const file of schemaFiles) {
377
- const absPath = path.resolve(projectRoot, file);
378
- try {
379
- const source = await fs.readFile(absPath, 'utf8');
380
- const createRe = /CREATE\s+(?:VIRTUAL\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(`[^`]+`|"[^"]+"|\w+)/gi;
381
- let match;
382
- while ((match = createRe.exec(source)) !== null) {
383
- tables.add(this.stripIdentifier(match[1]));
384
- }
385
- }
386
- catch {
387
- // Skip unreadable files
388
- }
389
- }
390
- return tables;
391
- }
392
- async _discoverTablesFromOrmSchemas(files) {
393
- const tables = new Set();
394
- for (const file of files) {
395
- const lowerFile = file.toLowerCase();
396
- if (/\.(ts|tsx|js|jsx)$/i.test(file)) {
397
- try {
398
- const source = await fs.readFile(file, 'utf8');
399
- if (/from\s+['"]drizzle-orm/.test(source)) {
400
- const builderRegex = /(?:pgTable|mysqlTable|sqliteTable)\s*\(\s*['"]([^'"]+)['"]/g;
401
- let match;
402
- while ((match = builderRegex.exec(source)) !== null) {
403
- tables.add(match[1]);
404
- }
405
- }
406
- }
407
- catch {
408
- // Skip unreadable files
409
- }
410
- }
411
- if (file.endsWith('schema.prisma') || file.endsWith('\\schema.prisma')) {
412
- try {
413
- const source = await fs.readFile(file, 'utf8');
414
- const modelRegex = /model\s+(\w+)\s*\{/g;
415
- let match;
416
- while ((match = modelRegex.exec(source)) !== null) {
417
- tables.add(match[1]);
418
- }
419
- }
420
- catch {
421
- // Skip unreadable files
422
- }
423
- }
424
- }
425
- return tables;
426
- }
427
- // ---------------------------------------------------------------------------
428
- // R2.2 — File gate
429
- // ---------------------------------------------------------------------------
430
- /**
431
- * Pre-filter: only analyze files that show DB usage.
432
- * Checks: .sql/migration glob, D1/SQL imports, env-binding patterns, DB calls.
433
- */
434
- passesFileGate(filePath, sourceCode, config, provenanceContext) {
435
- // Always pass .sql files and migration directories
436
- const gateGlobs = config.fileGateGlobs ?? ['**/*.sql', '**/migrations/**'];
437
- for (const glob of gateGlobs) {
438
- if (picomatch.isMatch(filePath, glob)) {
439
- return true;
440
- }
441
- }
442
- // Spec 21: Provenance-first DB detection — if any identifier is DB-provenanced,
443
- // this file passes the gate. This replaces the regex patterns for import/environment/
444
- // receiver.method checks in hybrid and provenance modes.
445
- if (provenanceContext && provenanceContext.mode !== 'names') {
446
- if (provenanceContext.dbProvenanced.size > 0) {
447
- return true;
448
- }
449
- }
450
- // Legacy name-based detection — used in 'names' mode or when no provenance context
451
- if (!provenanceContext || provenanceContext.mode === 'names') {
452
- // Check for D1 or SQL API imports
453
- const importPatterns = [
454
- /import\s+.*\b(D1Database|D1PreparedStatement|D1Result)\b/,
455
- /import\s+.*from\s+['"].*d1['"]/,
456
- /import\s+.*from\s+['"].*pg['"]/,
457
- /import\s+.*from\s+['"].*mysql['"]/,
458
- /import\s+.*from\s+['"].*sqlite['"]/,
459
- /import\s+.*from\s+['"].*knex['"]/,
460
- /import\s+.*from\s+['"].*drizzle['"]/,
461
- /import\s+.*from\s+['"].*prisma['"]/,
462
- ];
463
- for (const pat of importPatterns) {
464
- if (pat.test(sourceCode))
465
- return true;
466
- }
467
- // Check for env-binding patterns (e.g., env.DB in Cloudflare Workers)
468
- const bindingNames = config.dbBindingNames ?? [...DB_BINDING_NAMES];
469
- for (const binding of bindingNames) {
470
- if (sourceCode.includes(binding))
471
- return true;
472
- }
473
- // Check for DB call patterns (receiver.method)
474
- const receivers = config.dbReceiverNames ?? [...DB_RECEIVER_NAMES];
475
- const methods = config.dbCallMethods ?? [...DB_CALL_METHOD_NAMES];
476
- for (const receiver of receivers) {
477
- for (const method of methods) {
478
- const pattern = new RegExp(`\\b${escapeRegex(receiver)}\\.${escapeRegex(method)}\\s*\\(`);
479
- if (pattern.test(sourceCode))
480
- return true;
481
- }
482
- }
483
- }
484
- // Check for SQL tagged template literals (syntax feature, not naming convention)
485
- const sqlTags = config.sqlTagNames ?? [...SQL_TAG_NAMES];
486
- for (const tag of sqlTags) {
487
- const pattern = new RegExp(`\\b${escapeRegex(tag)}\`\\s*SELECT|\\b${escapeRegex(tag)}\`\\s*INSERT|\\b${escapeRegex(tag)}\`\\s*UPDATE|\\b${escapeRegex(tag)}\`\\s*DELETE|\\b${escapeRegex(tag)}\`\\s*CREATE`, 'i');
488
- if (pattern.test(sourceCode))
489
- return true;
490
- }
491
- return false;
492
- }
493
- // ---------------------------------------------------------------------------
494
- // R2.1 — AST-based table reference extraction (replaces legacy regex)
495
- // ---------------------------------------------------------------------------
496
- /**
497
- * Extract table references exclusively from SQL contexts in the AST.
498
- * R2.1: Only tagged template SQL and DB-call patterns produce candidates.
499
- * R2.3: Template expressions (${var}) resolved to wildcards.
500
- */
501
- findTableReferences(ast, adapter, sourceCode, config, provenanceContext, allTables) {
502
- const references = [];
503
- const sqlTags = config.sqlTagNames ?? [...SQL_TAG_NAMES];
504
- // (1) Tagged template SQL — e.g. sql`SELECT * FROM heroes`
505
- // This is a syntax feature, not a naming convention — keep the sqlTagNames gate.
506
- const taggedTemplates = adapter.findNodes(ast, {
507
- custom: (node) => {
508
- if (node.type !== 'call_expression')
509
- return false;
510
- // Callee must be an identifier matching sqlTagNames
511
- const callee = this.getCallee(node, adapter, sourceCode);
512
- if (!callee || !sqlTags.includes(callee))
513
- return false;
514
- // Must have a template string argument
515
- return this.hasTemplateArgument(node, adapter);
516
- },
86
+ appendSchemaViolations(violations, {
87
+ ast,
88
+ adapter,
89
+ sourceCode,
90
+ config: finalConfig,
91
+ tableRefs,
92
+ allTables,
517
93
  });
518
- for (const callNode of taggedTemplates) {
519
- const templateText = this.getTemplateText(callNode, adapter, sourceCode);
520
- if (!templateText)
521
- continue;
522
- const location = this.getCallLocation(callNode);
523
- const tableRefs = this.parseSqlTables(templateText, location, sourceCode, allTables);
524
- references.push(...tableRefs);
525
- }
526
- // (2) DB-call patterns — e.g. db.exec("SELECT * FROM heroes")
527
- // Spec 21: Replace name-based isDbMemberCall with provenance-based isDBProvenanced.
528
- const dbCalls = adapter.findNodes(ast, {
529
- custom: (node) => {
530
- if (node.type !== 'call_expression')
531
- return false;
532
- // Spec 21: Use provenance when available, fall back to name-based check
533
- if (provenanceContext && provenanceContext.mode !== 'names') {
534
- return isDBProvenanced(node, adapter, sourceCode, provenanceContext, DB_CALL_METHODS);
535
- }
536
- // Legacy name-based check for names mode / no context
537
- const callee = this.getCallee(node, adapter, sourceCode);
538
- if (!callee)
539
- return false;
540
- const dbMethods = config.dbCallMethods ?? [...DB_CALL_METHOD_NAMES];
541
- const dbReceivers = config.dbReceiverNames ?? [...DB_RECEIVER_NAMES];
542
- return this.isDbMemberCall(node, callee, dbMethods, dbReceivers, adapter, sourceCode);
543
- },
544
- });
545
- for (const callNode of dbCalls) {
546
- const firstArg = this.getFirstStringArgument(callNode, adapter, sourceCode);
547
- if (!firstArg)
548
- continue;
549
- const location = this.getCallLocation(callNode);
550
- const tableRefs = this.parseSqlTables(firstArg, location, sourceCode, allTables);
551
- references.push(...tableRefs);
552
- }
553
- // (3) .sql files and migration directories — scan the entire source
554
- if (ast.filePath.endsWith('.sql') ||
555
- ast.filePath.includes('/migrations/') ||
556
- ast.filePath.includes('\\migrations\\')) {
557
- const fileRefs = this.parseSqlTables(sourceCode, { line: 1, column: 1 }, sourceCode, allTables);
558
- references.push(...fileRefs);
559
- }
560
- // (4) Spec 15 R2 — ORM-aware extraction (Drizzle + Prisma)
561
- // Run ORM adapter extraction for files that match a registered adapter.
562
- // This complements raw-SQL extraction by picking up ORM-specific patterns
563
- // like db.select().from(users) and prisma.user.findMany().
564
- const ormRegistry = OrmAdapterRegistry.getInstance();
565
- const ormAdapter = ormRegistry.getAdapterForFile(ast.filePath);
566
- if (ormAdapter) {
567
- try {
568
- const ormRefs = ormAdapter.extractTableReferences(ast, adapter, sourceCode);
569
- for (const ormRef of ormRefs) {
570
- references.push({
571
- table: ormRef.table,
572
- type: ormRef.type,
573
- location: ormRef.location,
574
- context: ormRef.context,
575
- });
576
- }
577
- }
578
- catch {
579
- // ORM extraction is best-effort — failures don't block raw-SQL extraction.
580
- }
581
- }
582
- return references;
94
+ return violations;
583
95
  }
584
96
  /**
585
97
  * Spec 15 R1 — Record extracted table references to schema_usage for
@@ -589,15 +101,21 @@ export class UniversalSchemaAnalyzer extends UniversalAnalyzer {
589
101
  * Idempotent per-file: stale entries are cleared before fresh references
590
102
  * are inserted. For .sql/migration files, uses "schema-file" as the
591
103
  * function name since there's no AST function context.
104
+ * @returns
105
+ * @param adapter
106
+ * @param ast
107
+ * @param filePath
108
+ * @param references
109
+ * @returns
592
110
  */
593
111
  recordTableUsage(ast, adapter, filePath, references) {
594
112
  try {
595
113
  this._pendingSchemaRecords.clearFiles.push(filePath);
596
114
  for (const ref of references) {
597
115
  // Find enclosing function from the AST position
598
- const node = this.findClosestNodeAt(ast.root, ref.location, adapter);
116
+ const node = findClosestNodeAt(ast.root, ref.location, adapter);
599
117
  const functionName = node
600
- ? this.findEnclosingFunctionName(node, adapter)
118
+ ? findEnclosingFunctionName(node, adapter)
601
119
  : ast.filePath.endsWith('.sql') || ast.filePath.includes('/migrations/')
602
120
  ? 'schema-file'
603
121
  : 'top-level';
@@ -616,1015 +134,115 @@ export class UniversalSchemaAnalyzer extends UniversalAnalyzer {
616
134
  // Schema recording is best-effort — failures don't block analysis.
617
135
  }
618
136
  }
619
- /** Spec 25 B4 — Drain pending schema records for the pipeline to write. */
137
+ /**
138
+ * Spec 25 B4 — Drain pending schema records for the pipeline to write.
139
+ * @returns
140
+ */
620
141
  getPendingSchemaRecords() {
621
142
  const records = this._pendingSchemaRecords;
622
143
  this._pendingSchemaRecords = { clearFiles: [], usages: [] };
623
144
  return records;
624
145
  }
625
- /**
626
- * Parse SQL table names from a SQL text string.
627
- * R2.3: Template expressions (${...}) resolve portions to wildcards.
628
- */
629
- parseSqlTables(sqlText, baseLocation, sourceCode, allTables) {
630
- let references = [];
631
- // R2.3: Strip template expressions `${prefix}_builds` `_builds`
632
- // (the prefix is replaced with empty, the suffix remains for matching)
633
- const cleaned = this.resolveTemplateExpressions(sqlText);
634
- // SQL patterns anchored to SQL keywords (not arbitrary substrings).
635
- // Uses Unicode-aware \p{L} so non-Latin table names (日, 注文, пользователи)
636
- // are correctly matched — \w is ASCII-only. Spec 21 R5.
637
- const sqlPatterns = [
638
- // Note: no trailing \b — greedy [\p{L}\p{N}_]* consumes the full identifier and
639
- // \b after a closing quote (non-word char) fails, blocking quoted-table extraction.
640
- { regex: /\bFROM\s+([`"']?)([\p{L}_][\p{L}\p{N}_]*)\1/giu, type: 'select' },
641
- { regex: /\bJOIN\s+([`"']?)([\p{L}_][\p{L}\p{N}_]*)\1/giu, type: 'select' },
642
- { regex: /\bINSERT\s+INTO\s+([`"']?)([\p{L}_][\p{L}\p{N}_]*)\1/giu, type: 'insert' },
643
- { regex: /\bUPDATE\s+([`"']?)([\p{L}_][\p{L}\p{N}_]*)\1/giu, type: 'update' },
644
- { regex: /\bDELETE\s+FROM\s+([`"']?)([\p{L}_][\p{L}\p{N}_]*)\1/giu, type: 'delete' },
645
- { regex: /\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([`"']?)([\p{L}_][\p{L}\p{N}_]*)\1/giu, type: 'create' },
646
- ];
647
- for (const { regex, type } of sqlPatterns) {
648
- let match;
649
- // Create fresh regex since we might consume with exec
650
- const re = new RegExp(regex.source, regex.flags);
651
- while ((match = re.exec(cleaned)) !== null) {
652
- const table = match[2]; // The table name (capture group 2)
653
- if (!table || this.isSystemTable(table))
654
- continue;
655
- // v3.4.8: Skip very short identifiers (likely CTE names like 'x', 't',
656
- // aliases like 'o', 'c') unless they are known table names.
657
- // Single-char identifiers matched by FROM/JOIN regex capture short
658
- // CTE names that extractAliasIdentifiers() may miss (WITH x AS (...));
659
- // subquery bare aliases (FROM (SELECT ...) t) likewise. The guard
660
- // catches false positives from both gaps.
661
- if (!this.isSqlKeyword(table) && table.length < 3 && !allTables?.has(table.toLowerCase()))
662
- continue;
663
- // Skip common false positives: common variable names, keywords
664
- if (this.isSqlKeyword(table))
665
- continue;
666
- // Calculate position in original source
667
- const offset = sqlText.indexOf(match[0]);
668
- const location = offset >= 0
669
- ? this.offsetToLocation(sourceCode, sourceCode.indexOf(cleaned) + offset, baseLocation)
670
- : baseLocation;
671
- references.push({
672
- table,
673
- type,
674
- location,
675
- context: match[0].trim(),
676
- });
677
- }
678
- }
679
- // Template sentinel filter: resolveTemplateExpressions() replaces
680
- // ${...} with __TMPL__. Strip these before alias extraction and
681
- // before returning — __TMPL__ is never a real table name.
682
- references = references.filter(ref => !ref.table.startsWith('__TMPL__'));
683
- // Spec 22 R4.3: Filter out alias identifiers.
684
- // "FROM x AS t" defines t as an alias; later references like "JOIN t.posts"
685
- // would capture t via the JOIN regex. Scan for explicit AS aliases.
686
- const aliasIds = this.extractAliasIdentifiers(cleaned);
687
- if (aliasIds.size > 0) {
688
- return references.filter(ref => !aliasIds.has(ref.table.toLowerCase()));
689
- }
690
- return references;
691
- }
692
- /**
693
- * Spec 22 R4.3: Extract alias identifiers from SQL text.
694
- *
695
- * Detects both explicit (`FROM x AS t`) and bare (`FROM x t`) aliases
696
- * so they can be filtered from table-references in parseSqlTables().
697
- * Without this, "JOIN t.posts" captures t via the JOIN regex when t is
698
- * an alias for the real table x.
699
- */
700
- extractAliasIdentifiers(sqlText) {
701
- const aliases = new Set();
702
- // CTE: WITH <name> AS ( — the CTE name is an alias, not a real table.
703
- // Without this, "WITH fresh AS (SELECT ...)" causes 'fresh' to be
704
- // captured by FROM/JOIN/subquery patterns and flagged as unknown-table.
705
- const cteRe = /\bWITH\s+([\p{L}_][\p{L}\p{N}_]*)\s+AS\s*\(/giu;
706
- let m;
707
- while ((m = cteRe.exec(sqlText)) !== null) {
708
- aliases.add(m[1].toLowerCase());
709
- }
710
- // Explicit: FROM/JOIN <table> AS <alias>
711
- const explicitRe = /\b(?:FROM|JOIN)\s+[\p{L}_][\p{L}\p{N}_]*\s+AS\s+([\p{L}_][\p{L}\p{N}_]*)\b/giu;
712
- while ((m = explicitRe.exec(sqlText)) !== null) {
713
- aliases.add(m[1].toLowerCase());
714
- }
715
- // Subquery bare alias: FROM (SELECT ...) <alias>
716
- // The '(' stops the bare FROM/JOIN regex below because \w+ can't match it.
717
- // Pattern: FROM/JOIN \s* \( ... \) \s* <alias>
718
- const subqueryRe = /\b(?:FROM|JOIN)\s*\([^)]*\)\s+([\p{L}_][\p{L}\p{N}_]*)\b/giu;
719
- while ((m = subqueryRe.exec(sqlText)) !== null) {
720
- const alias = m[1];
721
- if (!this.isSqlKeyword(alias)) {
722
- aliases.add(alias.toLowerCase());
723
- }
724
- }
725
- // Bare: FROM/JOIN <table> <alias> (alias is a bare identifier, not a keyword)
726
- // Pattern: keyword + table + word — the third word is the alias if it's
727
- // not a SQL keyword and not followed by '.' (table.column reference).
728
- const bareRe = /\b(?:FROM|JOIN)\s+([\p{L}_][\p{L}\p{N}_]*)\s+([\p{L}_][\p{L}\p{N}_]*)\b/giu;
729
- while ((m = bareRe.exec(sqlText)) !== null) {
730
- const alias = m[2];
731
- // Don't add if it looks like a keyword or is followed by '.' (table ref)
732
- if (!this.isSqlKeyword(alias)) {
733
- const afterMatch = sqlText.substring(m.index + m[0].length);
734
- if (!/^\s*\./.test(afterMatch)) {
735
- aliases.add(alias.toLowerCase());
736
- }
737
- }
738
- }
739
- return aliases;
740
- }
741
- /**
742
- * R2.3: Resolve template expressions in SQL text.
743
- *
744
- * Uses the sentinel `__TMPL__` instead of an empty string. An empty
745
- * replacement produces whitespace artifacts (e.g. `FROM t WHERE`
746
- * when `${tableName}` is stripped), which causes the bare-alias regex
747
- * in extractAliasIdentifiers() to misalign: `t` lands in the table-name
748
- * capture group instead of the alias group, and is never denylisted.
749
- *
750
- * `__TMPL__` keeps the token boundaries intact so alias extraction
751
- * correctly identifies `t` as the alias. `__TMPL__` table references
752
- * are filtered in parseSqlTables().
753
- */
754
- resolveTemplateExpressions(text) {
755
- return text.replace(/\$\{[^}]+\}/g, '__TMPL__');
756
- }
757
- // ---------------------------------------------------------------------------
758
- // R2.4 — Levenshtein suggestions
759
- // ---------------------------------------------------------------------------
760
- /**
761
- * Return known table names within edit distance ≤ maxDist.
762
- */
763
- getNearestTableSuggestions(name, knownTables, maxDist) {
764
- const results = [];
765
- for (const known of knownTables) {
766
- const dist = this.levenshteinDistance(name.toLowerCase(), known.toLowerCase());
767
- if (dist <= maxDist) {
768
- results.push({ table: known, distance: dist });
769
- }
770
- }
771
- // Sort by distance ascending
772
- results.sort((a, b) => a.distance - b.distance);
773
- return results.slice(0, 3).map(r => `'${r.table}'`);
774
- }
775
- levenshteinDistance(a, b) {
776
- const m = a.length;
777
- const n = b.length;
778
- // Optimize: early exit if length difference exceeds threshold
779
- if (Math.abs(m - n) > 3)
780
- return Infinity;
781
- const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
782
- for (let i = 0; i <= m; i++)
783
- dp[i][0] = i;
784
- for (let j = 0; j <= n; j++)
785
- dp[0][j] = j;
786
- for (let i = 1; i <= m; i++) {
787
- for (let j = 1; j <= n; j++) {
788
- if (a[i - 1] === b[j - 1]) {
789
- dp[i][j] = dp[i - 1][j - 1];
790
- }
791
- else {
792
- dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
793
- }
794
- }
795
- }
796
- return dp[m][n];
797
- }
798
- // ---------------------------------------------------------------------------
799
- // Naming conventions
800
- // ---------------------------------------------------------------------------
801
- checkNamingConventions(references, filePath) {
802
- const violations = [];
803
- for (const ref of references) {
804
- if (/[A-Z]/.test(ref.table) && !ref.table.endsWith('Table')) {
805
- violations.push(this.createViolation(filePath, ref.location, `Table name '${ref.table}' should use snake_case convention`, 'suggestion', 'naming-convention', undefined, ref.table));
806
- }
807
- const reserved = ['user', 'order', 'group', 'table', 'column', 'index'];
808
- if (reserved.includes(ref.table.toLowerCase())) {
809
- violations.push(this.createViolation(filePath, ref.location, `Table name '${ref.table}' is a reserved word. Consider using a different name.`, 'warning', 'reserved-word', undefined, ref.table));
810
- }
811
- }
812
- return violations;
813
- }
814
- // ---------------------------------------------------------------------------
815
- // Query patterns
816
- // ---------------------------------------------------------------------------
817
- checkQueryPatterns(ast, adapter, sourceCode, config) {
818
- const violations = [];
819
- const functions = adapter.extractFunctions(ast);
820
- for (const func of functions) {
821
- const funcNode = this.findNodeByLocation(ast.root, func.location.start);
822
- if (!funcNode)
823
- continue;
824
- const funcText = adapter.getNodeText(funcNode, sourceCode);
825
- const queryCount = this.countQueries(funcText);
826
- if (queryCount > (config.maxQueriesPerFunction || 5)) {
827
- violations.push(this.createViolation(ast.filePath, func.location.start, `Function '${func.name}' has ${queryCount} queries, exceeding the maximum of ${config.maxQueriesPerFunction}`, 'warning', 'too-many-queries', undefined, func.name));
828
- }
829
- }
830
- // N+1 query detection is handled by the data-access analyzer (loop-query rule).
831
- // The two were consolidated in Spec-19 Corrective Batch Item 3 — see CHANGELOG.
832
- return violations;
833
- }
834
- // ---------------------------------------------------------------------------
835
- // SQL injection — R7: severity changed from critical to warning
836
- // ---------------------------------------------------------------------------
837
- // ---------------------------------------------------------------------------
838
- // SQL injection — per-call-site with enclosing-function + ordinal symbols
839
- // ---------------------------------------------------------------------------
840
- checkSQLInjection(ast, adapter, sourceCode) {
841
- const violations = [];
842
- const symbolOrdinals = new Map();
843
- // Use regex with global flag to find individual call sites
844
- const dangerousPatterns = [
845
- /query\s*\(\s*`[^`]*\$\{[^}]+\}[^`]*`/g,
846
- /query\s*\(\s*['"][^'"]*['"]?\s*\+/g,
847
- /execute\s*\(\s*['"][^'"]*['"]?\s*\+/g,
848
- ];
849
- for (const pattern of dangerousPatterns) {
850
- // Clone regex to reset state (global regexes track lastIndex)
851
- const re = new RegExp(pattern.source, pattern.flags);
852
- let match;
853
- while ((match = re.exec(sourceCode)) !== null) {
854
- const location = this.offsetToLocation(sourceCode, match.index, { line: 1, column: 1 });
855
- // Find enclosing function from the AST at this position
856
- const node = this.findClosestNodeAt(ast.root, location, adapter);
857
- const enclosingFn = node ? this.findEnclosingFunctionName(node, adapter) : 'top-level';
858
- const baseSymbol = `${enclosingFn}:sql-injection`;
859
- const ordinal = (symbolOrdinals.get(baseSymbol) ?? 0) + 1;
860
- symbolOrdinals.set(baseSymbol, ordinal);
861
- const symbol = ordinal > 1 ? `${baseSymbol}:${ordinal}` : baseSymbol;
862
- violations.push(this.createViolation(ast.filePath, location, 'Potential SQL injection vulnerability. Use parameterized queries.', 'suggestion', // Spec 11 R4 blanket demotion: all survivors → suggestion
863
- 'sql-injection', undefined, symbol));
864
- }
865
- }
866
- return violations;
867
- }
868
- // ---------------------------------------------------------------------------
869
- // AST helpers for R2.1
870
- // ---------------------------------------------------------------------------
871
- /**
872
- * Extract the callee text from a call_expression node.
873
- */
874
- getCallee(node, adapter, sourceCode) {
875
- // For db.exec() → callee is "db.exec"
876
- if (!node.children)
877
- return null;
878
- for (const child of node.children) {
879
- const type = adapter.getNodeType(child);
880
- if (type === 'identifier' ||
881
- type === 'member_expression' ||
882
- type === 'call_expression') {
883
- return adapter.getNodeText(child, sourceCode).trim();
884
- }
885
- }
886
- return null;
887
- }
888
- /**
889
- * Check if call_expression has a template string argument.
890
- */
891
- hasTemplateArgument(node, adapter) {
892
- if (!node.children)
893
- return false;
894
- for (const child of node.children) {
895
- const type = adapter.getNodeType(child);
896
- if (type === 'template_string' || type === 'template_literal') {
897
- return true;
898
- }
899
- }
900
- return false;
146
+ }
147
+ // ── Spec 34 analyze()/analyzeAST() extraction helpers ────────────────
148
+ // Extracted from the two methods above to keep them under the 50-line
149
+ // function-length gate. Pure module-level functions (no `this`), consistent
150
+ // with the functional-analyzer pattern.
151
+ /**
152
+ * Auto-discover known tables when no schemas are configured, returning the
153
+ * (possibly augmented) config. When schemas are already present, returns the
154
+ * config unchanged.
155
+ */
156
+ async function resolveSchemasViaAutoDiscovery(config, codeFiles) {
157
+ const schemas = config.schemas;
158
+ if (schemas && schemas.length > 0) {
159
+ return config;
160
+ }
161
+ const projectRoot = config.projectRoot || process.cwd();
162
+ const fromWrangler = await discoverTablesFromWrangler(projectRoot);
163
+ const schemaFiles = config.schemaFiles;
164
+ const fromSchemaFiles = schemaFiles && schemaFiles.length > 0
165
+ ? await discoverTablesFromSchemaFiles(schemaFiles, projectRoot)
166
+ : new Set();
167
+ const fromMigrations = await discoverTablesFromMigrations(projectRoot, config);
168
+ const fromOrm = await discoverTablesFromOrmSchemas(codeFiles);
169
+ const discovered = new Set([
170
+ ...fromWrangler,
171
+ ...fromSchemaFiles,
172
+ ...fromMigrations,
173
+ ...fromOrm,
174
+ ]);
175
+ if (discovered.size === 0) {
176
+ return config;
177
+ }
178
+ return {
179
+ ...config,
180
+ schemas: [{
181
+ name: 'auto-discovered',
182
+ tables: [...discovered].map(name => ({ name, columns: [] })),
183
+ }],
184
+ };
185
+ }
186
+ function emptySchemaResult(analyzerName) {
187
+ return {
188
+ violations: [],
189
+ executionTime: 0,
190
+ status: makeVisitorStatus(0),
191
+ analyzerName,
192
+ errors: [],
193
+ filesProcessed: 0,
194
+ };
195
+ }
196
+ function analyzeJsonFiles(jsonFiles, config, analyzerName) {
197
+ if (jsonFiles.length === 0) {
198
+ return emptySchemaResult(analyzerName);
901
199
  }
902
- /**
903
- * Get the text of the first template string argument.
904
- */
905
- getTemplateText(node, adapter, sourceCode) {
906
- if (!node.children)
907
- return null;
908
- for (const child of node.children) {
909
- const type = adapter.getNodeType(child);
910
- if (type === 'template_string' || type === 'template_literal') {
911
- return adapter.getNodeText(child, sourceCode).trim();
912
- }
200
+ const readJson = (file) => {
201
+ try {
202
+ const raw = readFileSync(file, 'utf8');
203
+ const parsed = JSON.parse(raw);
204
+ return parsed !== null && typeof parsed === 'object' ? parsed : null;
913
205
  }
914
- return null;
915
- }
916
- /**
917
- * Get the first string/template argument from a call expression.
918
- */
919
- getFirstStringArgument(node, adapter, sourceCode) {
920
- if (!node.children)
206
+ catch {
921
207
  return null;
922
- // Look for 'arguments' child first
923
- for (const child of node.children) {
924
- const type = adapter.getNodeType(child);
925
- if (type === 'arguments' && child.children) {
926
- for (const arg of child.children) {
927
- const argType = adapter.getNodeType(arg);
928
- if (argType === 'string' ||
929
- argType === 'template_string' ||
930
- argType === 'template_literal') {
931
- const text = adapter.getNodeText(arg, sourceCode).trim();
932
- // Strip surrounding quotes from string literals
933
- if ((text.startsWith("'") && text.endsWith("'")) ||
934
- (text.startsWith('"') && text.endsWith('"')) ||
935
- (text.startsWith('`') && text.endsWith('`'))) {
936
- return text.slice(1, -1);
937
- }
938
- return text;
939
- }
940
- }
941
- }
942
208
  }
943
- return null;
944
- }
945
- /**
946
- * Check if a callee is a DB member call like db.exec, database.query, etc.
947
- */
948
- isDbMemberCall(node, calleeText, methods, receivers, adapter, sourceCode) {
949
- // calleeText might be like "db.exec"
950
- const dotIdx = calleeText.indexOf('.');
951
- if (dotIdx === -1)
952
- return false;
953
- const receiver = calleeText.substring(0, dotIdx);
954
- const method = calleeText.substring(dotIdx + 1);
955
- return receivers.includes(receiver) && methods.includes(method);
956
- }
957
- /**
958
- * Get the line/column location of the call expression.
959
- */
960
- getCallLocation(node) {
961
- return node.location.start;
962
- }
963
- /**
964
- * Convert a character offset to a line/column location.
965
- */
966
- offsetToLocation(sourceCode, offset, base) {
967
- if (offset < 0 || offset >= sourceCode.length)
968
- return base;
969
- const before = sourceCode.substring(0, offset);
970
- const lineOffset = before.split('\n').length - 1;
971
- const lastNewline = before.lastIndexOf('\n');
972
- const column = lastNewline >= 0 ? offset - lastNewline : offset + 1;
973
- // offset is absolute in sourceCode — lineOffset is 0-based, so +1 gives
974
- // the correct 1-based line. base.line is the fallback guard only.
975
- return { line: lineOffset + 1, column };
976
- }
977
- // ---------------------------------------------------------------------------
978
- // General helpers
979
- // ---------------------------------------------------------------------------
980
- isSystemTable(table) {
981
- const systemTables = [
982
- 'information_schema',
983
- 'pg_catalog',
984
- 'mysql',
985
- 'performance_schema',
986
- 'sys',
987
- 'sqlite_master',
988
- 'sqlite_sequence',
989
- ];
990
- return systemTables.some(st => table.toLowerCase() === st || table.toLowerCase().startsWith(st + '.'));
991
- }
992
- /**
993
- * Common SQL keywords and identifiers that are not real table names.
994
- */
995
- isSqlKeyword(word) {
996
- const keywords = new Set([
997
- 'select', 'from', 'where', 'join', 'inner', 'outer', 'left', 'right',
998
- 'full', 'cross', 'on', 'and', 'or', 'not', 'in', 'as', 'is', 'null',
999
- 'like', 'between', 'order', 'group', 'by', 'having', 'limit', 'offset',
1000
- 'union', 'all', 'distinct', 'case', 'when', 'then', 'else', 'end',
1001
- 'insert', 'into', 'values', 'update', 'set', 'delete', 'create',
1002
- 'table', 'alter', 'drop', 'index', 'view', 'if', 'exists', 'primary',
1003
- 'key', 'foreign', 'references', 'constraint', 'default', 'unique',
1004
- 'check', 'asc', 'desc', 'count', 'sum', 'avg', 'min', 'max',
1005
- 'integer', 'text', 'varchar', 'text', 'boolean', 'float', 'blob',
1006
- 'real', 'timestamp', 'date', 'time', 'datetime', 'serial', 'bigint',
1007
- 'the', 'a', 'an',
1008
- ]);
1009
- return keywords.has(word.toLowerCase());
1010
- }
1011
- getQueryType(context) {
1012
- const upper = context.toUpperCase();
1013
- if (upper.includes('SELECT'))
1014
- return 'select';
1015
- if (upper.includes('INSERT'))
1016
- return 'insert';
1017
- if (upper.includes('UPDATE'))
1018
- return 'update';
1019
- if (upper.includes('DELETE'))
1020
- return 'delete';
1021
- if (upper.includes('CREATE'))
1022
- return 'create';
1023
- return 'reference';
1024
- }
1025
- countQueries(text) {
1026
- const patterns = [
1027
- /\.query\s*\(/g,
1028
- /\.execute\s*\(/g,
1029
- /SELECT\s+/gi,
1030
- /INSERT\s+INTO/gi,
1031
- /UPDATE\s+/gi,
1032
- /DELETE\s+FROM/gi,
1033
- ];
1034
- let count = 0;
1035
- for (const pattern of patterns) {
1036
- const matches = text.match(pattern);
1037
- if (matches)
1038
- count += matches.length;
1039
- }
1040
- return count;
1041
- }
1042
- findNodeByLocation(root, location) {
1043
- const queue = [root];
1044
- while (queue.length > 0) {
1045
- const node = queue.shift();
1046
- if (node.location.start.line === location.line &&
1047
- node.location.start.column === location.column) {
1048
- return node;
1049
- }
1050
- if (node.children) {
1051
- queue.push(...node.children);
1052
- }
1053
- }
1054
- return null;
1055
- }
1056
- /**
1057
- * Find the nearest AST node at a source location — walks the tree looking
1058
- * for the deepest node that contains the given line/column.
1059
- */
1060
- findClosestNodeAt(root, location, adapter) {
1061
- let best = null;
1062
- let bestDepth = -1;
1063
- const walk = (node, depth) => {
1064
- const start = node.location.start;
1065
- const end = node.location.end;
1066
- // Check if node contains the location
1067
- if ((start.line < location.line ||
1068
- (start.line === location.line && start.column <= location.column)) &&
1069
- (end.line > location.line ||
1070
- (end.line === location.line && end.column >= location.column))) {
1071
- if (depth > bestDepth) {
1072
- best = node;
1073
- bestDepth = depth;
1074
- }
1075
- if (node.children) {
1076
- for (const child of node.children) {
1077
- walk(child, depth + 1);
1078
- }
1079
- }
1080
- }
1081
- };
1082
- walk(root, 0);
1083
- return best;
1084
- }
1085
- /**
1086
- * Walk up the AST from a node to find the enclosing function or method name.
1087
- * Matches the same scheme as UniversalDataAccessAnalyzer.findEnclosingFunctionName.
1088
- */
1089
- findEnclosingFunctionName(node, adapter) {
1090
- let current = node;
1091
- while (current) {
1092
- const type = adapter.getNodeType(current);
1093
- if (type === 'arrow_function' ||
1094
- type === 'function_declaration' ||
1095
- type === 'function_expression' ||
1096
- type === 'generator_function_declaration' ||
1097
- type === 'generator_function_expression' ||
1098
- type === 'method_definition') {
1099
- const name = this.getNodeName(current, adapter);
1100
- if (name)
1101
- return name;
1102
- }
1103
- if (adapter.isMethod(current)) {
1104
- const name = this.getNodeName(current, adapter);
1105
- if (name)
1106
- return name;
1107
- }
1108
- current = adapter.getParent(current);
1109
- }
1110
- return 'top-level';
1111
- }
1112
- /**
1113
- * Extract a human-readable name from an AST node.
1114
- * Matches the same scheme as UniversalDataAccessAnalyzer.getNodeName.
1115
- */
1116
- getNodeName(node, adapter) {
1117
- // Try explicit name/text on the converted ASTNode (some adapters set it)
1118
- if (node.name && typeof node.name === 'string') {
1119
- return node.name;
1120
- }
1121
- if (node.text && typeof node.text === 'string') {
1122
- return node.text;
1123
- }
1124
- // Fall back to the raw tree-sitter node's text content (leaf identifiers etc.)
1125
- const rawText = node.raw?.text;
1126
- if (typeof rawText === 'string' && rawText.length > 0) {
1127
- return rawText;
1128
- }
1129
- if (node.children) {
1130
- for (const child of node.children) {
1131
- const childType = adapter.getNodeType(child);
1132
- if (childType === 'identifier' || childType === 'property_identifier') {
1133
- const name = this.getNodeName(child, adapter);
1134
- if (name)
1135
- return name;
1136
- }
1137
- }
1138
- }
1139
- return '';
1140
- }
1141
- // ---------------------------------------------------------------------------
1142
- // JSON Schema validation (unchanged from original)
1143
- // ---------------------------------------------------------------------------
1144
- /**
1145
- * Analyze JSON schemas and validate data files against them.
1146
- *
1147
- * Pipeline-refactored: accepts a Map of pre-loaded JSON contents instead of
1148
- * reading from the filesystem, so this can run inside a Stage 3 reducer.
1149
- *
1150
- * @param jsonContents Map from filePath → { parsed: pre-parsed object or null, raw: string }
1151
- * @param config Schema analyzer configuration
1152
- */
1153
- analyzeJsonSchemas(jsonContents, config) {
1154
- const violations = [];
1155
- const errors = [];
1156
- let filesProcessed = 0;
1157
- const startTime = Date.now();
1158
- const finalConfig = { ...DEFAULT_SCHEMA_CONFIG, ...config };
1159
- if (!finalConfig.validateJsonSchemas) {
1160
- return { violations, errors, status: makeVisitorStatus(filesProcessed), executionTime: 0, analyzerName: this.name };
1161
- }
1162
- const files = Array.from(jsonContents.keys());
1163
- const schemaFiles = this.identifySchemaFiles(files, finalConfig);
1164
- const dataFiles = this.identifyDataFiles(files, finalConfig);
1165
- const unknownJsonFiles = files.filter(f => !schemaFiles.includes(f) && !dataFiles.includes(f));
1166
- // Helper: parse from Map content (with fallback)
1167
- const getParsed = (filePath) => {
1168
- const entry = jsonContents.get(filePath);
1169
- if (!entry)
1170
- return null;
1171
- if (entry.parsed !== null)
1172
- return entry.parsed;
1173
- try {
1174
- return JSON.parse(entry.raw);
1175
- }
1176
- catch {
1177
- return null;
1178
- }
1179
- };
1180
- const schemas = new Map();
1181
- for (const file of schemaFiles) {
1182
- try {
1183
- const entry = jsonContents.get(file);
1184
- const content = entry?.raw;
1185
- if (!content) {
1186
- violations.push({
1187
- file,
1188
- line: 1,
1189
- column: 1,
1190
- severity: 'warning',
1191
- message: `Content not available for schema file`,
1192
- rule: 'file-error',
1193
- analyzer: 'schema'
1194
- });
1195
- filesProcessed++;
1196
- continue;
1197
- }
1198
- const schema = entry.parsed ?? (() => { try {
1199
- return JSON.parse(content);
1200
- }
1201
- catch {
1202
- return null;
1203
- } })();
1204
- if (schema === null)
1205
- throw new SyntaxError('JSON parse failed');
1206
- schemas.set(file, schema);
1207
- const fileViolations = this.validateJsonSchema(schema, file, finalConfig);
1208
- violations.push(...fileViolations);
1209
- filesProcessed++;
1210
- }
1211
- catch (error) {
1212
- if (error instanceof SyntaxError) {
1213
- violations.push({
1214
- file,
1215
- line: 1,
1216
- column: 1,
1217
- severity: 'warning',
1218
- message: `Invalid JSON: ${error.message}`,
1219
- rule: 'invalid-json',
1220
- analyzer: 'schema'
1221
- });
1222
- }
1223
- else {
1224
- errors.push({
1225
- file,
1226
- error: error instanceof Error ? error.message : String(error)
1227
- });
1228
- }
1229
- filesProcessed++;
1230
- }
1231
- }
1232
- if (finalConfig.schemaDataPairs) {
1233
- for (const pair of finalConfig.schemaDataPairs) {
1234
- const schema = schemas.get(pair.schema) ?? getParsed(pair.schema);
1235
- if (schema) {
1236
- const dataFiles = Array.isArray(pair.data) ? pair.data : [pair.data];
1237
- for (const dataFile of dataFiles) {
1238
- if (files.includes(dataFile)) {
1239
- const parsed = getParsed(dataFile);
1240
- if (parsed !== null) {
1241
- const dataViolations = [];
1242
- this.validateAgainstSchema(parsed, schema, dataFile, dataViolations, finalConfig);
1243
- violations.push(...dataViolations);
1244
- }
1245
- else {
1246
- violations.push({
1247
- file: dataFile,
1248
- line: 1,
1249
- column: 1,
1250
- severity: 'warning',
1251
- message: 'Invalid JSON in data file',
1252
- rule: 'invalid-json',
1253
- analyzer: 'schema'
1254
- });
1255
- }
1256
- filesProcessed++;
1257
- }
1258
- }
1259
- }
1260
- }
1261
- }
1262
- else {
1263
- for (const dataFile of [...dataFiles, ...unknownJsonFiles]) {
1264
- const matchedSchema = this.findMatchingSchema(dataFile, schemas, finalConfig);
1265
- if (matchedSchema) {
1266
- const parsed = getParsed(dataFile);
1267
- if (parsed !== null) {
1268
- const dataViolations = [];
1269
- this.validateAgainstSchema(parsed, matchedSchema, dataFile, dataViolations, finalConfig);
1270
- violations.push(...dataViolations);
1271
- }
1272
- }
1273
- else if (unknownJsonFiles.includes(dataFile)) {
1274
- const parsed = getParsed(dataFile);
1275
- if (parsed === null) {
1276
- violations.push({
1277
- file: dataFile,
1278
- line: 1,
1279
- column: 1,
1280
- severity: 'warning',
1281
- message: `Invalid JSON: Parse error`,
1282
- rule: 'invalid-json',
1283
- analyzer: 'schema'
1284
- });
1285
- }
1286
- }
1287
- filesProcessed++;
1288
- }
1289
- }
1290
- return {
1291
- violations,
1292
- errors,
1293
- status: makeVisitorStatus(filesProcessed),
1294
- executionTime: Date.now() - startTime,
1295
- analyzerName: this.name,
1296
- };
1297
- }
1298
- validateJsonSchema(schema, filePath, config) {
1299
- const violations = [];
1300
- if (!schema.$schema && config.jsonSchemaVersion) {
1301
- violations.push({
1302
- file: filePath,
1303
- line: 1,
1304
- column: 1,
1305
- severity: 'suggestion',
1306
- message: 'JSON Schema missing $schema declaration',
1307
- rule: 'missing-schema-declaration',
1308
- analyzer: 'schema'
1309
- });
1310
- }
1311
- this.validateSchemaTypes(schema, filePath, config, violations);
1312
- if (schema.type === 'object' && schema.properties) {
1313
- if (schema.required && Array.isArray(schema.required)) {
1314
- for (const field of schema.required) {
1315
- if (!schema.properties[field]) {
1316
- violations.push({
1317
- file: filePath,
1318
- line: 1,
1319
- column: 1,
1320
- severity: 'warning',
1321
- message: `Required field "${field}" not defined in properties`,
1322
- rule: 'undefined-required-field',
1323
- analyzer: 'schema'
1324
- });
1325
- }
1326
- }
1327
- }
1328
- }
1329
- return violations;
1330
- }
1331
- validateSchemaTypes(schema, filePath, config, violations, path = '') {
1332
- if (!schema || typeof schema !== 'object')
1333
- return;
1334
- if (schema.type && config.allowedJsonTypes) {
1335
- const types = Array.isArray(schema.type) ? schema.type : [schema.type];
1336
- for (const type of types) {
1337
- if (!config.allowedJsonTypes.includes(type)) {
1338
- violations.push({
1339
- file: filePath,
1340
- line: 1,
1341
- column: 1,
1342
- severity: 'warning',
1343
- message: `Invalid type "${type}" at ${path || 'root'}. Allowed types: ${config.allowedJsonTypes.join(', ')}`,
1344
- rule: 'invalid-type',
1345
- analyzer: 'schema'
1346
- });
1347
- }
1348
- }
1349
- }
1350
- if (schema.type === 'integer' || schema.type === 'number') {
1351
- if (schema.minimum !== undefined && schema.maximum !== undefined) {
1352
- if (schema.minimum > schema.maximum) {
1353
- violations.push({
1354
- file: filePath,
1355
- line: 1,
1356
- column: 1,
1357
- severity: 'warning',
1358
- message: `Invalid range at ${path}: minimum (${schema.minimum}) > maximum (${schema.maximum})`,
1359
- rule: 'invalid-range',
1360
- analyzer: 'schema'
1361
- });
1362
- }
1363
- }
1364
- }
1365
- if (schema.properties) {
1366
- for (const [key, value] of Object.entries(schema.properties)) {
1367
- this.validateSchemaTypes(value, filePath, config, violations, `${path}.${key}`);
1368
- }
1369
- }
1370
- if (schema.items) {
1371
- this.validateSchemaTypes(schema.items, filePath, config, violations, `${path}[items]`);
1372
- }
1373
- if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
1374
- this.validateSchemaTypes(schema.additionalProperties, filePath, config, violations, `${path}[additionalProperties]`);
209
+ };
210
+ return analyzeJsonSchemas(jsonFiles, readJson, config);
211
+ }
212
+ function buildSchemaProvenanceContext(ast, adapter, sourceCode, config) {
213
+ const detectionMode = config.detection?.mode ?? 'hybrid';
214
+ return buildProvenanceContext(ast, adapter, sourceCode, {
215
+ mode: detectionMode,
216
+ dbReceiverNames: config.dbReceiverNames ?? DEFAULT_SCHEMA_CONFIG.dbReceiverNames,
217
+ dbBindingNames: config.dbBindingNames ?? DEFAULT_SCHEMA_CONFIG.dbBindingNames,
218
+ dbCallMethods: config.dbCallMethods ?? DEFAULT_SCHEMA_CONFIG.dbCallMethods,
219
+ dbWrapperNames: config.dbWrapperNames ?? DEFAULT_SCHEMA_CONFIG.dbWrapperNames,
220
+ });
221
+ }
222
+ function collectAllTableNames(schemas) {
223
+ const allTables = new Set();
224
+ for (const schema of schemas ?? []) {
225
+ for (const table of schema.tables) {
226
+ allTables.add(table.name);
1375
227
  }
1376
228
  }
1377
- identifySchemaFiles(files, config) {
1378
- if (!config.schemaFilePatterns)
1379
- return [];
1380
- return files.filter(file => {
1381
- const fileName = file.split('/').pop() || '';
1382
- return config.schemaFilePatterns.some(pattern => {
1383
- const regex = new RegExp(pattern.replace('*', '.*'));
1384
- return regex.test(fileName);
1385
- });
1386
- });
1387
- }
1388
- identifyDataFiles(files, config) {
1389
- if (!config.dataFilePatterns)
1390
- return [];
1391
- return files.filter(file => {
1392
- const fileName = file.split('/').pop() || '';
1393
- return config.dataFilePatterns.some(pattern => {
1394
- const regex = new RegExp(pattern.replace('*', '.*'));
1395
- return regex.test(fileName);
1396
- });
1397
- });
229
+ return allTables;
230
+ }
231
+ function appendSchemaViolations(violations, ctx) {
232
+ const { ast, adapter, sourceCode, config, tableRefs, allTables } = ctx;
233
+ // Check for missing table references — R2.4: Levenshtein suggestions
234
+ if (config.checkMissingReferences) {
235
+ violations.push(...withRuleTiming('unknown-table', () => checkMissingReferences(tableRefs, allTables, ast.filePath)));
1398
236
  }
1399
- findMatchingSchema(dataFile, schemas, config) {
1400
- const dataFileName = dataFile.split('/').pop() || '';
1401
- const dataBaseName = dataFileName.replace(/\.(data|example|test)\.json$/, '');
1402
- for (const [schemaFile, schema] of schemas) {
1403
- const schemaFileName = schemaFile.split('/').pop() || '';
1404
- const schemaBaseName = schemaFileName.replace(/[.-]?schema\.json$/, '');
1405
- if (dataBaseName === schemaBaseName) {
1406
- return schema;
1407
- }
1408
- }
1409
- if (schemas.size === 1) {
1410
- return schemas.values().next().value;
1411
- }
1412
- return null;
237
+ if (config.checkNamingConventions) {
238
+ violations.push(...checkNamingConventions(tableRefs, ast.filePath));
1413
239
  }
1414
- validateAgainstSchema(data, schema, filePath, violations, config, path = '') {
1415
- if (schema.type) {
1416
- const actualType = Array.isArray(data) ? 'array' :
1417
- data === null ? 'null' :
1418
- typeof data;
1419
- const allowedTypes = Array.isArray(schema.type) ? schema.type : [schema.type];
1420
- const isValidType = allowedTypes.some((type) => {
1421
- if (type === 'integer') {
1422
- return typeof data === 'number' && Number.isInteger(data);
1423
- }
1424
- return type === actualType;
1425
- });
1426
- if (!isValidType) {
1427
- violations.push({
1428
- file: filePath,
1429
- line: 1,
1430
- column: 1,
1431
- severity: 'warning',
1432
- message: `Type mismatch at ${path || 'root'}: expected ${allowedTypes.join(' | ')}, got ${actualType}`,
1433
- rule: 'type-mismatch',
1434
- analyzer: 'schema'
1435
- });
1436
- return;
1437
- }
1438
- }
1439
- if (schema.type === 'string' && typeof data === 'string') {
1440
- if (schema.minLength !== undefined && data.length < schema.minLength) {
1441
- violations.push({
1442
- file: filePath,
1443
- line: 1,
1444
- column: 1,
1445
- severity: 'warning',
1446
- message: `String at ${path} too short: ${data.length} < ${schema.minLength}`,
1447
- rule: 'string-too-short',
1448
- analyzer: 'schema'
1449
- });
1450
- }
1451
- if (schema.maxLength !== undefined && data.length > schema.maxLength) {
1452
- violations.push({
1453
- file: filePath,
1454
- line: 1,
1455
- column: 1,
1456
- severity: 'warning',
1457
- message: `String at ${path} too long: ${data.length} > ${schema.maxLength}`,
1458
- rule: 'string-too-long',
1459
- analyzer: 'schema'
1460
- });
1461
- }
1462
- if (schema.pattern) {
1463
- const regex = new RegExp(schema.pattern);
1464
- if (!regex.test(data)) {
1465
- violations.push({
1466
- file: filePath,
1467
- line: 1,
1468
- column: 1,
1469
- severity: 'warning',
1470
- message: `String at ${path} doesn't match pattern: ${schema.pattern}`,
1471
- rule: 'pattern-mismatch',
1472
- analyzer: 'schema'
1473
- });
1474
- }
1475
- }
1476
- if (schema.format) {
1477
- switch (schema.format) {
1478
- case 'email':
1479
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data)) {
1480
- violations.push({
1481
- file: filePath,
1482
- line: 1,
1483
- column: 1,
1484
- severity: 'warning',
1485
- message: `Invalid email format at ${path}`,
1486
- rule: 'invalid-format',
1487
- analyzer: 'schema'
1488
- });
1489
- }
1490
- break;
1491
- case 'uuid':
1492
- if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(data)) {
1493
- violations.push({
1494
- file: filePath,
1495
- line: 1,
1496
- column: 1,
1497
- severity: 'warning',
1498
- message: `Invalid UUID format at ${path}`,
1499
- rule: 'invalid-format',
1500
- analyzer: 'schema'
1501
- });
1502
- }
1503
- break;
1504
- }
1505
- }
1506
- }
1507
- if ((schema.type === 'number' || schema.type === 'integer') && typeof data === 'number') {
1508
- if (schema.minimum !== undefined && data < schema.minimum) {
1509
- violations.push({
1510
- file: filePath,
1511
- line: 1,
1512
- column: 1,
1513
- severity: 'warning',
1514
- message: `Value at ${path} below minimum: ${data} < ${schema.minimum}`,
1515
- rule: 'below-minimum',
1516
- analyzer: 'schema'
1517
- });
1518
- }
1519
- if (schema.maximum !== undefined && data > schema.maximum) {
1520
- violations.push({
1521
- file: filePath,
1522
- line: 1,
1523
- column: 1,
1524
- severity: 'warning',
1525
- message: `Value at ${path} above maximum: ${data} > ${schema.maximum}`,
1526
- rule: 'above-maximum',
1527
- analyzer: 'schema'
1528
- });
1529
- }
1530
- }
1531
- if (schema.type === 'array' && Array.isArray(data)) {
1532
- if (schema.minItems !== undefined && data.length < schema.minItems) {
1533
- violations.push({
1534
- file: filePath,
1535
- line: 1,
1536
- column: 1,
1537
- severity: 'warning',
1538
- message: `Array at ${path} has too few items: ${data.length} < ${schema.minItems}`,
1539
- rule: 'too-few-items',
1540
- analyzer: 'schema'
1541
- });
1542
- }
1543
- if (schema.maxItems !== undefined && data.length > schema.maxItems) {
1544
- violations.push({
1545
- file: filePath,
1546
- line: 1,
1547
- column: 1,
1548
- severity: 'warning',
1549
- message: `Array at ${path} has too many items: ${data.length} > ${schema.maxItems}`,
1550
- rule: 'too-many-items',
1551
- analyzer: 'schema'
1552
- });
1553
- }
1554
- if (schema.items) {
1555
- data.forEach((item, index) => {
1556
- this.validateAgainstSchema(item, schema.items, filePath, violations, config, `${path}[${index}]`);
1557
- });
1558
- }
1559
- }
1560
- if (schema.type === 'object' && typeof data === 'object' && data !== null) {
1561
- if (schema.required && Array.isArray(schema.required)) {
1562
- for (const requiredField of schema.required) {
1563
- if (!(requiredField in data)) {
1564
- violations.push({
1565
- file: filePath,
1566
- line: 1,
1567
- column: 1,
1568
- severity: 'warning',
1569
- message: `Missing required field "${requiredField}" at ${path}`,
1570
- rule: 'missing-required-field',
1571
- analyzer: 'schema'
1572
- });
1573
- }
1574
- }
1575
- }
1576
- if (schema.properties) {
1577
- for (const [key, propSchema] of Object.entries(schema.properties)) {
1578
- if (key in data) {
1579
- this.validateAgainstSchema(data[key], propSchema, filePath, violations, config, path ? `${path}.${key}` : key);
1580
- }
1581
- }
1582
- }
1583
- if (schema.additionalProperties === false || (config.strictMode && !schema.additionalProperties)) {
1584
- const definedKeys = new Set(Object.keys(schema.properties || {}));
1585
- const actualKeys = Object.keys(data);
1586
- for (const key of actualKeys) {
1587
- if (!definedKeys.has(key)) {
1588
- violations.push({
1589
- file: filePath,
1590
- line: 1,
1591
- column: 1,
1592
- severity: 'warning',
1593
- message: `Unexpected property "${key}" at ${path}`,
1594
- rule: 'unexpected-property',
1595
- analyzer: 'schema'
1596
- });
1597
- }
1598
- }
1599
- }
1600
- else if (typeof schema.additionalProperties === 'object') {
1601
- const definedKeys = new Set(Object.keys(schema.properties || {}));
1602
- for (const [key, value] of Object.entries(data)) {
1603
- if (!definedKeys.has(key)) {
1604
- this.validateAgainstSchema(value, schema.additionalProperties, filePath, violations, config, path ? `${path}.${key}` : key);
1605
- }
1606
- }
1607
- }
1608
- }
1609
- if (schema.enum && Array.isArray(schema.enum)) {
1610
- if (!schema.enum.includes(data)) {
1611
- violations.push({
1612
- file: filePath,
1613
- line: 1,
1614
- column: 1,
1615
- severity: 'warning',
1616
- message: `Value at ${path} not in enum: ${JSON.stringify(data)}. Allowed: ${schema.enum.join(', ')}`,
1617
- rule: 'enum-mismatch',
1618
- analyzer: 'schema'
1619
- });
1620
- }
1621
- }
240
+ if (config.validateQueryPatterns) {
241
+ violations.push(...checkQueryPatterns(ast, adapter, sourceCode, config));
1622
242
  }
243
+ // Check for SQL injection patterns
244
+ violations.push(...checkSQLInjection(ast, adapter, sourceCode));
1623
245
  }
1624
- /**
1625
- * Escape regex special characters in a string.
1626
- */
1627
- function escapeRegex(s) {
1628
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1629
- }
246
+ import { analyzeJsonSchemas } from './schema/jsonSchema.js';
247
+ export { analyzeJsonSchemas };
1630
248
  //# sourceMappingURL=UniversalSchemaAnalyzer.js.map