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
@@ -11,6 +11,7 @@
11
11
  * two-phase tracking (seed + re-measure pass in auditRunner).
12
12
  */
13
13
  import { UniversalAnalyzer } from '../../languages/UniversalAnalyzer.js';
14
+ import { withRuleTiming } from '../ruleTiming.js';
14
15
  import * as crypto from 'crypto';
15
16
  export const DEFAULT_DRY_CONFIG = {
16
17
  // R3.2: floor raised from 5 → 15
@@ -25,6 +26,306 @@ export const DEFAULT_DRY_CONFIG = {
25
26
  // R4.2: structural similarity off by default
26
27
  checkStructuralSimilarity: false,
27
28
  };
29
+ // ── R3.1: Span-overlap helpers ──────────────────────────────────────
30
+ /**
31
+ * Returns true if the two blocks share code spans (same file + overlapping lines).
32
+ */
33
+ function spansOverlap(a, b) {
34
+ if (a.file !== b.file)
35
+ return false;
36
+ return !(a.end.line < b.start.line || b.end.line < a.start.line);
37
+ }
38
+ /**
39
+ * Sort comparator: earliest file+line first.
40
+ */
41
+ function byFileAndLine(a, b) {
42
+ if (a.file !== b.file)
43
+ return a.file.localeCompare(b.file);
44
+ return a.start.line - b.start.line;
45
+ }
46
+ /**
47
+ * R3.1: Deduplicate overlapping blocks. Prefers the innermost block when
48
+ * one block fully contains another (nesting), and the earliest block when
49
+ * blocks only partially overlap.
50
+ *
51
+ * This ensures that blocks nested inside functions/classes (e.g. for-loops
52
+ * inside a function body) surface for duplicate detection instead of being
53
+ * silently deduplicated by their outer container.
54
+ */
55
+ function deduplicateBlocks(blocks) {
56
+ if (blocks.length <= 1)
57
+ return blocks;
58
+ // Sort by (file, startLine)
59
+ const sorted = [...blocks].sort(byFileAndLine);
60
+ const result = [];
61
+ let last = null;
62
+ for (const block of sorted) {
63
+ if (last && last.file === block.file) {
64
+ // Same file — check for overlap
65
+ // Case 1: `last` fully contains `block` (nesting: last is outer, block is inner)
66
+ // Replace outer with inner — the inner block is more specific.
67
+ if (last.start.line <= block.start.line && last.end.line >= block.end.line) {
68
+ result.pop();
69
+ result.push(block);
70
+ last = block;
71
+ continue;
72
+ }
73
+ // Case 2: `block` fully contains `last` (nesting: block is outer, last is inner)
74
+ // Keep `last` (already inner in result), skip the outer block.
75
+ if (block.start.line <= last.start.line && block.end.line >= last.end.line) {
76
+ continue;
77
+ }
78
+ // Case 3: Partial overlap (neither fully contains the other)
79
+ // Keep the earlier block.
80
+ if (!(last.end.line < block.start.line)) {
81
+ continue;
82
+ }
83
+ }
84
+ result.push(block);
85
+ last = block;
86
+ }
87
+ return result;
88
+ }
89
+ // ── R3.3: Structural similarity helpers ──────────────────────────────
90
+ /**
91
+ * Group blocks by a key field into a map of key→blocks[].
92
+ */
93
+ function groupByHash(blocks, key) {
94
+ const map = new Map();
95
+ for (const block of blocks) {
96
+ const hash = block[key];
97
+ const existing = map.get(hash) || [];
98
+ existing.push(block);
99
+ map.set(hash, existing);
100
+ }
101
+ return map;
102
+ }
103
+ /**
104
+ * R3.3: Normalize code to its token-kind sequence.
105
+ * Identifiers → ID, string/number/regex literals → LIT.
106
+ */
107
+ function normalizeStructure(code) {
108
+ let normalized = code;
109
+ // Template expressions: strip dynamic parts for structural matching
110
+ normalized = normalized.replace(/\$\{[^}]*\}/g, 'ID');
111
+ // String literals (single, double, backtick) → LIT
112
+ normalized = normalized.replace(/(['"`])\1/g, 'LIT'); // empty strings
113
+ normalized = normalized.replace(/`[^`]*`/g, 'LIT');
114
+ normalized = normalized.replace(/'[^']*'/g, 'LIT');
115
+ normalized = normalized.replace(/"[^"]*"/g, 'LIT');
116
+ // Numeric literals → LIT
117
+ normalized = normalized.replace(/\b\d+\.?\d*\b/g, 'LIT');
118
+ // Regex literals → LIT (approximate — /pattern/flags)
119
+ normalized = normalized.replace(/\/[^/*][^/]*\/[gimsuy]*/g, 'LIT');
120
+ // Boolean/null literals
121
+ normalized = normalized.replace(/\b(true|false|null|undefined)\b/g, 'LIT');
122
+ // Identifiers → ID (after literals so we don't replace inside strings)
123
+ // Match camelCase, PascalCase, snake_case, dollar-prefixed, underscore-prefixed
124
+ normalized = normalized.replace(/\b[a-zA-Z_$][a-zA-Z0-9_$]*\b/g, (match) => {
125
+ // Keep keywords intact
126
+ const keywords = new Set([
127
+ 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'continue',
128
+ 'return', 'throw', 'try', 'catch', 'finally', 'new', 'delete', 'typeof',
129
+ 'instanceof', 'in', 'of', 'class', 'extends', 'super', 'this', 'function',
130
+ 'const', 'let', 'var', 'async', 'await', 'yield', 'import', 'export',
131
+ 'default', 'from', 'as', 'static', 'get', 'set', 'enum', 'type', 'interface',
132
+ 'implements', 'abstract', 'public', 'private', 'protected', 'readonly',
133
+ 'ID', 'LIT',
134
+ ]);
135
+ if (keywords.has(match))
136
+ return match;
137
+ return 'ID';
138
+ });
139
+ return normalized;
140
+ }
141
+ /**
142
+ * Normalize code for comparison
143
+ */
144
+ function normalizeCode(code, config) {
145
+ let normalized = code;
146
+ if (config.ignoreWhitespace) {
147
+ // Normalize whitespace but preserve structure
148
+ normalized = normalized
149
+ .split('\n')
150
+ .map(line => line.trim())
151
+ .filter(line => line.length > 0)
152
+ .join('\n');
153
+ }
154
+ if (config.ignoreComments) {
155
+ // Remove single-line comments
156
+ normalized = normalized.replace(/\/\/.*$/gm, '');
157
+ // Remove multi-line comments
158
+ normalized = normalized.replace(/\/\*[\s\S]*?\*\//g, '');
159
+ }
160
+ return normalized;
161
+ }
162
+ /**
163
+ * R3.3: Normalize code for structural comparison.
164
+ * First applies standard normalization (whitespace/comments), then
165
+ * replaces identifiers and literals with placeholders.
166
+ */
167
+ function normalizeCodeForStructure(code, config) {
168
+ const normalized = normalizeCode(code, config);
169
+ return normalizeStructure(normalized);
170
+ }
171
+ /**
172
+ * Hash code for comparison
173
+ */
174
+ function hashCode(text) {
175
+ return crypto.createHash('sha256').update(text).digest('hex');
176
+ }
177
+ /**
178
+ * Count lines in text
179
+ */
180
+ function countLines(text) {
181
+ return text.split('\n').filter(line => line.trim().length > 0).length;
182
+ }
183
+ /**
184
+ * Check if block is large enough to be considered
185
+ */
186
+ function isBlockLargeEnough(block, config) {
187
+ return block.lineCount >= (config.minLineThreshold || 5);
188
+ }
189
+ /**
190
+ * Compute the Jaccard similarity index between two tokenized strings.
191
+ * Jaccard = |intersection| / |union|. Range [0, 1].
192
+ */
193
+ function computeJaccardSimilarity(text1, text2) {
194
+ const tokens1 = new Set(text1.split(/\s+/).filter(Boolean));
195
+ const tokens2 = new Set(text2.split(/\s+/).filter(Boolean));
196
+ let intersection = 0;
197
+ for (const t of tokens1) {
198
+ if (tokens2.has(t))
199
+ intersection++;
200
+ }
201
+ const union = tokens1.size + tokens2.size - intersection;
202
+ return union === 0 ? 0 : intersection / union;
203
+ }
204
+ /**
205
+ * Compute an order-independent pair fingerprint from two code blocks.
206
+ * Uses SHA256(sorted(a, b).join('||')) so the same pair has the same
207
+ * fingerprint regardless of argument order.
208
+ */
209
+ function computePairFingerprint(original, block) {
210
+ const id1 = `${original.file}|${original.nodeType}|${original.start.line}`;
211
+ const id2 = `${block.file}|${block.nodeType}|${block.start.line}`;
212
+ const sorted = [id1, id2].sort();
213
+ return crypto.createHash('sha256').update(sorted.join('||')).digest('hex');
214
+ }
215
+ /**
216
+ * Find a node by its location via BFS.
217
+ */
218
+ function findNodeByLocation(root, location) {
219
+ const queue = [root];
220
+ while (queue.length > 0) {
221
+ const node = queue.shift();
222
+ if (node.location.start.line === location.line &&
223
+ node.location.start.column === location.column) {
224
+ return node;
225
+ }
226
+ if (node.children) {
227
+ queue.push(...node.children);
228
+ }
229
+ }
230
+ return null;
231
+ }
232
+ /**
233
+ * Check whether a file path matches any of the given glob-ish exclude patterns.
234
+ */
235
+ function isExcluded(filePath, patterns) {
236
+ return patterns.some(pattern => {
237
+ const regex = new RegExp(pattern.replace(/\*/g, '.*'));
238
+ return regex.test(filePath);
239
+ });
240
+ }
241
+ /**
242
+ * Walk the AST depth-first, invoking the callback on every node.
243
+ */
244
+ function walkAST(node, callback) {
245
+ callback(node);
246
+ if (node.children) {
247
+ for (const child of node.children) {
248
+ walkAST(child, callback);
249
+ }
250
+ }
251
+ }
252
+ /**
253
+ * Check whether a node type is a block-like structure (if, for, while, etc.).
254
+ * Pure check on the tree-sitter node type.
255
+ */
256
+ function isSignificantBlockType(type) {
257
+ const blockTypes = new Set([
258
+ 'if_statement', 'for_statement', 'for_in_statement',
259
+ 'while_statement', 'do_statement', 'switch_statement', 'try_statement',
260
+ ]);
261
+ return blockTypes.has(type);
262
+ }
263
+ /**
264
+ * Build a code block from a node, given the extraction context.
265
+ */
266
+ function createCodeBlock(ctx, node) {
267
+ const text = ctx.adapter.getNodeText(node, ctx.sourceCode);
268
+ if (!text)
269
+ return null;
270
+ const normalizedText = normalizeCode(text, ctx.config);
271
+ const lineCount = countLines(text);
272
+ // R3.3: Compute structural hash from token-kind sequence
273
+ const structuralHash = hashCode(normalizeCodeForStructure(text, ctx.config));
274
+ return {
275
+ file: ctx.ast.filePath,
276
+ start: node.location.start,
277
+ end: node.location.end,
278
+ text,
279
+ normalizedText,
280
+ hash: hashCode(normalizedText),
281
+ structuralHash,
282
+ nodeType: node.type,
283
+ lineCount
284
+ };
285
+ }
286
+ /**
287
+ * Locate the node at `location`, build its block, and append it if large enough.
288
+ */
289
+ function collectBlock(ctx, location, blocks) {
290
+ const node = findNodeByLocation(ctx.ast.root, location);
291
+ if (!node)
292
+ return;
293
+ const block = createCodeBlock(ctx, node);
294
+ if (block && isBlockLargeEnough(block, ctx.config)) {
295
+ blocks.push(block);
296
+ }
297
+ }
298
+ /**
299
+ * Extract all code blocks from an AST: functions, classes + methods, and
300
+ * significant control-flow blocks (loops/conditionals/etc.).
301
+ */
302
+ function extractCodeBlocks(ctx) {
303
+ const blocks = [];
304
+ for (const func of ctx.adapter.extractFunctions(ctx.ast)) {
305
+ collectBlock(ctx, func.location.start, blocks);
306
+ }
307
+ const classes = ctx.adapter.extractClasses(ctx.ast);
308
+ for (const cls of classes) {
309
+ collectBlock(ctx, cls.location.start, blocks);
310
+ for (const method of cls.methods) {
311
+ collectBlock(ctx, method.location.start, blocks);
312
+ }
313
+ }
314
+ walkAST(ctx.ast.root, node => {
315
+ if (isSignificantBlockType(node.type)) {
316
+ collectBlock(ctx, node.location.start, blocks);
317
+ }
318
+ });
319
+ return blocks;
320
+ }
321
+ /**
322
+ * Detects duplicate and structurally-similar code blocks across a codebase.
323
+ *
324
+ * Emits `dry/duplicate` (warning) for exact token matches and, when enabled,
325
+ * `dry/structural-similarity` (suggestion) for token-kind matches. During
326
+ * analysis it seeds {@link DryPairSeed} records for Spec-13 diverging-clone
327
+ * tracking.
328
+ */
28
329
  export class UniversalDRYAnalyzer extends UniversalAnalyzer {
29
330
  name = 'dry';
30
331
  description = 'Detects code duplication across the codebase';
@@ -43,364 +344,153 @@ export class UniversalDRYAnalyzer extends UniversalAnalyzer {
43
344
  // Reset pair collection for this file
44
345
  this._dryPairsForFile = [];
45
346
  // Skip if file matches exclude patterns
46
- if (this.isExcluded(ast.filePath, finalConfig.excludePatterns || [])) {
347
+ if (isExcluded(ast.filePath, finalConfig.excludePatterns || [])) {
47
348
  return violations;
48
349
  }
49
- // Extract code blocks from this file
50
- const blocks = this.extractCodeBlocks(ast, adapter, sourceCode, finalConfig);
350
+ const ctx = { ast, adapter, sourceCode, config: finalConfig };
351
+ const blocks = extractCodeBlocks(ctx);
51
352
  // R3.1: Deduplicate blocks — sort by (file, startLine) and merge overlapping spans
52
- const deduped = this.deduplicateBlocks(blocks);
53
- // ── R3.3: dry/duplicate — exact token-identical match (warning) ─────
54
- const exactHashmap = this.groupByHash(deduped, 'hash');
55
- for (const [, group] of exactHashmap) {
56
- if (group.length < 2)
57
- continue;
58
- // R3.1: Find earliest occurrence as "original" — sort by (file, startLine)
59
- const sorted = [...group].sort(this.byFileAndLine);
60
- const original = sorted[0];
61
- for (let i = 1; i < sorted.length; i++) {
62
- const block = sorted[i];
63
- // R3.1: Span-overlap check — skip if block overlaps with original
64
- if (this.spansOverlap(original, block))
65
- continue;
66
- violations.push(this.createViolation(block.file, block.start, `Duplicate code block detected (${block.lineCount} lines). ` +
67
- `First occurrence at ${original.file}:${original.start.line}`, 'warning', // R7
68
- 'dry/duplicate', {
69
- oldText: block.text,
70
- newText: `// Consider extracting to a shared function`
71
- }, block.hash));
72
- // Spec 13 R5 — seed pair for diverging-clone tracking
73
- this.seedPair(original, block, 1.0, 'dry/duplicate');
74
- }
75
- }
76
- // ── R3.3: dry/structural-similarity — token-kind match (suggestion) ─
77
- // R4.2: gated behind checkStructuralSimilarity (default off)
353
+ const deduped = deduplicateBlocks(blocks);
354
+ this.reportExactDuplicates(deduped, violations);
78
355
  if (finalConfig.checkStructuralSimilarity) {
79
- const structuralHashmap = this.groupByHash(deduped, 'structuralHash');
80
- for (const [, group] of structuralHashmap) {
81
- if (group.length < 2)
82
- continue;
83
- const sorted = [...group].sort(this.byFileAndLine);
84
- const original = sorted[0];
85
- for (let i = 1; i < sorted.length; i++) {
86
- const block = sorted[i];
87
- // Skip if these are already exact duplicates (reported above)
88
- if (original.hash === block.hash)
89
- continue;
90
- // R3.1: Span-overlap check
91
- if (this.spansOverlap(original, block))
92
- continue;
93
- violations.push(this.createViolation(block.file, block.start, `Structurally similar code block detected (${block.lineCount} lines). ` +
94
- `First occurrence at ${original.file}:${original.start.line}`, 'suggestion', // R7
95
- 'dry/structural-similarity', {
96
- oldText: block.text,
97
- newText: `// Consider extracting to a shared function`
98
- }, block.hash));
99
- // Spec 13 R5 — seed pair for diverging-clone tracking
100
- const jaccardSim = this.computeJaccardSimilarity(original.normalizedText, block.normalizedText);
101
- this.seedPair(original, block, jaccardSim, 'dry/structural-similarity');
102
- }
103
- }
104
- }
105
- // ── Cross-file duplicate detection (scoped audit) ──────────────────
106
- if (finalConfig.fullFunctionIndex && finalConfig.fullFunctionIndex.length > 0) {
107
- const fullHashmap = new Map();
108
- for (const func of finalConfig.fullFunctionIndex) {
109
- const body = func.body ?? func.metadata?.body;
110
- if (!body)
111
- continue;
112
- try {
113
- const normalized = this.normalizeCode(body, finalConfig);
114
- const hash = this.hashCode(normalized);
115
- if (!fullHashmap.has(hash)) {
116
- fullHashmap.set(hash, {
117
- file: func.filePath,
118
- name: func.name,
119
- line: func.startLine ?? func.lineNumber ?? 0
120
- });
121
- }
122
- }
123
- catch {
124
- // Skip functions whose body can't be normalized
125
- }
126
- }
127
- for (const block of blocks) {
128
- if (!this.isBlockLargeEnough(block, finalConfig))
129
- continue;
130
- const fullMatch = fullHashmap.get(block.hash);
131
- if (fullMatch && fullMatch.file !== block.file) {
132
- violations.push(this.createViolation(block.file, block.start, `Duplicate code block detected (${block.lineCount} lines). ` +
133
- `First occurrence in ${fullMatch.file}:${fullMatch.line} (${fullMatch.name})`, 'warning', 'dry/duplicate', {
134
- oldText: block.text,
135
- newText: `// Consider extracting to a shared function`
136
- }, block.hash));
137
- }
138
- }
356
+ this.reportStructuralDuplicates(deduped, violations);
139
357
  }
