@justinmoto/frontend-guardian-core 0.1.1 → 0.1.3
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/README.md +5 -1
- package/dist/scanEngine.d.ts +6 -0
- package/dist/scanEngine.d.ts.map +1 -1
- package/dist/scanEngine.js +163 -61
- package/package.json +1 -1
- package/src/scanEngine.ts +163 -64
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# @justinmoto/frontend-guardian-core
|
|
2
2
|
|
|
3
|
-
Library (scan engine) for Frontend Guardian
|
|
3
|
+
Library (scan engine) for **Frontend Guardian**.
|
|
4
|
+
|
|
5
|
+
**Frontend Guardian** helps frontend developers keep their UI consistent. After you ship a project it often *looks* good but when you (or someone else) opens it again later, you notice uneven spacing, inconsistent borders and radii, mixed colors, or copy-pasted components that drifted apart. Frontend Guardian scans your code and flags those issues so you can fix them before they pile up.
|
|
6
|
+
|
|
7
|
+
Use this package if you want to call the scanner from your own Node code.
|
|
4
8
|
|
|
5
9
|
**To run scans from the command line**, use the CLI instead:
|
|
6
10
|
|
package/dist/scanEngine.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export type Warning = {
|
|
2
2
|
file: string;
|
|
3
|
+
line?: number;
|
|
4
|
+
locations?: {
|
|
5
|
+
file: string;
|
|
6
|
+
line: number;
|
|
7
|
+
}[];
|
|
3
8
|
message: string;
|
|
4
9
|
suggestion?: string;
|
|
5
10
|
};
|
|
@@ -10,6 +15,7 @@ export type Duplicate = {
|
|
|
10
15
|
};
|
|
11
16
|
export type ScanResult = {
|
|
12
17
|
score: number;
|
|
18
|
+
filesScanned: number;
|
|
13
19
|
warnings: Warning[];
|
|
14
20
|
duplicates: Duplicate[];
|
|
15
21
|
};
|
package/dist/scanEngine.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scanEngine.d.ts","sourceRoot":"","sources":["../src/scanEngine.ts"],"names":[],"mappings":"AASA,MAAM,MAAM,OAAO,GAAG;
|
|
1
|
+
{"version":3,"file":"scanEngine.d.ts","sourceRoot":"","sources":["../src/scanEngine.ts"],"names":[],"mappings":"AASA,MAAM,MAAM,OAAO,GAAG;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC7C,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AACF,MAAM,MAAM,SAAS,GAAG;IAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAClF,MAAM,MAAM,UAAU,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,EAAE,CAAC;IAAC,UAAU,EAAE,SAAS,EAAE,CAAA;CAAE,CAAC;AA4S/G,wBAAsB,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAajE;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,GAAG,UAAU,CAG7E"}
|
package/dist/scanEngine.js
CHANGED
|
@@ -12,18 +12,20 @@ function isSourcePath(path) {
|
|
|
12
12
|
const normalized = path.replace(/\\/g, "/");
|
|
13
13
|
return !IGNORE_PATH_PARTS.some((part) => normalized.includes(part));
|
|
14
14
|
}
|
|
15
|
-
function
|
|
16
|
-
const
|
|
15
|
+
function extractClassNamesWithLines(ast) {
|
|
16
|
+
const out = [];
|
|
17
17
|
traverse(ast, {
|
|
18
18
|
JSXAttribute(path) {
|
|
19
19
|
if (path.node.name.type === "JSXIdentifier" && path.node.name.name === "className") {
|
|
20
20
|
const val = path.node.value;
|
|
21
|
-
if (val?.type === "StringLiteral")
|
|
22
|
-
|
|
21
|
+
if (val?.type === "StringLiteral") {
|
|
22
|
+
const line = path.node.loc?.start?.line ?? 0;
|
|
23
|
+
out.push({ value: val.value, line });
|
|
24
|
+
}
|
|
23
25
|
}
|
|
24
26
|
},
|
|
25
27
|
});
|
|
26
|
-
return
|
|
28
|
+
return out;
|
|
27
29
|
}
|
|
28
30
|
function normalizeJsxForHash(code) {
|
|
29
31
|
return code
|
|
@@ -32,8 +34,8 @@ function normalizeJsxForHash(code) {
|
|
|
32
34
|
.replace(/\bclassName=\{[^}]*\}/g, "className={}")
|
|
33
35
|
.trim();
|
|
34
36
|
}
|
|
35
|
-
function
|
|
36
|
-
const
|
|
37
|
+
function extractButtonClassNamesWithLine(ast) {
|
|
38
|
+
const out = [];
|
|
37
39
|
traverse(ast, {
|
|
38
40
|
JSXOpeningElement(path) {
|
|
39
41
|
const name = path.node.name;
|
|
@@ -45,17 +47,59 @@ function extractButtonClassNames(ast) {
|
|
|
45
47
|
break;
|
|
46
48
|
}
|
|
47
49
|
}
|
|
50
|
+
const line = path.node.loc?.start?.line ?? 0;
|
|
48
51
|
const isButton = tagName === "button" || (typeof className === "string" && /btn|button/i.test(className));
|
|
49
52
|
if (isButton && className)
|
|
50
|
-
|
|
53
|
+
out.push({ classes: className, line });
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
const SKIP_UNUSED_IMPORT_NAMES = new Set([
|
|
59
|
+
"React", "Metadata", "FormEvent", "ChangeEvent", "ReactElement", "ReactNode", "FC", "NextConfig",
|
|
60
|
+
"FormEventHandler", "ChangeEventHandler", "MouseEvent", "KeyboardEvent", "SyntheticEvent",
|
|
61
|
+
"Props", "ComponentProps", "ReactNode", "CSSProperties", "HTMLAttributes", "ButtonHTMLAttributes",
|
|
62
|
+
]);
|
|
63
|
+
const SKIP_IF_FROM_REACT = new Set(["React", "FC", "ReactNode", "ReactElement", "CSSProperties", "FormEvent", "ChangeEvent", "MouseEvent", "KeyboardEvent", "SyntheticEvent", "Props", "ComponentProps", "HTMLAttributes", "ButtonHTMLAttributes", "FormEventHandler", "ChangeEventHandler"]);
|
|
64
|
+
const SKIP_IF_FROM_NEXT = new Set(["Metadata", "NextConfig", "Image", "Link", "Router", "Head", "Script"]);
|
|
65
|
+
function getNamesUsedInTypePosition(ast) {
|
|
66
|
+
const used = new Set();
|
|
67
|
+
traverse(ast, {
|
|
68
|
+
TSTypeReference(path) {
|
|
69
|
+
const n = path.node.typeName;
|
|
70
|
+
if (n.type === "Identifier" && n.name)
|
|
71
|
+
used.add(n.name);
|
|
72
|
+
if (n.type === "TSQualifiedName") {
|
|
73
|
+
if (n.right?.name)
|
|
74
|
+
used.add(n.right.name);
|
|
75
|
+
if (n.left && "name" in n.left && n.left.name)
|
|
76
|
+
used.add(n.left.name);
|
|
77
|
+
}
|
|
51
78
|
},
|
|
52
79
|
});
|
|
53
|
-
return
|
|
80
|
+
return used;
|
|
54
81
|
}
|
|
55
|
-
function
|
|
82
|
+
function getNamesUsedInJSX(ast) {
|
|
83
|
+
const used = new Set();
|
|
84
|
+
traverse(ast, {
|
|
85
|
+
JSXOpeningElement(path) {
|
|
86
|
+
const name = path.node.name;
|
|
87
|
+
if (name.type === "JSXIdentifier" && name.name)
|
|
88
|
+
used.add(name.name);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
return used;
|
|
92
|
+
}
|
|
93
|
+
function getUnusedImportsWithLine(ast) {
|
|
94
|
+
const usedInTypes = getNamesUsedInTypePosition(ast);
|
|
95
|
+
const usedInJSX = getNamesUsedInJSX(ast);
|
|
56
96
|
const unused = [];
|
|
57
97
|
traverse(ast, {
|
|
58
98
|
ImportDeclaration(path) {
|
|
99
|
+
const line = path.node.loc?.start?.line ?? 0;
|
|
100
|
+
const source = path.node.source?.value ?? "";
|
|
101
|
+
const fromReact = source === "react" || source.startsWith("react/");
|
|
102
|
+
const fromNext = source === "next" || source.startsWith("next/");
|
|
59
103
|
for (const spec of path.node.specifiers) {
|
|
60
104
|
const s = spec;
|
|
61
105
|
const local = s.local?.name;
|
|
@@ -63,141 +107,199 @@ function getUnusedImports(ast) {
|
|
|
63
107
|
continue;
|
|
64
108
|
if (s.importKind === "type" || s.importKind === "typeof")
|
|
65
109
|
continue;
|
|
110
|
+
if (SKIP_UNUSED_IMPORT_NAMES.has(local))
|
|
111
|
+
continue;
|
|
112
|
+
if (fromReact && SKIP_IF_FROM_REACT.has(local))
|
|
113
|
+
continue;
|
|
114
|
+
if (fromNext && SKIP_IF_FROM_NEXT.has(local))
|
|
115
|
+
continue;
|
|
116
|
+
if (usedInTypes.has(local))
|
|
117
|
+
continue;
|
|
118
|
+
if (usedInJSX.has(local))
|
|
119
|
+
continue;
|
|
66
120
|
const binding = path.scope.getBinding(local);
|
|
67
121
|
if (binding && !binding.referenced)
|
|
68
|
-
unused.push(local);
|
|
122
|
+
unused.push({ name: local, line });
|
|
69
123
|
}
|
|
70
124
|
},
|
|
71
125
|
});
|
|
72
126
|
return unused;
|
|
73
127
|
}
|
|
128
|
+
function getUnusedVariablesWithLine(ast) {
|
|
129
|
+
const unused = [];
|
|
130
|
+
traverse(ast, {
|
|
131
|
+
VariableDeclarator(path) {
|
|
132
|
+
const id = path.node.id;
|
|
133
|
+
if (id.type !== "Identifier" || !id.name)
|
|
134
|
+
return;
|
|
135
|
+
if (id.name.startsWith("_"))
|
|
136
|
+
return;
|
|
137
|
+
const binding = path.scope.getBinding(id.name);
|
|
138
|
+
if (!binding || binding.referenced)
|
|
139
|
+
return;
|
|
140
|
+
unused.push({ name: id.name, line: id.loc?.start?.line ?? 0 });
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
return unused;
|
|
144
|
+
}
|
|
74
145
|
function runAnalysis(fileContents) {
|
|
75
146
|
const sourceOnly = fileContents.filter((f) => isSourcePath(f.path));
|
|
76
147
|
const warnings = [];
|
|
77
148
|
const jsxHashes = new Map();
|
|
78
|
-
const
|
|
79
|
-
const
|
|
149
|
+
const spacingData = new Map();
|
|
150
|
+
const radiusByFile = new Map();
|
|
80
151
|
const colorClassesByFile = new Map();
|
|
81
152
|
const arbitraryColorByFile = new Map();
|
|
82
|
-
const
|
|
83
|
-
|
|
153
|
+
const buttonDataByFile = new Map();
|
|
154
|
+
const MAX_LOCATIONS = 25;
|
|
155
|
+
for (const { path: filePath, code } of sourceOnly) {
|
|
84
156
|
try {
|
|
85
|
-
const ast = parser.parse(code, { sourceType: "module", plugins: ["jsx", "typescript"] });
|
|
86
|
-
const
|
|
87
|
-
for (const name of
|
|
157
|
+
const ast = parser.parse(code, { sourceType: "module", plugins: ["jsx", "typescript"], attachComment: false });
|
|
158
|
+
const unusedList = getUnusedImportsWithLine(ast);
|
|
159
|
+
for (const { name, line } of unusedList) {
|
|
88
160
|
warnings.push({
|
|
89
|
-
file:
|
|
161
|
+
file: filePath,
|
|
162
|
+
line,
|
|
90
163
|
message: `Unused import: ${name}`,
|
|
91
164
|
suggestion: "Remove unused imports to keep the file clean and avoid confusion. Use your editor's organize-imports or run ESLint with no-unused-vars.",
|
|
92
165
|
});
|
|
93
166
|
}
|
|
94
|
-
const
|
|
95
|
-
for (const
|
|
167
|
+
const unusedVars = getUnusedVariablesWithLine(ast);
|
|
168
|
+
for (const { name, line } of unusedVars) {
|
|
169
|
+
warnings.push({
|
|
170
|
+
file: filePath,
|
|
171
|
+
line,
|
|
172
|
+
message: `Unused variable: ${name}`,
|
|
173
|
+
suggestion: "Remove or use the variable. Prefix with _ if intentionally unused.",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const classEntries = extractClassNamesWithLines(ast);
|
|
177
|
+
for (const { value: cn, line } of classEntries) {
|
|
96
178
|
const parts = cn.split(/\s+/).filter(Boolean);
|
|
97
179
|
for (const p of parts) {
|
|
98
180
|
if (/^p[xy]?-[a-z0-9]+$/.test(p) || /^m[xy]?-[a-z0-9]+$/.test(p)) {
|
|
99
181
|
const key = p.replace(/[0-9]+$/, "N");
|
|
100
|
-
if (!
|
|
101
|
-
|
|
102
|
-
|
|
182
|
+
if (!spacingData.has(key))
|
|
183
|
+
spacingData.set(key, { values: new Set(), locations: [] });
|
|
184
|
+
const entry = spacingData.get(key);
|
|
185
|
+
entry.values.add(p);
|
|
186
|
+
if (entry.locations.length < MAX_LOCATIONS)
|
|
187
|
+
entry.locations.push({ file: filePath, line });
|
|
103
188
|
}
|
|
104
189
|
if (/^rounded[a-z0-9-]*$/.test(p)) {
|
|
105
|
-
if (!
|
|
106
|
-
|
|
107
|
-
|
|
190
|
+
if (!radiusByFile.has(filePath))
|
|
191
|
+
radiusByFile.set(filePath, { values: new Set(), line });
|
|
192
|
+
radiusByFile.get(filePath).values.add(p);
|
|
108
193
|
}
|
|
109
194
|
if (/^(bg|text|border)-(.+)$/.test(p)) {
|
|
110
|
-
if (!colorClassesByFile.has(
|
|
111
|
-
colorClassesByFile.set(
|
|
112
|
-
colorClassesByFile.get(
|
|
195
|
+
if (!colorClassesByFile.has(filePath))
|
|
196
|
+
colorClassesByFile.set(filePath, new Set());
|
|
197
|
+
colorClassesByFile.get(filePath).add(p);
|
|
113
198
|
if (/\[#|\[rgb|\[hsl/.test(p.replace(/^(bg|text|border)-/, "")))
|
|
114
|
-
arbitraryColorByFile.set(
|
|
199
|
+
arbitraryColorByFile.set(filePath, line);
|
|
115
200
|
}
|
|
116
201
|
}
|
|
117
202
|
}
|
|
118
|
-
const
|
|
119
|
-
if (
|
|
120
|
-
|
|
203
|
+
const btnEntries = extractButtonClassNamesWithLine(ast);
|
|
204
|
+
if (btnEntries.length > 0)
|
|
205
|
+
buttonDataByFile.set(filePath, btnEntries.map((e) => ({ classes: e.classes.split(/\s+/).filter(Boolean), line: e.line })));
|
|
121
206
|
const normalized = normalizeJsxForHash(code);
|
|
122
207
|
if (normalized.length > 50) {
|
|
123
208
|
const hash = crypto.createHash("md5").update(normalized).digest("hex");
|
|
124
209
|
if (!jsxHashes.has(hash))
|
|
125
210
|
jsxHashes.set(hash, []);
|
|
126
|
-
jsxHashes.get(hash).push(
|
|
211
|
+
jsxHashes.get(hash).push(filePath);
|
|
127
212
|
}
|
|
128
213
|
}
|
|
129
214
|
catch {
|
|
130
215
|
warnings.push({
|
|
131
|
-
file:
|
|
216
|
+
file: filePath,
|
|
132
217
|
message: "Parse error (not valid JS/TS/JSX)",
|
|
133
218
|
suggestion: "Check syntax, brackets, and that the file is valid JS/TS/JSX. Fix or remove the file from the scan.",
|
|
134
219
|
});
|
|
135
220
|
}
|
|
136
221
|
}
|
|
137
|
-
for (const [,
|
|
138
|
-
if (values.size > 1) {
|
|
222
|
+
for (const [, data] of spacingData) {
|
|
223
|
+
if (data.values.size > 1) {
|
|
139
224
|
warnings.push({
|
|
140
225
|
file: "(project)",
|
|
141
|
-
|
|
226
|
+
locations: data.locations.slice(0, MAX_LOCATIONS),
|
|
227
|
+
message: `Mixed spacing values: ${[...data.values].join(", ")}`,
|
|
142
228
|
suggestion: "Pick one spacing scale (e.g. px-4, py-2) for similar elements and use it across the project so spacing stays consistent.",
|
|
143
229
|
});
|
|
144
230
|
}
|
|
145
231
|
}
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (radiusByFile.size > 1) {
|
|
150
|
-
const unique = [...new Set([...radiusByFile.values()].flat())];
|
|
232
|
+
const radiusFileList = [...radiusByFile.entries()];
|
|
233
|
+
if (radiusFileList.length > 1) {
|
|
234
|
+
const unique = [...new Set(radiusFileList.flatMap(([, r]) => [...r.values]))];
|
|
151
235
|
if (unique.length > 1) {
|
|
152
236
|
warnings.push({
|
|
153
237
|
file: "(project)",
|
|
238
|
+
locations: radiusFileList.map(([p, r]) => ({ file: p, line: r.line })).slice(0, MAX_LOCATIONS),
|
|
154
239
|
message: `Inconsistent border-radius: ${unique.join(", ")}`,
|
|
155
240
|
suggestion: "Use one radius token (e.g. rounded-lg) for cards and buttons so the UI looks consistent.",
|
|
156
241
|
});
|
|
157
242
|
}
|
|
158
243
|
}
|
|
159
|
-
const
|
|
160
|
-
if (
|
|
244
|
+
const arbitraryEntries = [...arbitraryColorByFile.entries()];
|
|
245
|
+
if (arbitraryEntries.length > 0) {
|
|
246
|
+
const [firstFile, line] = arbitraryEntries[0];
|
|
161
247
|
warnings.push({
|
|
162
|
-
file:
|
|
248
|
+
file: firstFile,
|
|
249
|
+
line,
|
|
163
250
|
message: "Arbitrary color values (e.g. bg-[#hex], text-[rgb()]) detected.",
|
|
164
251
|
suggestion: "Prefer Tailwind palette classes (e.g. bg-zinc-100, text-emerald-600) for consistent theming and maintenance.",
|
|
165
252
|
});
|
|
166
253
|
}
|
|
167
254
|
const buttonPadding = new Map();
|
|
168
255
|
const buttonRadius = new Map();
|
|
169
|
-
for (const [filePath,
|
|
170
|
-
for (const cn of
|
|
171
|
-
for (const p of cn
|
|
256
|
+
for (const [filePath, entries] of buttonDataByFile) {
|
|
257
|
+
for (const { classes: cn, line } of entries) {
|
|
258
|
+
for (const p of cn) {
|
|
172
259
|
if (/^p[xy]?-[a-z0-9]+$/.test(p)) {
|
|
173
260
|
const key = p.replace(/[0-9]+$/, "N");
|
|
174
261
|
if (!buttonPadding.has(key))
|
|
175
|
-
buttonPadding.set(key, new Set());
|
|
176
|
-
buttonPadding.get(key)
|
|
262
|
+
buttonPadding.set(key, { values: new Set(), locations: [] });
|
|
263
|
+
const entry = buttonPadding.get(key);
|
|
264
|
+
entry.values.add(p);
|
|
265
|
+
if (entry.locations.length < MAX_LOCATIONS)
|
|
266
|
+
entry.locations.push({ file: filePath, line });
|
|
177
267
|
}
|
|
178
268
|
if (/^rounded[a-z0-9-]*$/.test(p)) {
|
|
179
269
|
if (!buttonRadius.has(filePath))
|
|
180
|
-
buttonRadius.set(filePath,
|
|
181
|
-
buttonRadius.get(filePath)
|
|
270
|
+
buttonRadius.set(filePath, []);
|
|
271
|
+
const arr = buttonRadius.get(filePath);
|
|
272
|
+
const existing = arr.find((x) => x.values.has(p));
|
|
273
|
+
if (!existing)
|
|
274
|
+
arr.push({ values: new Set([p]), line });
|
|
275
|
+
else
|
|
276
|
+
existing.values.add(p);
|
|
182
277
|
}
|
|
183
278
|
}
|
|
184
279
|
}
|
|
185
280
|
}
|
|
186
|
-
for (const [,
|
|
187
|
-
if (values.size > 1) {
|
|
281
|
+
for (const [, data] of buttonPadding) {
|
|
282
|
+
if (data.values.size > 1) {
|
|
188
283
|
warnings.push({
|
|
189
284
|
file: "(project)",
|
|
190
|
-
|
|
285
|
+
locations: data.locations.slice(0, MAX_LOCATIONS),
|
|
286
|
+
message: `Mixed button padding: ${[...data.values].join(", ")}`,
|
|
191
287
|
suggestion: "Use one button padding (e.g. px-4 py-2) across all buttons for a consistent look.",
|
|
192
288
|
});
|
|
193
289
|
break;
|
|
194
290
|
}
|
|
195
291
|
}
|
|
196
|
-
const
|
|
197
|
-
if (
|
|
292
|
+
const allBtnRadiusValues = [...buttonRadius.values()].flatMap((arr) => arr.flatMap((r) => [...r.values]));
|
|
293
|
+
if (allBtnRadiusValues.length > 1 && new Set(allBtnRadiusValues).size > 1) {
|
|
294
|
+
const locations = [];
|
|
295
|
+
for (const [file, arr] of buttonRadius.entries()) {
|
|
296
|
+
if (arr.length > 0 && locations.length < MAX_LOCATIONS)
|
|
297
|
+
locations.push({ file, line: arr[0].line });
|
|
298
|
+
}
|
|
198
299
|
warnings.push({
|
|
199
300
|
file: "(project)",
|
|
200
|
-
|
|
301
|
+
locations,
|
|
302
|
+
message: `Inconsistent button border-radius: ${[...new Set(allBtnRadiusValues)].join(", ")}`,
|
|
201
303
|
suggestion: "Use one radius (e.g. rounded-lg) for all buttons.",
|
|
202
304
|
});
|
|
203
305
|
}
|
|
@@ -210,7 +312,7 @@ function runAnalysis(fileContents) {
|
|
|
210
312
|
}
|
|
211
313
|
const penalty = warnings.length * 5 + duplicates.length * 10;
|
|
212
314
|
const score = Math.max(0, Math.min(100, 100 - penalty));
|
|
213
|
-
return { score, warnings, duplicates };
|
|
315
|
+
return { score, filesScanned: sourceOnly.length, warnings, duplicates };
|
|
214
316
|
}
|
|
215
317
|
export async function scanZip(buffer) {
|
|
216
318
|
const zip = await JSZip.loadAsync(buffer);
|
package/package.json
CHANGED
package/src/scanEngine.ts
CHANGED
|
@@ -7,9 +7,15 @@ import JSZip from "jszip";
|
|
|
7
7
|
const EXT = [".js", ".jsx", ".ts", ".tsx"];
|
|
8
8
|
const IGNORE_PATH_PARTS = ["node_modules", ".next", "dist", "build", ".git"];
|
|
9
9
|
|
|
10
|
-
export type Warning = {
|
|
10
|
+
export type Warning = {
|
|
11
|
+
file: string;
|
|
12
|
+
line?: number;
|
|
13
|
+
locations?: { file: string; line: number }[];
|
|
14
|
+
message: string;
|
|
15
|
+
suggestion?: string;
|
|
16
|
+
};
|
|
11
17
|
export type Duplicate = { files: string[]; reason?: string; suggestion?: string };
|
|
12
|
-
export type ScanResult = { score: number; warnings: Warning[]; duplicates: Duplicate[] };
|
|
18
|
+
export type ScanResult = { score: number; filesScanned: number; warnings: Warning[]; duplicates: Duplicate[] };
|
|
13
19
|
|
|
14
20
|
function isCodeFile(path: string): boolean {
|
|
15
21
|
return EXT.some((e) => path.toLowerCase().endsWith(e));
|
|
@@ -20,17 +26,20 @@ function isSourcePath(path: string): boolean {
|
|
|
20
26
|
return !IGNORE_PATH_PARTS.some((part) => normalized.includes(part));
|
|
21
27
|
}
|
|
22
28
|
|
|
23
|
-
function
|
|
24
|
-
const
|
|
29
|
+
function extractClassNamesWithLines(ast: ReturnType<typeof parser.parse>): { value: string; line: number }[] {
|
|
30
|
+
const out: { value: string; line: number }[] = [];
|
|
25
31
|
traverse(ast, {
|
|
26
|
-
JSXAttribute(path: { node: { name: { type: string; name: string }; value?: { type: string; value: string } } }) {
|
|
32
|
+
JSXAttribute(path: { node: { name: { type: string; name: string }; value?: { type: string; value: string }; loc?: { start: { line: number } } } }) {
|
|
27
33
|
if (path.node.name.type === "JSXIdentifier" && path.node.name.name === "className") {
|
|
28
34
|
const val = path.node.value;
|
|
29
|
-
if (val?.type === "StringLiteral")
|
|
35
|
+
if (val?.type === "StringLiteral") {
|
|
36
|
+
const line = path.node.loc?.start?.line ?? 0;
|
|
37
|
+
out.push({ value: val.value, line });
|
|
38
|
+
}
|
|
30
39
|
}
|
|
31
40
|
},
|
|
32
41
|
});
|
|
33
|
-
return
|
|
42
|
+
return out;
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
function normalizeJsxForHash(code: string): string {
|
|
@@ -41,10 +50,10 @@ function normalizeJsxForHash(code: string): string {
|
|
|
41
50
|
.trim();
|
|
42
51
|
}
|
|
43
52
|
|
|
44
|
-
function
|
|
45
|
-
const classes: string[] = [];
|
|
53
|
+
function extractButtonClassNamesWithLine(ast: ReturnType<typeof parser.parse>): { classes: string; line: number }[] {
|
|
54
|
+
const out: { classes: string; line: number }[] = [];
|
|
46
55
|
traverse(ast, {
|
|
47
|
-
JSXOpeningElement(path: { node: { name: { type: string; name: string }; attributes: unknown[] } }) {
|
|
56
|
+
JSXOpeningElement(path: { node: { name: { type: string; name: string }; attributes: unknown[]; loc?: { start: { line: number } } } }) {
|
|
48
57
|
const name = path.node.name;
|
|
49
58
|
const tagName = name.type === "JSXIdentifier" ? name.name : "";
|
|
50
59
|
let className = "";
|
|
@@ -54,152 +63,242 @@ function extractButtonClassNames(ast: ReturnType<typeof parser.parse>): string[]
|
|
|
54
63
|
break;
|
|
55
64
|
}
|
|
56
65
|
}
|
|
66
|
+
const line = path.node.loc?.start?.line ?? 0;
|
|
57
67
|
const isButton = tagName === "button" || (typeof className === "string" && /btn|button/i.test(className));
|
|
58
|
-
if (isButton && className)
|
|
68
|
+
if (isButton && className) out.push({ classes: className, line });
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const SKIP_UNUSED_IMPORT_NAMES = new Set([
|
|
75
|
+
"React", "Metadata", "FormEvent", "ChangeEvent", "ReactElement", "ReactNode", "FC", "NextConfig",
|
|
76
|
+
"FormEventHandler", "ChangeEventHandler", "MouseEvent", "KeyboardEvent", "SyntheticEvent",
|
|
77
|
+
"Props", "ComponentProps", "ReactNode", "CSSProperties", "HTMLAttributes", "ButtonHTMLAttributes",
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const SKIP_IF_FROM_REACT = new Set(["React", "FC", "ReactNode", "ReactElement", "CSSProperties", "FormEvent", "ChangeEvent", "MouseEvent", "KeyboardEvent", "SyntheticEvent", "Props", "ComponentProps", "HTMLAttributes", "ButtonHTMLAttributes", "FormEventHandler", "ChangeEventHandler"]);
|
|
81
|
+
const SKIP_IF_FROM_NEXT = new Set(["Metadata", "NextConfig", "Image", "Link", "Router", "Head", "Script"]);
|
|
82
|
+
|
|
83
|
+
function getNamesUsedInTypePosition(ast: ReturnType<typeof parser.parse>): Set<string> {
|
|
84
|
+
const used = new Set<string>();
|
|
85
|
+
traverse(ast, {
|
|
86
|
+
TSTypeReference(path: { node: { typeName: unknown } }) {
|
|
87
|
+
const n = path.node.typeName as { type?: string; name?: string; right?: { name: string }; left?: { name?: string } };
|
|
88
|
+
if (n.type === "Identifier" && n.name) used.add(n.name);
|
|
89
|
+
if (n.type === "TSQualifiedName") {
|
|
90
|
+
if (n.right?.name) used.add(n.right.name);
|
|
91
|
+
if (n.left && "name" in n.left && n.left.name) used.add(n.left.name);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
return used;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getNamesUsedInJSX(ast: ReturnType<typeof parser.parse>): Set<string> {
|
|
99
|
+
const used = new Set<string>();
|
|
100
|
+
traverse(ast, {
|
|
101
|
+
JSXOpeningElement(path: { node: { name: { type: string; name?: string } } }) {
|
|
102
|
+
const name = path.node.name;
|
|
103
|
+
if (name.type === "JSXIdentifier" && name.name) used.add(name.name);
|
|
59
104
|
},
|
|
60
105
|
});
|
|
61
|
-
return
|
|
106
|
+
return used;
|
|
62
107
|
}
|
|
63
108
|
|
|
64
|
-
function
|
|
65
|
-
const
|
|
109
|
+
function getUnusedImportsWithLine(ast: ReturnType<typeof parser.parse>): { name: string; line: number }[] {
|
|
110
|
+
const usedInTypes = getNamesUsedInTypePosition(ast);
|
|
111
|
+
const usedInJSX = getNamesUsedInJSX(ast);
|
|
112
|
+
const unused: { name: string; line: number }[] = [];
|
|
66
113
|
traverse(ast, {
|
|
67
|
-
ImportDeclaration(path: { node: { specifiers: unknown[] }; scope: { getBinding: (n: string) => { referenced: boolean } | undefined } }) {
|
|
114
|
+
ImportDeclaration(path: { node: { specifiers: unknown[]; source: { value?: string }; loc?: { start: { line: number } } }; scope: { getBinding: (n: string) => { referenced: boolean } | undefined } }) {
|
|
115
|
+
const line = path.node.loc?.start?.line ?? 0;
|
|
116
|
+
const source = (path.node.source?.value as string) ?? "";
|
|
117
|
+
const fromReact = source === "react" || source.startsWith("react/");
|
|
118
|
+
const fromNext = source === "next" || source.startsWith("next/");
|
|
68
119
|
for (const spec of path.node.specifiers) {
|
|
69
120
|
const s = spec as { local: { name: string }; importKind?: string };
|
|
70
121
|
const local = s.local?.name;
|
|
71
122
|
if (!local) continue;
|
|
72
123
|
if (s.importKind === "type" || s.importKind === "typeof") continue;
|
|
124
|
+
if (SKIP_UNUSED_IMPORT_NAMES.has(local)) continue;
|
|
125
|
+
if (fromReact && SKIP_IF_FROM_REACT.has(local)) continue;
|
|
126
|
+
if (fromNext && SKIP_IF_FROM_NEXT.has(local)) continue;
|
|
127
|
+
if (usedInTypes.has(local)) continue;
|
|
128
|
+
if (usedInJSX.has(local)) continue;
|
|
73
129
|
const binding = path.scope.getBinding(local);
|
|
74
|
-
if (binding && !binding.referenced) unused.push(local);
|
|
130
|
+
if (binding && !binding.referenced) unused.push({ name: local, line });
|
|
75
131
|
}
|
|
76
132
|
},
|
|
77
133
|
});
|
|
78
134
|
return unused;
|
|
79
135
|
}
|
|
80
136
|
|
|
137
|
+
function getUnusedVariablesWithLine(ast: ReturnType<typeof parser.parse>): { name: string; line: number }[] {
|
|
138
|
+
const unused: { name: string; line: number }[] = [];
|
|
139
|
+
traverse(ast, {
|
|
140
|
+
VariableDeclarator(path: { node: { id: { type?: string; name?: string; loc?: { start: { line: number } } } }; scope: { getBinding: (n: string) => { referenced: boolean } | undefined } }) {
|
|
141
|
+
const id = path.node.id as { type?: string; name?: string; loc?: { start: { line: number } } };
|
|
142
|
+
if (id.type !== "Identifier" || !id.name) return;
|
|
143
|
+
if (id.name.startsWith("_")) return;
|
|
144
|
+
const binding = path.scope.getBinding(id.name);
|
|
145
|
+
if (!binding || binding.referenced) return;
|
|
146
|
+
unused.push({ name: id.name, line: id.loc?.start?.line ?? 0 });
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
return unused;
|
|
150
|
+
}
|
|
151
|
+
|
|
81
152
|
function runAnalysis(fileContents: { path: string; code: string }[]): ScanResult {
|
|
82
153
|
const sourceOnly = fileContents.filter((f) => isSourcePath(f.path));
|
|
83
154
|
const warnings: Warning[] = [];
|
|
84
155
|
const jsxHashes = new Map<string, string[]>();
|
|
85
|
-
const
|
|
86
|
-
const
|
|
156
|
+
const spacingData = new Map<string, { values: Set<string>; locations: { file: string; line: number }[] }>();
|
|
157
|
+
const radiusByFile = new Map<string, { values: Set<string>; line: number }>();
|
|
87
158
|
const colorClassesByFile = new Map<string, Set<string>>();
|
|
88
|
-
const arbitraryColorByFile = new Map<string,
|
|
89
|
-
const
|
|
159
|
+
const arbitraryColorByFile = new Map<string, number>();
|
|
160
|
+
const buttonDataByFile = new Map<string, { classes: string[]; line: number }[]>();
|
|
161
|
+
|
|
162
|
+
const MAX_LOCATIONS = 25;
|
|
90
163
|
|
|
91
|
-
for (const { path, code } of sourceOnly) {
|
|
164
|
+
for (const { path: filePath, code } of sourceOnly) {
|
|
92
165
|
try {
|
|
93
|
-
const ast = parser.parse(code, { sourceType: "module", plugins: ["jsx", "typescript"] });
|
|
94
|
-
const
|
|
95
|
-
for (const name of
|
|
166
|
+
const ast = parser.parse(code, { sourceType: "module", plugins: ["jsx", "typescript"], attachComment: false });
|
|
167
|
+
const unusedList = getUnusedImportsWithLine(ast);
|
|
168
|
+
for (const { name, line } of unusedList) {
|
|
96
169
|
warnings.push({
|
|
97
|
-
file:
|
|
170
|
+
file: filePath,
|
|
171
|
+
line,
|
|
98
172
|
message: `Unused import: ${name}`,
|
|
99
173
|
suggestion: "Remove unused imports to keep the file clean and avoid confusion. Use your editor's organize-imports or run ESLint with no-unused-vars.",
|
|
100
174
|
});
|
|
101
175
|
}
|
|
102
|
-
const
|
|
103
|
-
for (const
|
|
176
|
+
const unusedVars = getUnusedVariablesWithLine(ast);
|
|
177
|
+
for (const { name, line } of unusedVars) {
|
|
178
|
+
warnings.push({
|
|
179
|
+
file: filePath,
|
|
180
|
+
line,
|
|
181
|
+
message: `Unused variable: ${name}`,
|
|
182
|
+
suggestion: "Remove or use the variable. Prefix with _ if intentionally unused.",
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const classEntries = extractClassNamesWithLines(ast);
|
|
186
|
+
for (const { value: cn, line } of classEntries) {
|
|
104
187
|
const parts = cn.split(/\s+/).filter(Boolean);
|
|
105
188
|
for (const p of parts) {
|
|
106
189
|
if (/^p[xy]?-[a-z0-9]+$/.test(p) || /^m[xy]?-[a-z0-9]+$/.test(p)) {
|
|
107
190
|
const key = p.replace(/[0-9]+$/, "N");
|
|
108
|
-
if (!
|
|
109
|
-
|
|
191
|
+
if (!spacingData.has(key)) spacingData.set(key, { values: new Set(), locations: [] });
|
|
192
|
+
const entry = spacingData.get(key)!;
|
|
193
|
+
entry.values.add(p);
|
|
194
|
+
if (entry.locations.length < MAX_LOCATIONS) entry.locations.push({ file: filePath, line });
|
|
110
195
|
}
|
|
111
196
|
if (/^rounded[a-z0-9-]*$/.test(p)) {
|
|
112
|
-
if (!
|
|
113
|
-
|
|
197
|
+
if (!radiusByFile.has(filePath)) radiusByFile.set(filePath, { values: new Set(), line });
|
|
198
|
+
radiusByFile.get(filePath)!.values.add(p);
|
|
114
199
|
}
|
|
115
200
|
if (/^(bg|text|border)-(.+)$/.test(p)) {
|
|
116
|
-
if (!colorClassesByFile.has(
|
|
117
|
-
colorClassesByFile.get(
|
|
118
|
-
if (/\[#|\[rgb|\[hsl/.test(p.replace(/^(bg|text|border)-/, ""))) arbitraryColorByFile.set(
|
|
201
|
+
if (!colorClassesByFile.has(filePath)) colorClassesByFile.set(filePath, new Set());
|
|
202
|
+
colorClassesByFile.get(filePath)!.add(p);
|
|
203
|
+
if (/\[#|\[rgb|\[hsl/.test(p.replace(/^(bg|text|border)-/, ""))) arbitraryColorByFile.set(filePath, line);
|
|
119
204
|
}
|
|
120
205
|
}
|
|
121
206
|
}
|
|
122
|
-
const
|
|
123
|
-
if (
|
|
207
|
+
const btnEntries = extractButtonClassNamesWithLine(ast);
|
|
208
|
+
if (btnEntries.length > 0) buttonDataByFile.set(filePath, btnEntries.map((e) => ({ classes: e.classes.split(/\s+/).filter(Boolean), line: e.line })));
|
|
124
209
|
|
|
125
210
|
const normalized = normalizeJsxForHash(code);
|
|
126
211
|
if (normalized.length > 50) {
|
|
127
212
|
const hash = crypto.createHash("md5").update(normalized).digest("hex");
|
|
128
213
|
if (!jsxHashes.has(hash)) jsxHashes.set(hash, []);
|
|
129
|
-
jsxHashes.get(hash)!.push(
|
|
214
|
+
jsxHashes.get(hash)!.push(filePath);
|
|
130
215
|
}
|
|
131
216
|
} catch {
|
|
132
217
|
warnings.push({
|
|
133
|
-
file:
|
|
218
|
+
file: filePath,
|
|
134
219
|
message: "Parse error (not valid JS/TS/JSX)",
|
|
135
220
|
suggestion: "Check syntax, brackets, and that the file is valid JS/TS/JSX. Fix or remove the file from the scan.",
|
|
136
221
|
});
|
|
137
222
|
}
|
|
138
223
|
}
|
|
139
224
|
|
|
140
|
-
for (const [,
|
|
141
|
-
if (values.size > 1) {
|
|
225
|
+
for (const [, data] of spacingData) {
|
|
226
|
+
if (data.values.size > 1) {
|
|
142
227
|
warnings.push({
|
|
143
228
|
file: "(project)",
|
|
144
|
-
|
|
229
|
+
locations: data.locations.slice(0, MAX_LOCATIONS),
|
|
230
|
+
message: `Mixed spacing values: ${[...data.values].join(", ")}`,
|
|
145
231
|
suggestion: "Pick one spacing scale (e.g. px-4, py-2) for similar elements and use it across the project so spacing stays consistent.",
|
|
146
232
|
});
|
|
147
233
|
}
|
|
148
234
|
}
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const unique = [...new Set([...radiusByFile.values()].flat())];
|
|
235
|
+
const radiusFileList = [...radiusByFile.entries()];
|
|
236
|
+
if (radiusFileList.length > 1) {
|
|
237
|
+
const unique = [...new Set(radiusFileList.flatMap(([, r]) => [...r.values]))];
|
|
153
238
|
if (unique.length > 1) {
|
|
154
239
|
warnings.push({
|
|
155
240
|
file: "(project)",
|
|
241
|
+
locations: radiusFileList.map(([p, r]) => ({ file: p, line: r.line })).slice(0, MAX_LOCATIONS),
|
|
156
242
|
message: `Inconsistent border-radius: ${unique.join(", ")}`,
|
|
157
243
|
suggestion: "Use one radius token (e.g. rounded-lg) for cards and buttons so the UI looks consistent.",
|
|
158
244
|
});
|
|
159
245
|
}
|
|
160
246
|
}
|
|
161
247
|
|
|
162
|
-
const
|
|
163
|
-
if (
|
|
248
|
+
const arbitraryEntries = [...arbitraryColorByFile.entries()];
|
|
249
|
+
if (arbitraryEntries.length > 0) {
|
|
250
|
+
const [firstFile, line] = arbitraryEntries[0];
|
|
164
251
|
warnings.push({
|
|
165
|
-
file:
|
|
252
|
+
file: firstFile,
|
|
253
|
+
line,
|
|
166
254
|
message: "Arbitrary color values (e.g. bg-[#hex], text-[rgb()]) detected.",
|
|
167
255
|
suggestion: "Prefer Tailwind palette classes (e.g. bg-zinc-100, text-emerald-600) for consistent theming and maintenance.",
|
|
168
256
|
});
|
|
169
257
|
}
|
|
170
258
|
|
|
171
|
-
const buttonPadding = new Map<string, Set<string
|
|
172
|
-
const buttonRadius = new Map<string, Set<string
|
|
173
|
-
for (const [filePath,
|
|
174
|
-
for (const cn of
|
|
175
|
-
for (const p of cn
|
|
259
|
+
const buttonPadding = new Map<string, { values: Set<string>; locations: { file: string; line: number }[] }>();
|
|
260
|
+
const buttonRadius = new Map<string, { values: Set<string>; line: number }[]>();
|
|
261
|
+
for (const [filePath, entries] of buttonDataByFile) {
|
|
262
|
+
for (const { classes: cn, line } of entries) {
|
|
263
|
+
for (const p of cn) {
|
|
176
264
|
if (/^p[xy]?-[a-z0-9]+$/.test(p)) {
|
|
177
265
|
const key = p.replace(/[0-9]+$/, "N");
|
|
178
|
-
if (!buttonPadding.has(key)) buttonPadding.set(key, new Set());
|
|
179
|
-
buttonPadding.get(key)
|
|
266
|
+
if (!buttonPadding.has(key)) buttonPadding.set(key, { values: new Set(), locations: [] });
|
|
267
|
+
const entry = buttonPadding.get(key)!;
|
|
268
|
+
entry.values.add(p);
|
|
269
|
+
if (entry.locations.length < MAX_LOCATIONS) entry.locations.push({ file: filePath, line });
|
|
180
270
|
}
|
|
181
271
|
if (/^rounded[a-z0-9-]*$/.test(p)) {
|
|
182
|
-
if (!buttonRadius.has(filePath)) buttonRadius.set(filePath,
|
|
183
|
-
buttonRadius.get(filePath)
|
|
272
|
+
if (!buttonRadius.has(filePath)) buttonRadius.set(filePath, []);
|
|
273
|
+
const arr = buttonRadius.get(filePath)!;
|
|
274
|
+
const existing = arr.find((x) => x.values.has(p));
|
|
275
|
+
if (!existing) arr.push({ values: new Set([p]), line });
|
|
276
|
+
else existing.values.add(p);
|
|
184
277
|
}
|
|
185
278
|
}
|
|
186
279
|
}
|
|
187
280
|
}
|
|
188
|
-
for (const [,
|
|
189
|
-
if (values.size > 1) {
|
|
281
|
+
for (const [, data] of buttonPadding) {
|
|
282
|
+
if (data.values.size > 1) {
|
|
190
283
|
warnings.push({
|
|
191
284
|
file: "(project)",
|
|
192
|
-
|
|
285
|
+
locations: data.locations.slice(0, MAX_LOCATIONS),
|
|
286
|
+
message: `Mixed button padding: ${[...data.values].join(", ")}`,
|
|
193
287
|
suggestion: "Use one button padding (e.g. px-4 py-2) across all buttons for a consistent look.",
|
|
194
288
|
});
|
|
195
289
|
break;
|
|
196
290
|
}
|
|
197
291
|
}
|
|
198
|
-
const
|
|
199
|
-
if (
|
|
292
|
+
const allBtnRadiusValues = [...buttonRadius.values()].flatMap((arr) => arr.flatMap((r) => [...r.values]));
|
|
293
|
+
if (allBtnRadiusValues.length > 1 && new Set(allBtnRadiusValues).size > 1) {
|
|
294
|
+
const locations: { file: string; line: number }[] = [];
|
|
295
|
+
for (const [file, arr] of buttonRadius.entries()) {
|
|
296
|
+
if (arr.length > 0 && locations.length < MAX_LOCATIONS) locations.push({ file, line: arr[0].line });
|
|
297
|
+
}
|
|
200
298
|
warnings.push({
|
|
201
299
|
file: "(project)",
|
|
202
|
-
|
|
300
|
+
locations,
|
|
301
|
+
message: `Inconsistent button border-radius: ${[...new Set(allBtnRadiusValues)].join(", ")}`,
|
|
203
302
|
suggestion: "Use one radius (e.g. rounded-lg) for all buttons.",
|
|
204
303
|
});
|
|
205
304
|
}
|
|
@@ -213,7 +312,7 @@ function runAnalysis(fileContents: { path: string; code: string }[]): ScanResult
|
|
|
213
312
|
|
|
214
313
|
const penalty = warnings.length * 5 + duplicates.length * 10;
|
|
215
314
|
const score = Math.max(0, Math.min(100, 100 - penalty));
|
|
216
|
-
return { score, warnings, duplicates };
|
|
315
|
+
return { score, filesScanned: sourceOnly.length, warnings, duplicates };
|
|
217
316
|
}
|
|
218
317
|
|
|
219
318
|
export async function scanZip(buffer: Buffer): Promise<ScanResult> {
|