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,782 @@
|
|
|
1
|
+
import { i as toLines, r as readFileContent } from "./file-utils-B_HFXhCs.js";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { extname, join, relative } from "node:path";
|
|
5
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
6
|
+
|
|
7
|
+
//#region src/engines/framework-lint/index.ts
|
|
8
|
+
/** Create a framework-lint diagnostic */
|
|
9
|
+
function diag(overrides) {
|
|
10
|
+
return {
|
|
11
|
+
engine: "framework-lint",
|
|
12
|
+
category: "style",
|
|
13
|
+
line: 1,
|
|
14
|
+
column: 1,
|
|
15
|
+
fixable: false,
|
|
16
|
+
help: "",
|
|
17
|
+
...overrides
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** File extensions this engine scans */
|
|
21
|
+
const SCAN_EXTENSIONS = new Set([
|
|
22
|
+
".ts",
|
|
23
|
+
".tsx",
|
|
24
|
+
".js",
|
|
25
|
+
".jsx",
|
|
26
|
+
".css"
|
|
27
|
+
]);
|
|
28
|
+
/** Detect if project uses Next.js (from deps or config) */
|
|
29
|
+
async function detectNextJs(rootDir) {
|
|
30
|
+
try {
|
|
31
|
+
const pkgPath = join(rootDir, "package.json");
|
|
32
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
|
|
33
|
+
if ({
|
|
34
|
+
...pkg.dependencies,
|
|
35
|
+
...pkg.devDependencies
|
|
36
|
+
}["next"]) return true;
|
|
37
|
+
} catch {}
|
|
38
|
+
for (const name of [
|
|
39
|
+
"next.config.js",
|
|
40
|
+
"next.config.mjs",
|
|
41
|
+
"next.config.ts",
|
|
42
|
+
"next.config.cjs"
|
|
43
|
+
]) if (existsSync(join(rootDir, name))) return true;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
/** Detect if project uses Tailwind CSS (from deps or config) */
|
|
47
|
+
async function detectTailwind(rootDir) {
|
|
48
|
+
try {
|
|
49
|
+
const pkgPath = join(rootDir, "package.json");
|
|
50
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
|
|
51
|
+
const allDeps = {
|
|
52
|
+
...pkg.dependencies,
|
|
53
|
+
...pkg.devDependencies
|
|
54
|
+
};
|
|
55
|
+
if (allDeps["tailwindcss"] || allDeps["@tailwindcss/postcss"] || allDeps["@tailwindcss/vite"]) return true;
|
|
56
|
+
} catch {}
|
|
57
|
+
if ((await readdir(rootDir).catch(() => [])).filter((e) => e.startsWith("tailwind.config")).length > 0) return true;
|
|
58
|
+
for (const name of [
|
|
59
|
+
"postcss.config.js",
|
|
60
|
+
"postcss.config.mjs",
|
|
61
|
+
"postcss.config.cjs",
|
|
62
|
+
"postcss.config.ts"
|
|
63
|
+
]) {
|
|
64
|
+
const fullPath = join(rootDir, name);
|
|
65
|
+
try {
|
|
66
|
+
const content = await readFileContent(fullPath);
|
|
67
|
+
if (content.includes("tailwindcss") || content.includes("@tailwindcss")) return true;
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
/** Check if App Router project (has app/ directory) */
|
|
73
|
+
async function isAppRouterProject(rootDir) {
|
|
74
|
+
const appDir = join(rootDir, "src", "app");
|
|
75
|
+
const appDirRoot = join(rootDir, "app");
|
|
76
|
+
try {
|
|
77
|
+
if ((await stat(appDir)).isDirectory()) return true;
|
|
78
|
+
} catch {}
|
|
79
|
+
try {
|
|
80
|
+
if ((await stat(appDirRoot)).isDirectory()) return true;
|
|
81
|
+
} catch {}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
/** Collect all scannable files */
|
|
85
|
+
async function collectScanFiles(rootDir) {
|
|
86
|
+
const files = [];
|
|
87
|
+
const ignoreDirs = new Set([
|
|
88
|
+
"node_modules",
|
|
89
|
+
".git",
|
|
90
|
+
".next",
|
|
91
|
+
"dist",
|
|
92
|
+
"build",
|
|
93
|
+
".cache",
|
|
94
|
+
"coverage"
|
|
95
|
+
]);
|
|
96
|
+
async function walk(dir) {
|
|
97
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
98
|
+
for (const entry of entries) if (entry.isDirectory()) {
|
|
99
|
+
if (!ignoreDirs.has(entry.name) && !entry.name.startsWith(".")) await walk(join(dir, entry.name));
|
|
100
|
+
} else if (entry.isFile()) {
|
|
101
|
+
const ext = extname(entry.name);
|
|
102
|
+
if (SCAN_EXTENSIONS.has(ext)) files.push(join(dir, entry.name));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
await walk(rootDir);
|
|
106
|
+
return files;
|
|
107
|
+
}
|
|
108
|
+
/** 1. nextjs/misplaced-use-client: Flags 'use client' on files with only server-safe code */
|
|
109
|
+
function checkMisplacedUseClient(filePath, relPath, content, lines) {
|
|
110
|
+
const diagnostics = [];
|
|
111
|
+
if (!lines.some((l) => l.text.trim() === "'use client'" || l.text.trim() === "\"use client\"")) return diagnostics;
|
|
112
|
+
if (![
|
|
113
|
+
/\buseState\b/,
|
|
114
|
+
/\buseEffect\b/,
|
|
115
|
+
/\buseRef\b/,
|
|
116
|
+
/\buseCallback\b/,
|
|
117
|
+
/\buseMemo\b/,
|
|
118
|
+
/\buseReducer\b/,
|
|
119
|
+
/\buseContext\b/,
|
|
120
|
+
/\buseLayoutEffect\b/,
|
|
121
|
+
/\buseSyncExternalStore\b/,
|
|
122
|
+
/\bonClick\b/,
|
|
123
|
+
/\bonChange\b/,
|
|
124
|
+
/\bonSubmit\b/,
|
|
125
|
+
/\bonKeyDown\b/,
|
|
126
|
+
/\bonMouseOver\b/,
|
|
127
|
+
/\bonFocus\b/,
|
|
128
|
+
/\bonBlur\b/,
|
|
129
|
+
/\bonInput\b/,
|
|
130
|
+
/\baddEventListener\b/,
|
|
131
|
+
/\bwindow\b/,
|
|
132
|
+
/\bdocument\b/,
|
|
133
|
+
/\blocalStorage\b/,
|
|
134
|
+
/\bsessionStorage\b/,
|
|
135
|
+
/\bfetch\b/,
|
|
136
|
+
/\bIntersectionObserver\b/,
|
|
137
|
+
/\bResizeObserver\b/,
|
|
138
|
+
/\bMutationObserver\b/
|
|
139
|
+
].some((p) => p.test(content))) {
|
|
140
|
+
const lineNum = lines.find((l) => l.text.trim() === "'use client'" || l.text.trim() === "\"use client\"")?.num ?? 1;
|
|
141
|
+
diagnostics.push(diag({
|
|
142
|
+
rule: "nextjs/misplaced-use-client",
|
|
143
|
+
severity: "warning",
|
|
144
|
+
filePath: relPath,
|
|
145
|
+
line: lineNum,
|
|
146
|
+
column: 1,
|
|
147
|
+
message: "'use client' directive on file with no client-side code",
|
|
148
|
+
help: "Remove 'use client' — this file only contains server-safe code. Unnecessary 'use client' directives increase client bundle size.",
|
|
149
|
+
category: "architecture"
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
return diagnostics;
|
|
153
|
+
}
|
|
154
|
+
/** 2. nextjs/missing-use-client: Flags client hooks/handlers without 'use client' */
|
|
155
|
+
function checkMissingUseClient(filePath, relPath, content, lines) {
|
|
156
|
+
const diagnostics = [];
|
|
157
|
+
if (lines.some((l) => l.text.trim() === "'use client'" || l.text.trim() === "\"use client\"")) return diagnostics;
|
|
158
|
+
const ext = extname(filePath);
|
|
159
|
+
if (ext !== ".tsx" && ext !== ".jsx") return diagnostics;
|
|
160
|
+
const clientHooks = [
|
|
161
|
+
{
|
|
162
|
+
pattern: /\buseState\b/,
|
|
163
|
+
name: "useState"
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
pattern: /\buseEffect\b/,
|
|
167
|
+
name: "useEffect"
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
pattern: /\buseRef\b/,
|
|
171
|
+
name: "useRef"
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
pattern: /\buseCallback\b/,
|
|
175
|
+
name: "useCallback"
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
pattern: /\buseMemo\b/,
|
|
179
|
+
name: "useMemo"
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
pattern: /\buseReducer\b/,
|
|
183
|
+
name: "useReducer"
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
pattern: /\buseLayoutEffect\b/,
|
|
187
|
+
name: "useLayoutEffect"
|
|
188
|
+
}
|
|
189
|
+
];
|
|
190
|
+
const eventHandlers = [
|
|
191
|
+
{
|
|
192
|
+
pattern: /\bonClick\s*=/,
|
|
193
|
+
name: "onClick"
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
pattern: /\bonChange\s*=/,
|
|
197
|
+
name: "onChange"
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
pattern: /\bonSubmit\s*=/,
|
|
201
|
+
name: "onSubmit"
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
pattern: /\bonKeyDown\s*=/,
|
|
205
|
+
name: "onKeyDown"
|
|
206
|
+
}
|
|
207
|
+
];
|
|
208
|
+
const foundHook = clientHooks.find((h) => h.pattern.test(content));
|
|
209
|
+
const foundHandler = eventHandlers.find((h) => h.pattern.test(content));
|
|
210
|
+
if (foundHook || foundHandler) {
|
|
211
|
+
const foundName = foundHook?.name ?? foundHandler?.name ?? "";
|
|
212
|
+
const pattern = foundHook?.pattern ?? foundHandler?.pattern;
|
|
213
|
+
const lineNum = (pattern ? lines.find((l) => pattern.test(l.text)) : void 0)?.num ?? 1;
|
|
214
|
+
diagnostics.push(diag({
|
|
215
|
+
rule: "nextjs/missing-use-client",
|
|
216
|
+
severity: "error",
|
|
217
|
+
filePath: relPath,
|
|
218
|
+
line: lineNum,
|
|
219
|
+
column: 1,
|
|
220
|
+
message: `${foundName} used without 'use client' directive`,
|
|
221
|
+
help: "Add 'use client' at the top of this file. React hooks and event handlers require client components in Next.js App Router.",
|
|
222
|
+
category: "architecture",
|
|
223
|
+
fixable: true,
|
|
224
|
+
suggestion: {
|
|
225
|
+
type: "insert",
|
|
226
|
+
text: "'use client'\n\n",
|
|
227
|
+
range: {
|
|
228
|
+
startLine: 1,
|
|
229
|
+
startCol: 1,
|
|
230
|
+
endLine: 1,
|
|
231
|
+
endCol: 1
|
|
232
|
+
},
|
|
233
|
+
confidence: .9,
|
|
234
|
+
reason: "Adding 'use client' at the top of the file enables React hooks and event handlers"
|
|
235
|
+
}
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
return diagnostics;
|
|
239
|
+
}
|
|
240
|
+
/** 3. nextjs/pages-router-in-app: Flags Pages Router functions in App Router projects */
|
|
241
|
+
function checkPagesRouterInApp(filePath, relPath, content, lines, isAppRouter) {
|
|
242
|
+
const diagnostics = [];
|
|
243
|
+
if (!isAppRouter) return diagnostics;
|
|
244
|
+
for (const { pattern, name } of [
|
|
245
|
+
{
|
|
246
|
+
pattern: /\bexport\s+(?:async\s+)?function\s+getServerSideProps\b/,
|
|
247
|
+
name: "getServerSideProps"
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
pattern: /\bexport\s+(?:async\s+)?function\s+getStaticProps\b/,
|
|
251
|
+
name: "getStaticProps"
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
pattern: /\bexport\s+(?:async\s+)?function\s+getStaticPaths\b/,
|
|
255
|
+
name: "getStaticPaths"
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
pattern: /\bexport\s+const\s+getServerSideProps\b/,
|
|
259
|
+
name: "getServerSideProps"
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
pattern: /\bexport\s+const\s+getStaticProps\b/,
|
|
263
|
+
name: "getStaticProps"
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
pattern: /\bexport\s+const\s+getStaticPaths\b/,
|
|
267
|
+
name: "getStaticPaths"
|
|
268
|
+
}
|
|
269
|
+
]) {
|
|
270
|
+
const matchLine = lines.find((l) => pattern.test(l.text));
|
|
271
|
+
if (matchLine) diagnostics.push(diag({
|
|
272
|
+
rule: "nextjs/pages-router-in-app",
|
|
273
|
+
severity: "warning",
|
|
274
|
+
filePath: relPath,
|
|
275
|
+
line: matchLine.num,
|
|
276
|
+
column: 1,
|
|
277
|
+
message: `Pages Router function '${name}' found in App Router project`,
|
|
278
|
+
help: `Replace with App Router equivalent: use 'fetch' in Server Components or 'use server' actions instead of ${name}. See https://nextjs.org/docs/app/building-your-application/upgrading/app-router-migration`,
|
|
279
|
+
category: "architecture"
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
return diagnostics;
|
|
283
|
+
}
|
|
284
|
+
/** 4. nextjs/next-router-vs-navigation: Flags import from 'next/router' in App Router */
|
|
285
|
+
function checkNextRouterVsNavigation(filePath, relPath, content, lines, isAppRouter) {
|
|
286
|
+
const diagnostics = [];
|
|
287
|
+
if (!isAppRouter) return diagnostics;
|
|
288
|
+
const routerImportPattern = /import\s+(?:\{[^}]*\}|[\w]+)\s+from\s+['"]next\/router['"]/;
|
|
289
|
+
const matchLine = lines.find((l) => routerImportPattern.test(l.text));
|
|
290
|
+
if (matchLine) diagnostics.push(diag({
|
|
291
|
+
rule: "nextjs/next-router-vs-navigation",
|
|
292
|
+
severity: "warning",
|
|
293
|
+
filePath: relPath,
|
|
294
|
+
line: matchLine.num,
|
|
295
|
+
column: 1,
|
|
296
|
+
message: "Import from 'next/router' in App Router project — use 'next/navigation' instead",
|
|
297
|
+
help: "In App Router, use 'next/navigation' hooks (useRouter, usePathname, useSearchParams) instead of 'next/router'. The old router API is for Pages Router only.",
|
|
298
|
+
category: "imports"
|
|
299
|
+
}));
|
|
300
|
+
return diagnostics;
|
|
301
|
+
}
|
|
302
|
+
/** 5. nextjs/image-missing-dimensions: Flags <Image> without width/height props */
|
|
303
|
+
function checkImageMissingDimensions(filePath, relPath, content, lines) {
|
|
304
|
+
const diagnostics = [];
|
|
305
|
+
const imageTagPattern = /<(Image|Img)\s+[^>]*>/g;
|
|
306
|
+
let match;
|
|
307
|
+
while ((match = imageTagPattern.exec(content)) !== null) {
|
|
308
|
+
const tagContent = match[0];
|
|
309
|
+
const tagStart = match.index;
|
|
310
|
+
const hasWidth = /\bwidth\s*=\s*[{"'0-9]/.test(tagContent);
|
|
311
|
+
const hasHeight = /\bheight\s*=\s*[{"'0-9]/.test(tagContent);
|
|
312
|
+
if (/\bfill(?:\s*=\s*\{?true\b)?/.test(tagContent)) continue;
|
|
313
|
+
if (!hasWidth || !hasHeight) {
|
|
314
|
+
const lineNum = (content.slice(0, tagStart).match(/\n/g) ?? []).length + 1;
|
|
315
|
+
const missing = !hasWidth && !hasHeight ? "width and height" : !hasWidth ? "width" : "height";
|
|
316
|
+
diagnostics.push(diag({
|
|
317
|
+
rule: "nextjs/image-missing-dimensions",
|
|
318
|
+
severity: "warning",
|
|
319
|
+
filePath: relPath,
|
|
320
|
+
line: lineNum,
|
|
321
|
+
column: 1,
|
|
322
|
+
message: `<Image> component missing ${missing} props`,
|
|
323
|
+
help: `Add ${missing} prop(s) to the Image component. Next.js requires explicit dimensions to prevent layout shift. Alternatively, use the 'fill' prop for responsive images with a sized container.`,
|
|
324
|
+
category: "performance"
|
|
325
|
+
}));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return diagnostics;
|
|
329
|
+
}
|
|
330
|
+
/** 6. nextjs/metadata-in-client: Flags metadata/generateMetadata export in 'use client' files */
|
|
331
|
+
function checkMetadataInClient(filePath, relPath, content, lines) {
|
|
332
|
+
const diagnostics = [];
|
|
333
|
+
if (!lines.some((l) => l.text.trim() === "'use client'" || l.text.trim() === "\"use client\"")) return diagnostics;
|
|
334
|
+
for (const { pattern, name } of [
|
|
335
|
+
{
|
|
336
|
+
pattern: /\bexport\s+const\s+metadata\b/,
|
|
337
|
+
name: "metadata"
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
pattern: /\bexport\s+(?:async\s+)?function\s+generateMetadata\b/,
|
|
341
|
+
name: "generateMetadata"
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
pattern: /\bexport\s+const\s+generateMetadata\b/,
|
|
345
|
+
name: "generateMetadata"
|
|
346
|
+
}
|
|
347
|
+
]) {
|
|
348
|
+
const matchLine = lines.find((l) => pattern.test(l.text));
|
|
349
|
+
if (matchLine) diagnostics.push(diag({
|
|
350
|
+
rule: "nextjs/metadata-in-client",
|
|
351
|
+
severity: "error",
|
|
352
|
+
filePath: relPath,
|
|
353
|
+
line: matchLine.num,
|
|
354
|
+
column: 1,
|
|
355
|
+
message: `${name} export in a 'use client' file — metadata must be in Server Components`,
|
|
356
|
+
help: `Move the ${name} export to a separate Server Component file (without 'use client'). Metadata is only supported in Server Components in Next.js App Router.`,
|
|
357
|
+
category: "architecture"
|
|
358
|
+
}));
|
|
359
|
+
}
|
|
360
|
+
return diagnostics;
|
|
361
|
+
}
|
|
362
|
+
/** 7. nextjs/hardcoded-env: Flags hardcoded URLs that should use env vars */
|
|
363
|
+
function checkHardcodedEnv(filePath, relPath, content, lines) {
|
|
364
|
+
const diagnostics = [];
|
|
365
|
+
const hardcodedUrlPatterns = [
|
|
366
|
+
/['"]http:\/\/localhost:\d+['"]/,
|
|
367
|
+
/['"]https?:\/\/localhost:\d+['"]/,
|
|
368
|
+
/['"]http:\/\/127\.0\.0\.1:\d+['"]/,
|
|
369
|
+
/['"]https?:\/\/[a-z0-9-]+\.example\.com(?:\/|$|['"])/,
|
|
370
|
+
/['"]http:\/\/0\.0\.0\.0:\d+['"]/
|
|
371
|
+
];
|
|
372
|
+
for (const { num, text } of lines) for (const pattern of hardcodedUrlPatterns) if (pattern.test(text)) {
|
|
373
|
+
const matched = text.match(pattern)?.[0] ?? "";
|
|
374
|
+
diagnostics.push(diag({
|
|
375
|
+
rule: "nextjs/hardcoded-env",
|
|
376
|
+
severity: "info",
|
|
377
|
+
filePath: relPath,
|
|
378
|
+
line: num,
|
|
379
|
+
column: 1,
|
|
380
|
+
message: `Hardcoded URL '${matched}' — should use NEXT_PUBLIC_ environment variable`,
|
|
381
|
+
help: "Replace hardcoded URLs with process.env.NEXT_PUBLIC_API_URL or similar. For server-only URLs, use process.env.API_URL (no NEXT_PUBLIC_ prefix).",
|
|
382
|
+
category: "config"
|
|
383
|
+
}));
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
return diagnostics;
|
|
387
|
+
}
|
|
388
|
+
/** 8. nextjs/link-without-aria: Flags <Link> without descriptive text or aria-label */
|
|
389
|
+
function checkLinkWithoutAria(filePath, relPath, content, lines) {
|
|
390
|
+
const diagnostics = [];
|
|
391
|
+
const linkPattern = /<Link\s+[^>]*>/g;
|
|
392
|
+
let match;
|
|
393
|
+
while ((match = linkPattern.exec(content)) !== null) {
|
|
394
|
+
const tagContent = match[0];
|
|
395
|
+
const tagStart = match.index;
|
|
396
|
+
const hasAriaLabel = /\baria-label\s*=/.test(tagContent);
|
|
397
|
+
const textContent = content.slice(tagStart).match(/<Link[^>]*>([\s\S]*?)<\/Link>/)?.[1]?.replace(/<[^>]*>/g, "").trim() ?? "";
|
|
398
|
+
if (!hasAriaLabel && (!textContent || textContent.length < 2)) {
|
|
399
|
+
const lineNum = (content.slice(0, tagStart).match(/\n/g) ?? []).length + 1;
|
|
400
|
+
diagnostics.push(diag({
|
|
401
|
+
rule: "nextjs/link-without-aria",
|
|
402
|
+
severity: "suggestion",
|
|
403
|
+
filePath: relPath,
|
|
404
|
+
line: lineNum,
|
|
405
|
+
column: 1,
|
|
406
|
+
message: "<Link> component without descriptive text or aria-label",
|
|
407
|
+
help: "Add descriptive text content inside <Link> or add an aria-label prop for accessibility. Screen readers need link text to convey purpose.",
|
|
408
|
+
category: "style"
|
|
409
|
+
}));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return diagnostics;
|
|
413
|
+
}
|
|
414
|
+
/** 9. tailwind/apply-anti-pattern: Flags @apply with utility classes */
|
|
415
|
+
function checkApplyAntiPattern(filePath, relPath, content, lines) {
|
|
416
|
+
const diagnostics = [];
|
|
417
|
+
for (const { num, text } of lines) if (text.match(/@apply\s+(.+)/)) diagnostics.push(diag({
|
|
418
|
+
rule: "tailwind/apply-anti-pattern",
|
|
419
|
+
severity: "warning",
|
|
420
|
+
filePath: relPath,
|
|
421
|
+
line: num,
|
|
422
|
+
column: 1,
|
|
423
|
+
message: "@apply with utility classes — prefer component extraction or inline classes",
|
|
424
|
+
help: "Instead of @apply, use: (1) inline Tailwind classes in JSX className, (2) extract a reusable React component, or (3) use @layer base/components/utilities for custom CSS. @apply negates Tailwind's utility-first approach and creates maintenance issues.",
|
|
425
|
+
category: "style"
|
|
426
|
+
}));
|
|
427
|
+
return diagnostics;
|
|
428
|
+
}
|
|
429
|
+
/** 10. tailwind/inline-style-conflict: Flags inline style= alongside Tailwind className */
|
|
430
|
+
function checkInlineStyleConflict(filePath, relPath, content, lines) {
|
|
431
|
+
const diagnostics = [];
|
|
432
|
+
const elementPattern = /<[A-Z][a-zA-Z]*\s[^>]*>|<\w+\s[^>]*>/g;
|
|
433
|
+
let match;
|
|
434
|
+
while ((match = elementPattern.exec(content)) !== null) {
|
|
435
|
+
const tagContent = match[0];
|
|
436
|
+
const tagStart = match.index;
|
|
437
|
+
const hasClassName = /\bclassName\s*=/.test(tagContent);
|
|
438
|
+
const hasInlineStyle = /\bstyle\s*=\s*\{/.test(tagContent);
|
|
439
|
+
if (hasClassName && hasInlineStyle) {
|
|
440
|
+
const lineNum = (content.slice(0, tagStart).match(/\n/g) ?? []).length + 1;
|
|
441
|
+
diagnostics.push(diag({
|
|
442
|
+
rule: "tailwind/inline-style-conflict",
|
|
443
|
+
severity: "warning",
|
|
444
|
+
filePath: relPath,
|
|
445
|
+
line: lineNum,
|
|
446
|
+
column: 1,
|
|
447
|
+
message: "Element has both Tailwind className and inline style — conflicting styling approaches",
|
|
448
|
+
help: "Use only Tailwind utility classes for styling. If Tailwind doesn't cover the style, consider: (1) using an arbitrary value like w-[123px], (2) adding a custom utility in tailwind.config, or (3) using the style prop only for truly dynamic values that can't be expressed as classes.",
|
|
449
|
+
category: "style"
|
|
450
|
+
}));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return diagnostics;
|
|
454
|
+
}
|
|
455
|
+
/** 11. tailwind/important-modifier: Flags !important modifier in classes */
|
|
456
|
+
function checkImportantModifier(filePath, relPath, content, lines) {
|
|
457
|
+
const diagnostics = [];
|
|
458
|
+
for (const { num, text } of lines) {
|
|
459
|
+
const classNameMatch = text.match(/className\s*=\s*["'`]([^"'`]+)["'`]/);
|
|
460
|
+
if (!classNameMatch) continue;
|
|
461
|
+
const classes = classNameMatch[1].trim().split(/\s+/);
|
|
462
|
+
if (classes.filter((c) => /^!/.test(c) || /^[a-z]+-.*!/.test(c)).length > 0) {
|
|
463
|
+
const bangClasses = classes.filter((c) => /^!/.test(c));
|
|
464
|
+
if (bangClasses.length > 0) diagnostics.push(diag({
|
|
465
|
+
rule: "tailwind/important-modifier",
|
|
466
|
+
severity: "info",
|
|
467
|
+
filePath: relPath,
|
|
468
|
+
line: num,
|
|
469
|
+
column: 1,
|
|
470
|
+
message: `Tailwind !important modifier used: ${bangClasses.join(", ")}`,
|
|
471
|
+
help: "Avoid the !important modifier (!-prefix). Instead, increase specificity by: (1) using a more specific Tailwind variant (hover:, focus:, etc.), (2) ordering classes correctly (last same-specificity class wins), or (3) using @layer to control cascade order.",
|
|
472
|
+
category: "style"
|
|
473
|
+
}));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return diagnostics;
|
|
477
|
+
}
|
|
478
|
+
/** 12. tailwind/duplicate-utilities: Flags conflicting utilities in same className */
|
|
479
|
+
function checkDuplicateUtilities(filePath, relPath, content, lines) {
|
|
480
|
+
const diagnostics = [];
|
|
481
|
+
const conflictGroups = [
|
|
482
|
+
{
|
|
483
|
+
prefix: "p-",
|
|
484
|
+
label: "padding"
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
prefix: "px-",
|
|
488
|
+
label: "padding-x"
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
prefix: "py-",
|
|
492
|
+
label: "padding-y"
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
prefix: "m-",
|
|
496
|
+
label: "margin"
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
prefix: "mx-",
|
|
500
|
+
label: "margin-x"
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
prefix: "my-",
|
|
504
|
+
label: "margin-y"
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
prefix: "w-",
|
|
508
|
+
label: "width"
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
prefix: "h-",
|
|
512
|
+
label: "height"
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
prefix: "text-",
|
|
516
|
+
label: "text-size/color"
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
prefix: "bg-",
|
|
520
|
+
label: "background"
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
prefix: "rounded-",
|
|
524
|
+
label: "border-radius"
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
prefix: "border-",
|
|
528
|
+
label: "border"
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
prefix: "font-",
|
|
532
|
+
label: "font-weight/family"
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
prefix: "leading-",
|
|
536
|
+
label: "line-height"
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
prefix: "tracking-",
|
|
540
|
+
label: "letter-spacing"
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
prefix: "gap-",
|
|
544
|
+
label: "gap"
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
prefix: "z-",
|
|
548
|
+
label: "z-index"
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
prefix: "opacity-",
|
|
552
|
+
label: "opacity"
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
prefix: "min-w-",
|
|
556
|
+
label: "min-width"
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
prefix: "max-w-",
|
|
560
|
+
label: "max-width"
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
prefix: "min-h-",
|
|
564
|
+
label: "min-height"
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
prefix: "max-h-",
|
|
568
|
+
label: "max-height"
|
|
569
|
+
}
|
|
570
|
+
];
|
|
571
|
+
for (const { num, text } of lines) {
|
|
572
|
+
const classNameMatch = text.match(/className\s*=\s*["'`]([^"'`]+)["'`]/);
|
|
573
|
+
if (!classNameMatch) continue;
|
|
574
|
+
const classes = classNameMatch[1].trim().split(/\s+/);
|
|
575
|
+
const seen = /* @__PURE__ */ new Map();
|
|
576
|
+
for (const cls of classes) {
|
|
577
|
+
const strippedCls = cls.replace(/^(?:sm|md|lg|xl|2xl|hover|focus|active|disabled|dark|group-hover|peer-hover|first|last|odd|even|focus-within|focus-visible):/, "");
|
|
578
|
+
for (const { prefix, label } of conflictGroups) if (strippedCls.startsWith(prefix)) {
|
|
579
|
+
if (seen.has(prefix)) {
|
|
580
|
+
diagnostics.push(diag({
|
|
581
|
+
rule: "tailwind/duplicate-utilities",
|
|
582
|
+
severity: "warning",
|
|
583
|
+
filePath: relPath,
|
|
584
|
+
line: num,
|
|
585
|
+
column: 1,
|
|
586
|
+
message: `Conflicting Tailwind utilities in same className: multiple '${label}' values`,
|
|
587
|
+
help: `Remove the duplicate ${label} utility. When conflicting utilities are present, only the last one in the class list takes effect (same specificity). This is likely a copy-paste error.`,
|
|
588
|
+
category: "style"
|
|
589
|
+
}));
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
seen.set(prefix, cls);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return diagnostics;
|
|
597
|
+
}
|
|
598
|
+
/** 13. tailwind/magic-values: Flags arbitrary values like w-[123px] */
|
|
599
|
+
function checkMagicValues(filePath, relPath, content, lines) {
|
|
600
|
+
const diagnostics = [];
|
|
601
|
+
const standardSpacing = new Set([
|
|
602
|
+
"0",
|
|
603
|
+
"0.5",
|
|
604
|
+
"1",
|
|
605
|
+
"1.5",
|
|
606
|
+
"2",
|
|
607
|
+
"2.5",
|
|
608
|
+
"3",
|
|
609
|
+
"3.5",
|
|
610
|
+
"4",
|
|
611
|
+
"5",
|
|
612
|
+
"6",
|
|
613
|
+
"7",
|
|
614
|
+
"8",
|
|
615
|
+
"9",
|
|
616
|
+
"10",
|
|
617
|
+
"11",
|
|
618
|
+
"12",
|
|
619
|
+
"14",
|
|
620
|
+
"16",
|
|
621
|
+
"20",
|
|
622
|
+
"24",
|
|
623
|
+
"28",
|
|
624
|
+
"32",
|
|
625
|
+
"36",
|
|
626
|
+
"40",
|
|
627
|
+
"44",
|
|
628
|
+
"48",
|
|
629
|
+
"52",
|
|
630
|
+
"56",
|
|
631
|
+
"60",
|
|
632
|
+
"64",
|
|
633
|
+
"72",
|
|
634
|
+
"80",
|
|
635
|
+
"96"
|
|
636
|
+
]);
|
|
637
|
+
const arbitraryValuePattern = /\b(?:w|h|min-w|max-w|min-h|max-h|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap|top|left|right|bottom|space-x|space-y)-\[([^\]]+)\]/g;
|
|
638
|
+
for (const { num, text } of lines) {
|
|
639
|
+
const classContent = text.match(/className\s*=\s*["'`]([^"'`]+)["'`]/)?.[1] ?? "";
|
|
640
|
+
let match;
|
|
641
|
+
arbitraryValuePattern.lastIndex = 0;
|
|
642
|
+
while ((match = arbitraryValuePattern.exec(classContent)) !== null) {
|
|
643
|
+
const value = match[1];
|
|
644
|
+
if (value.startsWith("var(") || value.startsWith("calc(") || value.startsWith("theme(")) continue;
|
|
645
|
+
const numericPart = value.replace(/px|rem|em|%|vw|vh|fr|deg|ms|s$/, "").trim();
|
|
646
|
+
if (/^\d+(\.\d+)?$/.test(numericPart) && !standardSpacing.has(numericPart)) diagnostics.push(diag({
|
|
647
|
+
rule: "tailwind/magic-values",
|
|
648
|
+
severity: "suggestion",
|
|
649
|
+
filePath: relPath,
|
|
650
|
+
line: num,
|
|
651
|
+
column: 1,
|
|
652
|
+
message: `Arbitrary Tailwind value '${match[0]}' — use standard spacing scale instead`,
|
|
653
|
+
help: "Use a standard Tailwind spacing value instead of an arbitrary value. For example, use 'p-4' (1rem) instead of 'p-[16px]'. If no standard value fits, consider extending your tailwind.config theme instead of using arbitrary values everywhere.",
|
|
654
|
+
category: "style"
|
|
655
|
+
}));
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return diagnostics;
|
|
659
|
+
}
|
|
660
|
+
/** 14. tailwind/incomplete-flex: Flags flex without items-center or justify-* */
|
|
661
|
+
function checkIncompleteFlex(filePath, relPath, content, lines) {
|
|
662
|
+
const diagnostics = [];
|
|
663
|
+
for (const { num, text } of lines) {
|
|
664
|
+
const classContent = text.match(/className\s*=\s*["'`]([^"'`]+)["'`]/)?.[1] ?? "";
|
|
665
|
+
if (!classContent) continue;
|
|
666
|
+
const classes = classContent.split(/\s+/);
|
|
667
|
+
if (!classes.some((c) => c === "flex" || c === "inline-flex")) continue;
|
|
668
|
+
const hasItemsAlign = classes.some((c) => c.startsWith("items-"));
|
|
669
|
+
const hasJustify = classes.some((c) => c.startsWith("justify-"));
|
|
670
|
+
if (!hasItemsAlign && !hasJustify) diagnostics.push(diag({
|
|
671
|
+
rule: "tailwind/incomplete-flex",
|
|
672
|
+
severity: "info",
|
|
673
|
+
filePath: relPath,
|
|
674
|
+
line: num,
|
|
675
|
+
column: 1,
|
|
676
|
+
message: "flex container without items-* or justify-* alignment",
|
|
677
|
+
help: "Add alignment utilities to the flex container: 'items-center' for cross-axis alignment and 'justify-between'/'justify-center' for main-axis alignment. Bare 'flex' defaults to stretch and start which may not be intended.",
|
|
678
|
+
category: "style"
|
|
679
|
+
}));
|
|
680
|
+
}
|
|
681
|
+
return diagnostics;
|
|
682
|
+
}
|
|
683
|
+
/** 15. tailwind/overloaded-classname: Flags className strings with 15+ utility classes */
|
|
684
|
+
function checkOverloadedClassname(filePath, relPath, content, lines) {
|
|
685
|
+
const diagnostics = [];
|
|
686
|
+
const CLASS_THRESHOLD = 15;
|
|
687
|
+
for (const { num, text } of lines) {
|
|
688
|
+
const classNameMatch = text.match(/className\s*=\s*["'`]([^"'`]+)["'`]/);
|
|
689
|
+
if (!classNameMatch) continue;
|
|
690
|
+
const classes = classNameMatch[1].trim().split(/\s+/).filter((c) => c.length > 0);
|
|
691
|
+
if (classes.length >= CLASS_THRESHOLD) diagnostics.push(diag({
|
|
692
|
+
rule: "tailwind/overloaded-classname",
|
|
693
|
+
severity: "suggestion",
|
|
694
|
+
filePath: relPath,
|
|
695
|
+
line: num,
|
|
696
|
+
column: 1,
|
|
697
|
+
message: `className has ${classes.length} utility classes (${CLASS_THRESHOLD}+ threshold) — extract to component`,
|
|
698
|
+
help: "Extract this element into a reusable component with descriptive props, or use cva (class-variance-authority) / clsx for conditional class composition. Long className strings are hard to read and maintain.",
|
|
699
|
+
category: "style"
|
|
700
|
+
}));
|
|
701
|
+
}
|
|
702
|
+
return diagnostics;
|
|
703
|
+
}
|
|
704
|
+
const frameworkLintEngine = {
|
|
705
|
+
name: "framework-lint",
|
|
706
|
+
description: "Framework-specific AI slop detection (Next.js, Tailwind CSS)",
|
|
707
|
+
supportedLanguages: [
|
|
708
|
+
"typescript",
|
|
709
|
+
"javascript",
|
|
710
|
+
"tsx",
|
|
711
|
+
"jsx"
|
|
712
|
+
],
|
|
713
|
+
async run(context) {
|
|
714
|
+
const start = performance.now();
|
|
715
|
+
const diagnostics = [];
|
|
716
|
+
const root = context.rootDirectory;
|
|
717
|
+
if (!(context.languages.includes("typescript") || context.languages.includes("javascript") || context.languages.includes("tsx") || context.languages.includes("jsx"))) return {
|
|
718
|
+
engine: this.name,
|
|
719
|
+
diagnostics: [],
|
|
720
|
+
elapsed: performance.now() - start,
|
|
721
|
+
skipped: true,
|
|
722
|
+
skipReason: "No TypeScript or JavaScript detected in project"
|
|
723
|
+
};
|
|
724
|
+
const hasNextJs = await detectNextJs(root);
|
|
725
|
+
const hasTailwind = await detectTailwind(root);
|
|
726
|
+
const isAppRouter = hasNextJs && await isAppRouterProject(root);
|
|
727
|
+
if (!hasNextJs && !hasTailwind) return {
|
|
728
|
+
engine: this.name,
|
|
729
|
+
diagnostics: [],
|
|
730
|
+
elapsed: performance.now() - start,
|
|
731
|
+
skipped: true,
|
|
732
|
+
skipReason: "No Next.js or Tailwind CSS detected in project"
|
|
733
|
+
};
|
|
734
|
+
const scanFiles = context.files?.length ? context.files.map((f) => join(root, f)) : await collectScanFiles(root);
|
|
735
|
+
for (const filePath of scanFiles) {
|
|
736
|
+
const ext = extname(filePath);
|
|
737
|
+
if (!SCAN_EXTENSIONS.has(ext)) continue;
|
|
738
|
+
let content;
|
|
739
|
+
try {
|
|
740
|
+
content = await readFileContent(filePath);
|
|
741
|
+
} catch {
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
const relPath = relative(root, filePath);
|
|
745
|
+
const lines = toLines(content);
|
|
746
|
+
if (hasNextJs && ext !== ".css") {
|
|
747
|
+
const nextDiagnostics = [
|
|
748
|
+
...checkMisplacedUseClient(filePath, relPath, content, lines),
|
|
749
|
+
...checkMissingUseClient(filePath, relPath, content, lines),
|
|
750
|
+
...checkPagesRouterInApp(filePath, relPath, content, lines, isAppRouter),
|
|
751
|
+
...checkNextRouterVsNavigation(filePath, relPath, content, lines, isAppRouter),
|
|
752
|
+
...checkImageMissingDimensions(filePath, relPath, content, lines),
|
|
753
|
+
...checkMetadataInClient(filePath, relPath, content, lines),
|
|
754
|
+
...checkHardcodedEnv(filePath, relPath, content, lines),
|
|
755
|
+
...checkLinkWithoutAria(filePath, relPath, content, lines)
|
|
756
|
+
];
|
|
757
|
+
diagnostics.push(...nextDiagnostics);
|
|
758
|
+
}
|
|
759
|
+
if (hasTailwind) {
|
|
760
|
+
const tailwindDiagnostics = [
|
|
761
|
+
...checkApplyAntiPattern(filePath, relPath, content, lines),
|
|
762
|
+
...checkInlineStyleConflict(filePath, relPath, content, lines),
|
|
763
|
+
...checkImportantModifier(filePath, relPath, content, lines),
|
|
764
|
+
...checkDuplicateUtilities(filePath, relPath, content, lines),
|
|
765
|
+
...checkMagicValues(filePath, relPath, content, lines),
|
|
766
|
+
...checkIncompleteFlex(filePath, relPath, content, lines),
|
|
767
|
+
...checkOverloadedClassname(filePath, relPath, content, lines)
|
|
768
|
+
];
|
|
769
|
+
diagnostics.push(...tailwindDiagnostics);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return {
|
|
773
|
+
engine: this.name,
|
|
774
|
+
diagnostics,
|
|
775
|
+
elapsed: performance.now() - start,
|
|
776
|
+
skipped: false
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
//#endregion
|
|
782
|
+
export { frameworkLintEngine };
|