358
+ this.reportCrossFileDuplicates(blocks, finalConfig, violations);
140
359
  // Check for duplicate string literals if enabled
141
360
  if (finalConfig.checkStrings) {
142
- const stringViolations = this.checkDuplicateStrings(ast, adapter, sourceCode);
143
- violations.push(...stringViolations);
361
+ violations.push(...this.checkDuplicateStrings(ast, adapter, sourceCode));
144
362
  }
145
363
  // Check for duplicate imports if enabled
146
364
  if (finalConfig.checkImports) {
147
- const importViolations = this.checkDuplicateImports(ast, adapter);
148
- violations.push(...importViolations);
365
+ violations.push(...this.checkDuplicateImports(ast, adapter));
149
366
  }
150
367
  // Merge per-file pair accumulator into global accumulator for diverging-clone tracking
151
368
  this._dryPairs.push(...this._dryPairsForFile);
152
369
  return violations;
153
370
  }
154
- // ── R3.1: Span-overlap helpers ──────────────────────────────────────
155
- /**
156
- * Returns true if the two blocks share code spans (same file + overlapping lines).
157
- */
158
- spansOverlap(a, b) {
159
- if (a.file !== b.file)
160
- return false;
161
- return !(a.end.line < b.start.line || b.end.line < a.start.line);
162
- }
163
371
  /**
164
- * Sort comparator: earliest file+line first.
372
+ * Report exact token-identical duplicates (dry/duplicate, warning).
165
373
  */
