@aiready/pattern-detect 0.1.2 → 0.2.0

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/index.d.ts CHANGED
@@ -5,6 +5,8 @@ interface DuplicatePattern {
5
5
  file2: string;
6
6
  line1: number;
7
7
  line2: number;
8
+ endLine1: number;
9
+ endLine2: number;
8
10
  similarity: number;
9
11
  snippet: string;
10
12
  patternType: PatternType;
@@ -19,23 +21,38 @@ interface FileContent {
19
21
  interface DetectionOptions {
20
22
  minSimilarity: number;
21
23
  minLines: number;
24
+ maxBlocks?: number;
25
+ batchSize?: number;
26
+ approx?: boolean;
27
+ minSharedTokens?: number;
28
+ maxCandidatesPerBlock?: number;
29
+ maxComparisons?: number;
30
+ streamResults?: boolean;
22
31
  }
23
32
  /**
24
33
  * Detect duplicate patterns across files with enhanced analysis
25
34
  */
26
- declare function detectDuplicatePatterns(files: FileContent[], options: DetectionOptions): DuplicatePattern[];
35
+ declare function detectDuplicatePatterns(files: FileContent[], options: DetectionOptions): Promise<DuplicatePattern[]>;
27
36
 
28
37
  interface PatternDetectOptions extends ScanOptions {
29
38
  minSimilarity?: number;
30
39
  minLines?: number;
40
+ batchSize?: number;
41
+ approx?: boolean;
42
+ minSharedTokens?: number;
43
+ maxCandidatesPerBlock?: number;
44
+ streamResults?: boolean;
31
45
  }
32
46
  interface PatternSummary {
33
47
  totalPatterns: number;
34
48
  totalTokenCost: number;
35
49
  patternsByType: Record<PatternType, number>;
36
50
  topDuplicates: Array<{
37
- file1: string;
38
- file2: string;
51
+ files: Array<{
52
+ path: string;
53
+ startLine: number;
54
+ endLine: number;
55
+ }>;
39
56
  similarity: number;
40
57
  patternType: PatternType;
41
58
  tokenCost: number;
package/dist/index.js CHANGED
@@ -80,6 +80,7 @@ function extractCodeBlocks(content, minLines) {
80
80
  blocks.push({
81
81
  content: blockContent,
82
82
  startLine: blockStart + 1,
83
+ endLine: i + 1,
83
84
  patternType: categorizePattern(blockContent),
84
85
  linesOfCode
85
86
  });
@@ -95,47 +96,198 @@ function extractCodeBlocks(content, minLines) {
95
96
  function normalizeCode(code) {
96
97
  return code.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/"[^"]*"/g, '"STR"').replace(/'[^']*'/g, "'STR'").replace(/`[^`]*`/g, "`STR`").replace(/\b\d+\b/g, "NUM").replace(/\s+/g, " ").trim();
97
98
  }
98
- function calculateSimilarity(block1, block2) {
99
- const norm1 = normalizeCode(block1);
100
- const norm2 = normalizeCode(block2);
101
- const baseSimilarity = (0, import_core.similarityScore)(norm1, norm2);
102
- const tokens1 = norm1.split(/[\s(){}[\];,]+/).filter(Boolean);
103
- const tokens2 = norm2.split(/[\s(){}[\];,]+/).filter(Boolean);
104
- const tokenSimilarity = (0, import_core.similarityScore)(tokens1.join(" "), tokens2.join(" "));
105
- return baseSimilarity * 0.4 + tokenSimilarity * 0.6;
99
+ function jaccardSimilarity(tokens1, tokens2) {
100
+ const set1 = new Set(tokens1);
101
+ const set2 = new Set(tokens2);
102
+ let intersection = 0;
103
+ for (const token of set1) {
104
+ if (set2.has(token)) intersection++;
105
+ }
106
+ const union = set1.size + set2.size - intersection;
107
+ return union === 0 ? 0 : intersection / union;
106
108
  }
107
- function detectDuplicatePatterns(files, options) {
108
- const { minSimilarity, minLines } = options;
109
+ async function detectDuplicatePatterns(files, options) {
110
+ const {
111
+ minSimilarity,
112
+ minLines,
113
+ batchSize = 100,
114
+ approx = true,
115
+ minSharedTokens = 8,
116
+ maxCandidatesPerBlock = 100,
117
+ streamResults = false
118
+ } = options;
109
119
  const duplicates = [];
120
+ const maxComparisons = approx ? Infinity : 5e5;
110
121
  const allBlocks = files.flatMap(
111
122
  (file) => extractCodeBlocks(file.content, minLines).map((block) => ({
112
- ...block,
123
+ content: block.content,
124
+ startLine: block.startLine,
125
+ endLine: block.endLine,
113
126
  file: file.file,
114
127
  normalized: normalizeCode(block.content),
115
- tokenCost: (0, import_core.estimateTokens)(block.content)
128
+ patternType: block.patternType,
129
+ tokenCost: (0, import_core.estimateTokens)(block.content),
130
+ linesOfCode: block.linesOfCode
116
131
  }))
117
132
  );
118
133
  console.log(`Extracted ${allBlocks.length} code blocks for analysis`);
134
+ if (!approx && allBlocks.length > 500) {
135
+ console.log(`\u26A0\uFE0F Using --no-approx mode with ${allBlocks.length} blocks may be slow (O(B\xB2) complexity).`);
136
+ console.log(` Consider using approximate mode (default) for better performance.`);
137
+ }
138
+ const stopwords = /* @__PURE__ */ new Set([
139
+ "return",
140
+ "const",
141
+ "let",
142
+ "var",
143
+ "function",
144
+ "class",
145
+ "new",
146
+ "if",
147
+ "else",
148
+ "for",
149
+ "while",
150
+ "async",
151
+ "await",
152
+ "try",
153
+ "catch",
154
+ "switch",
155
+ "case",
156
+ "default",
157
+ "import",
158
+ "export",
159
+ "from",
160
+ "true",
161
+ "false",
162
+ "null",
163
+ "undefined",
164
+ "this"
165
+ ]);
166
+ const tokenize = (norm) => norm.split(/[\s(){}\[\];,\.]+/).filter((t) => t && t.length >= 3 && !stopwords.has(t.toLowerCase()));
167
+ const blockTokens = allBlocks.map((b) => tokenize(b.normalized));
168
+ const invertedIndex = /* @__PURE__ */ new Map();
169
+ if (approx) {
170
+ for (let i = 0; i < blockTokens.length; i++) {
171
+ for (const tok of blockTokens[i]) {
172
+ let arr = invertedIndex.get(tok);
173
+ if (!arr) {
174
+ arr = [];
175
+ invertedIndex.set(tok, arr);
176
+ }
177
+ arr.push(i);
178
+ }
179
+ }
180
+ }
181
+ const totalComparisons = approx ? void 0 : allBlocks.length * (allBlocks.length - 1) / 2;
182
+ if (totalComparisons !== void 0) {
183
+ console.log(`Processing ${totalComparisons.toLocaleString()} comparisons in batches...`);
184
+ } else {
185
+ console.log(`Using approximate candidate selection to reduce comparisons...`);
186
+ }
187
+ let comparisonsProcessed = 0;
188
+ let comparisonsBudgetExhausted = false;
189
+ const startTime = Date.now();
119
190
  for (let i = 0; i < allBlocks.length; i++) {
120
- for (let j = i + 1; j < allBlocks.length; j++) {
121
- const block1 = allBlocks[i];
122
- const block2 = allBlocks[j];
123
- if (block1.file === block2.file) continue;
124
- const similarity = calculateSimilarity(block1.content, block2.content);
125
- if (similarity >= minSimilarity) {
126
- duplicates.push({
127
- file1: block1.file,
128
- file2: block2.file,
129
- line1: block1.startLine,
130
- line2: block2.startLine,
131
- similarity,
132
- snippet: block1.content.split("\n").slice(0, 5).join("\n") + "\n...",
133
- patternType: block1.patternType,
134
- tokenCost: block1.tokenCost + block2.tokenCost,
135
- linesOfCode: block1.linesOfCode
136
- });
191
+ if (maxComparisons && comparisonsProcessed >= maxComparisons) {
192
+ comparisonsBudgetExhausted = true;
193
+ break;
194
+ }
195
+ if (i % batchSize === 0 && i > 0) {
196
+ const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
197
+ const duplicatesFound = duplicates.length;
198
+ if (totalComparisons !== void 0) {
199
+ const progress = (comparisonsProcessed / totalComparisons * 100).toFixed(1);
200
+ const remaining = totalComparisons - comparisonsProcessed;
201
+ const rate = comparisonsProcessed / parseFloat(elapsed);
202
+ const eta = remaining > 0 ? (remaining / rate).toFixed(0) : 0;
203
+ console.log(` ${progress}% (${comparisonsProcessed.toLocaleString()}/${totalComparisons.toLocaleString()} comparisons, ${elapsed}s elapsed, ~${eta}s remaining, ${duplicatesFound} duplicates)`);
204
+ } else {
205
+ console.log(` Processed ${i.toLocaleString()}/${allBlocks.length} blocks (${elapsed}s elapsed, ${duplicatesFound} duplicates)`);
137
206
  }
207
+ await new Promise((resolve) => setImmediate(resolve));
138
208
  }
209
+ const block1 = allBlocks[i];
210
+ let candidates = null;
211
+ if (approx) {
212
+ const counts = /* @__PURE__ */ new Map();
213
+ for (const tok of blockTokens[i]) {
214
+ const ids = invertedIndex.get(tok);
215
+ if (!ids) continue;
216
+ for (const j of ids) {
217
+ if (j <= i) continue;
218
+ if (allBlocks[j].file === block1.file) continue;
219
+ counts.set(j, (counts.get(j) || 0) + 1);
220
+ }
221
+ }
222
+ candidates = Array.from(counts.entries()).filter(([, shared]) => shared >= minSharedTokens).sort((a, b) => b[1] - a[1]).slice(0, maxCandidatesPerBlock).map(([j, shared]) => ({ j, shared }));
223
+ }
224
+ if (approx && candidates) {
225
+ for (const { j } of candidates) {
226
+ if (!approx && maxComparisons !== Infinity && comparisonsProcessed >= maxComparisons) {
227
+ console.log(`\u26A0\uFE0F Comparison safety limit reached (${maxComparisons.toLocaleString()} comparisons in --no-approx mode).`);
228
+ console.log(` This prevents excessive runtime on large repos. Consider using approximate mode (default) or --min-lines to reduce blocks.`);
229
+ break;
230
+ }
231
+ comparisonsProcessed++;
232
+ const block2 = allBlocks[j];
233
+ const similarity = jaccardSimilarity(blockTokens[i], blockTokens[j]);
234
+ if (similarity >= minSimilarity) {
235
+ const duplicate = {
236
+ file1: block1.file,
237
+ file2: block2.file,
238
+ line1: block1.startLine,
239
+ line2: block2.startLine,
240
+ endLine1: block1.endLine,
241
+ endLine2: block2.endLine,
242
+ similarity,
243
+ snippet: block1.content.split("\n").slice(0, 5).join("\n") + "\n...",
244
+ patternType: block1.patternType,
245
+ tokenCost: block1.tokenCost + block2.tokenCost,
246
+ linesOfCode: block1.linesOfCode
247
+ };
248
+ duplicates.push(duplicate);
249
+ if (streamResults) {
250
+ console.log(`
251
+ \u2705 Found: ${duplicate.patternType} ${Math.round(similarity * 100)}% similar`);
252
+ console.log(` ${duplicate.file1}:${duplicate.line1}-${duplicate.endLine1} \u21D4 ${duplicate.file2}:${duplicate.line2}-${duplicate.endLine2}`);
253
+ console.log(` Token cost: ${duplicate.tokenCost.toLocaleString()}`);
254
+ }
255
+ }
256
+ }
257
+ } else {
258
+ for (let j = i + 1; j < allBlocks.length; j++) {
259
+ if (maxComparisons && comparisonsProcessed >= maxComparisons) break;
260
+ comparisonsProcessed++;
261
+ const block2 = allBlocks[j];
262
+ if (block1.file === block2.file) continue;
263
+ const similarity = jaccardSimilarity(blockTokens[i], blockTokens[j]);
264
+ if (similarity >= minSimilarity) {
265
+ const duplicate = {
266
+ file1: block1.file,
267
+ file2: block2.file,
268
+ line1: block1.startLine,
269
+ line2: block2.startLine,
270
+ endLine1: block1.endLine,
271
+ endLine2: block2.endLine,
272
+ similarity,
273
+ snippet: block1.content.split("\n").slice(0, 5).join("\n") + "\n...",
274
+ patternType: block1.patternType,
275
+ tokenCost: block1.tokenCost + block2.tokenCost,
276
+ linesOfCode: block1.linesOfCode
277
+ };
278
+ duplicates.push(duplicate);
279
+ if (streamResults) {
280
+ console.log(`
281
+ \u2705 Found: ${duplicate.patternType} ${Math.round(similarity * 100)}% similar`);
282
+ console.log(` ${duplicate.file1}:${duplicate.line1}-${duplicate.endLine1} \u21D4 ${duplicate.file2}:${duplicate.line2}-${duplicate.endLine2}`);
283
+ console.log(` Token cost: ${duplicate.tokenCost.toLocaleString()}`);
284
+ }
285
+ }
286
+ }
287
+ }
288
+ }
289
+ if (comparisonsBudgetExhausted) {
290
+ console.log(`\u26A0\uFE0F Comparison budget exhausted (${maxComparisons.toLocaleString()} comparisons). Use --max-comparisons to increase.`);
139
291
  }
140
292
  return duplicates.sort(
141
293
  (a, b) => b.similarity - a.similarity || b.tokenCost - a.tokenCost
@@ -157,7 +309,17 @@ function getRefactoringSuggestion(patternType, similarity) {
157
309
  return baseMessages[patternType] + urgency;
158
310
  }
159
311
  async function analyzePatterns(options) {
160
- const { minSimilarity = 0.85, minLines = 5, ...scanOptions } = options;
312
+ const {
313
+ minSimilarity = 0.4,
314
+ // Jaccard similarity default (40% threshold)
315
+ minLines = 5,
316
+ batchSize = 100,
317
+ approx = true,
318
+ minSharedTokens = 8,
319
+ maxCandidatesPerBlock = 100,
320
+ streamResults = false,
321
+ ...scanOptions
322
+ } = options;
161
323
  const files = await (0, import_core2.scanFiles)(scanOptions);
162
324
  const results = [];
163
325
  const fileContents = await Promise.all(
@@ -166,9 +328,14 @@ async function analyzePatterns(options) {
166
328
  content: await (0, import_core2.readFileContent)(file)
167
329
  }))
168
330
  );
169
- const duplicates = detectDuplicatePatterns(fileContents, {
331
+ const duplicates = await detectDuplicatePatterns(fileContents, {
170
332
  minSimilarity,
171
- minLines
333
+ minLines,
334
+ batchSize,
335
+ approx,
336
+ minSharedTokens,
337
+ maxCandidatesPerBlock,
338
+ streamResults
172
339
  });
173
340
  for (const file of files) {
174
341
  const fileDuplicates = duplicates.filter(
@@ -231,8 +398,21 @@ function generateSummary(results) {
231
398
  const typeMatch = issue.message.match(/^(\S+(?:-\S+)*) pattern/);
232
399
  const fileMatch = issue.message.match(/similar to (.+?) \(/);
233
400
  return {
234
- file1: issue.location.file,
235
- file2: fileMatch?.[1] || "unknown",
401
+ files: [
402
+ {
403
+ path: issue.location.file,
404
+ startLine: issue.location.line,
405
+ endLine: 0
406
+ // Not available from Issue
407
+ },
408
+ {
409
+ path: fileMatch?.[1] || "unknown",
410
+ startLine: 0,
411
+ // Not available from Issue
412
+ endLine: 0
413
+ // Not available from Issue
414
+ }
415
+ ],
236
416
  similarity: similarityMatch ? parseInt(similarityMatch[1]) / 100 : 0,
237
417
  patternType: typeMatch?.[1] || "unknown",
238
418
  tokenCost: tokenMatch ? parseInt(tokenMatch[1]) : 0
package/dist/index.mjs CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  analyzePatterns,
3
3
  detectDuplicatePatterns,
4
4
  generateSummary
5
- } from "./chunk-RLWJXASG.mjs";
5
+ } from "./chunk-JKVKOXYR.mjs";
6
6
  export {
7
7
  analyzePatterns,
8
8
  detectDuplicatePatterns,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiready/pattern-detect",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Semantic duplicate pattern detection for AI-generated code - finds similar implementations that waste AI context tokens",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -15,6 +15,13 @@
15
15
  "import": "./dist/index.mjs"
16
16
  }
17
17
  },
18
+ "scripts": {
19
+ "build": "tsup src/index.ts src/cli.ts --format cjs,esm --dts",
20
+ "dev": "tsup src/index.ts src/cli.ts --format cjs,esm --dts --watch",
21
+ "test": "vitest run",
22
+ "lint": "eslint src",
23
+ "clean": "rm -rf dist"
24
+ },
18
25
  "keywords": [
19
26
  "aiready",
20
27
  "duplicate-detection",
@@ -43,9 +50,9 @@
43
50
  "url": "https://github.com/caopengau/aiready-pattern-detect/issues"
44
51
  },
45
52
  "dependencies": {
53
+ "@aiready/core": "workspace:*",
46
54
  "commander": "^12.1.0",
47
- "chalk": "^5.3.0",
48
- "@aiready/core": "0.1.2"
55
+ "chalk": "^5.3.0"
49
56
  },
50
57
  "devDependencies": {
51
58
  "tsup": "^8.3.5",
@@ -62,12 +69,5 @@
62
69
  },
63
70
  "publishConfig": {
64
71
  "access": "public"
65
- },
66
- "scripts": {
67
- "build": "tsup src/index.ts src/cli.ts --format cjs,esm --dts",
68
- "dev": "tsup src/index.ts src/cli.ts --format cjs,esm --dts --watch",
69
- "test": "vitest run",
70
- "lint": "eslint src",
71
- "clean": "rm -rf dist"
72
72
  }
73
- }
73
+ }
@@ -1,114 +0,0 @@
1
- // src/index.ts
2
- import { scanFiles, readFileContent } from "@aiready/core";
3
-
4
- // src/detector.ts
5
- import { similarityScore } from "@aiready/core";
6
- function extractCodeBlocks(content, minLines) {
7
- const lines = content.split("\n");
8
- const blocks = [];
9
- let currentBlock = [];
10
- let blockStart = 0;
11
- let braceDepth = 0;
12
- for (let i = 0; i < lines.length; i++) {
13
- const line = lines[i];
14
- for (const char of line) {
15
- if (char === "{") braceDepth++;
16
- if (char === "}") braceDepth--;
17
- }
18
- currentBlock.push(line);
19
- if (braceDepth === 0 && currentBlock.length >= minLines) {
20
- blocks.push({
21
- content: currentBlock.join("\n"),
22
- startLine: blockStart + 1
23
- });
24
- currentBlock = [];
25
- blockStart = i + 1;
26
- } else if (braceDepth === 0) {
27
- currentBlock = [];
28
- blockStart = i + 1;
29
- }
30
- }
31
- return blocks;
32
- }
33
- function normalizeCode(code) {
34
- return code.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\s+/g, " ").trim();
35
- }
36
- function detectDuplicatePatterns(files, options) {
37
- const { minSimilarity, minLines } = options;
38
- const duplicates = [];
39
- const allBlocks = files.flatMap(
40
- (file) => extractCodeBlocks(file.content, minLines).map((block) => ({
41
- ...block,
42
- file: file.file,
43
- normalized: normalizeCode(block.content)
44
- }))
45
- );
46
- for (let i = 0; i < allBlocks.length; i++) {
47
- for (let j = i + 1; j < allBlocks.length; j++) {
48
- const block1 = allBlocks[i];
49
- const block2 = allBlocks[j];
50
- if (block1.file === block2.file) continue;
51
- const similarity = similarityScore(block1.normalized, block2.normalized);
52
- if (similarity >= minSimilarity) {
53
- duplicates.push({
54
- file1: block1.file,
55
- file2: block2.file,
56
- line1: block1.startLine,
57
- line2: block2.startLine,
58
- similarity,
59
- snippet: block1.content.split("\n").slice(0, 3).join("\n") + "..."
60
- });
61
- }
62
- }
63
- }
64
- return duplicates.sort((a, b) => b.similarity - a.similarity);
65
- }
66
-
67
- // src/index.ts
68
- async function analyzePatterns(options) {
69
- const {
70
- minSimilarity = 0.85,
71
- minLines = 5,
72
- ...scanOptions
73
- } = options;
74
- const files = await scanFiles(scanOptions);
75
- const results = [];
76
- const fileContents = await Promise.all(
77
- files.map(async (file) => ({
78
- file,
79
- content: await readFileContent(file)
80
- }))
81
- );
82
- const duplicates = detectDuplicatePatterns(fileContents, {
83
- minSimilarity,
84
- minLines
85
- });
86
- for (const file of files) {
87
- const fileDuplicates = duplicates.filter(
88
- (dup) => dup.file1 === file || dup.file2 === file
89
- );
90
- const issues = fileDuplicates.map((dup) => ({
91
- type: "duplicate-pattern",
92
- severity: dup.similarity > 0.95 ? "critical" : "major",
93
- message: `Similar pattern found in ${dup.file1 === file ? dup.file2 : dup.file1}`,
94
- location: {
95
- file,
96
- line: dup.file1 === file ? dup.line1 : dup.line2
97
- },
98
- suggestion: "Consider extracting common logic into a shared utility"
99
- }));
100
- results.push({
101
- fileName: file,
102
- issues,
103
- metrics: {
104
- consistencyScore: 1 - fileDuplicates.length * 0.1
105
- }
106
- });
107
- }
108
- return results;
109
- }
110
-
111
- export {
112
- detectDuplicatePatterns,
113
- analyzePatterns
114
- };