thuban 0.3.3 → 0.3.4
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/dist/LICENSE +21 -0
- package/dist/README.md +185 -0
- package/dist/cli.js +2 -0
- package/dist/packages/scanner/ai-confidence-scorer.js +1 -0
- package/dist/packages/scanner/alert-manager.js +1 -0
- package/dist/packages/scanner/baseline-manager.js +1 -0
- package/dist/packages/scanner/code-scanner.js +1 -0
- package/dist/packages/scanner/codebase-passport.js +1 -0
- package/dist/packages/scanner/copy-paste-detector.js +1 -0
- package/dist/packages/scanner/dependency-graph.js +1 -0
- package/dist/packages/scanner/drift-detector.js +1 -0
- package/dist/packages/scanner/executive-report.js +1 -0
- package/dist/packages/scanner/file-collector.js +1 -0
- package/dist/packages/scanner/file-watcher.js +1 -0
- package/dist/packages/scanner/ghost-code-detector.js +1 -0
- package/dist/packages/scanner/hallucination-detector.js +1 -0
- package/dist/packages/scanner/health-checker.js +1 -0
- package/dist/packages/scanner/html-report.js +1 -0
- package/dist/packages/scanner/index.js +1 -0
- package/dist/packages/scanner/license-manager.js +1 -0
- package/dist/packages/scanner/master-health-checker.js +1 -0
- package/dist/packages/scanner/monitor-notifier.js +1 -0
- package/dist/packages/scanner/monitor-service.js +1 -0
- package/dist/packages/scanner/monitor-store.js +1 -0
- package/dist/packages/scanner/pre-commit-gate.js +1 -0
- package/dist/packages/scanner/scan-diff.js +1 -0
- package/dist/packages/scanner/scan-runner.js +1 -0
- package/dist/packages/scanner/secret-scanner.js +1 -0
- package/dist/packages/scanner/sentinel-core.js +1 -0
- package/dist/packages/scanner/sentinel-knowledge.js +1 -0
- package/dist/packages/scanner/tech-debt-analyzer.js +1 -0
- package/dist/packages/scanner/tech-debt-cost.js +1 -0
- package/dist/packages/scanner/widget-generator.js +1 -0
- package/package.json +12 -7
- package/cli.js +0 -2627
- package/packages/scanner/ai-confidence-scorer.js +0 -260
- package/packages/scanner/alert-manager.js +0 -398
- package/packages/scanner/baseline-manager.js +0 -109
- package/packages/scanner/code-scanner.js +0 -758
- package/packages/scanner/codebase-passport.js +0 -299
- package/packages/scanner/copy-paste-detector.js +0 -276
- package/packages/scanner/dependency-graph.js +0 -541
- package/packages/scanner/drift-detector.js +0 -423
- package/packages/scanner/executive-report.js +0 -774
- package/packages/scanner/file-collector.js +0 -64
- package/packages/scanner/file-watcher.js +0 -323
- package/packages/scanner/ghost-code-detector.js +0 -301
- package/packages/scanner/hallucination-detector.js +0 -822
- package/packages/scanner/health-checker.js +0 -586
- package/packages/scanner/html-report.js +0 -634
- package/packages/scanner/index.js +0 -100
- package/packages/scanner/license-manager.js +0 -331
- package/packages/scanner/master-health-checker.js +0 -513
- package/packages/scanner/monitor-notifier.js +0 -64
- package/packages/scanner/monitor-service.js +0 -103
- package/packages/scanner/monitor-store.js +0 -117
- package/packages/scanner/pre-commit-gate.js +0 -216
- package/packages/scanner/scan-diff.js +0 -50
- package/packages/scanner/scan-runner.js +0 -172
- package/packages/scanner/secret-scanner.js +0 -612
- package/packages/scanner/secret-scanner.test.js +0 -103
- package/packages/scanner/sentinel-core.js +0 -616
- package/packages/scanner/sentinel-knowledge.js +0 -322
- package/packages/scanner/tech-debt-analyzer.js +0 -583
- package/packages/scanner/tech-debt-cost.js +0 -194
- package/packages/scanner/widget-generator.js +0 -415
|
@@ -1,299 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Thuban Codebase Passport Generator
|
|
3
|
-
*
|
|
4
|
-
* @purpose Generate a single JSON identity file for the entire codebase
|
|
5
|
-
* @module thuban
|
|
6
|
-
* @layer scanner
|
|
7
|
-
* @exports-to cli
|
|
8
|
-
* @critical-for Onboarding — new devs understand the codebase in minutes not weeks
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
const fs = require('fs');
|
|
12
|
-
const path = require('path');
|
|
13
|
-
const crypto = require('crypto');
|
|
14
|
-
|
|
15
|
-
class CodebasePassport {
|
|
16
|
-
constructor(opts = {}) {
|
|
17
|
-
this.rootPath = opts.rootPath || process.cwd();
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Generate the passport from scan results
|
|
22
|
-
*/
|
|
23
|
-
generate(files, opts = {}) {
|
|
24
|
-
const startTime = Date.now();
|
|
25
|
-
const fileMap = [];
|
|
26
|
-
const dependencies = {};
|
|
27
|
-
const exports = {};
|
|
28
|
-
const entryPoints = [];
|
|
29
|
-
const sacredFiles = [];
|
|
30
|
-
const riskAreas = [];
|
|
31
|
-
let totalLines = 0;
|
|
32
|
-
const languages = {};
|
|
33
|
-
const frameworks = new Set();
|
|
34
|
-
|
|
35
|
-
for (const file of files) {
|
|
36
|
-
let content;
|
|
37
|
-
try {
|
|
38
|
-
const stat = fs.statSync(file);
|
|
39
|
-
if (stat.size > 1024 * 1024) continue;
|
|
40
|
-
content = fs.readFileSync(file, 'utf-8');
|
|
41
|
-
} catch (e) {
|
|
42
|
-
continue;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const relPath = path.relative(this.rootPath, file);
|
|
46
|
-
const lines = content.split('\n');
|
|
47
|
-
totalLines += lines.length;
|
|
48
|
-
|
|
49
|
-
// Detect language
|
|
50
|
-
const ext = path.extname(file);
|
|
51
|
-
languages[ext] = (languages[ext] || 0) + lines.length;
|
|
52
|
-
|
|
53
|
-
// Detect frameworks
|
|
54
|
-
this._detectFrameworks(content, frameworks);
|
|
55
|
-
|
|
56
|
-
// Map imports and exports
|
|
57
|
-
const fileDeps = this._extractDependencies(content, relPath);
|
|
58
|
-
const fileExports = this._extractExports(content, relPath);
|
|
59
|
-
|
|
60
|
-
dependencies[relPath] = fileDeps;
|
|
61
|
-
if (fileExports.length > 0) exports[relPath] = fileExports;
|
|
62
|
-
|
|
63
|
-
// Detect entry points
|
|
64
|
-
if (this._isEntryPoint(relPath, content)) {
|
|
65
|
-
entryPoints.push(relPath);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Detect sacred files (high dependency count = risky to change)
|
|
69
|
-
const importedByCount = files.filter(f => {
|
|
70
|
-
try {
|
|
71
|
-
const c = fs.readFileSync(f, 'utf-8');
|
|
72
|
-
return c.includes(relPath.replace(/\\/g, '/')) || c.includes(relPath.replace(/\//g, '\\'));
|
|
73
|
-
} catch (e) { return false; }
|
|
74
|
-
}).length;
|
|
75
|
-
|
|
76
|
-
if (importedByCount > 5) {
|
|
77
|
-
sacredFiles.push({
|
|
78
|
-
file: relPath,
|
|
79
|
-
importedBy: importedByCount,
|
|
80
|
-
risk: importedByCount > 10 ? 'critical' : 'high',
|
|
81
|
-
note: `Changing this file affects ${importedByCount} other files. Proceed with caution.`,
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// File purpose summary
|
|
86
|
-
const purpose = this._detectPurpose(relPath, content);
|
|
87
|
-
fileMap.push({
|
|
88
|
-
path: relPath,
|
|
89
|
-
lines: lines.length,
|
|
90
|
-
purpose,
|
|
91
|
-
imports: fileDeps.length,
|
|
92
|
-
exports: fileExports.length,
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Build dependency graph summary
|
|
97
|
-
const depChains = this._findCriticalPaths(dependencies);
|
|
98
|
-
|
|
99
|
-
// Detect architecture pattern
|
|
100
|
-
const architecture = this._detectArchitecture(fileMap);
|
|
101
|
-
|
|
102
|
-
const passport = {
|
|
103
|
-
_version: '1.0.0',
|
|
104
|
-
_generated: new Date().toISOString(),
|
|
105
|
-
_generator: 'thuban',
|
|
106
|
-
|
|
107
|
-
project: {
|
|
108
|
-
name: path.basename(this.rootPath),
|
|
109
|
-
rootPath: this.rootPath,
|
|
110
|
-
totalFiles: files.length,
|
|
111
|
-
totalLines,
|
|
112
|
-
languages: Object.entries(languages)
|
|
113
|
-
.sort((a, b) => b[1] - a[1])
|
|
114
|
-
.map(([ext, lines]) => ({ extension: ext, lines, percentage: Math.round((lines / totalLines) * 100) })),
|
|
115
|
-
frameworks: [...frameworks],
|
|
116
|
-
architecture,
|
|
117
|
-
entryPoints,
|
|
118
|
-
},
|
|
119
|
-
|
|
120
|
-
files: fileMap
|
|
121
|
-
.sort((a, b) => b.imports - a.imports)
|
|
122
|
-
.slice(0, 200), // Cap to prevent huge passports
|
|
123
|
-
|
|
124
|
-
sacred: sacredFiles
|
|
125
|
-
.sort((a, b) => b.importedBy - a.importedBy),
|
|
126
|
-
|
|
127
|
-
dependencies: {
|
|
128
|
-
totalChains: Object.keys(dependencies).length,
|
|
129
|
-
criticalPaths: depChains.slice(0, 20),
|
|
130
|
-
circularRisks: this._findCircularRisks(dependencies),
|
|
131
|
-
},
|
|
132
|
-
|
|
133
|
-
onboarding: {
|
|
134
|
-
estimatedReadTimeMinutes: Math.ceil(totalLines / 200), // ~200 lines/min for scanning
|
|
135
|
-
startHere: entryPoints.slice(0, 5),
|
|
136
|
-
keyFiles: sacredFiles.slice(0, 10).map(s => s.file),
|
|
137
|
-
doNotTouch: sacredFiles.filter(s => s.risk === 'critical').map(s => s.file),
|
|
138
|
-
quickSummary: this._generateQuickSummary(fileMap, frameworks, architecture),
|
|
139
|
-
},
|
|
140
|
-
|
|
141
|
-
generatedIn: Date.now() - startTime + 'ms',
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
return passport;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Save passport to file
|
|
149
|
-
*/
|
|
150
|
-
save(passport, outputPath) {
|
|
151
|
-
const filePath = outputPath || path.join(this.rootPath, '.thuban-passport.json');
|
|
152
|
-
fs.writeFileSync(filePath, JSON.stringify(passport, null, 2), 'utf-8');
|
|
153
|
-
return filePath;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// ─── Private ─────────────────────────────────────────────
|
|
157
|
-
|
|
158
|
-
_extractDependencies(content, filePath) {
|
|
159
|
-
const deps = [];
|
|
160
|
-
const patterns = [
|
|
161
|
-
/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
162
|
-
/import\s+.*?from\s+['"]([^'"]+)['"]/g,
|
|
163
|
-
/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
164
|
-
];
|
|
165
|
-
|
|
166
|
-
for (const pattern of patterns) {
|
|
167
|
-
let match;
|
|
168
|
-
while ((match = pattern.exec(content)) !== null) {
|
|
169
|
-
deps.push(match[1]);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return [...new Set(deps)];
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
_extractExports(content, filePath) {
|
|
177
|
-
const exports = [];
|
|
178
|
-
|
|
179
|
-
// module.exports
|
|
180
|
-
const moduleExports = content.match(/module\.exports\s*=\s*(\{[\s\S]*?\}|[a-zA-Z_$]\w*)/);
|
|
181
|
-
if (moduleExports) exports.push('module.exports');
|
|
182
|
-
|
|
183
|
-
// Named exports
|
|
184
|
-
const namedExports = content.match(/exports\.([a-zA-Z_$]\w*)\s*=/g);
|
|
185
|
-
if (namedExports) {
|
|
186
|
-
namedExports.forEach(e => exports.push(e.replace(/\s*=\s*$/, '')));
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// ES6 exports
|
|
190
|
-
const es6Exports = content.match(/export\s+(?:default\s+)?(?:const|let|var|function|class)\s+([a-zA-Z_$]\w*)/g);
|
|
191
|
-
if (es6Exports) {
|
|
192
|
-
es6Exports.forEach(e => exports.push(e));
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return exports;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
_isEntryPoint(relPath, content) {
|
|
199
|
-
const entryNames = ['index.', 'main.', 'app.', 'server.', 'cli.', 'bin/'];
|
|
200
|
-
if (entryNames.some(n => relPath.includes(n))) return true;
|
|
201
|
-
if (content.includes('#!/usr/bin/env')) return true;
|
|
202
|
-
if (content.includes('createServer') || content.includes('.listen(')) return true;
|
|
203
|
-
return false;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
_detectPurpose(relPath, content) {
|
|
207
|
-
const lower = relPath.toLowerCase();
|
|
208
|
-
if (/route/i.test(lower)) return 'routing';
|
|
209
|
-
if (/middleware/i.test(lower)) return 'middleware';
|
|
210
|
-
if (/model/i.test(lower)) return 'data model';
|
|
211
|
-
if (/controller/i.test(lower)) return 'controller';
|
|
212
|
-
if (/service/i.test(lower)) return 'service';
|
|
213
|
-
if (/util|helper|lib/i.test(lower)) return 'utility';
|
|
214
|
-
if (/config/i.test(lower)) return 'configuration';
|
|
215
|
-
if (/test|spec/i.test(lower)) return 'test';
|
|
216
|
-
if (/component/i.test(lower)) return 'UI component';
|
|
217
|
-
if (/hook/i.test(lower)) return 'React hook';
|
|
218
|
-
if (/store|redux|state/i.test(lower)) return 'state management';
|
|
219
|
-
if (/api/i.test(lower)) return 'API layer';
|
|
220
|
-
if (/db|database|migration/i.test(lower)) return 'database';
|
|
221
|
-
if (/auth/i.test(lower)) return 'authentication';
|
|
222
|
-
if (/cli|command/i.test(lower)) return 'CLI';
|
|
223
|
-
|
|
224
|
-
// Check content for clues
|
|
225
|
-
if (content.includes('app.get(') || content.includes('router.')) return 'routing';
|
|
226
|
-
if (content.includes('mongoose.model') || content.includes('Schema')) return 'data model';
|
|
227
|
-
if (content.includes('React') || content.includes('jsx')) return 'React component';
|
|
228
|
-
|
|
229
|
-
return 'application logic';
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
_detectFrameworks(content, frameworks) {
|
|
233
|
-
if (content.includes('require(\'express\'') || content.includes('from \'express\'')) frameworks.add('Express');
|
|
234
|
-
if (content.includes('require(\'react\'') || content.includes('from \'react\'')) frameworks.add('React');
|
|
235
|
-
if (content.includes('require(\'next\'') || content.includes('from \'next\'')) frameworks.add('Next.js');
|
|
236
|
-
if (content.includes('require(\'mongoose\'') || content.includes('from \'mongoose\'')) frameworks.add('Mongoose');
|
|
237
|
-
if (content.includes('require(\'fastify\'') || content.includes('from \'fastify\'')) frameworks.add('Fastify');
|
|
238
|
-
if (content.includes('require(\'vue\'') || content.includes('from \'vue\'')) frameworks.add('Vue');
|
|
239
|
-
if (content.includes('require(\'stripe\'') || content.includes('from \'stripe\'')) frameworks.add('Stripe');
|
|
240
|
-
if (content.includes('@angular')) frameworks.add('Angular');
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
_detectArchitecture(fileMap) {
|
|
244
|
-
const paths = fileMap.map(f => f.path.toLowerCase());
|
|
245
|
-
if (paths.some(p => p.includes('controller')) && paths.some(p => p.includes('model'))) return 'MVC';
|
|
246
|
-
if (paths.some(p => p.includes('service')) && paths.some(p => p.includes('repository'))) return 'Service-Repository';
|
|
247
|
-
if (paths.some(p => p.includes('component')) && paths.some(p => p.includes('store'))) return 'Component-Store (SPA)';
|
|
248
|
-
if (paths.some(p => p.includes('pages/')) && paths.some(p => p.includes('api/'))) return 'Next.js (Pages Router)';
|
|
249
|
-
if (paths.some(p => p.includes('app/')) && paths.some(p => p.includes('route'))) return 'App Router';
|
|
250
|
-
if (paths.some(p => p.includes('packages/'))) return 'Monorepo';
|
|
251
|
-
return 'Flat';
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
_findCriticalPaths(dependencies) {
|
|
255
|
-
return Object.entries(dependencies)
|
|
256
|
-
.filter(([, deps]) => deps.length > 3)
|
|
257
|
-
.map(([file, deps]) => ({
|
|
258
|
-
file,
|
|
259
|
-
dependsOn: deps.length,
|
|
260
|
-
topDeps: deps.slice(0, 5),
|
|
261
|
-
}))
|
|
262
|
-
.sort((a, b) => b.dependsOn - a.dependsOn)
|
|
263
|
-
.slice(0, 20);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
_findCircularRisks(dependencies) {
|
|
267
|
-
const risks = [];
|
|
268
|
-
for (const [fileA, depsA] of Object.entries(dependencies)) {
|
|
269
|
-
for (const dep of depsA) {
|
|
270
|
-
const resolved = this._resolveRelative(fileA, dep);
|
|
271
|
-
if (resolved && dependencies[resolved]) {
|
|
272
|
-
const depsB = dependencies[resolved];
|
|
273
|
-
if (depsB.some(d => this._resolveRelative(resolved, d) === fileA)) {
|
|
274
|
-
risks.push({ fileA, fileB: resolved, risk: 'circular dependency' });
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
return risks.slice(0, 10);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
_resolveRelative(from, dep) {
|
|
283
|
-
if (dep.startsWith('.')) {
|
|
284
|
-
const dir = path.dirname(from);
|
|
285
|
-
let resolved = path.join(dir, dep).replace(/\\/g, '/');
|
|
286
|
-
if (!resolved.match(/\.\w+$/)) resolved += '.js';
|
|
287
|
-
return resolved;
|
|
288
|
-
}
|
|
289
|
-
return null;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
_generateQuickSummary(fileMap, frameworks, architecture) {
|
|
293
|
-
const totalFiles = fileMap.length;
|
|
294
|
-
const fw = [...frameworks].join(', ') || 'vanilla';
|
|
295
|
-
return `${totalFiles}-file ${architecture} project using ${fw}. Start with entry points listed above.`;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
module.exports = CodebasePassport;
|
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Thuban Copy-Paste Drift Detector
|
|
3
|
-
*
|
|
4
|
-
* @purpose Find similar code blocks pasted in multiple files with slight variations
|
|
5
|
-
* @module thuban
|
|
6
|
-
* @layer scanner
|
|
7
|
-
* @exports-to cli
|
|
8
|
-
* @critical-for Code quality — AI generates similar solutions everywhere
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
const fs = require('fs');
|
|
12
|
-
const path = require('path');
|
|
13
|
-
const crypto = require('crypto');
|
|
14
|
-
|
|
15
|
-
class CopyPasteDriftDetector {
|
|
16
|
-
constructor(opts = {}) {
|
|
17
|
-
this.rootPath = opts.rootPath || process.cwd();
|
|
18
|
-
this.minLines = opts.minLines || 5; // Minimum function size to consider
|
|
19
|
-
this.similarityThreshold = opts.similarity || 0.7; // 70% similarity = likely copy-paste
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Scan files for copy-paste clusters
|
|
24
|
-
*/
|
|
25
|
-
scan(files) {
|
|
26
|
-
const functions = [];
|
|
27
|
-
|
|
28
|
-
// Extract all functions with their normalized bodies
|
|
29
|
-
for (const file of files) {
|
|
30
|
-
let content;
|
|
31
|
-
try {
|
|
32
|
-
const stat = fs.statSync(file);
|
|
33
|
-
if (stat.size > 512 * 1024) continue;
|
|
34
|
-
content = fs.readFileSync(file, 'utf-8');
|
|
35
|
-
} catch (e) {
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const relPath = path.relative(this.rootPath, file);
|
|
40
|
-
if (this._isSkippable(relPath)) continue;
|
|
41
|
-
|
|
42
|
-
const fileFunctions = this._extractFunctions(content, relPath);
|
|
43
|
-
functions.push(...fileFunctions);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Compare all functions to find clusters
|
|
47
|
-
const clusters = this._findClusters(functions);
|
|
48
|
-
|
|
49
|
-
const totalDuplicateLines = clusters.reduce((sum, c) =>
|
|
50
|
-
sum + c.members.reduce((s, m) => s + m.lineCount, 0) - c.members[0].lineCount, 0);
|
|
51
|
-
|
|
52
|
-
return {
|
|
53
|
-
clusters,
|
|
54
|
-
totalClusters: clusters.length,
|
|
55
|
-
totalDuplicateInstances: clusters.reduce((sum, c) => sum + c.members.length, 0),
|
|
56
|
-
totalDuplicateLines,
|
|
57
|
-
recommendation: clusters.length > 0
|
|
58
|
-
? `Extract ${clusters.length} shared utilities to eliminate ${totalDuplicateLines} duplicate lines.`
|
|
59
|
-
: 'No copy-paste drift detected.',
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ─── Private ─────────────────────────────────────────────
|
|
64
|
-
|
|
65
|
-
_extractFunctions(content, filePath) {
|
|
66
|
-
const lines = content.split('\n');
|
|
67
|
-
const functions = [];
|
|
68
|
-
|
|
69
|
-
for (let i = 0; i < lines.length; i++) {
|
|
70
|
-
const fn = this._detectFunction(lines[i]);
|
|
71
|
-
if (!fn) continue;
|
|
72
|
-
|
|
73
|
-
const endLine = this._findFunctionEnd(lines, i);
|
|
74
|
-
const body = lines.slice(i, endLine + 1).join('\n');
|
|
75
|
-
const lineCount = endLine - i + 1;
|
|
76
|
-
|
|
77
|
-
if (lineCount < this.minLines) continue;
|
|
78
|
-
|
|
79
|
-
const normalized = this._normalize(body);
|
|
80
|
-
const hash = crypto.createHash('md5').update(normalized).digest('hex');
|
|
81
|
-
const tokens = this._tokenize(normalized);
|
|
82
|
-
|
|
83
|
-
functions.push({
|
|
84
|
-
file: filePath,
|
|
85
|
-
name: fn.name,
|
|
86
|
-
line: i + 1,
|
|
87
|
-
lineCount,
|
|
88
|
-
hash,
|
|
89
|
-
tokens,
|
|
90
|
-
normalized,
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
i = endLine;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return functions;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
_findClusters(functions) {
|
|
100
|
-
const visited = new Set();
|
|
101
|
-
const clusters = [];
|
|
102
|
-
|
|
103
|
-
for (let i = 0; i < functions.length; i++) {
|
|
104
|
-
if (visited.has(i)) continue;
|
|
105
|
-
|
|
106
|
-
const cluster = [functions[i]];
|
|
107
|
-
visited.add(i);
|
|
108
|
-
|
|
109
|
-
for (let j = i + 1; j < functions.length; j++) {
|
|
110
|
-
if (visited.has(j)) continue;
|
|
111
|
-
|
|
112
|
-
// Quick hash check for exact duplicates
|
|
113
|
-
if (functions[i].hash === functions[j].hash) {
|
|
114
|
-
cluster.push(functions[j]);
|
|
115
|
-
visited.add(j);
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Token-level similarity check
|
|
120
|
-
const similarity = this._tokenSimilarity(functions[i].tokens, functions[j].tokens);
|
|
121
|
-
if (similarity >= this.similarityThreshold) {
|
|
122
|
-
cluster.push({ ...functions[j], similarity: Math.round(similarity * 100) });
|
|
123
|
-
visited.add(j);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (cluster.length >= 2) {
|
|
128
|
-
// Find the differences between cluster members
|
|
129
|
-
const diffs = this._findDiffs(cluster);
|
|
130
|
-
|
|
131
|
-
clusters.push({
|
|
132
|
-
pattern: cluster[0].name,
|
|
133
|
-
members: cluster.map(m => ({
|
|
134
|
-
file: m.file,
|
|
135
|
-
name: m.name,
|
|
136
|
-
line: m.line,
|
|
137
|
-
lineCount: m.lineCount,
|
|
138
|
-
similarity: m.similarity || 100,
|
|
139
|
-
})),
|
|
140
|
-
totalInstances: cluster.length,
|
|
141
|
-
differences: diffs,
|
|
142
|
-
severity: cluster.length >= 4 ? 'high' : cluster.length >= 3 ? 'medium' : 'low',
|
|
143
|
-
recommendation: `Extract to shared utility. ${cluster.length} functions → 1.`,
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return clusters.sort((a, b) => b.totalInstances - a.totalInstances);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
_normalize(code) {
|
|
152
|
-
// Two-pass normalization:
|
|
153
|
-
// Pass 1: collect all declared variable names
|
|
154
|
-
const declaredNames = [];
|
|
155
|
-
const declPattern = /(?:const|let|var|function)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g;
|
|
156
|
-
let m;
|
|
157
|
-
while ((m = declPattern.exec(code)) !== null) {
|
|
158
|
-
if (!declaredNames.includes(m[1])) declaredNames.push(m[1]);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
let normalized = code
|
|
162
|
-
// Remove comments
|
|
163
|
-
.replace(/\/\/.*$/gm, '')
|
|
164
|
-
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
165
|
-
// Normalize string literals
|
|
166
|
-
.replace(/'[^']*'/g, 'STR')
|
|
167
|
-
.replace(/"[^"]*"/g, 'STR')
|
|
168
|
-
.replace(/`[^`]*`/g, 'STR')
|
|
169
|
-
// Normalize numbers
|
|
170
|
-
.replace(/\b\d+\.?\d*\b/g, 'NUM');
|
|
171
|
-
|
|
172
|
-
// Pass 2: replace ALL occurrences of each declared name with VAR_N
|
|
173
|
-
declaredNames.forEach((name, idx) => {
|
|
174
|
-
const nameRegex = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
|
|
175
|
-
normalized = normalized.replace(nameRegex, `VAR_${idx}`);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
// Normalize whitespace
|
|
179
|
-
return normalized.replace(/\s+/g, ' ').trim();
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
_tokenize(normalized) {
|
|
183
|
-
return normalized
|
|
184
|
-
.split(/[\s{}()\[\];,=+\-*/<>!&|?:.]+/)
|
|
185
|
-
.filter(t => t.length > 0);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
_tokenSimilarity(tokens1, tokens2) {
|
|
189
|
-
if (tokens1.length === 0 && tokens2.length === 0) return 1;
|
|
190
|
-
if (tokens1.length === 0 || tokens2.length === 0) return 0;
|
|
191
|
-
|
|
192
|
-
const set1 = new Set(tokens1);
|
|
193
|
-
const set2 = new Set(tokens2);
|
|
194
|
-
const intersection = [...set1].filter(t => set2.has(t)).length;
|
|
195
|
-
const union = new Set([...tokens1, ...tokens2]).size;
|
|
196
|
-
|
|
197
|
-
// Jaccard similarity
|
|
198
|
-
const jaccard = union > 0 ? intersection / union : 0;
|
|
199
|
-
|
|
200
|
-
// Also consider length similarity
|
|
201
|
-
const lengthRatio = Math.min(tokens1.length, tokens2.length) / Math.max(tokens1.length, tokens2.length);
|
|
202
|
-
|
|
203
|
-
return (jaccard * 0.7) + (lengthRatio * 0.3);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
_findDiffs(cluster) {
|
|
207
|
-
if (cluster.length < 2) return [];
|
|
208
|
-
|
|
209
|
-
const diffs = [];
|
|
210
|
-
const base = cluster[0].normalized;
|
|
211
|
-
|
|
212
|
-
for (let i = 1; i < cluster.length; i++) {
|
|
213
|
-
const other = cluster[i].normalized;
|
|
214
|
-
if (base !== other) {
|
|
215
|
-
const baseTokens = new Set(this._tokenize(base));
|
|
216
|
-
const otherTokens = new Set(this._tokenize(other));
|
|
217
|
-
const onlyInOther = [...otherTokens].filter(t => !baseTokens.has(t));
|
|
218
|
-
const onlyInBase = [...baseTokens].filter(t => !otherTokens.has(t));
|
|
219
|
-
if (onlyInOther.length > 0 || onlyInBase.length > 0) {
|
|
220
|
-
diffs.push({
|
|
221
|
-
file: cluster[i].file,
|
|
222
|
-
addedTokens: onlyInOther.slice(0, 5),
|
|
223
|
-
removedTokens: onlyInBase.slice(0, 5),
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
return diffs;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
_detectFunction(line) {
|
|
233
|
-
const trimmed = line.trim();
|
|
234
|
-
// JavaScript/TypeScript: function/const/let/var
|
|
235
|
-
let match = trimmed.match(/^(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/);
|
|
236
|
-
if (match) return { name: match[1] };
|
|
237
|
-
match = trimmed.match(/^(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:async\s+)?(?:function|\()/);
|
|
238
|
-
if (match) return { name: match[1] };
|
|
239
|
-
// Kotlin: fun
|
|
240
|
-
match = trimmed.match(/^(?:private\s+|public\s+|internal\s+|protected\s+)?(?:suspend\s+)?fun\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*[(<]/);
|
|
241
|
-
if (match) return { name: match[1] };
|
|
242
|
-
// Rust: fn
|
|
243
|
-
match = trimmed.match(/^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*[(<]/);
|
|
244
|
-
if (match) return { name: match[1] };
|
|
245
|
-
// PHP: function
|
|
246
|
-
match = trimmed.match(/^(?:(?:public|private|protected|static)\s+)*function\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/);
|
|
247
|
-
if (match) return { name: match[1] };
|
|
248
|
-
// Ruby: def
|
|
249
|
-
match = trimmed.match(/^def\s+(?:self\.)?([a-zA-Z_][a-zA-Z0-9_?!]*)\s*[(\s]?/);
|
|
250
|
-
if (match) return { name: match[1] };
|
|
251
|
-
return null;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
_findFunctionEnd(lines, startLine) {
|
|
255
|
-
let depth = 0;
|
|
256
|
-
let started = false;
|
|
257
|
-
for (let i = startLine; i < lines.length && i < startLine + 200; i++) {
|
|
258
|
-
for (const ch of lines[i]) {
|
|
259
|
-
if (ch === '{') { depth++; started = true; }
|
|
260
|
-
if (ch === '}') depth--;
|
|
261
|
-
}
|
|
262
|
-
if (started && depth <= 0) return i;
|
|
263
|
-
}
|
|
264
|
-
return Math.min(startLine + 20, lines.length - 1);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
_isSkippable(relPath) {
|
|
268
|
-
const skip = [
|
|
269
|
-
/\.test\./i, /\.spec\./i, /test[\/\\]/i, /node_modules/i,
|
|
270
|
-
/\.min\./i, /\.d\.ts$/, /\.config\./i,
|
|
271
|
-
];
|
|
272
|
-
return skip.some(r => r.test(relPath));
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
module.exports = CopyPasteDriftDetector;
|