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