deep-slop 1.4.1
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/.deep-slop/.deep-slop-ignore +13 -0
- package/LICENSE +21 -0
- package/README.md +1170 -0
- package/dist/arch-constraints-C7s1E_bc.js +450 -0
- package/dist/arch-rules-DI1SYPqu.js +358 -0
- package/dist/ast-slop-BGdr58wZ.js +1839 -0
- package/dist/config-lint-ph3vMUbg.js +371 -0
- package/dist/dead-flow-DHRkyxZT.js +1422 -0
- package/dist/deep-slop-bundled.js +33140 -0
- package/dist/discover-B_S_Fy2S.js +164 -0
- package/dist/dup-detect-DKRXM04q.js +709 -0
- package/dist/file-utils-B_HFXhCs.js +93 -0
- package/dist/format-lint-DeElllNm.js +445 -0
- package/dist/framework-lint-CqdlF9hX.js +782 -0
- package/dist/i18n-lint-CPzx7V8Q.js +605 -0
- package/dist/import-intelligence-SK4F7XpL.js +966 -0
- package/dist/index.d.ts +233 -0
- package/dist/index.js +1030 -0
- package/dist/knip-CgxnnTBZ.js +93 -0
- package/dist/lint-external-ZbW3jGvB.js +326 -0
- package/dist/markup-lint-DKVEDz9M.js +805 -0
- package/dist/mcp.js +35939 -0
- package/dist/meta-quality-Dai1W5iC.js +224 -0
- package/dist/perf-hints-BnWFMFff.js +500 -0
- package/dist/security-deep-DJRINs10.js +1198 -0
- package/dist/syntax-deep-ZQYMutky.js +624 -0
- package/dist/tree-sitter-CM-cP0nl.js +661 -0
- package/dist/type-safety-Dboj2C1t.js +519 -0
- package/package.json +92 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { i as toLines, r as readFileContent } from "./file-utils-B_HFXhCs.js";
|
|
2
|
+
import { extname, join, relative } from "node:path";
|
|
3
|
+
import { readdir } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
//#region src/engines/perf-hints/index.ts
|
|
6
|
+
const TS_JS_EXTENSIONS = new Set([
|
|
7
|
+
".ts",
|
|
8
|
+
".tsx",
|
|
9
|
+
".js",
|
|
10
|
+
".jsx",
|
|
11
|
+
".mjs",
|
|
12
|
+
".cjs"
|
|
13
|
+
]);
|
|
14
|
+
function isRelevantFile(filePath) {
|
|
15
|
+
const ext = extname(filePath);
|
|
16
|
+
return TS_JS_EXTENSIONS.has(ext);
|
|
17
|
+
}
|
|
18
|
+
/** Recursively collect file paths under root, respecting exclude list */
|
|
19
|
+
async function collectFiles(root, exclude) {
|
|
20
|
+
const results = [];
|
|
21
|
+
async function walk(dir) {
|
|
22
|
+
let entries;
|
|
23
|
+
try {
|
|
24
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
25
|
+
} catch {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
const full = join(dir, entry.name);
|
|
30
|
+
if (exclude.some((pat) => full.includes(pat))) continue;
|
|
31
|
+
if (entry.isDirectory()) await walk(full);
|
|
32
|
+
else if (entry.isFile() && isRelevantFile(full)) results.push(full);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
await walk(root);
|
|
36
|
+
return results;
|
|
37
|
+
}
|
|
38
|
+
/** Make a diagnostic with sensible defaults for perf-hints */
|
|
39
|
+
function makeDiagnostic(overrides) {
|
|
40
|
+
return {
|
|
41
|
+
engine: "perf-hints",
|
|
42
|
+
severity: "info",
|
|
43
|
+
column: 1,
|
|
44
|
+
category: "performance",
|
|
45
|
+
fixable: false,
|
|
46
|
+
help: "",
|
|
47
|
+
...overrides
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Parse the file into block ranges by tracking brace depth.
|
|
52
|
+
* This lets us answer "is line X inside a loop?" or "is line X inside an async function?"
|
|
53
|
+
*/
|
|
54
|
+
function parseBlocks(content) {
|
|
55
|
+
const lines = toLines(content);
|
|
56
|
+
const blocks = [];
|
|
57
|
+
const stack = [];
|
|
58
|
+
for (let i = 0; i < lines.length; i++) {
|
|
59
|
+
const text = lines[i].text;
|
|
60
|
+
for (let col = 0; col < text.length; col++) {
|
|
61
|
+
const ch = text[col];
|
|
62
|
+
if (ch === "\"" || ch === "'" || ch === "`") {
|
|
63
|
+
const quote = ch;
|
|
64
|
+
col++;
|
|
65
|
+
while (col < text.length && text[col] !== quote) {
|
|
66
|
+
if (text[col] === "\\") col++;
|
|
67
|
+
col++;
|
|
68
|
+
}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (ch === "/" && col + 1 < text.length) {
|
|
72
|
+
if (text[col + 1] === "/") break;
|
|
73
|
+
if (text[col + 1] === "*") {
|
|
74
|
+
const end = text.indexOf("*/", col + 2);
|
|
75
|
+
if (end !== -1) col = end + 1;
|
|
76
|
+
else break;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (ch === "{") {
|
|
81
|
+
const kind = detectConstructKind(lines, i, stack.length);
|
|
82
|
+
stack.push({
|
|
83
|
+
braceLineIdx: i,
|
|
84
|
+
headerLine: lines[i].num,
|
|
85
|
+
kind
|
|
86
|
+
});
|
|
87
|
+
} else if (ch === "}") {
|
|
88
|
+
if (stack.length > 0) {
|
|
89
|
+
const opener = stack.pop();
|
|
90
|
+
blocks.push({
|
|
91
|
+
startIdx: opener.braceLineIdx,
|
|
92
|
+
endIdx: i,
|
|
93
|
+
headerLine: opener.headerLine,
|
|
94
|
+
kind: opener.kind
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return blocks;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Look at the text before the opening brace on lineIdx to determine what
|
|
104
|
+
* construct this block belongs to.
|
|
105
|
+
*/
|
|
106
|
+
function detectConstructKind(lines, braceLineIdx, _depth) {
|
|
107
|
+
const chunks = [];
|
|
108
|
+
const braceLine = lines[braceLineIdx].text;
|
|
109
|
+
const bracePos = braceLine.indexOf("{");
|
|
110
|
+
if (bracePos > 0) chunks.push(braceLine.slice(0, bracePos));
|
|
111
|
+
for (let i = braceLineIdx - 1; i >= Math.max(0, braceLineIdx - 3); i--) chunks.unshift(lines[i].text.trim());
|
|
112
|
+
const header = chunks.join(" ");
|
|
113
|
+
if (/\basync\s+(?:function\b|[\w]+\s*\([^)]*\)\s*=>)/.test(header) || /\basync\s+function\b/.test(header)) return "async-function";
|
|
114
|
+
if (/\bfunction\b/.test(header) || /=>\s*$/.test(chunks[chunks.length - 1])) return "sync-function";
|
|
115
|
+
if (/\bfor\b/.test(header)) return "for";
|
|
116
|
+
if (/\bwhile\b/.test(header)) return "while";
|
|
117
|
+
if (/\bdo\b/.test(header)) return "do";
|
|
118
|
+
if (/\.(forEach|map|filter|reduce|flatMap|some|every)\s*\(/.test(header)) {
|
|
119
|
+
if (/\.forEach\b/.test(header)) return "forEach";
|
|
120
|
+
if (/\.map\b/.test(header)) return "map";
|
|
121
|
+
return "map";
|
|
122
|
+
}
|
|
123
|
+
return "other";
|
|
124
|
+
}
|
|
125
|
+
/** Check if a 0-based line index is inside a block of the given kind(s) */
|
|
126
|
+
function isInsideBlock(blocks, lineIdx, kinds) {
|
|
127
|
+
for (const block of blocks) if (lineIdx >= block.startIdx && lineIdx <= block.endIdx && kinds.has(block.kind)) return block;
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
function detectNPlusOne(content, filePath, blocks) {
|
|
131
|
+
const diagnostics = [];
|
|
132
|
+
const lines = toLines(content);
|
|
133
|
+
const dbCallRe = /\.(query|execute|find|findOne|findMany|insertOne|insertMany|updateOne|updateMany|deleteOne|deleteMany|raw|run|exec)\s*\(/;
|
|
134
|
+
const strictLoopKinds = new Set([
|
|
135
|
+
"for",
|
|
136
|
+
"while",
|
|
137
|
+
"do"
|
|
138
|
+
]);
|
|
139
|
+
for (let i = 0; i < lines.length; i++) {
|
|
140
|
+
const trimmed = lines[i].text.trim();
|
|
141
|
+
if (dbCallRe.test(trimmed)) {
|
|
142
|
+
const enclosingLoop = isInsideBlock(blocks, i, strictLoopKinds);
|
|
143
|
+
if (enclosingLoop) {
|
|
144
|
+
const hasAwait = /\bawait\b/.test(trimmed);
|
|
145
|
+
const key = `${filePath}:${enclosingLoop.startIdx}:perf-hints/n-plus-one`;
|
|
146
|
+
if (!seenKeys.has(key)) {
|
|
147
|
+
seenKeys.add(key);
|
|
148
|
+
diagnostics.push(makeDiagnostic({
|
|
149
|
+
filePath,
|
|
150
|
+
rule: "perf-hints/n-plus-one",
|
|
151
|
+
message: `Database call inside ${describeLoopKind(enclosingLoop.kind)} — potential N+1 query pattern`,
|
|
152
|
+
line: lines[i].num,
|
|
153
|
+
severity: "info",
|
|
154
|
+
help: "Batch database queries outside the loop or use a single query with IN clause to avoid N+1 round trips",
|
|
155
|
+
fixable: false,
|
|
156
|
+
suggestion: {
|
|
157
|
+
type: "refactor",
|
|
158
|
+
text: hasAwait ? "// Collect IDs, then batch: const results = await db.query('SELECT * FROM t WHERE id IN (?)', [ids])" : "// Batch the query outside the loop instead of calling per iteration",
|
|
159
|
+
confidence: .75,
|
|
160
|
+
reason: "Performing I/O on every loop iteration causes N+1 round trips; batching reduces this to 1"
|
|
161
|
+
},
|
|
162
|
+
detail: {
|
|
163
|
+
loopKind: enclosingLoop.kind,
|
|
164
|
+
loopHeaderLine: enclosingLoop.headerLine,
|
|
165
|
+
hasAwait,
|
|
166
|
+
isDbCall: true
|
|
167
|
+
}
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return diagnostics;
|
|
174
|
+
}
|
|
175
|
+
function detectReactMissingMemo(content, filePath, blocks) {
|
|
176
|
+
const diagnostics = [];
|
|
177
|
+
const lines = toLines(content);
|
|
178
|
+
if (!/\.(tsx|jsx)$/.test(filePath)) return diagnostics;
|
|
179
|
+
const functionKinds = new Set(["sync-function", "async-function"]);
|
|
180
|
+
const pascalFnRe = /^(?:const|let|function)\s+([A-Z][a-zA-Z0-9]*)\s*(?:=\s*(?:\([^)]*\)|[\w]*)\s*=>|[\s]*\()/;
|
|
181
|
+
for (let i = 0; i < lines.length; i++) {
|
|
182
|
+
const match = lines[i].text.trim().match(pascalFnRe);
|
|
183
|
+
if (!match) continue;
|
|
184
|
+
const componentName = match[1];
|
|
185
|
+
const enclosingFn = isInsideBlock(blocks, i, functionKinds);
|
|
186
|
+
if (!enclosingFn) continue;
|
|
187
|
+
const jsxAhead = contentAroundLine(lines, i, 15);
|
|
188
|
+
if (!(/<\s*[A-Za-z][A-Za-z0-9]*(?:\s[^>]*)?\/?>/.test(jsxAhead) || /React\.createElement/.test(jsxAhead))) continue;
|
|
189
|
+
diagnostics.push(makeDiagnostic({
|
|
190
|
+
filePath,
|
|
191
|
+
rule: "perf-hints/react-missing-memo",
|
|
192
|
+
message: `Component \`${componentName}\` is defined inside another component — recreated on every render`,
|
|
193
|
+
line: lines[i].num,
|
|
194
|
+
severity: "info",
|
|
195
|
+
help: `Move \`${componentName}\` outside the parent component or wrap with useMemo to avoid re-creation on every render`,
|
|
196
|
+
fixable: false,
|
|
197
|
+
suggestion: {
|
|
198
|
+
type: "refactor",
|
|
199
|
+
text: `// Move ${componentName} outside the parent component, or:\n// const ${componentName} = useMemo(() => (...) , [])`,
|
|
200
|
+
confidence: .8,
|
|
201
|
+
reason: "Inner component definitions create new function references on every parent render, causing unnecessary child re-renders"
|
|
202
|
+
},
|
|
203
|
+
detail: {
|
|
204
|
+
componentName,
|
|
205
|
+
parentHeaderLine: enclosingFn.headerLine
|
|
206
|
+
}
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
return diagnostics;
|
|
210
|
+
}
|
|
211
|
+
/** Get text around a line (N lines ahead from lineIdx, inclusive) */
|
|
212
|
+
function contentAroundLine(lines, lineIdx, ahead) {
|
|
213
|
+
const start = lineIdx;
|
|
214
|
+
const end = Math.min(lines.length, lineIdx + ahead);
|
|
215
|
+
const parts = [];
|
|
216
|
+
for (let i = start; i < end; i++) parts.push(lines[i].text);
|
|
217
|
+
return parts.join("\n");
|
|
218
|
+
}
|
|
219
|
+
function detectSyncInAsync(content, filePath, blocks) {
|
|
220
|
+
const diagnostics = [];
|
|
221
|
+
const lines = toLines(content);
|
|
222
|
+
const asyncKinds = new Set(["async-function"]);
|
|
223
|
+
const isCliFile = /(?:^|\\|\/)cli(?:\\|\/|[-_.])/i.test(filePath) || /(?:^|\\|\/)cli\./i.test(filePath);
|
|
224
|
+
const syncFsRe = /\b(readFileSync|writeFileSync|appendFileSync|existsSync|mkdirSync|rmdirSync|unlinkSync|renameSync|copyFileSync|readdirSync|statSync|lstatSync|fstatSync|accessSync|readlinkSync|symlinkSync|chmodSync|chownSync|utimesSync|realpathSync|mkdtempSync|truncateSync|openSync|closeSync|readSync|writeSync|fsyncSync|watchFile|unwatchFile)\s*\(/;
|
|
225
|
+
const cliWhitelist = new Set(["readFileSync", "writeFileSync"]);
|
|
226
|
+
for (let i = 0; i < lines.length; i++) {
|
|
227
|
+
const trimmed = lines[i].text.trim();
|
|
228
|
+
const match = trimmed.match(syncFsRe);
|
|
229
|
+
if (!match) continue;
|
|
230
|
+
const methodName = match[1];
|
|
231
|
+
if (isCliFile && cliWhitelist.has(methodName)) continue;
|
|
232
|
+
const enclosingAsync = isInsideBlock(blocks, i, asyncKinds);
|
|
233
|
+
if (!enclosingAsync) continue;
|
|
234
|
+
const asyncName = methodName.replace(/Sync$/, "");
|
|
235
|
+
diagnostics.push(makeDiagnostic({
|
|
236
|
+
filePath,
|
|
237
|
+
rule: "perf-hints/sync-in-async",
|
|
238
|
+
message: `Synchronous \`${methodName}\` inside async function — blocks the event loop`,
|
|
239
|
+
line: lines[i].num,
|
|
240
|
+
severity: "warning",
|
|
241
|
+
help: `Replace \`${methodName}\` with async \`${asyncName}\` to avoid blocking the event loop`,
|
|
242
|
+
fixable: true,
|
|
243
|
+
suggestion: {
|
|
244
|
+
type: "replace",
|
|
245
|
+
text: `await ${asyncName}(`,
|
|
246
|
+
confidence: .9,
|
|
247
|
+
reason: `Async functions should use the async version ${asyncName} instead of the synchronous ${methodName} to prevent blocking the event loop`,
|
|
248
|
+
range: {
|
|
249
|
+
startLine: lines[i].num,
|
|
250
|
+
startCol: trimmed.indexOf(methodName) + 1,
|
|
251
|
+
endLine: lines[i].num,
|
|
252
|
+
endCol: trimmed.indexOf(methodName) + methodName.length + 1
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
detail: {
|
|
256
|
+
syncMethod: methodName,
|
|
257
|
+
asyncMethod: asyncName,
|
|
258
|
+
asyncHeaderLine: enclosingAsync.headerLine
|
|
259
|
+
}
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
262
|
+
return diagnostics;
|
|
263
|
+
}
|
|
264
|
+
function detectLargeLoopAllocation(content, filePath, blocks) {
|
|
265
|
+
const diagnostics = [];
|
|
266
|
+
const lines = toLines(content);
|
|
267
|
+
const loopKinds = new Set([
|
|
268
|
+
"for",
|
|
269
|
+
"while",
|
|
270
|
+
"do",
|
|
271
|
+
"forEach",
|
|
272
|
+
"map"
|
|
273
|
+
]);
|
|
274
|
+
const typedArrayTypes = new Set([
|
|
275
|
+
"Float32Array",
|
|
276
|
+
"Float64Array",
|
|
277
|
+
"Int8Array",
|
|
278
|
+
"Int16Array",
|
|
279
|
+
"Int32Array",
|
|
280
|
+
"Uint8Array",
|
|
281
|
+
"Uint16Array",
|
|
282
|
+
"Uint32Array",
|
|
283
|
+
"Uint8ClampedArray",
|
|
284
|
+
"BigInt64Array",
|
|
285
|
+
"BigUint64Array"
|
|
286
|
+
]);
|
|
287
|
+
const skipTypes = new Set([
|
|
288
|
+
"Map",
|
|
289
|
+
"Set",
|
|
290
|
+
"WeakMap",
|
|
291
|
+
"WeakSet"
|
|
292
|
+
]);
|
|
293
|
+
const allocRe = /\bnew\s+(Array|Map|Set|WeakMap|WeakSet|Float32Array|Float64Array|Int8Array|Int16Array|Int32Array|Uint8Array|Uint16Array|Uint32Array|Uint8ClampedArray|BigInt64Array|BigUint64Array)\s*\(/;
|
|
294
|
+
for (let i = 0; i < lines.length; i++) {
|
|
295
|
+
const trimmed = lines[i].text.trim();
|
|
296
|
+
const arrayMatch = trimmed.match(allocRe);
|
|
297
|
+
if (!arrayMatch) continue;
|
|
298
|
+
const allocType = arrayMatch[1];
|
|
299
|
+
const enclosingLoop = isInsideBlock(blocks, i, loopKinds);
|
|
300
|
+
if (!enclosingLoop) continue;
|
|
301
|
+
if (skipTypes.has(allocType)) continue;
|
|
302
|
+
const loopDesc = describeLoopKind(enclosingLoop.kind);
|
|
303
|
+
if (allocType === "Array") {
|
|
304
|
+
const sizeMatch = trimmed.match(/\bnew\s+Array\s*\(\s*(\d+)\s*\)/);
|
|
305
|
+
if (!sizeMatch) continue;
|
|
306
|
+
const size = parseInt(sizeMatch[1], 10);
|
|
307
|
+
if (size <= 100) continue;
|
|
308
|
+
diagnostics.push(makeDiagnostic({
|
|
309
|
+
filePath,
|
|
310
|
+
rule: "perf-hints/large-loop-allocation",
|
|
311
|
+
message: `\`new Array(${size})\` allocation inside ${loopDesc} — consider pre-allocating outside the loop`,
|
|
312
|
+
line: lines[i].num,
|
|
313
|
+
severity: "suggestion",
|
|
314
|
+
help: "Move the array allocation outside the loop and re-use it, or use .push() on a pre-allocated array",
|
|
315
|
+
fixable: false,
|
|
316
|
+
suggestion: {
|
|
317
|
+
type: "refactor",
|
|
318
|
+
text: `// const arr = new Array(${size}); // outside loop\n// arr.fill(0); // reuse per iteration`,
|
|
319
|
+
confidence: .5,
|
|
320
|
+
reason: "Repeated large array allocations inside loops create GC pressure; pre-allocating outside the loop is more efficient"
|
|
321
|
+
},
|
|
322
|
+
detail: {
|
|
323
|
+
allocType,
|
|
324
|
+
loopKind: enclosingLoop.kind,
|
|
325
|
+
loopHeaderLine: enclosingLoop.headerLine,
|
|
326
|
+
hasSizeArg: true,
|
|
327
|
+
arraySize: size
|
|
328
|
+
}
|
|
329
|
+
}));
|
|
330
|
+
} else if (typedArrayTypes.has(allocType)) diagnostics.push(makeDiagnostic({
|
|
331
|
+
filePath,
|
|
332
|
+
rule: "perf-hints/large-loop-allocation",
|
|
333
|
+
message: `\`new ${allocType}()\` allocation inside ${loopDesc} — consider pre-allocating outside the loop`,
|
|
334
|
+
line: lines[i].num,
|
|
335
|
+
severity: "suggestion",
|
|
336
|
+
help: `Move the ${allocType} allocation outside the loop to reduce GC pressure`,
|
|
337
|
+
fixable: false,
|
|
338
|
+
suggestion: {
|
|
339
|
+
type: "refactor",
|
|
340
|
+
text: `// const buf = new ${allocType}(...); // allocate once outside the loop`,
|
|
341
|
+
confidence: .5,
|
|
342
|
+
reason: "Repeated typed array allocations inside loops create GC pressure; pre-allocating outside the loop is more efficient"
|
|
343
|
+
},
|
|
344
|
+
detail: {
|
|
345
|
+
allocType,
|
|
346
|
+
loopKind: enclosingLoop.kind,
|
|
347
|
+
loopHeaderLine: enclosingLoop.headerLine,
|
|
348
|
+
hasSizeArg: false
|
|
349
|
+
}
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
return diagnostics;
|
|
353
|
+
}
|
|
354
|
+
function detectUnnecessaryAwait(_content, _filePath) {
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
function detectStringConcatInLoop(content, filePath, blocks) {
|
|
358
|
+
const diagnostics = [];
|
|
359
|
+
const lines = toLines(content);
|
|
360
|
+
const loopKinds = new Set([
|
|
361
|
+
"for",
|
|
362
|
+
"while",
|
|
363
|
+
"do",
|
|
364
|
+
"forEach",
|
|
365
|
+
"map"
|
|
366
|
+
]);
|
|
367
|
+
const templateConcatRe = /\b(\w+)\s*\+=\s*`/;
|
|
368
|
+
const anyConcatRe = /\b(\w+)\s*\+=\s*["'`]/;
|
|
369
|
+
const concatCounts = /* @__PURE__ */ new Map();
|
|
370
|
+
for (let i = 0; i < lines.length; i++) {
|
|
371
|
+
const match = lines[i].text.trim().match(anyConcatRe);
|
|
372
|
+
if (!match) continue;
|
|
373
|
+
const varName = match[1];
|
|
374
|
+
const enclosingLoop = isInsideBlock(blocks, i, loopKinds);
|
|
375
|
+
if (!enclosingLoop) continue;
|
|
376
|
+
const key = `${varName}:${enclosingLoop.startIdx}`;
|
|
377
|
+
concatCounts.set(key, (concatCounts.get(key) ?? 0) + 1);
|
|
378
|
+
}
|
|
379
|
+
for (let i = 0; i < lines.length; i++) {
|
|
380
|
+
const trimmed = lines[i].text.trim();
|
|
381
|
+
const enclosingLoop = isInsideBlock(blocks, i, loopKinds);
|
|
382
|
+
if (!enclosingLoop) continue;
|
|
383
|
+
const templateMatch = trimmed.match(templateConcatRe);
|
|
384
|
+
if (templateMatch) {
|
|
385
|
+
const varName = templateMatch[1];
|
|
386
|
+
const loopDesc = describeLoopKind(enclosingLoop.kind);
|
|
387
|
+
diagnostics.push(makeDiagnostic({
|
|
388
|
+
filePath,
|
|
389
|
+
rule: "perf-hints/string-concat-in-loop",
|
|
390
|
+
message: `String concatenation (\`${varName} +=\` with template literal) inside ${loopDesc} — consider using array.join() pattern`,
|
|
391
|
+
line: lines[i].num,
|
|
392
|
+
severity: "warning",
|
|
393
|
+
help: `Use an array to collect strings and join once after the loop, which is O(n) instead of O(n²) for repeated concatenation`,
|
|
394
|
+
fixable: false,
|
|
395
|
+
suggestion: {
|
|
396
|
+
type: "refactor",
|
|
397
|
+
text: `// const parts: string[] = [];\n// parts.push(...); // inside loop\n// const ${varName} = parts.join(''); // after loop`,
|
|
398
|
+
confidence: .75,
|
|
399
|
+
reason: "Repeated string concatenation with += and template literals inside a loop is O(n²); pushing to an array and joining once is O(n)"
|
|
400
|
+
},
|
|
401
|
+
detail: {
|
|
402
|
+
variableName: varName,
|
|
403
|
+
loopKind: enclosingLoop.kind,
|
|
404
|
+
loopHeaderLine: enclosingLoop.headerLine
|
|
405
|
+
}
|
|
406
|
+
}));
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const anyMatch = trimmed.match(anyConcatRe);
|
|
410
|
+
if (anyMatch) {
|
|
411
|
+
const varName = anyMatch[1];
|
|
412
|
+
const key = `${varName}:${enclosingLoop.startIdx}`;
|
|
413
|
+
const count = concatCounts.get(key) ?? 0;
|
|
414
|
+
if (count >= 3) {
|
|
415
|
+
const loopDesc = describeLoopKind(enclosingLoop.kind);
|
|
416
|
+
diagnostics.push(makeDiagnostic({
|
|
417
|
+
filePath,
|
|
418
|
+
rule: "perf-hints/string-concat-in-loop",
|
|
419
|
+
message: `String concatenation (\`${varName} +=\`) inside ${loopDesc} — ${count} concatenations found, consider using array.join() pattern`,
|
|
420
|
+
line: lines[i].num,
|
|
421
|
+
severity: "warning",
|
|
422
|
+
help: `Use an array to collect strings and join once after the loop, which is O(n) instead of O(n²) for repeated concatenation`,
|
|
423
|
+
fixable: false,
|
|
424
|
+
suggestion: {
|
|
425
|
+
type: "refactor",
|
|
426
|
+
text: `// const parts: string[] = [];\n// parts.push(...); // inside loop\n// const ${varName} = parts.join(''); // after loop`,
|
|
427
|
+
confidence: .7,
|
|
428
|
+
reason: `${count} repeated string concatenations with += inside a loop is O(n²); pushing to an array and joining once is O(n)`
|
|
429
|
+
},
|
|
430
|
+
detail: {
|
|
431
|
+
variableName: varName,
|
|
432
|
+
loopKind: enclosingLoop.kind,
|
|
433
|
+
loopHeaderLine: enclosingLoop.headerLine,
|
|
434
|
+
concatCount: count
|
|
435
|
+
}
|
|
436
|
+
}));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return diagnostics;
|
|
441
|
+
}
|
|
442
|
+
/** Human-readable loop kind description */
|
|
443
|
+
function describeLoopKind(kind) {
|
|
444
|
+
switch (kind) {
|
|
445
|
+
case "for": return "for loop";
|
|
446
|
+
case "while": return "while loop";
|
|
447
|
+
case "do": return "do-while loop";
|
|
448
|
+
case "forEach": return ".forEach() callback";
|
|
449
|
+
case "map": return ".map() callback";
|
|
450
|
+
default: return "loop";
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/** Global seen-keys set per run to avoid duplicates across rules */
|
|
454
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
455
|
+
const perfHintsEngine = {
|
|
456
|
+
name: "perf-hints",
|
|
457
|
+
description: "Performance hints: N+1 query patterns, missing React memoization, sync I/O in async, loop allocations, unnecessary awaits, string concatenation in loops",
|
|
458
|
+
supportedLanguages: ["typescript", "javascript"],
|
|
459
|
+
async run(context) {
|
|
460
|
+
const start = Date.now();
|
|
461
|
+
const diagnostics = [];
|
|
462
|
+
const { rootDirectory, config, files: specifiedFiles } = context;
|
|
463
|
+
seenKeys.clear();
|
|
464
|
+
const filePaths = specifiedFiles ? specifiedFiles.filter(isRelevantFile) : await collectFiles(rootDirectory, config.exclude);
|
|
465
|
+
if (filePaths.length === 0) return {
|
|
466
|
+
engine: this.name,
|
|
467
|
+
diagnostics: [],
|
|
468
|
+
elapsed: Date.now() - start,
|
|
469
|
+
skipped: true,
|
|
470
|
+
skipReason: "No TypeScript/JavaScript files found to analyze"
|
|
471
|
+
};
|
|
472
|
+
for (const fp of filePaths) try {
|
|
473
|
+
const content = await readFileContent(fp);
|
|
474
|
+
const relPath = relative(rootDirectory, fp);
|
|
475
|
+
const blocks = parseBlocks(content);
|
|
476
|
+
diagnostics.push(...detectNPlusOne(content, relPath, blocks));
|
|
477
|
+
diagnostics.push(...detectReactMissingMemo(content, relPath, blocks));
|
|
478
|
+
diagnostics.push(...detectSyncInAsync(content, relPath, blocks));
|
|
479
|
+
diagnostics.push(...detectLargeLoopAllocation(content, relPath, blocks));
|
|
480
|
+
diagnostics.push(...detectUnnecessaryAwait(content, relPath));
|
|
481
|
+
diagnostics.push(...detectStringConcatInLoop(content, relPath, blocks));
|
|
482
|
+
} catch {}
|
|
483
|
+
const seen = /* @__PURE__ */ new Set();
|
|
484
|
+
const unique = diagnostics.filter((d) => {
|
|
485
|
+
const key = `${d.filePath}:${d.line}:${d.rule}`;
|
|
486
|
+
if (seen.has(key)) return false;
|
|
487
|
+
seen.add(key);
|
|
488
|
+
return true;
|
|
489
|
+
});
|
|
490
|
+
return {
|
|
491
|
+
engine: this.name,
|
|
492
|
+
diagnostics: unique,
|
|
493
|
+
elapsed: Date.now() - start,
|
|
494
|
+
skipped: false
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
//#endregion
|
|
500
|
+
export { perfHintsEngine };
|