166
- byFileAndLine(a, b) {
167
- if (a.file !== b.file)
168
- return a.file.localeCompare(b.file);
169
- return a.start.line - b.start.line;
170
- }
171
- /**
172
- * R3.1: Deduplicate overlapping blocks. Prefers the innermost block when
173
- * one block fully contains another (nesting), and the earliest block when
174
- * blocks only partially overlap.
175
- *
176
- * This ensures that blocks nested inside functions/classes (e.g. for-loops
177
- * inside a function body) surface for duplicate detection instead of being
178
- * silently deduplicated by their outer container.
179
- */
180
- deduplicateBlocks(blocks) {
181
- if (blocks.length <= 1)
182
- return blocks;
183
- // Sort by (file, startLine)
184
- const sorted = [...blocks].sort(this.byFileAndLine);
185
- const result = [];
186
- let last = null;
187
- for (const block of sorted) {
188
- if (last && last.file === block.file) {
189
- // Same file — check for overlap
190
- // Case 1: `last` fully contains `block` (nesting: last is outer, block is inner)
191
- // Replace outer with inner — the inner block is more specific.
192
- if (last.start.line <= block.start.line && last.end.line >= block.end.line) {
193
- result.pop();
194
- result.push(block);
195
- last = block;
196
- continue;
197
- }
198
- // Case 2: `block` fully contains `last` (nesting: block is outer, last is inner)
199
- // Keep `last` (already inner in result), skip the outer block.
200
- if (block.start.line <= last.start.line && block.end.line >= last.end.line) {
201
- continue;
202
- }
203
- // Case 3: Partial overlap (neither fully contains the other)
204
- // Keep the earlier block.
205
- if (!(last.end.line < block.start.line)) {
374
+ reportExactDuplicates(deduped, violations) {
375
+ withRuleTiming('dry/duplicate', () => {
376
+ const exactHashmap = groupByHash(deduped, 'hash');
377
+ for (const [, group] of exactHashmap) {
378
+ if (group.length < 2)
206
379
  continue;
380
+ const sorted = [...group].sort(byFileAndLine);
381
+ const original = sorted[0];
382
+ for (let i = 1; i < sorted.length; i++) {
383
+ const block = sorted[i];
384
+ // R3.1: Span-overlap check — skip if block overlaps with original
385
+ if (spansOverlap(original, block))
386
+ continue;
387
+ const violation = this.createViolation(block.file, block.start, `Duplicate code block detected (${block.lineCount} lines). ` +
388
+ `First occurrence at ${original.file}:${original.start.line}`, { severity: 'warning', rule: 'dry/duplicate', symbol: block.hash, // R7
389
+ resolution: {
390
+ action: 'extract-duplicate',
391
+ summary: `Extract the ${block.lineCount}-line block duplicated at ${original.file}:${original.start.line} into a shared function both sites call.`,
392
+ files: [block.file, original.file],
393
+ lines: [block.start.line, original.start.line],
394
+ } });
395
+ violation.fix = {
396
+ oldText: block.text,
397
+ newText: `// Consider extracting to a shared function`
398
+ };
399
+ violations.push(violation);
400
+ // Spec 13 R5 — seed pair for diverging-clone tracking
401
+ this.seedPair(original, block, 1.0, 'dry/duplicate');
207
402
  }
208
403
  }
209
- result.push(block);
210
- last = block;
211
- }
212
- return result;
404
+ });
213
405
  }
214
- // ── R3.3: Structural similarity helpers ──────────────────────────────
215
406
  /**
216
- * Group blocks by a key field into a map of key→blocks[].
407
+ * Report token-kind-identical duplicates (dry/structural-similarity, suggestion).
217
408
  */
218
- groupByHash(blocks, key) {
219
- const map = new Map();
220
- for (const block of blocks) {
221
- const hash = block[key];
222
- const existing = map.get(hash) || [];
223
- existing.push(block);
224
- map.set(hash, existing);
409
+ reportStructuralDuplicates(deduped, violations) {
410
+ const structuralHashmap = groupByHash(deduped, 'structuralHash');
411
+ for (const [, group] of structuralHashmap) {
412
+ if (group.length < 2)
413
+ continue;
414
+ const sorted = [...group].sort(byFileAndLine);
415
+ const original = sorted[0];
416
+ for (let i = 1; i < sorted.length; i++) {
417
+ const block = sorted[i];
418
+ // Skip if these are already exact duplicates (reported above)
419
+ if (original.hash === block.hash)
420
+ continue;
421
+ // R3.1: Span-overlap check
422
+ if (spansOverlap(original, block))
423
+ continue;
424
+ const violation = this.createViolation(block.file, block.start, `Structurally similar code block detected (${block.lineCount} lines). ` +
425
+ `First occurrence at ${original.file}:${original.start.line}`, { severity: 'suggestion', rule: 'dry/structural-similarity', symbol: block.hash } // R7
426
+ );
427
+ violation.fix = {
428
+ oldText: block.text,
429
+ newText: `// Consider extracting to a shared function`
430
+ };
431
+ violations.push(violation);
432
+ // Spec 13 R5 — seed pair for diverging-clone tracking
433
+ const jaccardSim = computeJaccardSimilarity(original.normalizedText, block.normalizedText);
434
+ this.seedPair(original, block, jaccardSim, 'dry/structural-similarity');
435
+ }
225
436
  }
226
- return map;
227
437
  }
228
438
  /**
229
- * R3.3: Normalize code to its token-kind sequence.
230
- * Identifiers ID, string/number/regex literals → LIT.
439
+ * Report blocks that duplicate a function body from the full codebase index.
440
+ * Only used in scoped (changed-file) audits.
231
441
  */
232
- normalizeStructure(code) {
233
- let normalized = code;
234
- // Template expressions: strip dynamic parts for structural matching
235
- normalized = normalized.replace(/\$\{[^}]*\}/g, 'ID');
236
- // String literals (single, double, backtick) → LIT
237
- normalized = normalized.replace(/(['"`])\1/g, 'LIT'); // empty strings
238
- normalized = normalized.replace(/`[^`]*`/g, 'LIT');
239
- normalized = normalized.replace(/'[^']*'/g, 'LIT');
240
- normalized = normalized.replace(/"[^"]*"/g, 'LIT');
241
- // Numeric literals LIT
242
- normalized = normalized.replace(/\b\d+\.?\d*\b/g, 'LIT');
243
- // Regex literals LIT (approximate /pattern/flags)
244
- normalized = normalized.replace(/\/[^/*][^/]*\/[gimsuy]*/g, 'LIT');
245
- // Boolean/null literals
246
- normalized = normalized.replace(/\b(true|false|null|undefined)\b/g, 'LIT');
247
- // Identifiers → ID (after literals so we don't replace inside strings)
248
- // Match camelCase, PascalCase, snake_case, dollar-prefixed, underscore-prefixed
249
- normalized = normalized.replace(/\b[a-zA-Z_$][a-zA-Z0-9_$]*\b/g, (match) => {
250
- // Keep keywords intact
251
- const keywords = new Set([
252
- 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'continue',
253
- 'return', 'throw', 'try', 'catch', 'finally', 'new', 'delete', 'typeof',
254
- 'instanceof', 'in', 'of', 'class', 'extends', 'super', 'this', 'function',
255
- 'const', 'let', 'var', 'async', 'await', 'yield', 'import', 'export',
256
- 'default', 'from', 'as', 'static', 'get', 'set', 'enum', 'type', 'interface',
257
- 'implements', 'abstract', 'public', 'private', 'protected', 'readonly',
258
- 'ID', 'LIT',
259
- ]);
260
- if (keywords.has(match))
261
- return match;
262
- return 'ID';
442
+ reportCrossFileDuplicates(blocks, config, violations) {
443
+ withRuleTiming('dry/duplicate', () => {
444
+ if (!config.fullFunctionIndex || config.fullFunctionIndex.length === 0)
445
+ return;
446
+ const fullHashmap = this.buildFullFunctionHashmap(config);
447
+ for (const block of blocks) {
448
+ if (!isBlockLargeEnough(block, config))
449
+ continue;
450
+ const fullMatch = fullHashmap.get(block.hash);
451
+ if (fullMatch && fullMatch.file !== block.file) {
452
+ const violation = this.createViolation(block.file, block.start, `Duplicate code block detected (${block.lineCount} lines). ` +
453
+ `First occurrence in ${fullMatch.file}:${fullMatch.line} (${fullMatch.name})`, { severity: 'warning', rule: 'dry/duplicate', symbol: block.hash,
454
+ resolution: {
455
+ action: 'extract-duplicate',
456
+ summary: `Extract the ${block.lineCount}-line block duplicated in ${fullMatch.file}:${fullMatch.line} (${fullMatch.name}) into a shared function both sites call.`,
457
+ files: [block.file, fullMatch.file],
458
+ lines: [block.start.line, fullMatch.line],
459
+ } });
460
+ violation.fix = {
461
+ oldText: block.text,
462
+ newText: `// Consider extracting to a shared function`
463
+ };
464
+ violations.push(violation);
465
+ }
466
+ }
263
467
  });
264
- return normalized;
265
468
  }
266
469
  /**
267
- * Extract code blocks from AST
470
+ * Build a hash→location map of every function body in the full codebase index.
268
471
  */
269
- extractCodeBlocks(ast, adapter, sourceCode, config) {
270
- const blocks = [];
271
- // Extract functions
272
- const functions = adapter.extractFunctions(ast);
273
- for (const func of functions) {
274
- const node = this.findNodeByLocation(ast.root, func.location.start);
275
- if (node) {
276
- const block = this.createCodeBlock(ast.filePath, node, adapter, sourceCode, config);
277
- if (block && this.isBlockLargeEnough(block, config)) {
278
- blocks.push(block);
279
- }
280
- }
281
- }
282
- // Extract classes and their methods
283
- const classes = adapter.extractClasses(ast);
284
- for (const cls of classes) {
285
- // Add the class itself
286
- const classNode = this.findNodeByLocation(ast.root, cls.location.start);
287
- if (classNode) {
288
- const block = this.createCodeBlock(ast.filePath, classNode, adapter, sourceCode, config);
289
- if (block && this.isBlockLargeEnough(block, config)) {
290
- blocks.push(block);
291
- }
292
- }
293
- // Add methods
294
- for (const method of cls.methods) {
295
- const methodNode = this.findNodeByLocation(ast.root, method.location.start);
296
- if (methodNode) {
297
- const block = this.createCodeBlock(ast.filePath, methodNode, adapter, sourceCode, config);
298
- if (block && this.isBlockLargeEnough(block, config)) {
299
- blocks.push(block);
300
- }
472
+ buildFullFunctionHashmap(config) {
473
+ const fullHashmap = new Map();
474
+ for (const func of config.fullFunctionIndex || []) {
475
+ const body = func.body ?? func.metadata?.body;
476
+ if (!body)
477
+ continue;
478
+ try {
479
+ const normalized = normalizeCode(body, config);
480
+ const hash = hashCode(normalized);
481
+ if (!fullHashmap.has(hash)) {
482
+ fullHashmap.set(hash, {
483
+ file: func.filePath,
484
+ name: func.name,
485
+ line: func.startLine ?? func.lineNumber ?? 0
486
+ });
301
487
  }
302
488
  }
303
- }
304
- // Extract significant code blocks (loops, conditionals, etc.)
305
- this.walkAST(ast.root, node => {
306
- if (this.isSignificantBlock(node, adapter)) {
307
- const block = this.createCodeBlock(ast.filePath, node, adapter, sourceCode, config);
308
- if (block && this.isBlockLargeEnough(block, config)) {
309
- blocks.push(block);
310
- }
489
+ catch {
490
+ // Skip functions whose body can't be normalized
311
491
  }
312
- });
313
- return blocks;
314
- }
315
- /**
316
- * Create a code block from an AST node
317
- */
318
- createCodeBlock(filePath, node, adapter, sourceCode, config) {
319
- const text = adapter.getNodeText(node, sourceCode);
320
- if (!text)
321
- return null;
322
- const normalizedText = this.normalizeCode(text, config);
323
- const lineCount = this.countLines(text);
324
- // R3.3: Compute structural hash from token-kind sequence
325
- const structuralNormalized = this.normalizeCodeForStructure(text, config);
326
- const structuralHash = this.hashCode(structuralNormalized);
327
- return {
328
- file: filePath,
329
- start: node.location.start,
330
- end: node.location.end,
331
- text,
332
- normalizedText,
333
- hash: this.hashCode(normalizedText),
334
- structuralHash,
335
- nodeType: node.type,
336
- lineCount
337
- };
338
- }
339
- /**
340
- * R3.3: Normalize code for structural comparison.
341
- * First applies standard normalization (whitespace/comments), then
342
- * replaces identifiers and literals with placeholders.
343
- */
344
- normalizeCodeForStructure(code, config) {
345
- const normalized = this.normalizeCode(code, config);
346
- return this.normalizeStructure(normalized);
347
- }
348
- /**
349
- * Normalize code for comparison
350
- */
351
- normalizeCode(code, config) {
352
- let normalized = code;
353
- if (config.ignoreWhitespace) {
354
- // Normalize whitespace but preserve structure
355
- normalized = normalized
356
- .split('\n')
357
- .map(line => line.trim())
358
- .filter(line => line.length > 0)
359
- .join('\n');
360
- }
361
- if (config.ignoreComments) {
362
- // Remove single-line comments
363
- normalized = normalized.replace(/\/\/.*$/gm, '');
364
- // Remove multi-line comments
365
- normalized = normalized.replace(/\/\*[\s\S]*?\*\//g, '');
366
- }
367
- return normalized;
368
- }
369
- /**
370
- * Hash code for comparison
371
- */
372
- hashCode(text) {
373
- return crypto.createHash('sha256').update(text).digest('hex');
374
- }
375
- /**
376
- * Count lines in text
377
- */
378
- countLines(text) {
379
- return text.split('\n').filter(line => line.trim().length > 0).length;
380
- }
381
- /**
382
- * Check if block is large enough to be considered
383
- */
384
- isBlockLargeEnough(block, config) {
385
- return block.lineCount >= (config.minLineThreshold || 5);
386
- }
387
- /**
388
- * Build local index for duplicate detection
389
- */
390
- buildLocalIndex(blocks) {
391
- const index = {
392
- blocks,
393
- hashMap: new Map(),
394
- stringLiterals: new Map(),
395
- imports: new Map()
396
- };
397
- // Group blocks by hash
398
- for (const block of blocks) {
399
- const existing = index.hashMap.get(block.hash) || [];
400
- existing.push(block);
401
- index.hashMap.set(block.hash, existing);
402
492
  }
403
- return index;
493
+ return fullHashmap;
404
494
  }
405
495
  /**
406
496
  * Check for duplicate string literals
@@ -423,10 +513,12 @@ export class UniversalDRYAnalyzer extends UniversalAnalyzer {
423
513
  // Report duplicates
424
514
  for (const [value, locations] of stringMap) {
425
515
  if (locations.length > 2) { // More than 2 occurrences
426
- violations.push(this.createViolation(ast.filePath, locations[0], `String literal "${value.substring(0, 30)}..." is duplicated ${locations.length} times`, 'suggestion', 'duplicate-string-literal', {
516
+ const violation = this.createViolation(ast.filePath, locations[0], `String literal "${value.substring(0, 30)}..." is duplicated ${locations.length} times`, { severity: 'suggestion', rule: 'duplicate-string-literal', symbol: value.substring(0, 50) });
517
+ violation.fix = {
427
518
  oldText: value,
428
519
  newText: '// Consider extracting to a constant'
429
- }, value.substring(0, 50)));
520
+ };
521
+ violations.push(violation);
430
522
  }
431
523
  }
432
524
  return violations;
@@ -447,80 +539,22 @@ export class UniversalDRYAnalyzer extends UniversalAnalyzer {
447
539
  for (const [source, count] of importMap) {
448
540
  if (count > 1) {
449
541
  violations.push(this.createViolation(ast.filePath, { line: 1, column: 1 }, // Import section is typically at the top
450
- `Module "${source}" is imported ${count} times`, 'warning', 'duplicate-import', undefined, source));
542
+ `Module "${source}" is imported ${count} times`, { severity: 'warning', rule: 'duplicate-import', symbol: source }));
451
543
  }
452
544
  }
453
545
  return violations;
454
546
  }
455
547
  /**
456
- * Helper methods
548
+ * Check if this is a block-like structure (if, for, while, etc.).
457
549
  */
458
- isExcluded(filePath, patterns) {
459
- return patterns.some(pattern => {
460
- const regex = new RegExp(pattern.replace(/\*/g, '.*'));
461
- return regex.test(filePath);
462
- });
463
- }
464
- findNodeByLocation(root, location) {
465
- const queue = [root];
466
- while (queue.length > 0) {
467
- const node = queue.shift();
468
- if (node.location.start.line === location.line &&
469
- node.location.start.column === location.column) {
470
- return node;
471
- }
472
- if (node.children) {
473
- queue.push(...node.children);
474
- }
475
- }
476
- return null;
477
- }
478
- walkAST(node, callback) {
479
- callback(node);
480
- if (node.children) {
481
- for (const child of node.children) {
482
- this.walkAST(child, callback);
483
- }
484
- }
485
- }
486
550
  isSignificantBlock(node, adapter) {
487
- // Check if this is a block-like structure (if, for, while, etc.)
488
- // Uses exact snake_case matches against tree-sitter node types.
489
- const blockTypes = new Set([
490
- 'if_statement', 'for_statement', 'for_in_statement',
491
- 'while_statement', 'do_statement', 'switch_statement', 'try_statement',
492
- ]);
493
- return blockTypes.has(node.type);
551
+ return isSignificantBlockType(node.type);
494
552
  }
495
- isStringLiteral(node, adapter) {
496
- return node.type === 'string' || node.type === 'template_string';
497
- }
498
- // ── Spec 13 R5: Diverging-clone seed helpers ──────────────────────────
499
553
  /**
500
- * Compute the Jaccard similarity index between two tokenized strings.
501
- * Jaccard = |intersection| / |union|. Range [0, 1].
554
+ * Check if a node is a string or template-string literal.
502
555
  */
503
- computeJaccardSimilarity(text1, text2) {
504
- const tokens1 = new Set(text1.split(/\s+/).filter(Boolean));
505
- const tokens2 = new Set(text2.split(/\s+/).filter(Boolean));
506
- let intersection = 0;
507
- for (const t of tokens1) {
508
- if (tokens2.has(t))
509
- intersection++;
510
- }
511
- const union = tokens1.size + tokens2.size - intersection;
512
- return union === 0 ? 0 : intersection / union;
513
- }
514
- /**
515
- * Compute an order-independent pair fingerprint from file + line + nodeType.
516
- * Uses SHA256(sorted(a, b).join('||')) so the same pair has the same
517
- * fingerprint regardless of argument order.
518
- */
519
- computePairFingerprint(file1, line1, nodeType1, file2, line2, nodeType2) {
520
- const id1 = `${file1}|${nodeType1}|${line1}`;
521
- const id2 = `${file2}|${nodeType2}|${line2}`;
522
- const sorted = [id1, id2].sort();
523
- return crypto.createHash('sha256').update(sorted.join('||')).digest('hex');
556
+ isStringLiteral(node, adapter) {
557
+ return node.type === 'string' || node.type === 'template_string';
524
558
  }
525
559
  /**
526
560
  * Seed a pair into the per-file accumulator for diverging-clone tracking.
@@ -528,7 +562,7 @@ export class UniversalDRYAnalyzer extends UniversalAnalyzer {
528
562
  * is detected.
529
563
  */
530
564
  seedPair(original, block, similarity, rule) {
531
- const pairFingerprint = this.computePairFingerprint(original.file, original.start.line, original.nodeType, block.file, block.start.line, block.nodeType);
565
+ const pairFingerprint = computePairFingerprint(original, block);
532
566
  this._dryPairsForFile.push({
533
567
  pairFingerprint,
534
568
  file1: original.file,
@@ -544,7 +578,12 @@ export class UniversalDRYAnalyzer extends UniversalAnalyzer {
544
578
  });
545
579
  }
546
580
  /**
547
- * Override analyze() to attach seeded pairs to the AnalyzerResult.
581
+ * Run DRY analysis and attach the seeded pair list to the result.
582
+ *
583
+ * @param files List of file paths to analyze.
584
+ * @param config Analyzer configuration merged over {@link DEFAULT_DRY_CONFIG}.
585
+ * @param options Additional analyzer options (unused by DRY).
586
+ * @returns The analyzer result, with `dryPairs` attached for diverging-clone tracking.
548
587
  */
549
588
  async analyze(files, config = {}, options = {}) {
550
589
  const result = await super.analyze(files, config, options);