uilint-semantic 0.2.138 → 0.2.139
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/chunk-PXUZIL7H.js +1 -0
- package/dist/chunk-PXUZIL7H.js.map +1 -0
- package/dist/chunk-QDQHFV7C.js +605 -0
- package/dist/chunk-QDQHFV7C.js.map +1 -0
- package/dist/chunk-RK3ZBOS3.js +401 -0
- package/dist/chunk-RK3ZBOS3.js.map +1 -0
- package/dist/eslint-rules/index.d.ts +4 -0
- package/dist/eslint-rules/index.js +16 -0
- package/dist/eslint-rules/index.js.map +1 -0
- package/dist/eslint-rules/no-semantic-duplicates.d.ts +46 -0
- package/dist/eslint-rules/no-semantic-duplicates.js +11 -0
- package/dist/eslint-rules/no-semantic-duplicates.js.map +1 -0
- package/dist/eslint-rules/register.d.ts +2 -0
- package/dist/eslint-rules/register.js +24 -0
- package/dist/eslint-rules/register.js.map +1 -0
- package/dist/eslint-rules/semantic/index.d.ts +25 -0
- package/dist/eslint-rules/semantic/index.js +9 -0
- package/dist/eslint-rules/semantic/index.js.map +1 -0
- package/package.json +19 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
//# sourceMappingURL=chunk-PXUZIL7H.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
// src/eslint-rules/no-semantic-duplicates.ts
|
|
2
|
+
import { createRule, defineRuleMeta } from "uilint-eslint";
|
|
3
|
+
import { existsSync, readFileSync, appendFileSync, writeFileSync } from "fs";
|
|
4
|
+
import { dirname, join, relative } from "path";
|
|
5
|
+
var logFile = null;
|
|
6
|
+
var logInitialized = false;
|
|
7
|
+
function initLog(projectRoot) {
|
|
8
|
+
if (logFile) return;
|
|
9
|
+
const uilintDir = join(projectRoot, ".uilint");
|
|
10
|
+
if (existsSync(uilintDir)) {
|
|
11
|
+
logFile = join(uilintDir, "no-semantic-duplicates.log");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function log(message) {
|
|
15
|
+
if (!logFile) return;
|
|
16
|
+
try {
|
|
17
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
18
|
+
const line = `[${timestamp}] ${message}
|
|
19
|
+
`;
|
|
20
|
+
if (!logInitialized) {
|
|
21
|
+
writeFileSync(logFile, line);
|
|
22
|
+
logInitialized = true;
|
|
23
|
+
} else {
|
|
24
|
+
appendFileSync(logFile, line);
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
var meta = defineRuleMeta({
|
|
30
|
+
id: "no-semantic-duplicates",
|
|
31
|
+
version: "1.0.0",
|
|
32
|
+
name: "No Semantic Duplicates",
|
|
33
|
+
description: "Warn when code is semantically similar to existing code",
|
|
34
|
+
defaultSeverity: "warn",
|
|
35
|
+
category: "semantic",
|
|
36
|
+
icon: "\u{1F50D}",
|
|
37
|
+
hint: "Finds similar code via embeddings",
|
|
38
|
+
defaultEnabled: false,
|
|
39
|
+
plugin: "semantic",
|
|
40
|
+
eslintImport: "uilint-semantic/eslint-rules/no-semantic-duplicates",
|
|
41
|
+
customInspector: "duplicates",
|
|
42
|
+
requirements: [
|
|
43
|
+
{
|
|
44
|
+
type: "semantic-index",
|
|
45
|
+
description: "Requires semantic index for duplicate detection",
|
|
46
|
+
setupHint: "Run: uilint duplicates index"
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
postInstallInstructions: "Run 'uilint duplicates index' to build the semantic index before using this rule.",
|
|
50
|
+
defaultOptions: [{
|
|
51
|
+
threshold: 0.75,
|
|
52
|
+
indexPath: ".uilint/.duplicates-index",
|
|
53
|
+
minLines: 3,
|
|
54
|
+
confidenceLevel: "low",
|
|
55
|
+
useStructuralBoost: true,
|
|
56
|
+
includeSameFile: false,
|
|
57
|
+
kind: "all"
|
|
58
|
+
}],
|
|
59
|
+
optionSchema: {
|
|
60
|
+
fields: [
|
|
61
|
+
{
|
|
62
|
+
key: "threshold",
|
|
63
|
+
label: "Similarity threshold",
|
|
64
|
+
type: "number",
|
|
65
|
+
defaultValue: 0.75,
|
|
66
|
+
description: "Minimum similarity score (0-1) to report as duplicate. Lower values catch more potential duplicates. Recommended: 0.75 (default), 0.85 (strict), 0.65 (lenient)."
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
key: "confidenceLevel",
|
|
70
|
+
label: "Minimum confidence",
|
|
71
|
+
type: "select",
|
|
72
|
+
defaultValue: "low",
|
|
73
|
+
options: [
|
|
74
|
+
{ value: "high", label: "High (\u226590%) - Likely copy-paste" },
|
|
75
|
+
{ value: "medium", label: "Medium (\u226575%) - Semantically similar" },
|
|
76
|
+
{ value: "low", label: "Low (\u226560%) - Possibly related" }
|
|
77
|
+
],
|
|
78
|
+
description: "Only report duplicates at or above this confidence level. High = fewer but more certain matches."
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
key: "useStructuralBoost",
|
|
82
|
+
label: "Use structural similarity",
|
|
83
|
+
type: "boolean",
|
|
84
|
+
defaultValue: true,
|
|
85
|
+
description: "Boost similarity scores based on structural overlap (props, JSX elements, hooks). Helps catch duplicates with different variable names."
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
key: "kind",
|
|
89
|
+
label: "Code kind filter",
|
|
90
|
+
type: "select",
|
|
91
|
+
defaultValue: "all",
|
|
92
|
+
options: [
|
|
93
|
+
{ value: "all", label: "All code" },
|
|
94
|
+
{ value: "component", label: "Components only" },
|
|
95
|
+
{ value: "hook", label: "Hooks only" },
|
|
96
|
+
{ value: "function", label: "Functions only" }
|
|
97
|
+
],
|
|
98
|
+
description: "Only detect duplicates of a specific code type."
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
key: "includeSameFile",
|
|
102
|
+
label: "Include same-file duplicates",
|
|
103
|
+
type: "boolean",
|
|
104
|
+
defaultValue: false,
|
|
105
|
+
description: "Report duplicates within the same file (e.g., Card and CardAlt in cards.tsx)."
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
key: "indexPath",
|
|
109
|
+
label: "Index path",
|
|
110
|
+
type: "text",
|
|
111
|
+
defaultValue: ".uilint/.duplicates-index",
|
|
112
|
+
description: "Path to the semantic duplicates index directory."
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
key: "minLines",
|
|
116
|
+
label: "Minimum lines",
|
|
117
|
+
type: "number",
|
|
118
|
+
defaultValue: 3,
|
|
119
|
+
description: "Minimum number of lines for a chunk to be reported as a potential duplicate."
|
|
120
|
+
}
|
|
121
|
+
]
|
|
122
|
+
},
|
|
123
|
+
docs: `
|
|
124
|
+
## What it does
|
|
125
|
+
|
|
126
|
+
Warns when code (components, hooks, functions) is semantically similar to other
|
|
127
|
+
code in the codebase. Unlike syntactic duplicate detection, this finds code that
|
|
128
|
+
implements similar functionality even if written differently.
|
|
129
|
+
|
|
130
|
+
## Prerequisites
|
|
131
|
+
|
|
132
|
+
Before using this rule, you must build the semantic index:
|
|
133
|
+
|
|
134
|
+
\`\`\`bash
|
|
135
|
+
uilint duplicates index
|
|
136
|
+
\`\`\`
|
|
137
|
+
|
|
138
|
+
This creates an embedding-based index at \`.uilint/.duplicates-index/\`.
|
|
139
|
+
|
|
140
|
+
## Why it's useful
|
|
141
|
+
|
|
142
|
+
- **Reduce Duplication**: Find components/hooks that could be consolidated
|
|
143
|
+
- **Discover Patterns**: Identify similar code that could be abstracted
|
|
144
|
+
- **Code Quality**: Encourage reuse over reimplementation
|
|
145
|
+
- **Fast**: Queries pre-built index, no LLM calls during linting
|
|
146
|
+
|
|
147
|
+
## How it works
|
|
148
|
+
|
|
149
|
+
1. The rule checks if the current file is in the semantic index
|
|
150
|
+
2. For each indexed code chunk, it looks up similar chunks
|
|
151
|
+
3. If similar chunks exist above the threshold, it reports a warning
|
|
152
|
+
|
|
153
|
+
## Examples
|
|
154
|
+
|
|
155
|
+
### Semantic duplicates detected:
|
|
156
|
+
|
|
157
|
+
\`\`\`tsx
|
|
158
|
+
// UserCard.tsx - Original component
|
|
159
|
+
export function UserCard({ user }) {
|
|
160
|
+
return (
|
|
161
|
+
<div className="card">
|
|
162
|
+
<img src={user.avatar} />
|
|
163
|
+
<h3>{user.name}</h3>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ProfileCard.tsx - Semantically similar (warning!)
|
|
169
|
+
export function ProfileCard({ profile }) {
|
|
170
|
+
return (
|
|
171
|
+
<article className="profile">
|
|
172
|
+
<img src={profile.avatarUrl} />
|
|
173
|
+
<h2>{profile.displayName}</h2>
|
|
174
|
+
</article>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
\`\`\`
|
|
178
|
+
|
|
179
|
+
## Configuration
|
|
180
|
+
|
|
181
|
+
\`\`\`js
|
|
182
|
+
// eslint.config.js
|
|
183
|
+
"uilint/no-semantic-duplicates": ["warn", {
|
|
184
|
+
// Core detection settings
|
|
185
|
+
threshold: 0.75, // Similarity threshold (0-1). Lower = more matches.
|
|
186
|
+
confidenceLevel: "medium", // "high" (\u226590%), "medium" (\u226575%), "low" (\u226560%)
|
|
187
|
+
|
|
188
|
+
// Detection enhancements
|
|
189
|
+
useStructuralBoost: true, // Boost scores based on props/JSX/hooks overlap
|
|
190
|
+
|
|
191
|
+
// Filtering
|
|
192
|
+
kind: "all", // "all", "component", "hook", "function"
|
|
193
|
+
includeSameFile: false, // Include duplicates within the same file
|
|
194
|
+
minLines: 3, // Minimum lines to report
|
|
195
|
+
|
|
196
|
+
// Index location
|
|
197
|
+
indexPath: ".uilint/.duplicates-index"
|
|
198
|
+
}]
|
|
199
|
+
\`\`\`
|
|
200
|
+
|
|
201
|
+
### Preset configurations
|
|
202
|
+
|
|
203
|
+
**Strict** - High-confidence duplicates only:
|
|
204
|
+
\`\`\`js
|
|
205
|
+
{ threshold: 0.85, confidenceLevel: "high" }
|
|
206
|
+
\`\`\`
|
|
207
|
+
|
|
208
|
+
**Normal** (default) - Balanced detection:
|
|
209
|
+
\`\`\`js
|
|
210
|
+
{ threshold: 0.75, confidenceLevel: "low" }
|
|
211
|
+
\`\`\`
|
|
212
|
+
|
|
213
|
+
**Lenient** - Catch more potential duplicates:
|
|
214
|
+
\`\`\`js
|
|
215
|
+
{ threshold: 0.65, confidenceLevel: "low" }
|
|
216
|
+
\`\`\`
|
|
217
|
+
|
|
218
|
+
## Confidence Levels
|
|
219
|
+
|
|
220
|
+
The rule assigns confidence levels based on similarity scores:
|
|
221
|
+
|
|
222
|
+
- \u{1F534} **High (\u226590%)** - Likely copy-paste or near-identical code. Strongly recommend consolidation.
|
|
223
|
+
- \u{1F7E1} **Medium (75-89%)** - Semantically similar. Review for potential abstraction.
|
|
224
|
+
- \u{1F7E2} **Low (60-74%)** - Possibly related patterns. Optional review.
|
|
225
|
+
|
|
226
|
+
## Notes
|
|
227
|
+
|
|
228
|
+
- Run \`uilint duplicates index\` after significant code changes
|
|
229
|
+
- Use \`uilint duplicates find\` to explore all duplicate groups
|
|
230
|
+
- The rule only reports if the file is in the index
|
|
231
|
+
- Structural boost helps detect duplicates with different variable names (e.g., Badge vs Tag)
|
|
232
|
+
`
|
|
233
|
+
});
|
|
234
|
+
var indexCache = null;
|
|
235
|
+
function clearIndexCache() {
|
|
236
|
+
indexCache = null;
|
|
237
|
+
}
|
|
238
|
+
function findProjectRoot(startPath, indexPath) {
|
|
239
|
+
let current = startPath;
|
|
240
|
+
let lastPackageJson = null;
|
|
241
|
+
while (current !== dirname(current)) {
|
|
242
|
+
const uilintDir = join(current, indexPath);
|
|
243
|
+
if (existsSync(join(uilintDir, "manifest.json"))) {
|
|
244
|
+
return current;
|
|
245
|
+
}
|
|
246
|
+
if (existsSync(join(current, "package.json"))) {
|
|
247
|
+
lastPackageJson = current;
|
|
248
|
+
}
|
|
249
|
+
current = dirname(current);
|
|
250
|
+
}
|
|
251
|
+
return lastPackageJson || startPath;
|
|
252
|
+
}
|
|
253
|
+
function loadIndex(projectRoot, indexPath) {
|
|
254
|
+
const fullIndexPath = join(projectRoot, indexPath);
|
|
255
|
+
log(`loadIndex called: projectRoot=${projectRoot}, indexPath=${indexPath}`);
|
|
256
|
+
log(`fullIndexPath=${fullIndexPath}`);
|
|
257
|
+
if (indexCache && indexCache.projectRoot === projectRoot) {
|
|
258
|
+
log(`Using cached index (${indexCache.vectorStore.size} vectors, ${indexCache.fileToChunks.size} files)`);
|
|
259
|
+
return indexCache;
|
|
260
|
+
}
|
|
261
|
+
const manifestPath = join(fullIndexPath, "manifest.json");
|
|
262
|
+
if (!existsSync(manifestPath)) {
|
|
263
|
+
log(`Index not found: manifest.json missing at ${manifestPath}`);
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
const metadataPath = join(fullIndexPath, "metadata.json");
|
|
268
|
+
if (!existsSync(metadataPath)) {
|
|
269
|
+
log(`Index not found: metadata.json missing at ${metadataPath}`);
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
const metadataContent = readFileSync(metadataPath, "utf-8");
|
|
273
|
+
const metadataJson = JSON.parse(metadataContent);
|
|
274
|
+
const entries = metadataJson.entries || metadataJson;
|
|
275
|
+
log(`Loaded metadata.json: ${Object.keys(entries).length} entries`);
|
|
276
|
+
const metadataStore = /* @__PURE__ */ new Map();
|
|
277
|
+
const fileToChunks = /* @__PURE__ */ new Map();
|
|
278
|
+
for (const [id, meta2] of Object.entries(entries)) {
|
|
279
|
+
const m = meta2;
|
|
280
|
+
metadataStore.set(id, {
|
|
281
|
+
filePath: m.filePath,
|
|
282
|
+
startLine: m.startLine,
|
|
283
|
+
endLine: m.endLine,
|
|
284
|
+
startColumn: m.startColumn ?? 0,
|
|
285
|
+
endColumn: m.endColumn ?? 0,
|
|
286
|
+
name: m.name,
|
|
287
|
+
kind: m.kind
|
|
288
|
+
});
|
|
289
|
+
const chunks = fileToChunks.get(m.filePath) || [];
|
|
290
|
+
chunks.push(id);
|
|
291
|
+
fileToChunks.set(m.filePath, chunks);
|
|
292
|
+
}
|
|
293
|
+
log(`File to chunks mapping:`);
|
|
294
|
+
for (const [filePath, chunks] of fileToChunks.entries()) {
|
|
295
|
+
log(` ${filePath}: ${chunks.length} chunks (${chunks.join(", ")})`);
|
|
296
|
+
}
|
|
297
|
+
const vectorsPath = join(fullIndexPath, "embeddings.bin");
|
|
298
|
+
const idsPath = join(fullIndexPath, "ids.json");
|
|
299
|
+
const vectorStore = /* @__PURE__ */ new Map();
|
|
300
|
+
if (existsSync(vectorsPath) && existsSync(idsPath)) {
|
|
301
|
+
const idsContent = readFileSync(idsPath, "utf-8");
|
|
302
|
+
const ids = JSON.parse(idsContent);
|
|
303
|
+
log(`Loaded ids.json: ${ids.length} IDs`);
|
|
304
|
+
const buffer = readFileSync(vectorsPath);
|
|
305
|
+
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
306
|
+
const dimension = view.getUint32(0, true);
|
|
307
|
+
const count = view.getUint32(4, true);
|
|
308
|
+
log(`Embeddings binary: dimension=${dimension}, count=${count}`);
|
|
309
|
+
let offset = 8;
|
|
310
|
+
for (let i = 0; i < count && i < ids.length; i++) {
|
|
311
|
+
const vector = [];
|
|
312
|
+
for (let j = 0; j < dimension; j++) {
|
|
313
|
+
vector.push(view.getFloat32(offset, true));
|
|
314
|
+
offset += 4;
|
|
315
|
+
}
|
|
316
|
+
vectorStore.set(ids[i], vector);
|
|
317
|
+
}
|
|
318
|
+
log(`Loaded ${vectorStore.size} vectors into store`);
|
|
319
|
+
} else {
|
|
320
|
+
log(`Missing vectors or ids files: vectorsPath=${existsSync(vectorsPath)}, idsPath=${existsSync(idsPath)}`);
|
|
321
|
+
}
|
|
322
|
+
indexCache = {
|
|
323
|
+
projectRoot,
|
|
324
|
+
vectorStore,
|
|
325
|
+
metadataStore,
|
|
326
|
+
fileToChunks
|
|
327
|
+
};
|
|
328
|
+
log(`Index loaded successfully: ${vectorStore.size} vectors, ${metadataStore.size} metadata entries, ${fileToChunks.size} files`);
|
|
329
|
+
return indexCache;
|
|
330
|
+
} catch (err) {
|
|
331
|
+
log(`Error loading index: ${err}`);
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
function cosineSimilarity(a, b) {
|
|
336
|
+
if (a.length !== b.length) return 0;
|
|
337
|
+
let dotProduct = 0;
|
|
338
|
+
let normA = 0;
|
|
339
|
+
let normB = 0;
|
|
340
|
+
for (let i = 0; i < a.length; i++) {
|
|
341
|
+
dotProduct += a[i] * b[i];
|
|
342
|
+
normA += a[i] * a[i];
|
|
343
|
+
normB += b[i] * b[i];
|
|
344
|
+
}
|
|
345
|
+
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
|
|
346
|
+
return denominator === 0 ? 0 : dotProduct / denominator;
|
|
347
|
+
}
|
|
348
|
+
function extractCodeFromFile(filePath, startLine, endLine) {
|
|
349
|
+
try {
|
|
350
|
+
if (!existsSync(filePath)) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
const content = readFileSync(filePath, "utf-8");
|
|
354
|
+
const lines = content.split("\n");
|
|
355
|
+
const start = Math.max(0, startLine - 1);
|
|
356
|
+
const end = Math.min(lines.length, endLine);
|
|
357
|
+
return lines.slice(start, end).join("\n");
|
|
358
|
+
} catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function findSimilarChunks(index, chunkId, threshold) {
|
|
363
|
+
log(`findSimilarChunks: chunkId=${chunkId}, threshold=${threshold}`);
|
|
364
|
+
const vector = index.vectorStore.get(chunkId);
|
|
365
|
+
if (!vector) {
|
|
366
|
+
log(` No vector found for chunk ${chunkId}`);
|
|
367
|
+
return [];
|
|
368
|
+
}
|
|
369
|
+
log(` Vector found: dimension=${vector.length}`);
|
|
370
|
+
const results = [];
|
|
371
|
+
const allScores = [];
|
|
372
|
+
for (const [id, vec] of index.vectorStore.entries()) {
|
|
373
|
+
if (id === chunkId) continue;
|
|
374
|
+
const score = cosineSimilarity(vector, vec);
|
|
375
|
+
allScores.push({ id, score });
|
|
376
|
+
if (score >= threshold) {
|
|
377
|
+
results.push({ id, score });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const sortedAll = allScores.sort((a, b) => b.score - a.score).slice(0, 10);
|
|
381
|
+
log(` Top 10 similarity scores (threshold=${threshold}):`);
|
|
382
|
+
for (const { id, score } of sortedAll) {
|
|
383
|
+
const meta2 = index.metadataStore.get(id);
|
|
384
|
+
const meetsThreshold = score >= threshold ? "\u2713" : "\u2717";
|
|
385
|
+
log(` ${meetsThreshold} ${(score * 100).toFixed(1)}% - ${id} (${meta2?.name || "anonymous"} in ${meta2?.filePath})`);
|
|
386
|
+
}
|
|
387
|
+
log(` Found ${results.length} chunks above threshold`);
|
|
388
|
+
return results.sort((a, b) => b.score - a.score);
|
|
389
|
+
}
|
|
390
|
+
var no_semantic_duplicates_default = createRule({
|
|
391
|
+
name: "no-semantic-duplicates",
|
|
392
|
+
meta: {
|
|
393
|
+
type: "suggestion",
|
|
394
|
+
docs: {
|
|
395
|
+
description: "Warn when code is semantically similar to existing code"
|
|
396
|
+
},
|
|
397
|
+
messages: {
|
|
398
|
+
semanticDuplicate: "This {{kind}} '{{name}}' is {{similarity}}% similar to '{{otherName}}' at {{otherLocation}}. Consider consolidating.",
|
|
399
|
+
noIndex: "Semantic duplicates index not found. Run 'uilint duplicates index' first."
|
|
400
|
+
},
|
|
401
|
+
schema: [
|
|
402
|
+
{
|
|
403
|
+
type: "object",
|
|
404
|
+
properties: {
|
|
405
|
+
threshold: {
|
|
406
|
+
type: "number",
|
|
407
|
+
minimum: 0,
|
|
408
|
+
maximum: 1,
|
|
409
|
+
description: "Similarity threshold (0-1)"
|
|
410
|
+
},
|
|
411
|
+
indexPath: {
|
|
412
|
+
type: "string",
|
|
413
|
+
description: "Path to the index directory"
|
|
414
|
+
},
|
|
415
|
+
minLines: {
|
|
416
|
+
type: "integer",
|
|
417
|
+
minimum: 1,
|
|
418
|
+
description: "Minimum number of lines for a chunk to be reported"
|
|
419
|
+
},
|
|
420
|
+
confidenceLevel: {
|
|
421
|
+
type: "string",
|
|
422
|
+
enum: ["high", "medium", "low"],
|
|
423
|
+
description: "Minimum confidence level to report"
|
|
424
|
+
},
|
|
425
|
+
useStructuralBoost: {
|
|
426
|
+
type: "boolean",
|
|
427
|
+
description: "Use structural similarity boost (props, JSX, hooks overlap)"
|
|
428
|
+
},
|
|
429
|
+
includeSameFile: {
|
|
430
|
+
type: "boolean",
|
|
431
|
+
description: "Include duplicates within the same file"
|
|
432
|
+
},
|
|
433
|
+
kind: {
|
|
434
|
+
type: "string",
|
|
435
|
+
enum: ["component", "hook", "function", "all"],
|
|
436
|
+
description: "Filter by code kind"
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
additionalProperties: false
|
|
440
|
+
}
|
|
441
|
+
]
|
|
442
|
+
},
|
|
443
|
+
defaultOptions: [
|
|
444
|
+
{
|
|
445
|
+
threshold: 0.75,
|
|
446
|
+
indexPath: ".uilint/.duplicates-index",
|
|
447
|
+
minLines: 3,
|
|
448
|
+
confidenceLevel: "low",
|
|
449
|
+
useStructuralBoost: true,
|
|
450
|
+
includeSameFile: false,
|
|
451
|
+
kind: "all"
|
|
452
|
+
}
|
|
453
|
+
],
|
|
454
|
+
create(context) {
|
|
455
|
+
const options = context.options[0] || {};
|
|
456
|
+
const threshold = options.threshold ?? 0.85;
|
|
457
|
+
const indexPath = options.indexPath ?? ".uilint/.duplicates-index";
|
|
458
|
+
const minLines = options.minLines ?? 3;
|
|
459
|
+
const filename = context.filename || context.getFilename();
|
|
460
|
+
const projectRoot = findProjectRoot(dirname(filename), indexPath);
|
|
461
|
+
const relativeFilename = relative(projectRoot, filename);
|
|
462
|
+
initLog(projectRoot);
|
|
463
|
+
log(`
|
|
464
|
+
========== Rule create() ==========`);
|
|
465
|
+
log(`Filename: ${filename}`);
|
|
466
|
+
log(`Relative filename: ${relativeFilename}`);
|
|
467
|
+
log(`Threshold: ${threshold}`);
|
|
468
|
+
log(`Index path: ${indexPath}`);
|
|
469
|
+
log(`Min lines: ${minLines}`);
|
|
470
|
+
log(`Project root: ${projectRoot}`);
|
|
471
|
+
const index = loadIndex(projectRoot, indexPath);
|
|
472
|
+
const reportedChunks = /* @__PURE__ */ new Set();
|
|
473
|
+
function checkForDuplicates(node, name) {
|
|
474
|
+
log(`checkForDuplicates: name=${name}, file=${relativeFilename}`);
|
|
475
|
+
if (!index) {
|
|
476
|
+
log(` No index loaded`);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const fileChunks = index.fileToChunks.get(relativeFilename);
|
|
480
|
+
log(` Looking for chunks for file: ${relativeFilename}`);
|
|
481
|
+
log(` Files in index: ${Array.from(index.fileToChunks.keys()).join(", ")}`);
|
|
482
|
+
if (!fileChunks || fileChunks.length === 0) {
|
|
483
|
+
log(` No chunks found for this file`);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
log(` Found ${fileChunks.length} chunks: ${fileChunks.join(", ")}`);
|
|
487
|
+
const nodeLine = node.loc?.start.line;
|
|
488
|
+
if (!nodeLine) {
|
|
489
|
+
log(` No node line number`);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
log(` Node starts at line ${nodeLine}`);
|
|
493
|
+
for (const chunkId of fileChunks) {
|
|
494
|
+
if (reportedChunks.has(chunkId)) {
|
|
495
|
+
log(` Chunk ${chunkId} already reported, skipping`);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const meta2 = index.metadataStore.get(chunkId);
|
|
499
|
+
if (!meta2) {
|
|
500
|
+
log(` No metadata for chunk ${chunkId}`);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
log(` Checking chunk ${chunkId}: lines ${meta2.startLine}-${meta2.endLine} (node at line ${nodeLine})`);
|
|
504
|
+
if (nodeLine >= meta2.startLine && nodeLine <= meta2.endLine) {
|
|
505
|
+
log(` Node is within chunk range, searching for similar chunks...`);
|
|
506
|
+
const similar = findSimilarChunks(index, chunkId, threshold);
|
|
507
|
+
if (similar.length > 0) {
|
|
508
|
+
const best = similar[0];
|
|
509
|
+
const bestMeta = index.metadataStore.get(best.id);
|
|
510
|
+
if (bestMeta) {
|
|
511
|
+
const chunkLines = meta2.endLine - meta2.startLine + 1;
|
|
512
|
+
if (chunkLines < minLines) {
|
|
513
|
+
log(` Skipping: chunk has ${chunkLines} lines, below minLines=${minLines}`);
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
reportedChunks.add(chunkId);
|
|
517
|
+
const relPath = bestMeta.filePath;
|
|
518
|
+
const similarity = Math.round(best.score * 100);
|
|
519
|
+
const sourceCode = extractCodeFromFile(
|
|
520
|
+
filename,
|
|
521
|
+
meta2.startLine,
|
|
522
|
+
meta2.endLine
|
|
523
|
+
);
|
|
524
|
+
const targetAbsolutePath = join(projectRoot, bestMeta.filePath);
|
|
525
|
+
const targetCode = extractCodeFromFile(
|
|
526
|
+
targetAbsolutePath,
|
|
527
|
+
bestMeta.startLine,
|
|
528
|
+
bestMeta.endLine
|
|
529
|
+
);
|
|
530
|
+
log(` REPORTING: ${meta2.kind} '${name || meta2.name}' is ${similarity}% similar to '${bestMeta.name}' at ${relPath}:${bestMeta.startLine}`);
|
|
531
|
+
context.report({
|
|
532
|
+
node,
|
|
533
|
+
loc: {
|
|
534
|
+
start: { line: meta2.startLine, column: meta2.startColumn },
|
|
535
|
+
end: { line: meta2.endLine, column: meta2.endColumn }
|
|
536
|
+
},
|
|
537
|
+
messageId: "semanticDuplicate",
|
|
538
|
+
data: {
|
|
539
|
+
kind: meta2.kind,
|
|
540
|
+
name: name || meta2.name || "(anonymous)",
|
|
541
|
+
similarity: String(similarity),
|
|
542
|
+
otherName: bestMeta.name || "(anonymous)",
|
|
543
|
+
otherLocation: `${relPath}:${bestMeta.startLine}`,
|
|
544
|
+
// Extended data for inspector panel (use relative paths for portability)
|
|
545
|
+
sourceCode: sourceCode || "",
|
|
546
|
+
targetCode: targetCode || "",
|
|
547
|
+
sourceLocation: JSON.stringify({
|
|
548
|
+
filePath: relativeFilename,
|
|
549
|
+
startLine: meta2.startLine,
|
|
550
|
+
endLine: meta2.endLine,
|
|
551
|
+
startColumn: meta2.startColumn,
|
|
552
|
+
endColumn: meta2.endColumn
|
|
553
|
+
}),
|
|
554
|
+
targetLocation: JSON.stringify({
|
|
555
|
+
filePath: bestMeta.filePath,
|
|
556
|
+
// Already relative from index
|
|
557
|
+
startLine: bestMeta.startLine,
|
|
558
|
+
endLine: bestMeta.endLine,
|
|
559
|
+
startColumn: bestMeta.startColumn,
|
|
560
|
+
endColumn: bestMeta.endColumn
|
|
561
|
+
}),
|
|
562
|
+
sourceName: name || meta2.name || "(anonymous)",
|
|
563
|
+
targetName: bestMeta.name || "(anonymous)",
|
|
564
|
+
similarityScore: String(best.score)
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
} else {
|
|
569
|
+
log(` No similar chunks found above threshold`);
|
|
570
|
+
}
|
|
571
|
+
} else {
|
|
572
|
+
log(` Node line ${nodeLine} not in chunk range ${meta2.startLine}-${meta2.endLine}`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return {
|
|
577
|
+
// Check function declarations
|
|
578
|
+
FunctionDeclaration(node) {
|
|
579
|
+
const name = node.id?.name || null;
|
|
580
|
+
checkForDuplicates(node, name);
|
|
581
|
+
},
|
|
582
|
+
// Check arrow functions assigned to variables
|
|
583
|
+
"VariableDeclarator[init.type='ArrowFunctionExpression']"(node) {
|
|
584
|
+
const name = node.id.type === "Identifier" ? node.id.name : null;
|
|
585
|
+
if (node.init) {
|
|
586
|
+
checkForDuplicates(node.init, name);
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
// Check function expressions
|
|
590
|
+
"VariableDeclarator[init.type='FunctionExpression']"(node) {
|
|
591
|
+
const name = node.id.type === "Identifier" ? node.id.name : null;
|
|
592
|
+
if (node.init) {
|
|
593
|
+
checkForDuplicates(node.init, name);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
export {
|
|
601
|
+
meta,
|
|
602
|
+
clearIndexCache,
|
|
603
|
+
no_semantic_duplicates_default
|
|
604
|
+
};
|
|
605
|
+
//# sourceMappingURL=chunk-QDQHFV7C.js.map
|