frontend-guardian 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PUBLISH.md +81 -0
- package/README.md +57 -0
- package/dist/cli.js +218 -0
- package/package.json +25 -0
- package/src/cli.ts +223 -0
- package/tsconfig.json +13 -0
package/PUBLISH.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Publish frontend-guardian to npm
|
|
2
|
+
|
|
3
|
+
So anyone can run `npx frontend-guardian .` without your repo.
|
|
4
|
+
|
|
5
|
+
## 1. npm account
|
|
6
|
+
|
|
7
|
+
- Sign up at https://www.npmjs.com/signup
|
|
8
|
+
- Login in terminal: `npm login` (username, password, email, OTP if 2FA)
|
|
9
|
+
|
|
10
|
+
## 2. Check package name
|
|
11
|
+
|
|
12
|
+
- If **frontend-guardian** is taken on npm, use a scoped name in `package.json`:
|
|
13
|
+
`"name": "@YOUR_NPM_USERNAME/frontend-guardian"`
|
|
14
|
+
Then users run: `npx @YOUR_NPM_USERNAME/frontend-guardian .`
|
|
15
|
+
|
|
16
|
+
## 3. Publish core first (CLI depends on it)
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
cd C:/Users/ADMIN/Desktop/frontend-guardian
|
|
20
|
+
pnpm run build:packages
|
|
21
|
+
cd packages/core
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
In `packages/core/package.json` temporarily remove the line `"private": true` (or set to `false`).
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm publish --access public
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
(Scoped packages like `@frontend-guardian/core` need `--access public`.)
|
|
31
|
+
|
|
32
|
+
Put `"private": true` back in core's package.json if you want to keep the repo from publishing it again by mistake.
|
|
33
|
+
|
|
34
|
+
## 4. Point CLI to published core
|
|
35
|
+
|
|
36
|
+
In `packages/cli/package.json`, change:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@frontend-guardian/core": "workspace:*"
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
to:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@frontend-guardian/core": "^0.1.0"
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
(Use the same version you published for core, e.g. `^0.1.0`.)
|
|
53
|
+
|
|
54
|
+
## 5. Publish CLI
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
cd C:/Users/ADMIN/Desktop/frontend-guardian/packages/cli
|
|
58
|
+
npm publish
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
If the name is scoped (`@YOUR_NPM_USERNAME/frontend-guardian`):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm publish --access public
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 6. After publish
|
|
68
|
+
|
|
69
|
+
Anyone can run:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npx frontend-guardian .
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
(or `npx @YOUR_NPM_USERNAME/frontend-guardian .` if you used a scoped name.)
|
|
76
|
+
|
|
77
|
+
## Updating later
|
|
78
|
+
|
|
79
|
+
1. Bump `version` in `packages/core/package.json` and `packages/cli/package.json`.
|
|
80
|
+
2. In core: `npm publish --access public`
|
|
81
|
+
3. In cli: set `"@frontend-guardian/core": "^0.1.1"` (or new version), then `npm publish` (or `--access public` if scoped).
|
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frontend-guardian (CLI)
|
|
2
|
+
|
|
3
|
+
Phase 1 CLI: scan a folder or ZIP for Tailwind & component consistency.
|
|
4
|
+
|
|
5
|
+
## Local (from repo)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Build packages first (once after clone)
|
|
9
|
+
pnpm run build:packages
|
|
10
|
+
|
|
11
|
+
# Run CLI
|
|
12
|
+
pnpm cli <path>
|
|
13
|
+
pnpm cli .
|
|
14
|
+
pnpm cli ./src
|
|
15
|
+
pnpm cli project.zip
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or run the built binary:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
node packages/cli/dist/cli.js .
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Use globally (from repo, no publish)
|
|
25
|
+
|
|
26
|
+
From repo root, after `pnpm run build:packages`:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
cd packages/cli
|
|
30
|
+
npm link
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then run `frontend-guardian .` from any folder. (On Windows/Git Bash, if `pnpm link --global` fails with PATH, use `npm link` instead.)
|
|
34
|
+
|
|
35
|
+
## Publish to npm
|
|
36
|
+
|
|
37
|
+
From repo root:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pnpm run build:packages
|
|
41
|
+
cd packages/cli
|
|
42
|
+
npm publish
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Then anyone can run:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npx frontend-guardian .
|
|
49
|
+
npx frontend-guardian ./src
|
|
50
|
+
npx frontend-guardian project.zip
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Output
|
|
54
|
+
|
|
55
|
+
- Score (0–100)
|
|
56
|
+
- Warnings (spacing, radius, colors, buttons, unused imports, parse errors)
|
|
57
|
+
- Duplicate components (same JSX structure)
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import ora from "ora";
|
|
7
|
+
import { scanFiles, scanZip } from "@justinmoto/frontend-guardian-core";
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const EXT = [".js", ".jsx", ".ts", ".tsx"];
|
|
10
|
+
const IGNORE = ["node_modules", ".next", "dist", "build", ".git"];
|
|
11
|
+
function isCodeFile(name) {
|
|
12
|
+
return EXT.some((e) => name.toLowerCase().endsWith(e));
|
|
13
|
+
}
|
|
14
|
+
function isSourcePath(relativePath) {
|
|
15
|
+
const normalized = relativePath.replace(/\\/g, "/");
|
|
16
|
+
return !IGNORE.some((part) => normalized.includes(part));
|
|
17
|
+
}
|
|
18
|
+
function readDirRecursive(dir, base = "") {
|
|
19
|
+
const out = [];
|
|
20
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
21
|
+
for (const e of entries) {
|
|
22
|
+
const rel = base ? `${base}/${e.name}` : e.name;
|
|
23
|
+
const full = path.join(dir, e.name);
|
|
24
|
+
if (e.isDirectory()) {
|
|
25
|
+
if (IGNORE.includes(e.name))
|
|
26
|
+
continue;
|
|
27
|
+
out.push(...readDirRecursive(full, rel));
|
|
28
|
+
}
|
|
29
|
+
else if (e.isFile() && isCodeFile(e.name) && isSourcePath(rel)) {
|
|
30
|
+
try {
|
|
31
|
+
const code = fs.readFileSync(full, "utf-8");
|
|
32
|
+
out.push({ path: rel, code });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// skip
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
function issueType(w) {
|
|
42
|
+
const msg = w.message;
|
|
43
|
+
if (msg.startsWith("Mixed spacing values:"))
|
|
44
|
+
return { title: "Mixed Spacing", detected: msg.replace(/^Mixed spacing values:\s*/, "") };
|
|
45
|
+
if (msg.startsWith("Inconsistent border-radius:"))
|
|
46
|
+
return { title: "Inconsistent Border Radius", detected: msg.replace(/^Inconsistent border-radius:\s*/, "") };
|
|
47
|
+
if (msg.startsWith("Arbitrary color"))
|
|
48
|
+
return { title: "Arbitrary Colors", detected: msg };
|
|
49
|
+
if (msg.startsWith("Unused import:"))
|
|
50
|
+
return { title: "Unused Imports", detected: msg.replace(/^Unused import:\s*/, "") };
|
|
51
|
+
if (msg.startsWith("Parse error"))
|
|
52
|
+
return { title: "Parse Error", detected: msg };
|
|
53
|
+
if (msg.startsWith("Mixed button padding:"))
|
|
54
|
+
return { title: "Mixed Button Padding", detected: msg.replace(/^Mixed button padding:\s*/, "") };
|
|
55
|
+
if (msg.startsWith("Inconsistent button border-radius:"))
|
|
56
|
+
return { title: "Inconsistent Button Border Radius", detected: msg.replace(/^Inconsistent button border-radius:\s*/, "") };
|
|
57
|
+
return { title: "Other", detected: msg };
|
|
58
|
+
}
|
|
59
|
+
function scoreColor(score) {
|
|
60
|
+
if (score >= 70)
|
|
61
|
+
return chalk.green;
|
|
62
|
+
if (score >= 40)
|
|
63
|
+
return chalk.yellow;
|
|
64
|
+
return chalk.red;
|
|
65
|
+
}
|
|
66
|
+
const SHORT_SUGGESTION = {
|
|
67
|
+
"Mixed Spacing": "standardize spacing scale",
|
|
68
|
+
"Inconsistent Border Radius": "use one border radius token",
|
|
69
|
+
"Arbitrary Colors": "use Tailwind theme colors",
|
|
70
|
+
"Unused Imports": "remove unused imports",
|
|
71
|
+
"Parse Error": "fix syntax or remove file",
|
|
72
|
+
"Mixed Button Padding": "use one button padding",
|
|
73
|
+
"Inconsistent Button Border Radius": "use one radius for all buttons",
|
|
74
|
+
"Duplicate Components": "extract shared component and reuse",
|
|
75
|
+
};
|
|
76
|
+
function shortSuggestion(title, full) {
|
|
77
|
+
return SHORT_SUGGESTION[title] ?? (full.slice(0, 55) + (full.length > 55 ? "…" : ""));
|
|
78
|
+
}
|
|
79
|
+
const WRAP_WIDTH = 72;
|
|
80
|
+
const INDENT = " ";
|
|
81
|
+
function wrapLine(prefix, text) {
|
|
82
|
+
const maxLen = WRAP_WIDTH - prefix.length;
|
|
83
|
+
if (text.length <= maxLen) {
|
|
84
|
+
console.log(prefix + text);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
let rest = text;
|
|
88
|
+
let first = true;
|
|
89
|
+
while (rest.length > 0) {
|
|
90
|
+
const use = first ? prefix : INDENT;
|
|
91
|
+
const allowed = WRAP_WIDTH - use.length;
|
|
92
|
+
if (rest.length <= allowed) {
|
|
93
|
+
console.log(use + rest);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
let breakAt = rest.slice(0, allowed).lastIndexOf(", ") + 1 || rest.slice(0, allowed).lastIndexOf("; ") + 1;
|
|
97
|
+
if (breakAt <= 0)
|
|
98
|
+
breakAt = allowed;
|
|
99
|
+
const chunk = rest.slice(0, breakAt).trim();
|
|
100
|
+
rest = rest.slice(breakAt).trim();
|
|
101
|
+
if (chunk)
|
|
102
|
+
console.log(use + chunk);
|
|
103
|
+
first = false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function printResult(projectName, result) {
|
|
107
|
+
const line = chalk.gray("━".repeat(28));
|
|
108
|
+
console.log("\n" + line);
|
|
109
|
+
console.log(chalk.bold(" 🛡 Frontend Guardian"));
|
|
110
|
+
console.log(line + "\n");
|
|
111
|
+
let filesScanned = "filesScanned" in result ? result.filesScanned : 0;
|
|
112
|
+
if (filesScanned === 0 && (result.warnings.length > 0 || result.duplicates.length > 0)) {
|
|
113
|
+
const files = new Set();
|
|
114
|
+
result.warnings.forEach((w) => files.add(w.file));
|
|
115
|
+
result.duplicates.forEach((d) => d.files.forEach((f) => files.add(f)));
|
|
116
|
+
filesScanned = files.size;
|
|
117
|
+
}
|
|
118
|
+
console.log(" Project: " + projectName);
|
|
119
|
+
console.log(" Files scanned: " + String(filesScanned));
|
|
120
|
+
const scoreColorFn = scoreColor(result.score);
|
|
121
|
+
console.log(" Consistency Score: " + scoreColorFn(result.score + " / 100"));
|
|
122
|
+
console.log(chalk.gray(" (each warning −5, each duplicate group −10, from 100)") + "\n");
|
|
123
|
+
const byTitle = new Map();
|
|
124
|
+
for (const w of result.warnings) {
|
|
125
|
+
const { title, detected } = issueType(w);
|
|
126
|
+
const sug = w.suggestion ?? "";
|
|
127
|
+
if (!byTitle.has(title))
|
|
128
|
+
byTitle.set(title, { detected: [], suggestion: sug });
|
|
129
|
+
const entry = byTitle.get(title);
|
|
130
|
+
if (!entry.detected.includes(detected))
|
|
131
|
+
entry.detected.push(detected);
|
|
132
|
+
}
|
|
133
|
+
for (const d of result.duplicates) {
|
|
134
|
+
const title = "Duplicate Components";
|
|
135
|
+
const detected = d.files.join(", ");
|
|
136
|
+
if (!byTitle.has(title))
|
|
137
|
+
byTitle.set(title, { detected: [], suggestion: d.suggestion ?? "" });
|
|
138
|
+
byTitle.get(title).detected.push(detected);
|
|
139
|
+
}
|
|
140
|
+
const allIssues = [...byTitle.entries()].map(([title, { detected, suggestion }]) => ({
|
|
141
|
+
title,
|
|
142
|
+
detected: detected.join("; "),
|
|
143
|
+
suggestion: shortSuggestion(title, suggestion),
|
|
144
|
+
}));
|
|
145
|
+
if (allIssues.length > 0) {
|
|
146
|
+
console.log(chalk.yellow(" ⚠ ") + chalk.bold("Issues Found") + "\n");
|
|
147
|
+
allIssues.forEach((issue, i) => {
|
|
148
|
+
console.log(" " + (i + 1) + ". " + issue.title);
|
|
149
|
+
if (issue.title === "Unused Imports") {
|
|
150
|
+
wrapLine(INDENT, issue.detected);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
wrapLine(INDENT + "Detected: ", issue.detected);
|
|
154
|
+
console.log(INDENT + "Suggestion: " + issue.suggestion);
|
|
155
|
+
}
|
|
156
|
+
if (i < allIssues.length - 1)
|
|
157
|
+
console.log("");
|
|
158
|
+
});
|
|
159
|
+
console.log("");
|
|
160
|
+
}
|
|
161
|
+
console.log(line);
|
|
162
|
+
console.log(chalk.bold(" Scan Complete") + "\n");
|
|
163
|
+
}
|
|
164
|
+
async function main() {
|
|
165
|
+
const arg = process.argv[2];
|
|
166
|
+
if (!arg) {
|
|
167
|
+
console.error("Usage: frontend-guardian <path>");
|
|
168
|
+
console.error(" npx frontend-guardian .");
|
|
169
|
+
console.error(" npx frontend-guardian ./src");
|
|
170
|
+
console.error(" npx frontend-guardian project.zip");
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
const resolved = path.resolve(process.cwd(), arg);
|
|
174
|
+
if (!fs.existsSync(resolved)) {
|
|
175
|
+
console.error("Path not found:", resolved);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
const projectName = path.basename(resolved).replace(/\.zip$/i, "") || "project";
|
|
179
|
+
const stat = fs.statSync(resolved);
|
|
180
|
+
let result;
|
|
181
|
+
if (stat.isDirectory()) {
|
|
182
|
+
const files = readDirRecursive(resolved);
|
|
183
|
+
if (files.length === 0) {
|
|
184
|
+
console.error("No .js/.jsx/.ts/.tsx files found (or all under node_modules/.next/dist/build).");
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
const spinner = ora("Scanning " + files.length + " file(s)...").start();
|
|
188
|
+
try {
|
|
189
|
+
result = scanFiles(files);
|
|
190
|
+
spinner.succeed("Scan complete.");
|
|
191
|
+
}
|
|
192
|
+
catch (e) {
|
|
193
|
+
spinner.fail("Scan failed.");
|
|
194
|
+
throw e;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else if (resolved.toLowerCase().endsWith(".zip")) {
|
|
198
|
+
const spinner = ora("Scanning ZIP...").start();
|
|
199
|
+
try {
|
|
200
|
+
const buf = fs.readFileSync(resolved);
|
|
201
|
+
result = await scanZip(buf);
|
|
202
|
+
spinner.succeed("Scan complete.");
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
spinner.fail("Scan failed.");
|
|
206
|
+
throw e;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
console.error("Provide a folder path or a .zip file.");
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
printResult(projectName, result);
|
|
214
|
+
}
|
|
215
|
+
main().catch((err) => {
|
|
216
|
+
console.error(err);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "frontend-guardian",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Scan frontend projects for Tailwind & component consistency (Phase 1 CLI)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"frontend-guardian": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@justinmoto/frontend-guardian-core": "^0.1.2",
|
|
14
|
+
"chalk": "^5.3.0",
|
|
15
|
+
"ora": "^8.0.1"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"typescript": "^5"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"keywords": ["tailwind", "consistency", "frontend", "scan", "cli"],
|
|
24
|
+
"license": "MIT"
|
|
25
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import ora from "ora";
|
|
7
|
+
import { scanFiles, scanZip, type ScanResult, type Warning } from "@justinmoto/frontend-guardian-core";
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const EXT = [".js", ".jsx", ".ts", ".tsx"];
|
|
11
|
+
const IGNORE = ["node_modules", ".next", "dist", "build", ".git"];
|
|
12
|
+
|
|
13
|
+
function isCodeFile(name: string): boolean {
|
|
14
|
+
return EXT.some((e) => name.toLowerCase().endsWith(e));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isSourcePath(relativePath: string): boolean {
|
|
18
|
+
const normalized = relativePath.replace(/\\/g, "/");
|
|
19
|
+
return !IGNORE.some((part) => normalized.includes(part));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readDirRecursive(dir: string, base = ""): { path: string; code: string }[] {
|
|
23
|
+
const out: { path: string; code: string }[] = [];
|
|
24
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
25
|
+
for (const e of entries) {
|
|
26
|
+
const rel = base ? `${base}/${e.name}` : e.name;
|
|
27
|
+
const full = path.join(dir, e.name);
|
|
28
|
+
if (e.isDirectory()) {
|
|
29
|
+
if (IGNORE.includes(e.name)) continue;
|
|
30
|
+
out.push(...readDirRecursive(full, rel));
|
|
31
|
+
} else if (e.isFile() && isCodeFile(e.name) && isSourcePath(rel)) {
|
|
32
|
+
try {
|
|
33
|
+
const code = fs.readFileSync(full, "utf-8");
|
|
34
|
+
out.push({ path: rel, code });
|
|
35
|
+
} catch {
|
|
36
|
+
// skip
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function issueType(w: Warning): { title: string; detected: string } {
|
|
44
|
+
const msg = w.message;
|
|
45
|
+
if (msg.startsWith("Mixed spacing values:"))
|
|
46
|
+
return { title: "Mixed Spacing", detected: msg.replace(/^Mixed spacing values:\s*/, "") };
|
|
47
|
+
if (msg.startsWith("Inconsistent border-radius:"))
|
|
48
|
+
return { title: "Inconsistent Border Radius", detected: msg.replace(/^Inconsistent border-radius:\s*/, "") };
|
|
49
|
+
if (msg.startsWith("Arbitrary color"))
|
|
50
|
+
return { title: "Arbitrary Colors", detected: msg };
|
|
51
|
+
if (msg.startsWith("Unused import:"))
|
|
52
|
+
return { title: "Unused Imports", detected: msg.replace(/^Unused import:\s*/, "") };
|
|
53
|
+
if (msg.startsWith("Parse error"))
|
|
54
|
+
return { title: "Parse Error", detected: msg };
|
|
55
|
+
if (msg.startsWith("Mixed button padding:"))
|
|
56
|
+
return { title: "Mixed Button Padding", detected: msg.replace(/^Mixed button padding:\s*/, "") };
|
|
57
|
+
if (msg.startsWith("Inconsistent button border-radius:"))
|
|
58
|
+
return { title: "Inconsistent Button Border Radius", detected: msg.replace(/^Inconsistent button border-radius:\s*/, "") };
|
|
59
|
+
return { title: "Other", detected: msg };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function scoreColor(score: number): (s: string) => string {
|
|
63
|
+
if (score >= 70) return chalk.green;
|
|
64
|
+
if (score >= 40) return chalk.yellow;
|
|
65
|
+
return chalk.red;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const SHORT_SUGGESTION: Record<string, string> = {
|
|
69
|
+
"Mixed Spacing": "standardize spacing scale",
|
|
70
|
+
"Inconsistent Border Radius": "use one border radius token",
|
|
71
|
+
"Arbitrary Colors": "use Tailwind theme colors",
|
|
72
|
+
"Unused Imports": "remove unused imports",
|
|
73
|
+
"Parse Error": "fix syntax or remove file",
|
|
74
|
+
"Mixed Button Padding": "use one button padding",
|
|
75
|
+
"Inconsistent Button Border Radius": "use one radius for all buttons",
|
|
76
|
+
"Duplicate Components": "extract shared component and reuse",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function shortSuggestion(title: string, full: string): string {
|
|
80
|
+
return SHORT_SUGGESTION[title] ?? (full.slice(0, 55) + (full.length > 55 ? "…" : ""));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const WRAP_WIDTH = 72;
|
|
84
|
+
const INDENT = " ";
|
|
85
|
+
|
|
86
|
+
function wrapLine(prefix: string, text: string): void {
|
|
87
|
+
const maxLen = WRAP_WIDTH - prefix.length;
|
|
88
|
+
if (text.length <= maxLen) {
|
|
89
|
+
console.log(prefix + text);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
let rest = text;
|
|
93
|
+
let first = true;
|
|
94
|
+
while (rest.length > 0) {
|
|
95
|
+
const use = first ? prefix : INDENT;
|
|
96
|
+
const allowed = WRAP_WIDTH - use.length;
|
|
97
|
+
if (rest.length <= allowed) {
|
|
98
|
+
console.log(use + rest);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
let breakAt = rest.slice(0, allowed).lastIndexOf(", ") + 1 || rest.slice(0, allowed).lastIndexOf("; ") + 1;
|
|
102
|
+
if (breakAt <= 0) breakAt = allowed;
|
|
103
|
+
const chunk = rest.slice(0, breakAt).trim();
|
|
104
|
+
rest = rest.slice(breakAt).trim();
|
|
105
|
+
if (chunk) console.log(use + chunk);
|
|
106
|
+
first = false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function printResult(projectName: string, result: ScanResult) {
|
|
111
|
+
const line = chalk.gray("━".repeat(28));
|
|
112
|
+
console.log("\n" + line);
|
|
113
|
+
console.log(chalk.bold(" 🛡 Frontend Guardian"));
|
|
114
|
+
console.log(line + "\n");
|
|
115
|
+
|
|
116
|
+
let filesScanned = "filesScanned" in result ? (result as ScanResult & { filesScanned?: number }).filesScanned : 0;
|
|
117
|
+
if (filesScanned === 0 && (result.warnings.length > 0 || result.duplicates.length > 0)) {
|
|
118
|
+
const files = new Set<string>();
|
|
119
|
+
result.warnings.forEach((w) => files.add(w.file));
|
|
120
|
+
result.duplicates.forEach((d) => d.files.forEach((f) => files.add(f)));
|
|
121
|
+
filesScanned = files.size;
|
|
122
|
+
}
|
|
123
|
+
console.log(" Project: " + projectName);
|
|
124
|
+
console.log(" Files scanned: " + String(filesScanned));
|
|
125
|
+
const scoreColorFn = scoreColor(result.score);
|
|
126
|
+
console.log(" Consistency Score: " + scoreColorFn(result.score + " / 100"));
|
|
127
|
+
console.log(chalk.gray(" (each warning −5, each duplicate group −10, from 100)") + "\n");
|
|
128
|
+
|
|
129
|
+
const byTitle = new Map<string, { detected: string[]; suggestion: string }>();
|
|
130
|
+
for (const w of result.warnings) {
|
|
131
|
+
const { title, detected } = issueType(w);
|
|
132
|
+
const sug = w.suggestion ?? "";
|
|
133
|
+
if (!byTitle.has(title)) byTitle.set(title, { detected: [], suggestion: sug });
|
|
134
|
+
const entry = byTitle.get(title)!;
|
|
135
|
+
if (!entry.detected.includes(detected)) entry.detected.push(detected);
|
|
136
|
+
}
|
|
137
|
+
for (const d of result.duplicates) {
|
|
138
|
+
const title = "Duplicate Components";
|
|
139
|
+
const detected = d.files.join(", ");
|
|
140
|
+
if (!byTitle.has(title)) byTitle.set(title, { detected: [], suggestion: d.suggestion ?? "" });
|
|
141
|
+
byTitle.get(title)!.detected.push(detected);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const allIssues = [...byTitle.entries()].map(([title, { detected, suggestion }]) => ({
|
|
145
|
+
title,
|
|
146
|
+
detected: detected.join("; "),
|
|
147
|
+
suggestion: shortSuggestion(title, suggestion),
|
|
148
|
+
}));
|
|
149
|
+
|
|
150
|
+
if (allIssues.length > 0) {
|
|
151
|
+
console.log(chalk.yellow(" ⚠ ") + chalk.bold("Issues Found") + "\n");
|
|
152
|
+
allIssues.forEach((issue, i) => {
|
|
153
|
+
console.log(" " + (i + 1) + ". " + issue.title);
|
|
154
|
+
if (issue.title === "Unused Imports") {
|
|
155
|
+
wrapLine(INDENT, issue.detected);
|
|
156
|
+
} else {
|
|
157
|
+
wrapLine(INDENT + "Detected: ", issue.detected);
|
|
158
|
+
console.log(INDENT + "Suggestion: " + issue.suggestion);
|
|
159
|
+
}
|
|
160
|
+
if (i < allIssues.length - 1) console.log("");
|
|
161
|
+
});
|
|
162
|
+
console.log("");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log(line);
|
|
166
|
+
console.log(chalk.bold(" Scan Complete") + "\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function main() {
|
|
170
|
+
const arg = process.argv[2];
|
|
171
|
+
if (!arg) {
|
|
172
|
+
console.error("Usage: frontend-guardian <path>");
|
|
173
|
+
console.error(" npx frontend-guardian .");
|
|
174
|
+
console.error(" npx frontend-guardian ./src");
|
|
175
|
+
console.error(" npx frontend-guardian project.zip");
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
const resolved = path.resolve(process.cwd(), arg);
|
|
179
|
+
if (!fs.existsSync(resolved)) {
|
|
180
|
+
console.error("Path not found:", resolved);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
const projectName = path.basename(resolved).replace(/\.zip$/i, "") || "project";
|
|
184
|
+
|
|
185
|
+
const stat = fs.statSync(resolved);
|
|
186
|
+
let result: ScanResult;
|
|
187
|
+
|
|
188
|
+
if (stat.isDirectory()) {
|
|
189
|
+
const files = readDirRecursive(resolved);
|
|
190
|
+
if (files.length === 0) {
|
|
191
|
+
console.error("No .js/.jsx/.ts/.tsx files found (or all under node_modules/.next/dist/build).");
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
const spinner = ora("Scanning " + files.length + " file(s)...").start();
|
|
195
|
+
try {
|
|
196
|
+
result = scanFiles(files);
|
|
197
|
+
spinner.succeed("Scan complete.");
|
|
198
|
+
} catch (e) {
|
|
199
|
+
spinner.fail("Scan failed.");
|
|
200
|
+
throw e;
|
|
201
|
+
}
|
|
202
|
+
} else if (resolved.toLowerCase().endsWith(".zip")) {
|
|
203
|
+
const spinner = ora("Scanning ZIP...").start();
|
|
204
|
+
try {
|
|
205
|
+
const buf = fs.readFileSync(resolved);
|
|
206
|
+
result = await scanZip(buf);
|
|
207
|
+
spinner.succeed("Scan complete.");
|
|
208
|
+
} catch (e) {
|
|
209
|
+
spinner.fail("Scan failed.");
|
|
210
|
+
throw e;
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
console.error("Provide a folder path or a .zip file.");
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
printResult(projectName, result);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
main().catch((err) => {
|
|
221
|
+
console.error(err);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
},
|
|
11
|
+
"include": ["src/**/*.ts"],
|
|
12
|
+
"exclude": ["node_modules", "dist"]
|
|
13
|
+
}
|