@justinmoto/frontend-guardian-core 0.1.3 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justinmoto/frontend-guardian-core",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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",
@@ -12,6 +12,7 @@
12
12
  "default": "./dist/index.js"
13
13
  }
14
14
  },
15
+ "files": ["dist", "README.md"],
15
16
  "scripts": {
16
17
  "build": "tsc"
17
18
  },
@@ -1,3 +0,0 @@
1
- declare module "@babel/traverse" {
2
- export default function traverse(ast: unknown, visitors: Record<string, unknown>): void;
3
- }
package/src/index.ts DELETED
@@ -1 +0,0 @@
1
- export { scanZip, scanFiles, type ScanResult, type Warning, type Duplicate } from "./scanEngine.js";
package/src/scanEngine.ts DELETED
@@ -1,336 +0,0 @@
1
- import * as parser from "@babel/parser";
2
- import _traverse from "@babel/traverse";
3
- const traverse = (typeof _traverse === "function" ? _traverse : (_traverse as { default: typeof _traverse }).default)!;
4
- import * as crypto from "crypto";
5
- import JSZip from "jszip";
6
-
7
- const EXT = [".js", ".jsx", ".ts", ".tsx"];
8
- const IGNORE_PATH_PARTS = ["node_modules", ".next", "dist", "build", ".git"];
9
-
10
- export type Warning = {
11
- file: string;
12
- line?: number;
13
- locations?: { file: string; line: number }[];
14
- message: string;
15
- suggestion?: string;
16
- };
17
- export type Duplicate = { files: string[]; reason?: string; suggestion?: string };
18
- export type ScanResult = { score: number; filesScanned: number; warnings: Warning[]; duplicates: Duplicate[] };
19
-
20
- function isCodeFile(path: string): boolean {
21
- return EXT.some((e) => path.toLowerCase().endsWith(e));
22
- }
23
-
24
- function isSourcePath(path: string): boolean {
25
- const normalized = path.replace(/\\/g, "/");
26
- return !IGNORE_PATH_PARTS.some((part) => normalized.includes(part));
27
- }
28
-
29
- function extractClassNamesWithLines(ast: ReturnType<typeof parser.parse>): { value: string; line: number }[] {
30
- const out: { value: string; line: number }[] = [];
31
- traverse(ast, {
32
- JSXAttribute(path: { node: { name: { type: string; name: string }; value?: { type: string; value: string }; loc?: { start: { line: number } } } }) {
33
- if (path.node.name.type === "JSXIdentifier" && path.node.name.name === "className") {
34
- const val = path.node.value;
35
- if (val?.type === "StringLiteral") {
36
- const line = path.node.loc?.start?.line ?? 0;
37
- out.push({ value: val.value, line });
38
- }
39
- }
40
- },
41
- });
42
- return out;
43
- }
44
-
45
- function normalizeJsxForHash(code: string): string {
46
- return code
47
- .replace(/\s+/g, " ")
48
- .replace(/\bclassName="[^"]*"/g, 'className=""')
49
- .replace(/\bclassName=\{[^}]*\}/g, "className={}")
50
- .trim();
51
- }
52
-
53
- function extractButtonClassNamesWithLine(ast: ReturnType<typeof parser.parse>): { classes: string; line: number }[] {
54
- const out: { classes: string; line: number }[] = [];
55
- traverse(ast, {
56
- JSXOpeningElement(path: { node: { name: { type: string; name: string }; attributes: unknown[]; loc?: { start: { line: number } } } }) {
57
- const name = path.node.name;
58
- const tagName = name.type === "JSXIdentifier" ? name.name : "";
59
- let className = "";
60
- for (const attr of path.node.attributes as { type: string; name?: { name: string }; value?: { type: string; value: string } }[]) {
61
- if (attr.type === "JSXAttribute" && attr.name?.name === "className" && attr.value?.type === "StringLiteral") {
62
- className = attr.value.value;
63
- break;
64
- }
65
- }
66
- const line = path.node.loc?.start?.line ?? 0;
67
- const isButton = tagName === "button" || (typeof className === "string" && /btn|button/i.test(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);
104
- },
105
- });
106
- return used;
107
- }
108
-
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 }[] = [];
113
- traverse(ast, {
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/");
119
- for (const spec of path.node.specifiers) {
120
- const s = spec as { local: { name: string }; importKind?: string };
121
- const local = s.local?.name;
122
- if (!local) continue;
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;
129
- const binding = path.scope.getBinding(local);
130
- if (binding && !binding.referenced) unused.push({ name: local, line });
131
- }
132
- },
133
- });
134
- return unused;
135
- }
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
-
152
- function runAnalysis(fileContents: { path: string; code: string }[]): ScanResult {
153
- const sourceOnly = fileContents.filter((f) => isSourcePath(f.path));
154
- const warnings: Warning[] = [];
155
- const jsxHashes = new Map<string, 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 }>();
158
- const colorClassesByFile = new Map<string, Set<string>>();
159
- const arbitraryColorByFile = new Map<string, number>();
160
- const buttonDataByFile = new Map<string, { classes: string[]; line: number }[]>();
161
-
162
- const MAX_LOCATIONS = 25;
163
-
164
- for (const { path: filePath, code } of sourceOnly) {
165
- try {
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) {
169
- warnings.push({
170
- file: filePath,
171
- line,
172
- message: `Unused import: ${name}`,
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.",
174
- });
175
- }
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) {
187
- const parts = cn.split(/\s+/).filter(Boolean);
188
- for (const p of parts) {
189
- if (/^p[xy]?-[a-z0-9]+$/.test(p) || /^m[xy]?-[a-z0-9]+$/.test(p)) {
190
- const key = p.replace(/[0-9]+$/, "N");
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 });
195
- }
196
- if (/^rounded[a-z0-9-]*$/.test(p)) {
197
- if (!radiusByFile.has(filePath)) radiusByFile.set(filePath, { values: new Set(), line });
198
- radiusByFile.get(filePath)!.values.add(p);
199
- }
200
- if (/^(bg|text|border)-(.+)$/.test(p)) {
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);
204
- }
205
- }
206
- }
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 })));
209
-
210
- const normalized = normalizeJsxForHash(code);
211
- if (normalized.length > 50) {
212
- const hash = crypto.createHash("md5").update(normalized).digest("hex");
213
- if (!jsxHashes.has(hash)) jsxHashes.set(hash, []);
214
- jsxHashes.get(hash)!.push(filePath);
215
- }
216
- } catch {
217
- warnings.push({
218
- file: filePath,
219
- message: "Parse error (not valid JS/TS/JSX)",
220
- suggestion: "Check syntax, brackets, and that the file is valid JS/TS/JSX. Fix or remove the file from the scan.",
221
- });
222
- }
223
- }
224
-
225
- for (const [, data] of spacingData) {
226
- if (data.values.size > 1) {
227
- warnings.push({
228
- file: "(project)",
229
- locations: data.locations.slice(0, MAX_LOCATIONS),
230
- message: `Mixed spacing values: ${[...data.values].join(", ")}`,
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.",
232
- });
233
- }
234
- }
235
- const radiusFileList = [...radiusByFile.entries()];
236
- if (radiusFileList.length > 1) {
237
- const unique = [...new Set(radiusFileList.flatMap(([, r]) => [...r.values]))];
238
- if (unique.length > 1) {
239
- warnings.push({
240
- file: "(project)",
241
- locations: radiusFileList.map(([p, r]) => ({ file: p, line: r.line })).slice(0, MAX_LOCATIONS),
242
- message: `Inconsistent border-radius: ${unique.join(", ")}`,
243
- suggestion: "Use one radius token (e.g. rounded-lg) for cards and buttons so the UI looks consistent.",
244
- });
245
- }
246
- }
247
-
248
- const arbitraryEntries = [...arbitraryColorByFile.entries()];
249
- if (arbitraryEntries.length > 0) {
250
- const [firstFile, line] = arbitraryEntries[0];
251
- warnings.push({
252
- file: firstFile,
253
- line,
254
- message: "Arbitrary color values (e.g. bg-[#hex], text-[rgb()]) detected.",
255
- suggestion: "Prefer Tailwind palette classes (e.g. bg-zinc-100, text-emerald-600) for consistent theming and maintenance.",
256
- });
257
- }
258
-
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) {
264
- if (/^p[xy]?-[a-z0-9]+$/.test(p)) {
265
- const key = p.replace(/[0-9]+$/, "N");
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 });
270
- }
271
- if (/^rounded[a-z0-9-]*$/.test(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);
277
- }
278
- }
279
- }
280
- }
281
- for (const [, data] of buttonPadding) {
282
- if (data.values.size > 1) {
283
- warnings.push({
284
- file: "(project)",
285
- locations: data.locations.slice(0, MAX_LOCATIONS),
286
- message: `Mixed button padding: ${[...data.values].join(", ")}`,
287
- suggestion: "Use one button padding (e.g. px-4 py-2) across all buttons for a consistent look.",
288
- });
289
- break;
290
- }
291
- }
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
- }
298
- warnings.push({
299
- file: "(project)",
300
- locations,
301
- message: `Inconsistent button border-radius: ${[...new Set(allBtnRadiusValues)].join(", ")}`,
302
- suggestion: "Use one radius (e.g. rounded-lg) for all buttons.",
303
- });
304
- }
305
-
306
- const duplicates: Duplicate[] = [];
307
- const dupReason = "Same JSX structure (excluding class names) — likely copy-pasted or very similar components.";
308
- const dupSuggestion = "Extract into one shared component and reuse it; this avoids drift and makes updates easier.";
309
- for (const files of jsxHashes.values()) {
310
- if (files.length > 1) duplicates.push({ files, reason: dupReason, suggestion: dupSuggestion });
311
- }
312
-
313
- const penalty = warnings.length * 5 + duplicates.length * 10;
314
- const score = Math.max(0, Math.min(100, 100 - penalty));
315
- return { score, filesScanned: sourceOnly.length, warnings, duplicates };
316
- }
317
-
318
- export async function scanZip(buffer: Buffer): Promise<ScanResult> {
319
- const zip = await JSZip.loadAsync(buffer);
320
- const fileContents: { path: string; code: string }[] = [];
321
- for (const [path, entry] of Object.entries(zip.files)) {
322
- if (entry.dir || !isCodeFile(path) || !isSourcePath(path)) continue;
323
- try {
324
- const code = await entry.async("string");
325
- fileContents.push({ path, code });
326
- } catch {
327
- fileContents.push({ path, code: "" });
328
- }
329
- }
330
- return runAnalysis(fileContents);
331
- }
332
-
333
- export function scanFiles(files: { path: string; code: string }[]): ScanResult {
334
- const filtered = files.filter((f) => isCodeFile(f.path) && isSourcePath(f.path));
335
- return runAnalysis(filtered);
336
- }
package/tsconfig.json DELETED
@@ -1,15 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2020",
4
- "module": "NodeNext",
5
- "moduleResolution": "NodeNext",
6
- "outDir": "dist",
7
- "rootDir": "src",
8
- "strict": true,
9
- "skipLibCheck": true,
10
- "declaration": true,
11
- "declarationMap": true
12
- },
13
- "include": ["src/**/*.ts", "*.d.ts"],
14
- "exclude": ["node_modules", "dist"]
15
- }