@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 CHANGED
@@ -1,6 +1,10 @@
1
1
  # @justinmoto/frontend-guardian-core
2
2
 
3
- Library (scan engine) for Frontend Guardian. Use this package if you want to call the scanner from your own Node code.
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
 
@@ -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
  };
@@ -1 +1 @@
1
- {"version":3,"file":"scanEngine.d.ts","sourceRoot":"","sources":["../src/scanEngine.ts"],"names":[],"mappings":"AASA,MAAM,MAAM,OAAO,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAC7E,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,QAAQ,EAAE,OAAO,EAAE,CAAC;IAAC,UAAU,EAAE,SAAS,EAAE,CAAA;CAAE,CAAC;AA+MzF,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"}
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"}
@@ -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 extractClassNames(ast) {
16
- const classes = [];
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
- classes.push(val.value);
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 classes;
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 extractButtonClassNames(ast) {
36
- const classes = [];
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
- classes.push(className);
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 classes;
80
+ return used;
54
81
  }
55
- function getUnusedImports(ast) {
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 spacingValues = new Map();
79
- const radiusValues = new Map();
149
+ const spacingData = new Map();
150
+ const radiusByFile = new Map();
80
151
  const colorClassesByFile = new Map();
81
152
  const arbitraryColorByFile = new Map();
82
- const buttonClassesByFile = new Map();
83
- for (const { path, code } of sourceOnly) {
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 unused = getUnusedImports(ast);
87
- for (const name of unused) {
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: path,
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 classNames = extractClassNames(ast);
95
- for (const cn of classNames) {
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 (!spacingValues.has(key))
101
- spacingValues.set(key, new Set());
102
- spacingValues.get(key).add(p);
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 (!radiusValues.has(path))
106
- radiusValues.set(path, new Set());
107
- radiusValues.get(path).add(p);
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(path))
111
- colorClassesByFile.set(path, new Set());
112
- colorClassesByFile.get(path).add(p);
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(path, true);
199
+ arbitraryColorByFile.set(filePath, line);
115
200
  }
116
201
  }
117
202
  }
118
- const btnClasses = extractButtonClassNames(ast);
119
- if (btnClasses.length > 0)
120
- buttonClassesByFile.set(path, btnClasses);
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(path);
211
+ jsxHashes.get(hash).push(filePath);
127
212
  }
128
213
  }
129
214
  catch {
130
215
  warnings.push({
131
- file: path,
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 [, values] of spacingValues) {
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
- message: `Mixed spacing values: ${[...values].join(", ")}`,
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 radiusByFile = new Map();
147
- for (const [path, values] of radiusValues)
148
- radiusByFile.set(path, [...values]);
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 filesWithArbitraryColors = [...arbitraryColorByFile.entries()].filter(([, v]) => v).map(([p]) => p);
160
- if (filesWithArbitraryColors.length > 0) {
244
+ const arbitraryEntries = [...arbitraryColorByFile.entries()];
245
+ if (arbitraryEntries.length > 0) {
246
+ const [firstFile, line] = arbitraryEntries[0];
161
247
  warnings.push({
162
- file: filesWithArbitraryColors[0],
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, classes] of buttonClassesByFile) {
170
- for (const cn of classes) {
171
- for (const p of cn.split(/\s+/)) {
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).add(p);
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, new Set());
181
- buttonRadius.get(filePath).add(p);
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 [, values] of buttonPadding) {
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
- message: `Mixed button padding: ${[...values].join(", ")}`,
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 allBtnRadius = [...buttonRadius.values()].flatMap((s) => [...s]);
197
- if (allBtnRadius.length > 1 && new Set(allBtnRadius).size > 1) {
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
- message: `Inconsistent button border-radius: ${[...new Set(allBtnRadius)].join(", ")}`,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justinmoto/frontend-guardian-core",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Scan engine for Frontend Guardian. To run scans from the CLI use: npx frontend-guardian .",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
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 = { file: string; message: string; suggestion?: string };
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 extractClassNames(ast: ReturnType<typeof parser.parse>): string[] {
24
- const classes: string[] = [];
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") classes.push(val.value);
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 classes;
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 extractButtonClassNames(ast: ReturnType<typeof parser.parse>): string[] {
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) classes.push(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 classes;
106
+ return used;
62
107
  }
63
108
 
64
- function getUnusedImports(ast: ReturnType<typeof parser.parse>): string[] {
65
- const unused: string[] = [];
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 spacingValues = new Map<string, Set<string>>();
86
- const radiusValues = new Map<string, Set<string>>();
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, boolean>();
89
- const buttonClassesByFile = new Map<string, string[]>();
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 unused = getUnusedImports(ast);
95
- for (const name of unused) {
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: path,
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 classNames = extractClassNames(ast);
103
- for (const cn of classNames) {
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 (!spacingValues.has(key)) spacingValues.set(key, new Set());
109
- spacingValues.get(key)!.add(p);
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 (!radiusValues.has(path)) radiusValues.set(path, new Set());
113
- radiusValues.get(path)!.add(p);
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(path)) colorClassesByFile.set(path, new Set());
117
- colorClassesByFile.get(path)!.add(p);
118
- if (/\[#|\[rgb|\[hsl/.test(p.replace(/^(bg|text|border)-/, ""))) arbitraryColorByFile.set(path, true);
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 btnClasses = extractButtonClassNames(ast);
123
- if (btnClasses.length > 0) buttonClassesByFile.set(path, btnClasses);
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(path);
214
+ jsxHashes.get(hash)!.push(filePath);
130
215
  }
131
216
  } catch {
132
217
  warnings.push({
133
- file: path,
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 [, values] of spacingValues) {
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
- message: `Mixed spacing values: ${[...values].join(", ")}`,
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 radiusByFile = new Map<string, string[]>();
150
- for (const [path, values] of radiusValues) radiusByFile.set(path, [...values]);
151
- if (radiusByFile.size > 1) {
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 filesWithArbitraryColors = [...arbitraryColorByFile.entries()].filter(([, v]) => v).map(([p]) => p);
163
- if (filesWithArbitraryColors.length > 0) {
248
+ const arbitraryEntries = [...arbitraryColorByFile.entries()];
249
+ if (arbitraryEntries.length > 0) {
250
+ const [firstFile, line] = arbitraryEntries[0];
164
251
  warnings.push({
165
- file: filesWithArbitraryColors[0],
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, classes] of buttonClassesByFile) {
174
- for (const cn of classes) {
175
- for (const p of cn.split(/\s+/)) {
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)!.add(p);
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, new Set());
183
- buttonRadius.get(filePath)!.add(p);
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 [, values] of buttonPadding) {
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
- message: `Mixed button padding: ${[...values].join(", ")}`,
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 allBtnRadius = [...buttonRadius.values()].flatMap((s) => [...s]);
199
- if (allBtnRadius.length > 1 && new Set(allBtnRadius).size > 1) {
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
- message: `Inconsistent button border-radius: ${[...new Set(allBtnRadius)].join(", ")}`,
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> {