@justinmoto/frontend-guardian-core 0.1.0

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.
@@ -0,0 +1,3 @@
1
+ declare module "@babel/traverse" {
2
+ export default function traverse(ast: unknown, visitors: Record<string, unknown>): void;
3
+ }
@@ -0,0 +1,2 @@
1
+ export { scanZip, scanFiles, type ScanResult, type Warning, type Duplicate } from "./scanEngine.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,KAAK,UAAU,EAAE,KAAK,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { scanZip, scanFiles } from "./scanEngine.js";
@@ -0,0 +1,21 @@
1
+ export type Warning = {
2
+ file: string;
3
+ message: string;
4
+ suggestion?: string;
5
+ };
6
+ export type Duplicate = {
7
+ files: string[];
8
+ reason?: string;
9
+ suggestion?: string;
10
+ };
11
+ export type ScanResult = {
12
+ score: number;
13
+ warnings: Warning[];
14
+ duplicates: Duplicate[];
15
+ };
16
+ export declare function scanZip(buffer: Buffer): Promise<ScanResult>;
17
+ export declare function scanFiles(files: {
18
+ path: string;
19
+ code: string;
20
+ }[]): ScanResult;
21
+ //# sourceMappingURL=scanEngine.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,234 @@
1
+ import * as parser from "@babel/parser";
2
+ import _traverse from "@babel/traverse";
3
+ const traverse = (typeof _traverse === "function" ? _traverse : _traverse.default);
4
+ import * as crypto from "crypto";
5
+ import JSZip from "jszip";
6
+ const EXT = [".js", ".jsx", ".ts", ".tsx"];
7
+ const IGNORE_PATH_PARTS = ["node_modules", ".next", "dist", "build", ".git"];
8
+ function isCodeFile(path) {
9
+ return EXT.some((e) => path.toLowerCase().endsWith(e));
10
+ }
11
+ function isSourcePath(path) {
12
+ const normalized = path.replace(/\\/g, "/");
13
+ return !IGNORE_PATH_PARTS.some((part) => normalized.includes(part));
14
+ }
15
+ function extractClassNames(ast) {
16
+ const classes = [];
17
+ traverse(ast, {
18
+ JSXAttribute(path) {
19
+ if (path.node.name.type === "JSXIdentifier" && path.node.name.name === "className") {
20
+ const val = path.node.value;
21
+ if (val?.type === "StringLiteral")
22
+ classes.push(val.value);
23
+ }
24
+ },
25
+ });
26
+ return classes;
27
+ }
28
+ function normalizeJsxForHash(code) {
29
+ return code
30
+ .replace(/\s+/g, " ")
31
+ .replace(/\bclassName="[^"]*"/g, 'className=""')
32
+ .replace(/\bclassName=\{[^}]*\}/g, "className={}")
33
+ .trim();
34
+ }
35
+ function extractButtonClassNames(ast) {
36
+ const classes = [];
37
+ traverse(ast, {
38
+ JSXOpeningElement(path) {
39
+ const name = path.node.name;
40
+ const tagName = name.type === "JSXIdentifier" ? name.name : "";
41
+ let className = "";
42
+ for (const attr of path.node.attributes) {
43
+ if (attr.type === "JSXAttribute" && attr.name?.name === "className" && attr.value?.type === "StringLiteral") {
44
+ className = attr.value.value;
45
+ break;
46
+ }
47
+ }
48
+ const isButton = tagName === "button" || (typeof className === "string" && /btn|button/i.test(className));
49
+ if (isButton && className)
50
+ classes.push(className);
51
+ },
52
+ });
53
+ return classes;
54
+ }
55
+ function getUnusedImports(ast) {
56
+ const unused = [];
57
+ traverse(ast, {
58
+ ImportDeclaration(path) {
59
+ for (const spec of path.node.specifiers) {
60
+ const s = spec;
61
+ const local = s.local?.name;
62
+ if (!local)
63
+ continue;
64
+ if (s.importKind === "type" || s.importKind === "typeof")
65
+ continue;
66
+ const binding = path.scope.getBinding(local);
67
+ if (binding && !binding.referenced)
68
+ unused.push(local);
69
+ }
70
+ },
71
+ });
72
+ return unused;
73
+ }
74
+ function runAnalysis(fileContents) {
75
+ const sourceOnly = fileContents.filter((f) => isSourcePath(f.path));
76
+ const warnings = [];
77
+ const jsxHashes = new Map();
78
+ const spacingValues = new Map();
79
+ const radiusValues = new Map();
80
+ const colorClassesByFile = new Map();
81
+ const arbitraryColorByFile = new Map();
82
+ const buttonClassesByFile = new Map();
83
+ for (const { path, code } of sourceOnly) {
84
+ try {
85
+ const ast = parser.parse(code, { sourceType: "module", plugins: ["jsx", "typescript"] });
86
+ const unused = getUnusedImports(ast);
87
+ for (const name of unused) {
88
+ warnings.push({
89
+ file: path,
90
+ message: `Unused import: ${name}`,
91
+ 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
+ });
93
+ }
94
+ const classNames = extractClassNames(ast);
95
+ for (const cn of classNames) {
96
+ const parts = cn.split(/\s+/).filter(Boolean);
97
+ for (const p of parts) {
98
+ if (/^p[xy]?-[a-z0-9]+$/.test(p) || /^m[xy]?-[a-z0-9]+$/.test(p)) {
99
+ const key = p.replace(/[0-9]+$/, "N");
100
+ if (!spacingValues.has(key))
101
+ spacingValues.set(key, new Set());
102
+ spacingValues.get(key).add(p);
103
+ }
104
+ if (/^rounded[a-z0-9-]*$/.test(p)) {
105
+ if (!radiusValues.has(path))
106
+ radiusValues.set(path, new Set());
107
+ radiusValues.get(path).add(p);
108
+ }
109
+ if (/^(bg|text|border)-(.+)$/.test(p)) {
110
+ if (!colorClassesByFile.has(path))
111
+ colorClassesByFile.set(path, new Set());
112
+ colorClassesByFile.get(path).add(p);
113
+ if (/\[#|\[rgb|\[hsl/.test(p.replace(/^(bg|text|border)-/, "")))
114
+ arbitraryColorByFile.set(path, true);
115
+ }
116
+ }
117
+ }
118
+ const btnClasses = extractButtonClassNames(ast);
119
+ if (btnClasses.length > 0)
120
+ buttonClassesByFile.set(path, btnClasses);
121
+ const normalized = normalizeJsxForHash(code);
122
+ if (normalized.length > 50) {
123
+ const hash = crypto.createHash("md5").update(normalized).digest("hex");
124
+ if (!jsxHashes.has(hash))
125
+ jsxHashes.set(hash, []);
126
+ jsxHashes.get(hash).push(path);
127
+ }
128
+ }
129
+ catch {
130
+ warnings.push({
131
+ file: path,
132
+ message: "Parse error (not valid JS/TS/JSX)",
133
+ suggestion: "Check syntax, brackets, and that the file is valid JS/TS/JSX. Fix or remove the file from the scan.",
134
+ });
135
+ }
136
+ }
137
+ for (const [, values] of spacingValues) {
138
+ if (values.size > 1) {
139
+ warnings.push({
140
+ file: "(project)",
141
+ message: `Mixed spacing values: ${[...values].join(", ")}`,
142
+ 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
+ });
144
+ }
145
+ }
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())];
151
+ if (unique.length > 1) {
152
+ warnings.push({
153
+ file: "(project)",
154
+ message: `Inconsistent border-radius: ${unique.join(", ")}`,
155
+ suggestion: "Use one radius token (e.g. rounded-lg) for cards and buttons so the UI looks consistent.",
156
+ });
157
+ }
158
+ }
159
+ const filesWithArbitraryColors = [...arbitraryColorByFile.entries()].filter(([, v]) => v).map(([p]) => p);
160
+ if (filesWithArbitraryColors.length > 0) {
161
+ warnings.push({
162
+ file: filesWithArbitraryColors[0],
163
+ message: "Arbitrary color values (e.g. bg-[#hex], text-[rgb()]) detected.",
164
+ suggestion: "Prefer Tailwind palette classes (e.g. bg-zinc-100, text-emerald-600) for consistent theming and maintenance.",
165
+ });
166
+ }
167
+ const buttonPadding = new Map();
168
+ 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+/)) {
172
+ if (/^p[xy]?-[a-z0-9]+$/.test(p)) {
173
+ const key = p.replace(/[0-9]+$/, "N");
174
+ if (!buttonPadding.has(key))
175
+ buttonPadding.set(key, new Set());
176
+ buttonPadding.get(key).add(p);
177
+ }
178
+ if (/^rounded[a-z0-9-]*$/.test(p)) {
179
+ if (!buttonRadius.has(filePath))
180
+ buttonRadius.set(filePath, new Set());
181
+ buttonRadius.get(filePath).add(p);
182
+ }
183
+ }
184
+ }
185
+ }
186
+ for (const [, values] of buttonPadding) {
187
+ if (values.size > 1) {
188
+ warnings.push({
189
+ file: "(project)",
190
+ message: `Mixed button padding: ${[...values].join(", ")}`,
191
+ suggestion: "Use one button padding (e.g. px-4 py-2) across all buttons for a consistent look.",
192
+ });
193
+ break;
194
+ }
195
+ }
196
+ const allBtnRadius = [...buttonRadius.values()].flatMap((s) => [...s]);
197
+ if (allBtnRadius.length > 1 && new Set(allBtnRadius).size > 1) {
198
+ warnings.push({
199
+ file: "(project)",
200
+ message: `Inconsistent button border-radius: ${[...new Set(allBtnRadius)].join(", ")}`,
201
+ suggestion: "Use one radius (e.g. rounded-lg) for all buttons.",
202
+ });
203
+ }
204
+ const duplicates = [];
205
+ const dupReason = "Same JSX structure (excluding class names) — likely copy-pasted or very similar components.";
206
+ const dupSuggestion = "Extract into one shared component and reuse it; this avoids drift and makes updates easier.";
207
+ for (const files of jsxHashes.values()) {
208
+ if (files.length > 1)
209
+ duplicates.push({ files, reason: dupReason, suggestion: dupSuggestion });
210
+ }
211
+ const penalty = warnings.length * 5 + duplicates.length * 10;
212
+ const score = Math.max(0, Math.min(100, 100 - penalty));
213
+ return { score, warnings, duplicates };
214
+ }
215
+ export async function scanZip(buffer) {
216
+ const zip = await JSZip.loadAsync(buffer);
217
+ const fileContents = [];
218
+ for (const [path, entry] of Object.entries(zip.files)) {
219
+ if (entry.dir || !isCodeFile(path) || !isSourcePath(path))
220
+ continue;
221
+ try {
222
+ const code = await entry.async("string");
223
+ fileContents.push({ path, code });
224
+ }
225
+ catch {
226
+ fileContents.push({ path, code: "" });
227
+ }
228
+ }
229
+ return runAnalysis(fileContents);
230
+ }
231
+ export function scanFiles(files) {
232
+ const filtered = files.filter((f) => isCodeFile(f.path) && isSourcePath(f.path));
233
+ return runAnalysis(filtered);
234
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@justinmoto/frontend-guardian-core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsc"
16
+ },
17
+ "dependencies": {
18
+ "@babel/parser": "^7.26.0",
19
+ "@babel/traverse": "^7.26.0",
20
+ "jszip": "^3.10.1"
21
+ },
22
+ "devDependencies": {
23
+ "typescript": "^5"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ }
28
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { scanZip, scanFiles, type ScanResult, type Warning, type Duplicate } from "./scanEngine.js";
@@ -0,0 +1,237 @@
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 = { file: string; message: string; suggestion?: string };
11
+ export type Duplicate = { files: string[]; reason?: string; suggestion?: string };
12
+ export type ScanResult = { score: number; warnings: Warning[]; duplicates: Duplicate[] };
13
+
14
+ function isCodeFile(path: string): boolean {
15
+ return EXT.some((e) => path.toLowerCase().endsWith(e));
16
+ }
17
+
18
+ function isSourcePath(path: string): boolean {
19
+ const normalized = path.replace(/\\/g, "/");
20
+ return !IGNORE_PATH_PARTS.some((part) => normalized.includes(part));
21
+ }
22
+
23
+ function extractClassNames(ast: ReturnType<typeof parser.parse>): string[] {
24
+ const classes: string[] = [];
25
+ traverse(ast, {
26
+ JSXAttribute(path: { node: { name: { type: string; name: string }; value?: { type: string; value: string } } }) {
27
+ if (path.node.name.type === "JSXIdentifier" && path.node.name.name === "className") {
28
+ const val = path.node.value;
29
+ if (val?.type === "StringLiteral") classes.push(val.value);
30
+ }
31
+ },
32
+ });
33
+ return classes;
34
+ }
35
+
36
+ function normalizeJsxForHash(code: string): string {
37
+ return code
38
+ .replace(/\s+/g, " ")
39
+ .replace(/\bclassName="[^"]*"/g, 'className=""')
40
+ .replace(/\bclassName=\{[^}]*\}/g, "className={}")
41
+ .trim();
42
+ }
43
+
44
+ function extractButtonClassNames(ast: ReturnType<typeof parser.parse>): string[] {
45
+ const classes: string[] = [];
46
+ traverse(ast, {
47
+ JSXOpeningElement(path: { node: { name: { type: string; name: string }; attributes: unknown[] } }) {
48
+ const name = path.node.name;
49
+ const tagName = name.type === "JSXIdentifier" ? name.name : "";
50
+ let className = "";
51
+ for (const attr of path.node.attributes as { type: string; name?: { name: string }; value?: { type: string; value: string } }[]) {
52
+ if (attr.type === "JSXAttribute" && attr.name?.name === "className" && attr.value?.type === "StringLiteral") {
53
+ className = attr.value.value;
54
+ break;
55
+ }
56
+ }
57
+ const isButton = tagName === "button" || (typeof className === "string" && /btn|button/i.test(className));
58
+ if (isButton && className) classes.push(className);
59
+ },
60
+ });
61
+ return classes;
62
+ }
63
+
64
+ function getUnusedImports(ast: ReturnType<typeof parser.parse>): string[] {
65
+ const unused: string[] = [];
66
+ traverse(ast, {
67
+ ImportDeclaration(path: { node: { specifiers: unknown[] }; scope: { getBinding: (n: string) => { referenced: boolean } | undefined } }) {
68
+ for (const spec of path.node.specifiers) {
69
+ const s = spec as { local: { name: string }; importKind?: string };
70
+ const local = s.local?.name;
71
+ if (!local) continue;
72
+ if (s.importKind === "type" || s.importKind === "typeof") continue;
73
+ const binding = path.scope.getBinding(local);
74
+ if (binding && !binding.referenced) unused.push(local);
75
+ }
76
+ },
77
+ });
78
+ return unused;
79
+ }
80
+
81
+ function runAnalysis(fileContents: { path: string; code: string }[]): ScanResult {
82
+ const sourceOnly = fileContents.filter((f) => isSourcePath(f.path));
83
+ const warnings: Warning[] = [];
84
+ const jsxHashes = new Map<string, string[]>();
85
+ const spacingValues = new Map<string, Set<string>>();
86
+ const radiusValues = new Map<string, Set<string>>();
87
+ const colorClassesByFile = new Map<string, Set<string>>();
88
+ const arbitraryColorByFile = new Map<string, boolean>();
89
+ const buttonClassesByFile = new Map<string, string[]>();
90
+
91
+ for (const { path, code } of sourceOnly) {
92
+ try {
93
+ const ast = parser.parse(code, { sourceType: "module", plugins: ["jsx", "typescript"] });
94
+ const unused = getUnusedImports(ast);
95
+ for (const name of unused) {
96
+ warnings.push({
97
+ file: path,
98
+ message: `Unused import: ${name}`,
99
+ 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
+ });
101
+ }
102
+ const classNames = extractClassNames(ast);
103
+ for (const cn of classNames) {
104
+ const parts = cn.split(/\s+/).filter(Boolean);
105
+ for (const p of parts) {
106
+ if (/^p[xy]?-[a-z0-9]+$/.test(p) || /^m[xy]?-[a-z0-9]+$/.test(p)) {
107
+ const key = p.replace(/[0-9]+$/, "N");
108
+ if (!spacingValues.has(key)) spacingValues.set(key, new Set());
109
+ spacingValues.get(key)!.add(p);
110
+ }
111
+ if (/^rounded[a-z0-9-]*$/.test(p)) {
112
+ if (!radiusValues.has(path)) radiusValues.set(path, new Set());
113
+ radiusValues.get(path)!.add(p);
114
+ }
115
+ 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);
119
+ }
120
+ }
121
+ }
122
+ const btnClasses = extractButtonClassNames(ast);
123
+ if (btnClasses.length > 0) buttonClassesByFile.set(path, btnClasses);
124
+
125
+ const normalized = normalizeJsxForHash(code);
126
+ if (normalized.length > 50) {
127
+ const hash = crypto.createHash("md5").update(normalized).digest("hex");
128
+ if (!jsxHashes.has(hash)) jsxHashes.set(hash, []);
129
+ jsxHashes.get(hash)!.push(path);
130
+ }
131
+ } catch {
132
+ warnings.push({
133
+ file: path,
134
+ message: "Parse error (not valid JS/TS/JSX)",
135
+ suggestion: "Check syntax, brackets, and that the file is valid JS/TS/JSX. Fix or remove the file from the scan.",
136
+ });
137
+ }
138
+ }
139
+
140
+ for (const [, values] of spacingValues) {
141
+ if (values.size > 1) {
142
+ warnings.push({
143
+ file: "(project)",
144
+ message: `Mixed spacing values: ${[...values].join(", ")}`,
145
+ 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
+ });
147
+ }
148
+ }
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())];
153
+ if (unique.length > 1) {
154
+ warnings.push({
155
+ file: "(project)",
156
+ message: `Inconsistent border-radius: ${unique.join(", ")}`,
157
+ suggestion: "Use one radius token (e.g. rounded-lg) for cards and buttons so the UI looks consistent.",
158
+ });
159
+ }
160
+ }
161
+
162
+ const filesWithArbitraryColors = [...arbitraryColorByFile.entries()].filter(([, v]) => v).map(([p]) => p);
163
+ if (filesWithArbitraryColors.length > 0) {
164
+ warnings.push({
165
+ file: filesWithArbitraryColors[0],
166
+ message: "Arbitrary color values (e.g. bg-[#hex], text-[rgb()]) detected.",
167
+ suggestion: "Prefer Tailwind palette classes (e.g. bg-zinc-100, text-emerald-600) for consistent theming and maintenance.",
168
+ });
169
+ }
170
+
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+/)) {
176
+ if (/^p[xy]?-[a-z0-9]+$/.test(p)) {
177
+ const key = p.replace(/[0-9]+$/, "N");
178
+ if (!buttonPadding.has(key)) buttonPadding.set(key, new Set());
179
+ buttonPadding.get(key)!.add(p);
180
+ }
181
+ if (/^rounded[a-z0-9-]*$/.test(p)) {
182
+ if (!buttonRadius.has(filePath)) buttonRadius.set(filePath, new Set());
183
+ buttonRadius.get(filePath)!.add(p);
184
+ }
185
+ }
186
+ }
187
+ }
188
+ for (const [, values] of buttonPadding) {
189
+ if (values.size > 1) {
190
+ warnings.push({
191
+ file: "(project)",
192
+ message: `Mixed button padding: ${[...values].join(", ")}`,
193
+ suggestion: "Use one button padding (e.g. px-4 py-2) across all buttons for a consistent look.",
194
+ });
195
+ break;
196
+ }
197
+ }
198
+ const allBtnRadius = [...buttonRadius.values()].flatMap((s) => [...s]);
199
+ if (allBtnRadius.length > 1 && new Set(allBtnRadius).size > 1) {
200
+ warnings.push({
201
+ file: "(project)",
202
+ message: `Inconsistent button border-radius: ${[...new Set(allBtnRadius)].join(", ")}`,
203
+ suggestion: "Use one radius (e.g. rounded-lg) for all buttons.",
204
+ });
205
+ }
206
+
207
+ const duplicates: Duplicate[] = [];
208
+ const dupReason = "Same JSX structure (excluding class names) — likely copy-pasted or very similar components.";
209
+ const dupSuggestion = "Extract into one shared component and reuse it; this avoids drift and makes updates easier.";
210
+ for (const files of jsxHashes.values()) {
211
+ if (files.length > 1) duplicates.push({ files, reason: dupReason, suggestion: dupSuggestion });
212
+ }
213
+
214
+ const penalty = warnings.length * 5 + duplicates.length * 10;
215
+ const score = Math.max(0, Math.min(100, 100 - penalty));
216
+ return { score, warnings, duplicates };
217
+ }
218
+
219
+ export async function scanZip(buffer: Buffer): Promise<ScanResult> {
220
+ const zip = await JSZip.loadAsync(buffer);
221
+ const fileContents: { path: string; code: string }[] = [];
222
+ for (const [path, entry] of Object.entries(zip.files)) {
223
+ if (entry.dir || !isCodeFile(path) || !isSourcePath(path)) continue;
224
+ try {
225
+ const code = await entry.async("string");
226
+ fileContents.push({ path, code });
227
+ } catch {
228
+ fileContents.push({ path, code: "" });
229
+ }
230
+ }
231
+ return runAnalysis(fileContents);
232
+ }
233
+
234
+ export function scanFiles(files: { path: string; code: string }[]): ScanResult {
235
+ const filtered = files.filter((f) => isCodeFile(f.path) && isSourcePath(f.path));
236
+ return runAnalysis(filtered);
237
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
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
+ }