code-auditor-mcp 3.4.12 → 3.4.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -1
- package/dist/analyzers/__verify_self_probe.d.ts +2 -0
- package/dist/analyzers/__verify_self_probe.d.ts.map +1 -0
- package/dist/analyzers/__verify_self_probe.js +5 -0
- package/dist/analyzers/__verify_self_probe.js.map +1 -0
- package/dist/analyzers/analyzerUtils.d.ts +34 -1
- package/dist/analyzers/analyzerUtils.d.ts.map +1 -1
- package/dist/analyzers/analyzerUtils.js +61 -22
- package/dist/analyzers/analyzerUtils.js.map +1 -1
- package/dist/analyzers/cross-language/APIContractAnalyzer.d.ts +18 -23
- package/dist/analyzers/cross-language/APIContractAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/cross-language/APIContractAnalyzer.js +198 -186
- package/dist/analyzers/cross-language/APIContractAnalyzer.js.map +1 -1
- package/dist/analyzers/cross-language/DependencyGraphBuilder.d.ts +78 -55
- package/dist/analyzers/cross-language/DependencyGraphBuilder.d.ts.map +1 -1
- package/dist/analyzers/cross-language/DependencyGraphBuilder.js +244 -243
- package/dist/analyzers/cross-language/DependencyGraphBuilder.js.map +1 -1
- package/dist/analyzers/cross-language/SchemaValidator.d.ts +27 -37
- package/dist/analyzers/cross-language/SchemaValidator.d.ts.map +1 -1
- package/dist/analyzers/cross-language/SchemaValidator.js +314 -275
- package/dist/analyzers/cross-language/SchemaValidator.js.map +1 -1
- package/dist/analyzers/crossDomain/CrossDomainAnalyzer.d.ts +17 -46
- package/dist/analyzers/crossDomain/CrossDomainAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js +620 -523
- package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js.map +1 -1
- package/dist/analyzers/documentationAnalyzer.d.ts +7 -2
- package/dist/analyzers/documentationAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/documentationAnalyzer.js +259 -198
- package/dist/analyzers/documentationAnalyzer.js.map +1 -1
- package/dist/analyzers/invariantsAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/invariantsAnalyzer.js +57 -50
- package/dist/analyzers/invariantsAnalyzer.js.map +1 -1
- package/dist/analyzers/orm/adapterRegistry.d.ts +10 -0
- package/dist/analyzers/orm/adapterRegistry.d.ts.map +1 -1
- package/dist/analyzers/orm/adapterRegistry.js +10 -0
- package/dist/analyzers/orm/adapterRegistry.js.map +1 -1
- package/dist/analyzers/orm/drizzleAdapter.d.ts +22 -0
- package/dist/analyzers/orm/drizzleAdapter.d.ts.map +1 -1
- package/dist/analyzers/orm/drizzleAdapter.js +42 -44
- package/dist/analyzers/orm/drizzleAdapter.js.map +1 -1
- package/dist/analyzers/orm/prismaAdapter.d.ts +30 -0
- package/dist/analyzers/orm/prismaAdapter.d.ts.map +1 -1
- package/dist/analyzers/orm/prismaAdapter.js +30 -0
- package/dist/analyzers/orm/prismaAdapter.js.map +1 -1
- package/dist/analyzers/provenance.d.ts +62 -2
- package/dist/analyzers/provenance.d.ts.map +1 -1
- package/dist/analyzers/provenance.js +443 -349
- package/dist/analyzers/provenance.js.map +1 -1
- package/dist/analyzers/reactAnalyzer.d.ts +11 -0
- package/dist/analyzers/reactAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/reactAnalyzer.js +188 -125
- package/dist/analyzers/reactAnalyzer.js.map +1 -1
- package/dist/analyzers/ruleRegistry.d.ts +110 -1
- package/dist/analyzers/ruleRegistry.d.ts.map +1 -1
- package/dist/analyzers/ruleRegistry.js +1714 -124
- package/dist/analyzers/ruleRegistry.js.map +1 -1
- package/dist/analyzers/ruleTiming.d.ts +54 -0
- package/dist/analyzers/ruleTiming.d.ts.map +1 -0
- package/dist/analyzers/ruleTiming.js +71 -0
- package/dist/analyzers/ruleTiming.js.map +1 -0
- package/dist/analyzers/universal/UniversalConventionsAnalyzer.d.ts +13 -0
- package/dist/analyzers/universal/UniversalConventionsAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalConventionsAnalyzer.js +362 -283
- package/dist/analyzers/universal/UniversalConventionsAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts +26 -68
- package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalDRYAnalyzer.js +430 -391
- package/dist/analyzers/universal/UniversalDRYAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts +17 -202
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js +1073 -1121
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalDocumentationAnalyzer.d.ts +3 -49
- package/dist/analyzers/universal/UniversalDocumentationAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js +414 -340
- package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts +48 -5
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js +262 -134
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalSchemaAnalyzer.d.ts +23 -214
- package/dist/analyzers/universal/UniversalSchemaAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalSchemaAnalyzer.js +139 -1521
- package/dist/analyzers/universal/UniversalSchemaAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts +78 -79
- package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalStylesAnalyzer.js +613 -480
- package/dist/analyzers/universal/UniversalStylesAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/schema/codeAnalysis.d.ts +307 -0
- package/dist/analyzers/universal/schema/codeAnalysis.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/codeAnalysis.js +965 -0
- package/dist/analyzers/universal/schema/codeAnalysis.js.map +1 -0
- package/dist/analyzers/universal/schema/config.d.ts +31 -0
- package/dist/analyzers/universal/schema/config.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/config.js +57 -0
- package/dist/analyzers/universal/schema/config.js.map +1 -0
- package/dist/analyzers/universal/schema/discovery.d.ts +167 -0
- package/dist/analyzers/universal/schema/discovery.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/discovery.js +616 -0
- package/dist/analyzers/universal/schema/discovery.js.map +1 -0
- package/dist/analyzers/universal/schema/jsonSchema.d.ts +27 -0
- package/dist/analyzers/universal/schema/jsonSchema.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/jsonSchema.js +368 -0
- package/dist/analyzers/universal/schema/jsonSchema.js.map +1 -0
- package/dist/analyzers/universal/schema/migrations.d.ts +90 -0
- package/dist/analyzers/universal/schema/migrations.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/migrations.js +238 -0
- package/dist/analyzers/universal/schema/migrations.js.map +1 -0
- package/dist/analyzers/universal/schema/types.d.ts +131 -0
- package/dist/analyzers/universal/schema/types.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/types.js +10 -0
- package/dist/analyzers/universal/schema/types.js.map +1 -0
- package/dist/analyzers/universal/schema/violations.d.ts +44 -0
- package/dist/analyzers/universal/schema/violations.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/violations.js +54 -0
- package/dist/analyzers/universal/schema/violations.js.map +1 -0
- package/dist/auditRunner.d.ts.map +1 -1
- package/dist/auditRunner.js +89 -13
- package/dist/auditRunner.js.map +1 -1
- package/dist/cli.js +315 -40
- package/dist/cli.js.map +1 -1
- package/dist/config/configLoader.d.ts.map +1 -1
- package/dist/config/configLoader.js +9 -7
- package/dist/config/configLoader.js.map +1 -1
- package/dist/config/defaults.d.ts +12 -3
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +15 -5
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/effectiveConfig.d.ts +75 -0
- package/dist/config/effectiveConfig.d.ts.map +1 -0
- package/dist/config/effectiveConfig.js +213 -0
- package/dist/config/effectiveConfig.js.map +1 -0
- package/dist/config/pathProfiles.d.ts +7 -4
- package/dist/config/pathProfiles.d.ts.map +1 -1
- package/dist/config/pathProfiles.js +8 -10
- package/dist/config/pathProfiles.js.map +1 -1
- package/dist/config/thresholdRationales.d.ts +45 -0
- package/dist/config/thresholdRationales.d.ts.map +1 -0
- package/dist/config/thresholdRationales.js +67 -0
- package/dist/config/thresholdRationales.js.map +1 -0
- package/dist/conventions/conventionMiner.d.ts.map +1 -1
- package/dist/conventions/conventionMiner.js +6 -2
- package/dist/conventions/conventionMiner.js.map +1 -1
- package/dist/enforcement/diffGate.d.ts +55 -0
- package/dist/enforcement/diffGate.d.ts.map +1 -0
- package/dist/enforcement/diffGate.js +136 -0
- package/dist/enforcement/diffGate.js.map +1 -0
- package/dist/enforcement/gate.d.ts +36 -0
- package/dist/enforcement/gate.d.ts.map +1 -0
- package/dist/enforcement/gate.js +72 -0
- package/dist/enforcement/gate.js.map +1 -0
- package/dist/enforcement/suppressions.d.ts +41 -0
- package/dist/enforcement/suppressions.d.ts.map +1 -0
- package/dist/enforcement/suppressions.js +170 -0
- package/dist/enforcement/suppressions.js.map +1 -0
- package/dist/fingerprint.d.ts.map +1 -1
- package/dist/fingerprint.js +4 -1
- package/dist/fingerprint.js.map +1 -1
- package/dist/invariants/ruleEngine.d.ts +3 -4
- package/dist/invariants/ruleEngine.d.ts.map +1 -1
- package/dist/invariants/ruleEngine.js +2 -2
- package/dist/invariants/ruleEngine.js.map +1 -1
- package/dist/languages/LanguageOrchestrator.d.ts +14 -0
- package/dist/languages/LanguageOrchestrator.d.ts.map +1 -1
- package/dist/languages/LanguageOrchestrator.js +93 -55
- package/dist/languages/LanguageOrchestrator.js.map +1 -1
- package/dist/languages/LanguageRegistry.d.ts +8 -0
- package/dist/languages/LanguageRegistry.d.ts.map +1 -1
- package/dist/languages/LanguageRegistry.js +8 -0
- package/dist/languages/LanguageRegistry.js.map +1 -1
- package/dist/languages/RuntimeManager.d.ts +82 -60
- package/dist/languages/RuntimeManager.d.ts.map +1 -1
- package/dist/languages/RuntimeManager.js +404 -349
- package/dist/languages/RuntimeManager.js.map +1 -1
- package/dist/languages/UniversalAnalyzer.d.ts +51 -6
- package/dist/languages/UniversalAnalyzer.d.ts.map +1 -1
- package/dist/languages/UniversalAnalyzer.js +127 -88
- package/dist/languages/UniversalAnalyzer.js.map +1 -1
- package/dist/languages/adapterBridge.d.ts +36 -0
- package/dist/languages/adapterBridge.d.ts.map +1 -1
- package/dist/languages/adapterBridge.js +89 -35
- package/dist/languages/adapterBridge.js.map +1 -1
- package/dist/languages/go/GoAdapter.d.ts +66 -23
- package/dist/languages/go/GoAdapter.d.ts.map +1 -1
- package/dist/languages/go/GoAdapter.js +553 -474
- package/dist/languages/go/GoAdapter.js.map +1 -1
- package/dist/languages/tree-sitter/TreeSitterCssAdapter.d.ts +33 -4
- package/dist/languages/tree-sitter/TreeSitterCssAdapter.d.ts.map +1 -1
- package/dist/languages/tree-sitter/TreeSitterCssAdapter.js +101 -66
- package/dist/languages/tree-sitter/TreeSitterCssAdapter.js.map +1 -1
- package/dist/languages/tree-sitter/converter.d.ts +18 -0
- package/dist/languages/tree-sitter/converter.d.ts.map +1 -1
- package/dist/languages/tree-sitter/converter.js +18 -0
- package/dist/languages/tree-sitter/converter.js.map +1 -1
- package/dist/languages/tree-sitter/parser.d.ts +39 -2
- package/dist/languages/tree-sitter/parser.d.ts.map +1 -1
- package/dist/languages/tree-sitter/parser.js +93 -12
- package/dist/languages/tree-sitter/parser.js.map +1 -1
- package/dist/languages/types.d.ts +67 -2
- package/dist/languages/types.d.ts.map +1 -1
- package/dist/languages/typescript/TreeSitterTypeScriptAdapter.d.ts +253 -72
- package/dist/languages/typescript/TreeSitterTypeScriptAdapter.d.ts.map +1 -1
- package/dist/languages/typescript/TreeSitterTypeScriptAdapter.js +1419 -923
- package/dist/languages/typescript/TreeSitterTypeScriptAdapter.js.map +1 -1
- package/dist/mcp-tools-shared.d.ts.map +1 -1
- package/dist/mcp-tools-shared.js.map +1 -1
- package/dist/pipeline.d.ts +13 -5
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +381 -200
- package/dist/pipeline.js.map +1 -1
- package/dist/pipelineAdapters.d.ts +3 -8
- package/dist/pipelineAdapters.d.ts.map +1 -1
- package/dist/pipelineAdapters.js +149 -94
- package/dist/pipelineAdapters.js.map +1 -1
- package/dist/presets/presets.d.ts +54 -0
- package/dist/presets/presets.d.ts.map +1 -0
- package/dist/presets/presets.js +181 -0
- package/dist/presets/presets.js.map +1 -0
- package/dist/reporting/sarifReportGenerator.d.ts.map +1 -1
- package/dist/reporting/sarifReportGenerator.js +0 -2
- package/dist/reporting/sarifReportGenerator.js.map +1 -1
- package/dist/ruleAliases.d.ts +67 -0
- package/dist/ruleAliases.d.ts.map +1 -0
- package/dist/ruleAliases.js +79 -0
- package/dist/ruleAliases.js.map +1 -0
- package/dist/styles/cssAstExtractor.d.ts.map +1 -1
- package/dist/styles/cssAstExtractor.js +78 -18
- package/dist/styles/cssAstExtractor.js.map +1 -1
- package/dist/styles/styleIndexer.js +10 -1
- package/dist/styles/styleIndexer.js.map +1 -1
- package/dist/styles/tailwindConfigLoader.d.ts +8 -6
- package/dist/styles/tailwindConfigLoader.d.ts.map +1 -1
- package/dist/styles/tailwindConfigLoader.js +61 -15
- package/dist/styles/tailwindConfigLoader.js.map +1 -1
- package/dist/styles/tailwindUtilityExpander.d.ts.map +1 -1
- package/dist/styles/tailwindUtilityExpander.js +13 -1
- package/dist/styles/tailwindUtilityExpander.js.map +1 -1
- package/dist/types.d.ts +181 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -1
- package/package.json +5 -3
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/scripts/hook-audit.sh +25 -7
- 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 (
|
|
347
|
+
if (isExcluded(ast.filePath, finalConfig.excludePatterns || [])) {
|
|
47
348
|
return violations;
|
|
48
349
|
}
|
|
49
|
-
|
|
50
|
-
const blocks =
|
|
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 =
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
372
|
+
* Report exact token-identical duplicates (dry/duplicate, warning).
|
|
165
373
|
*/
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
210
|
-
last = block;
|
|
211
|
-
}
|
|
212
|
-
return result;
|
|
404
|
+
});
|
|
213
405
|
}
|
|
214
|
-
// ── R3.3: Structural similarity helpers ──────────────────────────────
|
|
215
406
|
/**
|
|
216
|
-
*
|
|
407
|
+
* Report token-kind-identical duplicates (dry/structural-similarity, suggestion).
|
|
217
408
|
*/
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
for (const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
*
|
|
230
|
-
*
|
|
439
|
+
* Report blocks that duplicate a function body from the full codebase index.
|
|
440
|
+
* Only used in scoped (changed-file) audits.
|
|
231
441
|
*/
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
*
|
|
470
|
+
* Build a hash→location map of every function body in the full codebase index.
|
|
268
471
|
*/
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
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',
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
501
|
-
* Jaccard = |intersection| / |union|. Range [0, 1].
|
|
554
|
+
* Check if a node is a string or template-string literal.
|
|
502
555
|
*/
|
|
503
|
-
|
|
504
|
-
|
|
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 =
|
|
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
|
-
*
|
|
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);
|