proj-pulse 1.0.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.
- package/Readme.md +95 -0
- package/bin/cli.js +13 -0
- package/package.json +28 -0
- package/src/checks/envVars.js +84 -0
- package/src/checks/gitignore.js +71 -0
- package/src/checks/largeFiles.js +66 -0
- package/src/checks/staleTodos.js +82 -0
- package/src/checks/unusedDeps.js +61 -0
- package/src/index.js +99 -0
package/Readme.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# ๐ฉบ proj-pulse
|
|
2
|
+
|
|
3
|
+
> A health checkup for your project โ one command to catch what's quietly rotting.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx proj-pulse
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## What it checks
|
|
12
|
+
|
|
13
|
+
| Check | What it finds |
|
|
14
|
+
|---|---|
|
|
15
|
+
| ๐ฆ **Unused Dependencies** | npm packages installed but never imported |
|
|
16
|
+
| ๐ **Env Var Health** | Missing keys, weak secrets, .env not in .gitignore |
|
|
17
|
+
| ๐ **Stale TODOs** | TODO/FIXME comments older than 30 days |
|
|
18
|
+
| ๐ **Large Files** | Source files over 500 KB that shouldn't be in git |
|
|
19
|
+
| ๐ก๏ธ **.gitignore Safety** | Missing critical entries (node_modules, .env, etc.) |
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Run instantly with npx (no install needed)
|
|
27
|
+
npx proj-pulse
|
|
28
|
+
|
|
29
|
+
# Or install globally
|
|
30
|
+
npm install -g proj-pulse
|
|
31
|
+
proj-pulse
|
|
32
|
+
|
|
33
|
+
# Or scan a specific folder
|
|
34
|
+
proj-pulse /path/to/my/project
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Example Output
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
๐ฉบ proj-pulse Project Health Check
|
|
43
|
+
|
|
44
|
+
Scanning: /Users/dev/my-app
|
|
45
|
+
|
|
46
|
+
โ Unused Dependencies
|
|
47
|
+
All 12 dependencies appear to be used.
|
|
48
|
+
|
|
49
|
+
โ Env Var Health
|
|
50
|
+
2 env issues detected:
|
|
51
|
+
ยท .env is NOT listed in .gitignore โ risk of leaking secrets
|
|
52
|
+
ยท Line 4: DB_PASSWORD โ weak/short secret
|
|
53
|
+
โ Fix: Review your .env file and update insecure or missing values.
|
|
54
|
+
|
|
55
|
+
โ Stale TODOs
|
|
56
|
+
5 TODO/FIXME found, 3 older than 30 days:
|
|
57
|
+
ยท [TODO] src/api/auth.js:42 โ "refactor token handling" (87d old)
|
|
58
|
+
ยท [FIXME] src/utils/parser.js:11 โ "edge case for empty array" (63d old)
|
|
59
|
+
ยท [HACK] src/db/index.js:7 โ "replace with proper pool" (45d old)
|
|
60
|
+
โ Fix: Address or remove comments older than 30 days.
|
|
61
|
+
|
|
62
|
+
โ Large Files
|
|
63
|
+
No unexpectedly large source files found (threshold: 500 KB).
|
|
64
|
+
|
|
65
|
+
โ .gitignore Safety
|
|
66
|
+
1 .gitignore issue found:
|
|
67
|
+
ยท Missing: ".env" (secrets / API keys)
|
|
68
|
+
โ Fix: Add missing entries to your .gitignore file.
|
|
69
|
+
|
|
70
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
71
|
+
Summary
|
|
72
|
+
|
|
73
|
+
โ Passed 2/5
|
|
74
|
+
โ Warnings 2/5
|
|
75
|
+
โ Failed 1/5
|
|
76
|
+
|
|
77
|
+
Health Score: 40%
|
|
78
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Roadmap
|
|
84
|
+
|
|
85
|
+
- [ ] `--fix` flag to auto-resolve safe issues
|
|
86
|
+
- [ ] JSON output for CI integration (`--json`)
|
|
87
|
+
- [ ] Config file (`.proj-pulse.json`) for custom thresholds
|
|
88
|
+
- [ ] Git history analysis for churn / hotspot detection
|
|
89
|
+
- [ ] Dead code detection
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
package/bin/cli.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "proj-pulse",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A health checkup for your project โ dead deps, stale TODOs, unused env vars, and more.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"proj-pulse": "./bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/cli.js",
|
|
11
|
+
"test": "node bin/cli.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"developer-tools",
|
|
15
|
+
"cli",
|
|
16
|
+
"health-check",
|
|
17
|
+
"project",
|
|
18
|
+
"dependencies",
|
|
19
|
+
"audit"
|
|
20
|
+
],
|
|
21
|
+
"author": "",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"chalk": "^4.1.2",
|
|
25
|
+
"glob": "^8.1.0",
|
|
26
|
+
"ora": "^5.4.1"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
|
|
5
|
+
const INSECURE_PATTERNS = [
|
|
6
|
+
{ pattern: /^(password|pass|pwd|secret|key|token|api_key)=.{1,7}$/i, label: "weak/short secret" },
|
|
7
|
+
{ pattern: /=password$/i, label: "default password value" },
|
|
8
|
+
{ pattern: /=secret$/i, label: "default secret value" },
|
|
9
|
+
{ pattern: /=changeme$/i, label: "placeholder value (changeme)" },
|
|
10
|
+
{ pattern: /=12345/, label: "weak numeric value" },
|
|
11
|
+
{ pattern: /=true$/i, label: "boolean flag โ confirm intentional" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
async function checkEnvVars(projectRoot) {
|
|
15
|
+
const envPath = path.join(projectRoot, ".env");
|
|
16
|
+
const examplePath = path.join(projectRoot, ".env.example");
|
|
17
|
+
const templatePath = path.join(projectRoot, ".env.template");
|
|
18
|
+
|
|
19
|
+
const hasEnv = fs.existsSync(envPath);
|
|
20
|
+
const hasExample = fs.existsSync(examplePath) || fs.existsSync(templatePath);
|
|
21
|
+
|
|
22
|
+
if (!hasEnv && !hasExample) {
|
|
23
|
+
return { status: "skip", message: "No .env or .env.example file found." };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const issues = [];
|
|
27
|
+
|
|
28
|
+
// Check .env is NOT committed (check .gitignore)
|
|
29
|
+
const gitignorePath = path.join(projectRoot, ".gitignore");
|
|
30
|
+
if (hasEnv && fs.existsSync(gitignorePath)) {
|
|
31
|
+
const gi = fs.readFileSync(gitignorePath, "utf8");
|
|
32
|
+
if (!gi.includes(".env")) {
|
|
33
|
+
issues.push(".env is NOT listed in .gitignore โ risk of leaking secrets");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Parse .env and check for insecure patterns
|
|
38
|
+
if (hasEnv) {
|
|
39
|
+
const lines = fs.readFileSync(envPath, "utf8").split("\n");
|
|
40
|
+
lines.forEach((line, i) => {
|
|
41
|
+
const trimmed = line.trim();
|
|
42
|
+
if (!trimmed || trimmed.startsWith("#")) return;
|
|
43
|
+
|
|
44
|
+
INSECURE_PATTERNS.forEach(({ pattern, label }) => {
|
|
45
|
+
if (pattern.test(trimmed)) {
|
|
46
|
+
const key = trimmed.split("=")[0];
|
|
47
|
+
issues.push(`Line ${i + 1}: ${key} โ ${label}`);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Cross-check .env vs .env.example for missing keys
|
|
53
|
+
if (hasExample) {
|
|
54
|
+
const exPath = fs.existsSync(examplePath) ? examplePath : templatePath;
|
|
55
|
+
const exampleKeys = fs.readFileSync(exPath, "utf8")
|
|
56
|
+
.split("\n")
|
|
57
|
+
.filter(l => l.includes("=") && !l.startsWith("#"))
|
|
58
|
+
.map(l => l.split("=")[0].trim());
|
|
59
|
+
|
|
60
|
+
const envKeys = fs.readFileSync(envPath, "utf8")
|
|
61
|
+
.split("\n")
|
|
62
|
+
.filter(l => l.includes("=") && !l.startsWith("#"))
|
|
63
|
+
.map(l => l.split("=")[0].trim());
|
|
64
|
+
|
|
65
|
+
const missing = exampleKeys.filter(k => !envKeys.includes(k));
|
|
66
|
+
if (missing.length > 0) {
|
|
67
|
+
missing.forEach(k => issues.push(`Missing key from .env.example: ${k}`));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (issues.length === 0) {
|
|
73
|
+
return { status: "pass", message: ".env looks healthy โ no obvious issues found." };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
status: "warn",
|
|
78
|
+
message: `${issues.length} env ${issues.length === 1 ? "issue" : "issues"} detected:`,
|
|
79
|
+
items: issues,
|
|
80
|
+
fix: "Review your .env file and update insecure or missing values.",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = checkEnvVars;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
const MUST_IGNORE = [
|
|
7
|
+
{ entry: ".env", reason: "secrets / API keys" },
|
|
8
|
+
{ entry: "node_modules", reason: "dependencies (bloats repo)" },
|
|
9
|
+
{ entry: ".DS_Store", reason: "macOS metadata noise" },
|
|
10
|
+
{ entry: "*.log", reason: "log files" },
|
|
11
|
+
{ entry: "dist", reason: "build output" },
|
|
12
|
+
{ entry: "coverage", reason: "test coverage output" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const SENSITIVE_FILES = [
|
|
16
|
+
".env", ".env.local", ".env.production",
|
|
17
|
+
"*.pem", "*.key", "*.p12", "*.pfx",
|
|
18
|
+
"secrets.json", "credentials.json",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
async function checkGitignore(projectRoot) {
|
|
22
|
+
const gitignorePath = path.join(projectRoot, ".gitignore");
|
|
23
|
+
|
|
24
|
+
if (!fs.existsSync(gitignorePath)) {
|
|
25
|
+
return {
|
|
26
|
+
status: "fail",
|
|
27
|
+
message: "No .gitignore file found! This is risky for any project.",
|
|
28
|
+
fix: "Run: npx gitignore node (or create one manually)",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const content = fs.readFileSync(gitignorePath, "utf8");
|
|
33
|
+
const lines = content.split("\n").map(l => l.trim()).filter(Boolean);
|
|
34
|
+
|
|
35
|
+
const isIgnored = (entry) =>
|
|
36
|
+
lines.some(l => l === entry || l === `/${entry}` || l === `${entry}/`);
|
|
37
|
+
|
|
38
|
+
const missing = MUST_IGNORE.filter(({ entry }) => !isIgnored(entry));
|
|
39
|
+
|
|
40
|
+
// Check if any sensitive files actually exist but are NOT gitignored
|
|
41
|
+
const exposedSecrets = SENSITIVE_FILES.filter(pattern => {
|
|
42
|
+
const cleanPattern = pattern.replace(/\*/g, "");
|
|
43
|
+
const exists = fs.existsSync(path.join(projectRoot, cleanPattern.startsWith(".") ? cleanPattern : `.${cleanPattern}`))
|
|
44
|
+
|| fs.existsSync(path.join(projectRoot, cleanPattern));
|
|
45
|
+
const ignored = lines.some(l => l.includes(cleanPattern) || l === pattern);
|
|
46
|
+
return exists && !ignored;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const issues = [
|
|
50
|
+
...missing.map(({ entry, reason }) => `Missing: "${entry}" (${reason})`),
|
|
51
|
+
...exposedSecrets.map(f => `โ Sensitive file exists and is NOT ignored: ${f}`),
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
if (issues.length === 0) {
|
|
55
|
+
return {
|
|
56
|
+
status: "pass",
|
|
57
|
+
message: ".gitignore covers all common dangerous entries.",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const hasCritical = exposedSecrets.length > 0;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
status: hasCritical ? "fail" : "warn",
|
|
65
|
+
message: `${issues.length} .gitignore ${issues.length === 1 ? "issue" : "issues"} found:`,
|
|
66
|
+
items: issues,
|
|
67
|
+
fix: `Add missing entries to your .gitignore file.`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = checkGitignore;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const glob = require('glob')
|
|
6
|
+
|
|
7
|
+
const SIZE_WARN_MB = 0.5; // 500 KB
|
|
8
|
+
const SIZE_FAIL_MB = 2; // 2 MB
|
|
9
|
+
|
|
10
|
+
const IGNORED_EXTENSIONS = [
|
|
11
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico",
|
|
12
|
+
".mp4", ".mov", ".avi", ".mp3", ".wav",
|
|
13
|
+
".zip", ".tar", ".gz", ".rar",
|
|
14
|
+
".pdf", ".woff", ".woff2", ".ttf", ".eot",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function formatSize(bytes) {
|
|
18
|
+
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
19
|
+
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
20
|
+
return `${bytes} B`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function checkLargeFiles(projectRoot) {
|
|
24
|
+
const allFiles = glob.sync("**/*", {
|
|
25
|
+
cwd: projectRoot,
|
|
26
|
+
ignore: ["node_modules/**", "dist/**", "build/**", ".next/**", ".git/**"],
|
|
27
|
+
absolute: true,
|
|
28
|
+
nodir: true,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const large = [];
|
|
32
|
+
|
|
33
|
+
for (const filePath of allFiles) {
|
|
34
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
35
|
+
if (IGNORED_EXTENSIONS.includes(ext)) continue;
|
|
36
|
+
|
|
37
|
+
let stat;
|
|
38
|
+
try { stat = fs.statSync(filePath); } catch { continue; }
|
|
39
|
+
|
|
40
|
+
const mb = stat.size / (1024 * 1024);
|
|
41
|
+
if (mb >= SIZE_WARN_MB) {
|
|
42
|
+
const relPath = path.relative(projectRoot, filePath);
|
|
43
|
+
large.push({ relPath, size: stat.size, mb });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (large.length === 0) {
|
|
48
|
+
return { status: "pass", message: `No unexpectedly large source files found (threshold: ${SIZE_WARN_MB * 1000} KB).` };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
large.sort((a, b) => b.size - a.size);
|
|
52
|
+
|
|
53
|
+
const hasFail = large.some(f => f.mb >= SIZE_FAIL_MB);
|
|
54
|
+
const status = hasFail ? "fail" : "warn";
|
|
55
|
+
|
|
56
|
+
const items = large.map(f => `${f.relPath} (${formatSize(f.size)})`);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
status,
|
|
60
|
+
message: `${large.length} large ${large.length === 1 ? "file" : "files"} found in source:`,
|
|
61
|
+
items,
|
|
62
|
+
fix: "Consider moving large files to a CDN or adding them to .gitignore.",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = checkLargeFiles;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const glob = require("glob");
|
|
6
|
+
const { execSync } = require("child_process");
|
|
7
|
+
|
|
8
|
+
const TODO_PATTERN = /\/\/\s*(TODO|FIXME|HACK|XXX|BUG):?\s*(.+)/i;
|
|
9
|
+
|
|
10
|
+
function getFileAge(filePath) {
|
|
11
|
+
try {
|
|
12
|
+
const out = execSync(`git log -1 --format="%ci" -- "${filePath}" 2>/dev/null`, {
|
|
13
|
+
encoding: "utf8",
|
|
14
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
15
|
+
}).trim();
|
|
16
|
+
if (!out) return null;
|
|
17
|
+
return new Date(out);
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function daysSince(date) {
|
|
24
|
+
return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function checkStaleTodos(projectRoot) {
|
|
28
|
+
const sourceFiles = glob.sync("**/*.{js,ts,jsx,tsx,mjs,cjs,py,rb,go,java,cs}", {
|
|
29
|
+
cwd: projectRoot,
|
|
30
|
+
ignore: ["node_modules/**", "dist/**", "build/**", ".next/**"],
|
|
31
|
+
absolute: true,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (sourceFiles.length === 0) {
|
|
35
|
+
return { status: "skip", message: "No source files found." };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const todos = [];
|
|
39
|
+
|
|
40
|
+
for (const filePath of sourceFiles) {
|
|
41
|
+
let content;
|
|
42
|
+
try { content = fs.readFileSync(filePath, "utf8"); } catch { continue; }
|
|
43
|
+
|
|
44
|
+
const lines = content.split("\n");
|
|
45
|
+
lines.forEach((line, i) => {
|
|
46
|
+
const match = line.match(TODO_PATTERN);
|
|
47
|
+
if (!match) return;
|
|
48
|
+
|
|
49
|
+
const relPath = path.relative(projectRoot, filePath);
|
|
50
|
+
const age = getFileAge(filePath);
|
|
51
|
+
const days = age ? daysSince(age) : null;
|
|
52
|
+
const label = match[1].toUpperCase();
|
|
53
|
+
const text = match[2].trim().slice(0, 60);
|
|
54
|
+
const ageStr = days !== null ? `${days}d old` : "age unknown";
|
|
55
|
+
|
|
56
|
+
todos.push({ relPath, line: i + 1, label, text, days, ageStr });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (todos.length === 0) {
|
|
61
|
+
return { status: "pass", message: "No TODO/FIXME comments found. Clean codebase!" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Sort by oldest first
|
|
65
|
+
todos.sort((a, b) => (b.days || 0) - (a.days || 0));
|
|
66
|
+
|
|
67
|
+
const stale = todos.filter(t => t.days !== null && t.days > 30);
|
|
68
|
+
const status = stale.length > 5 ? "fail" : stale.length > 0 ? "warn" : "pass";
|
|
69
|
+
|
|
70
|
+
const items = todos.map(t =>
|
|
71
|
+
`[${t.label}] ${t.relPath}:${t.line} โ "${t.text}" (${t.ageStr})`
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
status,
|
|
76
|
+
message: `${todos.length} TODO/FIXME found, ${stale.length} older than 30 days:`,
|
|
77
|
+
items,
|
|
78
|
+
fix: stale.length > 0 ? "Address or remove comments older than 30 days." : undefined,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = checkStaleTodos;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const glob = require("glob");
|
|
5
|
+
|
|
6
|
+
async function checkUnusedDependencies(projectRoot) {
|
|
7
|
+
const pkgPath = path.join(projectRoot, "package.json");
|
|
8
|
+
|
|
9
|
+
if (!fs.existsSync(pkgPath)) {
|
|
10
|
+
return { status: "skip", message: "No package.json found." };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
14
|
+
const deps = Object.keys(pkg.dependencies || {});
|
|
15
|
+
|
|
16
|
+
if (deps.length === 0) {
|
|
17
|
+
return { status: "pass", message: "No dependencies declared." };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Gather all source files
|
|
21
|
+
const sourceFiles = glob.sync("**/*.{js,ts,jsx,tsx,mjs,cjs}", {
|
|
22
|
+
cwd: projectRoot,
|
|
23
|
+
ignore: ["node_modules/**", "dist/**", "build/**", ".next/**"],
|
|
24
|
+
absolute: true,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (sourceFiles.length === 0) {
|
|
28
|
+
return { status: "skip", message: "No source files found to scan." };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Read all source content once
|
|
32
|
+
const allSource = sourceFiles.map(f => {
|
|
33
|
+
try { return fs.readFileSync(f, "utf8"); } catch { return ""; }
|
|
34
|
+
}).join("\n");
|
|
35
|
+
|
|
36
|
+
const unused = deps.filter(dep => {
|
|
37
|
+
// Match require("dep"), require('dep'), from "dep", from 'dep'
|
|
38
|
+
const escaped = dep.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
|
|
39
|
+
const pattern = new RegExp(
|
|
40
|
+
`require\\(['"](${escaped})['"](\\/.+)?['"]\\)|from\\s+['"](${escaped})(\\/.+)?['"]`,
|
|
41
|
+
"m"
|
|
42
|
+
);
|
|
43
|
+
return !pattern.test(allSource);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (unused.length === 0) {
|
|
47
|
+
return {
|
|
48
|
+
status: "pass",
|
|
49
|
+
message: `All ${deps.length} dependencies appear to be used.`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
status: "warn",
|
|
55
|
+
message: `${unused.length} potentially unused ${unused.length === 1 ? "dependency" : "dependencies"} found:`,
|
|
56
|
+
items: unused.map(d => `${d}`),
|
|
57
|
+
fix: `npm uninstall ${unused.slice(0, 3).join(" ")}${unused.length > 3 ? " ..." : ""}`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = checkUnusedDependencies;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const chalk = require("chalk");
|
|
4
|
+
const ora = require("ora");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
|
|
7
|
+
const checkUnusedDependencies = require("./checks/unusedDeps");
|
|
8
|
+
const checkEnvVars = require("./checks/envVars");
|
|
9
|
+
const checkStaleTodos = require("./checks/staleTodos");
|
|
10
|
+
const checkLargeFiles = require("./checks/largeFiles");
|
|
11
|
+
const checkGitignore = require("./checks/gitignore");
|
|
12
|
+
|
|
13
|
+
const CHECKS = [
|
|
14
|
+
{ name: "Unused Dependencies", fn: checkUnusedDependencies },
|
|
15
|
+
{ name: "Env Var Health", fn: checkEnvVars },
|
|
16
|
+
{ name: "Stale TODOs", fn: checkStaleTodos },
|
|
17
|
+
{ name: "Large Files", fn: checkLargeFiles },
|
|
18
|
+
{ name: ".gitignore Safety", fn: checkGitignore },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function printHeader() {
|
|
22
|
+
console.log("\n" + chalk.bgGreen.black.bold(" ๐ฉบ proj-pulse ") + chalk.gray(" Project Health Check\n"));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function printSummary(results) {
|
|
26
|
+
const total = results.length;
|
|
27
|
+
const passed = results.filter(r => r.status === "pass").length;
|
|
28
|
+
const warned = results.filter(r => r.status === "warn").length;
|
|
29
|
+
const failed = results.filter(r => r.status === "fail").length;
|
|
30
|
+
|
|
31
|
+
console.log("\n" + chalk.bold("โ".repeat(50)));
|
|
32
|
+
console.log(chalk.bold(" Summary\n"));
|
|
33
|
+
console.log(` ${chalk.green("โ")} Passed ${chalk.green.bold(passed)}/${total}`);
|
|
34
|
+
if (warned) console.log(` ${chalk.yellow("โ ")} Warnings ${chalk.yellow.bold(warned)}/${total}`);
|
|
35
|
+
if (failed) console.log(` ${chalk.red("โ")} Failed ${chalk.red.bold(failed)}/${total}`);
|
|
36
|
+
|
|
37
|
+
const score = Math.round((passed / total) * 100);
|
|
38
|
+
const scoreColor = score >= 80 ? chalk.green : score >= 50 ? chalk.yellow : chalk.red;
|
|
39
|
+
console.log("\n " + chalk.bold("Health Score: ") + scoreColor.bold(`${score}%`));
|
|
40
|
+
console.log(chalk.bold("โ".repeat(50)) + "\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function printCheckResult(result) {
|
|
44
|
+
const icons = { pass: chalk.green("โ"), warn: chalk.yellow("โ "), fail: chalk.red("โ"), skip: chalk.gray("โ") };
|
|
45
|
+
const colors = { pass: chalk.green, warn: chalk.yellow, fail: chalk.red, skip: chalk.gray };
|
|
46
|
+
|
|
47
|
+
const icon = icons[result.status] || chalk.gray("โ");
|
|
48
|
+
const color = colors[result.status] || chalk.gray;
|
|
49
|
+
|
|
50
|
+
console.log(`\n ${icon} ${chalk.bold(result.name)}`);
|
|
51
|
+
|
|
52
|
+
if (result.message) {
|
|
53
|
+
console.log(` ${color(result.message)}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (result.items && result.items.length > 0) {
|
|
57
|
+
result.items.slice(0, 8).forEach(item => {
|
|
58
|
+
console.log(` ${chalk.gray("ยท")} ${item}`);
|
|
59
|
+
});
|
|
60
|
+
if (result.items.length > 8) {
|
|
61
|
+
console.log(` ${chalk.gray(`... and ${result.items.length - 8} more`)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (result.fix) {
|
|
66
|
+
console.log(` ${chalk.cyan("โ Fix:")} ${chalk.cyan(result.fix)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function runPulse(projectRoot) {
|
|
71
|
+
printHeader();
|
|
72
|
+
console.log(chalk.gray(` Scanning: ${projectRoot}\n`));
|
|
73
|
+
|
|
74
|
+
const results = [];
|
|
75
|
+
|
|
76
|
+
for (const check of CHECKS) {
|
|
77
|
+
const spinner = ora({ text: chalk.gray(`Checking ${check.name}...`), spinner: "dots" }).start();
|
|
78
|
+
try {
|
|
79
|
+
const result = await check.fn(projectRoot);
|
|
80
|
+
result.name = check.name;
|
|
81
|
+
results.push(result);
|
|
82
|
+
spinner.stop();
|
|
83
|
+
printCheckResult(result);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
spinner.stop();
|
|
86
|
+
const errResult = {
|
|
87
|
+
name: check.name,
|
|
88
|
+
status: "skip",
|
|
89
|
+
message: `Skipped โ ${err.message}`,
|
|
90
|
+
};
|
|
91
|
+
results.push(errResult);
|
|
92
|
+
printCheckResult(errResult);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
printSummary(results);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { runPulse };
|