check-project-health 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 +114 -0
- package/bin/cli.js +70 -0
- package/package.json +48 -0
- package/src/checkers/dependencies.js +113 -0
- package/src/checkers/env.js +117 -0
- package/src/checkers/git.js +122 -0
- package/src/checkers/index.js +30 -0
- package/src/checkers/security.js +98 -0
- package/src/checkers/tests.js +142 -0
- package/src/checkers/todos.js +86 -0
- package/src/engine.js +47 -0
- package/src/scorer.js +32 -0
- package/src/ui/colors.js +19 -0
- package/src/ui/dashboard.js +84 -0
- package/src/ui/spinner.js +10 -0
- package/src/utils/execAsync.js +15 -0
- package/src/utils/fileScanner.js +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# check-project-health
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/check-project-health)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
|
|
7
|
+
**Terminal health dashboard for developers.** Run one command in any project directory and get a beautiful, real-time health report: dependencies, git status, code quality, environment config, tests, and security — all in one place.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
╭─────────────────────────────────────╮
|
|
11
|
+
│ 🏥 PROJECT HEALTH REPORT │
|
|
12
|
+
│ Score: 78/100 Grade: B │
|
|
13
|
+
│ Status: Good │
|
|
14
|
+
╰─────────────────────────────────────╯
|
|
15
|
+
|
|
16
|
+
┌──────────────────┬────────┬──────────┬────────────────────────────┐
|
|
17
|
+
│ Checker │ Score │ Status │ Summary │
|
|
18
|
+
├──────────────────┼────────┼──────────┼────────────────────────────┤
|
|
19
|
+
│ Dependencies │ 85 │ ✅ ok │ 2 packages outdated │
|
|
20
|
+
│ Git │ 90 │ ✅ ok │ Repository in good shape │
|
|
21
|
+
│ Code Quality │ 72 │ ⚠️ warn │ 5 TODOs across 2 files │
|
|
22
|
+
└──────────────────┴────────┴──────────┴────────────────────────────┘
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install -g check-project-health
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
Run from any project directory (uses current working directory):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
check-project-health
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Options
|
|
44
|
+
|
|
45
|
+
| Flag | Description |
|
|
46
|
+
|------|-------------|
|
|
47
|
+
| `--json` | Output raw JSON instead of the visual dashboard (for CI or tooling) |
|
|
48
|
+
| `--no-color` | Disable colored output |
|
|
49
|
+
| `--skip <checkers>` | Comma-separated checker names to skip (e.g. `--skip "Git,Security"`) |
|
|
50
|
+
| `--run-tests` | Run the test script as part of the Tests checker (adds time) |
|
|
51
|
+
|
|
52
|
+
**Examples:**
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
check-project-health --json > report.json
|
|
56
|
+
check-project-health --no-color
|
|
57
|
+
check-project-health --skip "Tests,Security"
|
|
58
|
+
check-project-health --run-tests
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## How checkers work
|
|
64
|
+
|
|
65
|
+
| Checker | Weight | What it does |
|
|
66
|
+
|---------|--------|--------------|
|
|
67
|
+
| **Dependencies** | 25% | Runs `npm outdated`, penalizes major/minor/patch outdated packages, suggests updates |
|
|
68
|
+
| **Git** | 20% | Uses `simple-git`: status (untracked, modified, staged), last commit age, branch, unpushed commits |
|
|
69
|
+
| **Code Quality** | 15% | Scans `.js`, `.ts`, `.jsx`, `.tsx`, `.vue`, `.py` for TODO, FIXME, HACK, XXX, BUG comments |
|
|
70
|
+
| **Environment** | 20% | Compares `.env` and `.env.example` keys; warns if keys are missing or undocumented |
|
|
71
|
+
| **Tests** | 10% | Looks for test script, test files (`*.test.js`, `*.spec.js`, configs); optionally runs tests with `--run-tests` |
|
|
72
|
+
| **Security** | 10% | Runs `npm audit`, scores by severity (critical/high/moderate/low), suggests `npm audit fix` |
|
|
73
|
+
|
|
74
|
+
**Scoring:** Each checker returns a 0–100 score. The overall score is a weighted average. Grades: **A** (90+), **B** (75+), **C** (60+), **D** (45+), **F** (<45).
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Adding custom checkers
|
|
79
|
+
|
|
80
|
+
1. Create a new file in `src/checkers/`, e.g. `src/checkers/custom.js`.
|
|
81
|
+
2. Export a default async function with signature:
|
|
82
|
+
```js
|
|
83
|
+
export default async function check(projectPath, options = {}) {
|
|
84
|
+
return {
|
|
85
|
+
name: 'Custom',
|
|
86
|
+
weight: 10,
|
|
87
|
+
status: 'ok' | 'warn' | 'fail' | 'skip' | 'error',
|
|
88
|
+
score: 0–100,
|
|
89
|
+
summary: 'One-line summary',
|
|
90
|
+
details: ['Detail 1', 'Detail 2'],
|
|
91
|
+
fixes: ['Suggested command or action'],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
3. Register it in `src/checkers/index.js`:
|
|
96
|
+
```js
|
|
97
|
+
import checkCustom from './custom.js';
|
|
98
|
+
const checkers = [
|
|
99
|
+
// ...existing
|
|
100
|
+
{ name: 'Custom', check: checkCustom },
|
|
101
|
+
];
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Contributing
|
|
107
|
+
|
|
108
|
+
Contributions are welcome. Please open an issue or PR on GitHub. Ensure tests and lint pass before submitting.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { program } from 'commander';
|
|
4
|
+
import figlet from 'figlet';
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { dirname, join } from 'path';
|
|
8
|
+
import { runEngine } from '../src/engine.js';
|
|
9
|
+
import { calculateScore } from '../src/scorer.js';
|
|
10
|
+
import { renderDashboard } from '../src/ui/dashboard.js';
|
|
11
|
+
import { colors } from '../src/ui/colors.js';
|
|
12
|
+
|
|
13
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
+
const __dirname = dirname(__filename);
|
|
15
|
+
|
|
16
|
+
program
|
|
17
|
+
.name('check-project-health')
|
|
18
|
+
.description('Analyze a project and show a terminal health dashboard')
|
|
19
|
+
.option('--json', 'Output raw JSON instead of the visual dashboard')
|
|
20
|
+
.option('--no-color', 'Disable colored output')
|
|
21
|
+
.option('--skip <checkers>', 'Comma-separated list of checker names to skip')
|
|
22
|
+
.option('--run-tests', 'Run test script as part of Tests checker')
|
|
23
|
+
.parse(process.argv);
|
|
24
|
+
|
|
25
|
+
const opts = program.opts();
|
|
26
|
+
const projectPath = process.cwd();
|
|
27
|
+
const skipList = opts.skip ? opts.skip.split(',').map((s) => s.trim()) : [];
|
|
28
|
+
const runTests = opts.runTests ?? false;
|
|
29
|
+
|
|
30
|
+
async function main() {
|
|
31
|
+
if (!opts.json) {
|
|
32
|
+
console.log(
|
|
33
|
+
colors.title(
|
|
34
|
+
figlet.textSync('check-project-health', { horizontalLayout: 'fitted', font: 'Slant' })
|
|
35
|
+
)
|
|
36
|
+
);
|
|
37
|
+
console.log(colors.dim(' Analyzing your project...\n'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const spinner = opts.json ? null : ora('Running health checkers...').start();
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const results = await runEngine(projectPath, { skipList, runTests });
|
|
44
|
+
const { score, grade, label } = calculateScore(results);
|
|
45
|
+
|
|
46
|
+
if (spinner) spinner.succeed('Analysis complete.\n');
|
|
47
|
+
|
|
48
|
+
if (opts.json) {
|
|
49
|
+
console.log(
|
|
50
|
+
JSON.stringify(
|
|
51
|
+
{ score, grade, label, results, timestamp: new Date().toISOString() },
|
|
52
|
+
null,
|
|
53
|
+
2
|
|
54
|
+
)
|
|
55
|
+
);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
renderDashboard({ results, score, grade, label }, { color: opts.color !== false });
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (spinner) spinner.fail('Analysis failed.');
|
|
62
|
+
console.error(colors.fail('\nError: ' + (err.message || String(err))));
|
|
63
|
+
if (err.stack && process.env.DEBUG) {
|
|
64
|
+
console.error(colors.dim(err.stack));
|
|
65
|
+
}
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "check-project-health",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Terminal health dashboard for developers",
|
|
5
|
+
"author": "Yash Nandvana <contact@yashnandvana.in>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/yash-nandvana/project-health#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/yash-nandvana/project-health.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/yash-nandvana/project-health/issues"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"check-project-health": "./bin/cli.js"
|
|
17
|
+
},
|
|
18
|
+
"main": "./src/engine.js",
|
|
19
|
+
"files": [
|
|
20
|
+
"bin/",
|
|
21
|
+
"src/",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18.0.0"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"cli",
|
|
30
|
+
"developer-tools",
|
|
31
|
+
"health",
|
|
32
|
+
"dashboard",
|
|
33
|
+
"devtools",
|
|
34
|
+
"npm-audit",
|
|
35
|
+
"git",
|
|
36
|
+
"terminal"
|
|
37
|
+
],
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"chalk": "^5.3.0",
|
|
40
|
+
"commander": "^12.1.0",
|
|
41
|
+
"cli-table3": "^0.6.3",
|
|
42
|
+
"glob": "^10.3.10",
|
|
43
|
+
"simple-git": "^3.22.0",
|
|
44
|
+
"ora": "^8.0.1",
|
|
45
|
+
"boxen": "^8.0.0",
|
|
46
|
+
"figlet": "^1.7.0"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import execAsync from '../utils/execAsync.js';
|
|
4
|
+
|
|
5
|
+
const WEIGHT = 25;
|
|
6
|
+
const PENALTY = { major: 10, minor: 5, patch: 2 };
|
|
7
|
+
|
|
8
|
+
function statusFromScore(score) {
|
|
9
|
+
if (score >= 80) return 'ok';
|
|
10
|
+
if (score >= 50) return 'warn';
|
|
11
|
+
return 'fail';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} projectPath
|
|
16
|
+
* @returns {Promise<import('./index.js').CheckerResult>}
|
|
17
|
+
*/
|
|
18
|
+
export default async function checkDependencies(projectPath) {
|
|
19
|
+
const pkgPath = join(projectPath, 'package.json');
|
|
20
|
+
let pkg;
|
|
21
|
+
try {
|
|
22
|
+
const raw = await readFile(pkgPath, 'utf-8');
|
|
23
|
+
pkg = JSON.parse(raw);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
return {
|
|
26
|
+
name: 'Dependencies',
|
|
27
|
+
weight: WEIGHT,
|
|
28
|
+
status: 'skip',
|
|
29
|
+
score: 0,
|
|
30
|
+
summary: 'No package.json found',
|
|
31
|
+
details: [],
|
|
32
|
+
fixes: [],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const deps = {
|
|
37
|
+
...(pkg.dependencies || {}),
|
|
38
|
+
...(pkg.devDependencies || {}),
|
|
39
|
+
};
|
|
40
|
+
const totalDeps = Object.keys(deps).length;
|
|
41
|
+
|
|
42
|
+
if (totalDeps === 0) {
|
|
43
|
+
return {
|
|
44
|
+
name: 'Dependencies',
|
|
45
|
+
weight: WEIGHT,
|
|
46
|
+
status: 'ok',
|
|
47
|
+
score: 100,
|
|
48
|
+
summary: 'No dependencies to check',
|
|
49
|
+
details: [],
|
|
50
|
+
fixes: [],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let outdated = {};
|
|
55
|
+
try {
|
|
56
|
+
const { stdout } = await execAsync('npm outdated --json', projectPath);
|
|
57
|
+
outdated = JSON.parse(stdout.trim() || '{}');
|
|
58
|
+
} catch (e) {
|
|
59
|
+
if (e.code === 1 && e.stdout) {
|
|
60
|
+
try {
|
|
61
|
+
outdated = JSON.parse((e.stdout || '').trim() || '{}');
|
|
62
|
+
} catch (_) {}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const details = [];
|
|
67
|
+
const fixes = new Set(['Run: npm update']);
|
|
68
|
+
let deduction = 0;
|
|
69
|
+
|
|
70
|
+
for (const [name, info] of Object.entries(outdated)) {
|
|
71
|
+
if (!info || typeof info !== 'object') continue;
|
|
72
|
+
const current = info.current || info.wanted || '?';
|
|
73
|
+
const latest = info.latest ?? info.wanted ?? '?';
|
|
74
|
+
let type = 'patch';
|
|
75
|
+
if (typeof latest === 'string' && typeof current === 'string') {
|
|
76
|
+
const [curMajor] = current.split('.').map(Number);
|
|
77
|
+
const [latMajor, latMinor] = latest.split('.').map(Number);
|
|
78
|
+
if (latMajor > curMajor) type = 'major';
|
|
79
|
+
else if (latMinor > (current.split('.')[1] || 0)) type = 'minor';
|
|
80
|
+
}
|
|
81
|
+
deduction += PENALTY[type] || 2;
|
|
82
|
+
details.push(`${name}: ${current} → ${latest} (${type})`);
|
|
83
|
+
if (type === 'major') fixes.add(`Run: npm install ${name}@latest`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const count = { major: 0, minor: 0, patch: 0 };
|
|
87
|
+
for (const [, info] of Object.entries(outdated)) {
|
|
88
|
+
if (!info || typeof info !== 'object') continue;
|
|
89
|
+
const current = String(info.current || info.wanted || '');
|
|
90
|
+
const latest = String(info.latest ?? info.wanted ?? '');
|
|
91
|
+
const [curMajor] = current.split('.').map(Number);
|
|
92
|
+
const [latMajor, latMinor] = latest.split('.').map(Number);
|
|
93
|
+
if (latMajor > curMajor) count.major++;
|
|
94
|
+
else if (latMinor > parseInt(current.split('.')[1], 10)) count.minor++;
|
|
95
|
+
else count.patch++;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const score = Math.max(0, 100 - deduction);
|
|
99
|
+
const summary =
|
|
100
|
+
Object.keys(outdated).length === 0
|
|
101
|
+
? `All ${totalDeps} packages up to date`
|
|
102
|
+
: `${Object.keys(outdated).length} packages outdated (${count.major} major, ${count.minor} minor, ${count.patch} patch)`;
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
name: 'Dependencies',
|
|
106
|
+
weight: WEIGHT,
|
|
107
|
+
status: statusFromScore(score),
|
|
108
|
+
score,
|
|
109
|
+
summary,
|
|
110
|
+
details,
|
|
111
|
+
fixes: Array.from(fixes),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { readFile, access } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
const WEIGHT = 20;
|
|
5
|
+
|
|
6
|
+
function parseEnvKeys(content) {
|
|
7
|
+
const keys = new Set();
|
|
8
|
+
const lines = (content || '').split(/\r?\n/);
|
|
9
|
+
for (const line of lines) {
|
|
10
|
+
const trimmed = line.trim();
|
|
11
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
12
|
+
const eq = trimmed.indexOf('=');
|
|
13
|
+
if (eq > 0) {
|
|
14
|
+
const key = trimmed.slice(0, eq).trim();
|
|
15
|
+
if (key) keys.add(key);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return keys;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} projectPath
|
|
23
|
+
* @returns {Promise<import('./index.js').CheckerResult>}
|
|
24
|
+
*/
|
|
25
|
+
export default async function checkEnv(projectPath) {
|
|
26
|
+
const examplePath = join(projectPath, '.env.example');
|
|
27
|
+
const envPath = join(projectPath, '.env');
|
|
28
|
+
|
|
29
|
+
let hasExample = false;
|
|
30
|
+
try {
|
|
31
|
+
await access(examplePath);
|
|
32
|
+
hasExample = true;
|
|
33
|
+
} catch (_) {}
|
|
34
|
+
|
|
35
|
+
if (!hasExample) {
|
|
36
|
+
return {
|
|
37
|
+
name: 'Environment',
|
|
38
|
+
weight: WEIGHT,
|
|
39
|
+
status: 'skip',
|
|
40
|
+
score: 0,
|
|
41
|
+
summary: 'No .env.example found',
|
|
42
|
+
details: [],
|
|
43
|
+
fixes: ['Add a .env.example documenting required environment variables'],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let exampleContent = '';
|
|
48
|
+
let envContent = '';
|
|
49
|
+
try {
|
|
50
|
+
exampleContent = await readFile(examplePath, 'utf-8');
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return {
|
|
53
|
+
name: 'Environment',
|
|
54
|
+
weight: WEIGHT,
|
|
55
|
+
status: 'error',
|
|
56
|
+
score: 0,
|
|
57
|
+
summary: 'Could not read .env.example',
|
|
58
|
+
details: [],
|
|
59
|
+
fixes: [],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let hasEnv = false;
|
|
64
|
+
try {
|
|
65
|
+
await access(envPath);
|
|
66
|
+
hasEnv = true;
|
|
67
|
+
envContent = await readFile(envPath, 'utf-8');
|
|
68
|
+
} catch (_) {}
|
|
69
|
+
|
|
70
|
+
if (!hasEnv) {
|
|
71
|
+
return {
|
|
72
|
+
name: 'Environment',
|
|
73
|
+
weight: WEIGHT,
|
|
74
|
+
status: 'warn',
|
|
75
|
+
score: 50,
|
|
76
|
+
summary: '.env.example exists but .env is missing',
|
|
77
|
+
details: ['.env file not found'],
|
|
78
|
+
fixes: ['Copy .env.example to .env and fill in values: cp .env.example .env'],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const exampleKeys = parseEnvKeys(exampleContent);
|
|
83
|
+
const envKeys = parseEnvKeys(envContent);
|
|
84
|
+
|
|
85
|
+
const missingInEnv = [...exampleKeys].filter((k) => !envKeys.has(k));
|
|
86
|
+
const undocumented = [...envKeys].filter((k) => !exampleKeys.has(k));
|
|
87
|
+
|
|
88
|
+
let score = 100;
|
|
89
|
+
score -= missingInEnv.length * 20;
|
|
90
|
+
score -= undocumented.length * 10;
|
|
91
|
+
score = Math.max(0, score);
|
|
92
|
+
|
|
93
|
+
const status = score >= 80 ? 'ok' : score >= 50 ? 'warn' : 'fail';
|
|
94
|
+
const details = [];
|
|
95
|
+
if (missingInEnv.length) details.push(`Missing in .env: ${missingInEnv.join(', ')}`);
|
|
96
|
+
if (undocumented.length) details.push(`Undocumented in .env.example: ${undocumented.join(', ')}`);
|
|
97
|
+
const fixes = [];
|
|
98
|
+
if (missingInEnv.length) fixes.push('Add missing keys to .env from .env.example');
|
|
99
|
+
if (undocumented.length) fixes.push('Document all .env keys in .env.example');
|
|
100
|
+
|
|
101
|
+
const summary =
|
|
102
|
+
score === 100
|
|
103
|
+
? '.env and .env.example are in sync'
|
|
104
|
+
: missingInEnv.length
|
|
105
|
+
? `${missingInEnv.length} key(s) missing in .env`
|
|
106
|
+
: `${undocumented.length} key(s) not in .env.example`;
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
name: 'Environment',
|
|
110
|
+
weight: WEIGHT,
|
|
111
|
+
status,
|
|
112
|
+
score,
|
|
113
|
+
summary,
|
|
114
|
+
details,
|
|
115
|
+
fixes,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import simpleGit from 'simple-git';
|
|
2
|
+
|
|
3
|
+
const WEIGHT = 20;
|
|
4
|
+
|
|
5
|
+
function statusFromScore(score) {
|
|
6
|
+
if (score >= 80) return 'ok';
|
|
7
|
+
if (score >= 50) return 'warn';
|
|
8
|
+
return 'fail';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} projectPath
|
|
13
|
+
* @returns {Promise<import('./index.js').CheckerResult>}
|
|
14
|
+
*/
|
|
15
|
+
export default async function checkGit(projectPath) {
|
|
16
|
+
const git = simpleGit({ baseDir: projectPath });
|
|
17
|
+
let score = 100;
|
|
18
|
+
const details = [];
|
|
19
|
+
const fixes = [];
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const isRepo = await git.checkIsRepo();
|
|
23
|
+
if (!isRepo) {
|
|
24
|
+
return {
|
|
25
|
+
name: 'Git',
|
|
26
|
+
weight: WEIGHT,
|
|
27
|
+
status: 'skip',
|
|
28
|
+
score: 0,
|
|
29
|
+
summary: 'Not a git repository',
|
|
30
|
+
details: [],
|
|
31
|
+
fixes: ['Run: git init'],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
} catch (_) {
|
|
35
|
+
return {
|
|
36
|
+
name: 'Git',
|
|
37
|
+
weight: WEIGHT,
|
|
38
|
+
status: 'error',
|
|
39
|
+
score: 0,
|
|
40
|
+
summary: 'Git check failed',
|
|
41
|
+
details: [],
|
|
42
|
+
fixes: [],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const status = await git.status();
|
|
48
|
+
const untracked = status.not_added?.length ?? 0;
|
|
49
|
+
const modified = status.modified?.length ?? 0;
|
|
50
|
+
const staged = status.staged?.length ?? 0;
|
|
51
|
+
|
|
52
|
+
if (untracked > 10) {
|
|
53
|
+
score -= 15;
|
|
54
|
+
details.push(`${untracked} untracked files`);
|
|
55
|
+
fixes.push('Add or ignore untracked files: git add . or update .gitignore');
|
|
56
|
+
}
|
|
57
|
+
if (modified > 5) {
|
|
58
|
+
score -= 10;
|
|
59
|
+
details.push(`${modified} modified files`);
|
|
60
|
+
fixes.push('Commit or stash changes: git add . && git commit -m "..."');
|
|
61
|
+
}
|
|
62
|
+
if (staged > 0) details.push(`${staged} staged files`);
|
|
63
|
+
|
|
64
|
+
const log = await git.log({ maxCount: 1 });
|
|
65
|
+
const lastCommit = log.latest?.date;
|
|
66
|
+
if (lastCommit) {
|
|
67
|
+
const daysSince = (Date.now() - new Date(lastCommit).getTime()) / (1000 * 60 * 60 * 24);
|
|
68
|
+
if (daysSince > 30) {
|
|
69
|
+
score -= 20;
|
|
70
|
+
details.push(`${Math.floor(daysSince)} days since last commit`);
|
|
71
|
+
fixes.push('Commit regularly to keep history fresh');
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
details.push('No commits yet');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const branch = await git.revparse(['--abbrev-ref', 'HEAD']);
|
|
78
|
+
const branchName = branch.trim().replace(/\r\n?/g, '');
|
|
79
|
+
details.push(`Branch: ${branchName}`);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const remotes = await git.getRemotes(true);
|
|
83
|
+
if (Object.keys(remotes).length > 0) {
|
|
84
|
+
const pushStatus = await git.status();
|
|
85
|
+
const ahead = pushStatus.ahead ?? 0;
|
|
86
|
+
if (ahead > 0) {
|
|
87
|
+
score -= 10;
|
|
88
|
+
details.push(`${ahead} unpushed commit(s)`);
|
|
89
|
+
fixes.push('Push your commits: git push');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch (_) {}
|
|
93
|
+
|
|
94
|
+
score = Math.max(0, score);
|
|
95
|
+
const summary =
|
|
96
|
+
score >= 80
|
|
97
|
+
? 'Repository in good shape'
|
|
98
|
+
: score >= 50
|
|
99
|
+
? 'Some git issues to address'
|
|
100
|
+
: 'Git health needs attention';
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
name: 'Git',
|
|
104
|
+
weight: WEIGHT,
|
|
105
|
+
status: statusFromScore(score),
|
|
106
|
+
score,
|
|
107
|
+
summary,
|
|
108
|
+
details,
|
|
109
|
+
fixes,
|
|
110
|
+
};
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return {
|
|
113
|
+
name: 'Git',
|
|
114
|
+
weight: WEIGHT,
|
|
115
|
+
status: 'error',
|
|
116
|
+
score: 0,
|
|
117
|
+
summary: 'Checker failed: ' + (err.message || 'Unknown error'),
|
|
118
|
+
details: [],
|
|
119
|
+
fixes: [],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {Object} CheckerResult
|
|
3
|
+
* @property {string} name
|
|
4
|
+
* @property {number} weight
|
|
5
|
+
* @property {'ok'|'warn'|'fail'|'skip'|'error'} status
|
|
6
|
+
* @property {number} score
|
|
7
|
+
* @property {string} summary
|
|
8
|
+
* @property {string[]} details
|
|
9
|
+
* @property {string[]} fixes
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import checkDependencies from './dependencies.js';
|
|
13
|
+
import checkGit from './git.js';
|
|
14
|
+
import checkTodos from './todos.js';
|
|
15
|
+
import checkEnv from './env.js';
|
|
16
|
+
import checkTests from './tests.js';
|
|
17
|
+
import checkSecurity from './security.js';
|
|
18
|
+
|
|
19
|
+
/** @type {{ name: string, check: (path: string, options?: any) => Promise<CheckerResult> }[]} */
|
|
20
|
+
const checkers = [
|
|
21
|
+
{ name: 'Dependencies', check: checkDependencies },
|
|
22
|
+
{ name: 'Git', check: checkGit },
|
|
23
|
+
{ name: 'Code Quality', check: checkTodos },
|
|
24
|
+
{ name: 'Environment', check: checkEnv },
|
|
25
|
+
{ name: 'Tests', check: checkTests },
|
|
26
|
+
{ name: 'Security', check: checkSecurity },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export default checkers;
|
|
30
|
+
export { checkDependencies, checkGit, checkTodos, checkEnv, checkTests, checkSecurity };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import execAsync from '../utils/execAsync.js';
|
|
2
|
+
|
|
3
|
+
const WEIGHT = 10;
|
|
4
|
+
const PENALTY = { critical: 30, high: 15, moderate: 5, low: 1 };
|
|
5
|
+
|
|
6
|
+
function statusFromScore(score) {
|
|
7
|
+
if (score >= 80) return 'ok';
|
|
8
|
+
if (score >= 50) return 'warn';
|
|
9
|
+
return 'fail';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} projectPath
|
|
14
|
+
* @returns {Promise<import('./index.js').CheckerResult>}
|
|
15
|
+
*/
|
|
16
|
+
export default async function checkSecurity(projectPath) {
|
|
17
|
+
let raw = '';
|
|
18
|
+
try {
|
|
19
|
+
const { stdout } = await execAsync('npm audit --json', projectPath);
|
|
20
|
+
raw = stdout;
|
|
21
|
+
} catch (e) {
|
|
22
|
+
if (e.stdout) raw = e.stdout;
|
|
23
|
+
else
|
|
24
|
+
return {
|
|
25
|
+
name: 'Security',
|
|
26
|
+
weight: WEIGHT,
|
|
27
|
+
status: 'skip',
|
|
28
|
+
score: 100,
|
|
29
|
+
summary: 'npm audit not available (no package.json or no deps)',
|
|
30
|
+
details: [],
|
|
31
|
+
fixes: [],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let data = {};
|
|
36
|
+
try {
|
|
37
|
+
raw = (raw || '').trim().replace(/\r\n/g, '\n');
|
|
38
|
+
data = JSON.parse(raw || '{}');
|
|
39
|
+
} catch (_) {
|
|
40
|
+
return {
|
|
41
|
+
name: 'Security',
|
|
42
|
+
weight: WEIGHT,
|
|
43
|
+
status: 'ok',
|
|
44
|
+
score: 100,
|
|
45
|
+
summary: 'No vulnerabilities reported',
|
|
46
|
+
details: [],
|
|
47
|
+
fixes: [],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const vulns = data.vulnerabilities || {};
|
|
52
|
+
const counts = { critical: 0, high: 0, moderate: 0, low: 0 };
|
|
53
|
+
const details = [];
|
|
54
|
+
const fixCommands = new Set();
|
|
55
|
+
|
|
56
|
+
for (const [name, v] of Object.entries(vulns)) {
|
|
57
|
+
if (!v || typeof v !== 'object') continue;
|
|
58
|
+
const sev = (v.severity || 'moderate').toLowerCase();
|
|
59
|
+
if (counts[sev] !== undefined) counts[sev]++;
|
|
60
|
+
details.push(`${name}: ${sev} - ${v.via?.[0]?.title || v.via?.[0]?.url || 'vulnerability'}`);
|
|
61
|
+
if (v.fixAvailable && typeof v.fixAvailable === 'object' && v.fixAvailable.name) {
|
|
62
|
+
fixCommands.add(`npm audit fix`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (data.auditReportVersion >= 2 && data.metadata?.vulnerabilities) {
|
|
67
|
+
const meta = data.metadata.vulnerabilities;
|
|
68
|
+
counts.critical = meta.critical ?? 0;
|
|
69
|
+
counts.high = meta.high ?? 0;
|
|
70
|
+
counts.moderate = meta.moderate ?? 0;
|
|
71
|
+
counts.low = meta.low ?? 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let score = 100;
|
|
75
|
+
score -= (counts.critical || 0) * PENALTY.critical;
|
|
76
|
+
score -= (counts.high || 0) * PENALTY.high;
|
|
77
|
+
score -= (counts.moderate || 0) * PENALTY.moderate;
|
|
78
|
+
score -= (counts.low || 0) * PENALTY.low;
|
|
79
|
+
score = Math.max(0, score);
|
|
80
|
+
|
|
81
|
+
if (score < 100 && !fixCommands.size) fixCommands.add('npm audit fix');
|
|
82
|
+
|
|
83
|
+
const total = counts.critical + counts.high + counts.moderate + counts.low;
|
|
84
|
+
const summary =
|
|
85
|
+
total === 0
|
|
86
|
+
? 'No known vulnerabilities'
|
|
87
|
+
: `${total} vulnerability(ies): ${counts.critical} critical, ${counts.high} high, ${counts.moderate} moderate, ${counts.low} low`;
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
name: 'Security',
|
|
91
|
+
weight: WEIGHT,
|
|
92
|
+
status: statusFromScore(score),
|
|
93
|
+
score,
|
|
94
|
+
summary,
|
|
95
|
+
details: details.slice(0, 20),
|
|
96
|
+
fixes: Array.from(fixCommands),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { glob } from 'glob';
|
|
5
|
+
import execAsync from '../utils/execAsync.js';
|
|
6
|
+
|
|
7
|
+
const WEIGHT = 10;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} projectPath
|
|
11
|
+
* @param {{ runTests?: boolean }} [options]
|
|
12
|
+
* @returns {Promise<import('./index.js').CheckerResult>}
|
|
13
|
+
*/
|
|
14
|
+
export default async function checkTests(projectPath, options = {}) {
|
|
15
|
+
const runTests = options.runTests === true;
|
|
16
|
+
const pkgPath = join(projectPath, 'package.json');
|
|
17
|
+
let pkg;
|
|
18
|
+
try {
|
|
19
|
+
const raw = await readFile(pkgPath, 'utf-8');
|
|
20
|
+
pkg = JSON.parse(raw);
|
|
21
|
+
} catch (_) {
|
|
22
|
+
return {
|
|
23
|
+
name: 'Tests',
|
|
24
|
+
weight: WEIGHT,
|
|
25
|
+
status: 'skip',
|
|
26
|
+
score: 0,
|
|
27
|
+
summary: 'No package.json',
|
|
28
|
+
details: [],
|
|
29
|
+
fixes: [],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const scripts = pkg.scripts || {};
|
|
34
|
+
const hasTestScript = !!(
|
|
35
|
+
scripts.test ||
|
|
36
|
+
scripts['test:ci'] ||
|
|
37
|
+
scripts['test:unit'] ||
|
|
38
|
+
scripts['test:integration']
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const patterns = [
|
|
42
|
+
'**/*.test.js',
|
|
43
|
+
'**/*.spec.js',
|
|
44
|
+
'**/*.test.ts',
|
|
45
|
+
'**/*.spec.ts',
|
|
46
|
+
'**/__tests__/**',
|
|
47
|
+
];
|
|
48
|
+
let testFiles = [];
|
|
49
|
+
try {
|
|
50
|
+
for (const p of patterns) {
|
|
51
|
+
const files = await glob(p, { cwd: projectPath, ignore: ['**/node_modules/**'] });
|
|
52
|
+
testFiles = testFiles.concat(files);
|
|
53
|
+
}
|
|
54
|
+
testFiles = [...new Set(testFiles)];
|
|
55
|
+
} catch (_) {}
|
|
56
|
+
|
|
57
|
+
const configs = [
|
|
58
|
+
'jest.config.js',
|
|
59
|
+
'jest.config.ts',
|
|
60
|
+
'jest.config.cjs',
|
|
61
|
+
'vitest.config.js',
|
|
62
|
+
'vitest.config.ts',
|
|
63
|
+
'.mocharc.js',
|
|
64
|
+
'.mocharc.json',
|
|
65
|
+
'vitest.config.js',
|
|
66
|
+
];
|
|
67
|
+
let hasConfig = false;
|
|
68
|
+
for (const c of configs) {
|
|
69
|
+
if (existsSync(join(projectPath, c))) {
|
|
70
|
+
hasConfig = true;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let score = 0;
|
|
76
|
+
let status = 'fail';
|
|
77
|
+
let summary = 'No tests found';
|
|
78
|
+
const details = [];
|
|
79
|
+
const fixes = [];
|
|
80
|
+
|
|
81
|
+
if (!hasTestScript && testFiles.length === 0 && !hasConfig) {
|
|
82
|
+
return {
|
|
83
|
+
name: 'Tests',
|
|
84
|
+
weight: WEIGHT,
|
|
85
|
+
status: 'warn',
|
|
86
|
+
score: 0,
|
|
87
|
+
summary: 'No test setup detected',
|
|
88
|
+
details: [],
|
|
89
|
+
fixes: ['Add a test script to package.json and test files (*.test.js, *.spec.js, etc.)'],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (testFiles.length === 0 && !hasConfig) {
|
|
94
|
+
score = hasTestScript ? 50 : 0;
|
|
95
|
+
summary = 'Test script exists but no test files or config found';
|
|
96
|
+
details.push('Add *.test.js / *.spec.js or jest/vitest config');
|
|
97
|
+
fixes.push('Create test files or add jest.config.js / vitest.config.js');
|
|
98
|
+
} else if (!hasConfig) {
|
|
99
|
+
score = 50;
|
|
100
|
+
summary = 'Test files found but no framework config';
|
|
101
|
+
details.push(`${testFiles.length} test file(s), no jest/vitest/mocha config`);
|
|
102
|
+
fixes.push('Add jest.config.js, vitest.config.js, or .mocharc.js');
|
|
103
|
+
} else {
|
|
104
|
+
score = 80;
|
|
105
|
+
summary = 'Test setup detected';
|
|
106
|
+
details.push(`${testFiles.length} test file(s), config present`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (runTests && hasTestScript && score >= 80) {
|
|
110
|
+
try {
|
|
111
|
+
const timeout = 30000;
|
|
112
|
+
const { stdout, stderr } = await Promise.race([
|
|
113
|
+
execAsync('npm test -- --passWithNoTests 2>&1', projectPath),
|
|
114
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error('Test run timed out')), timeout)),
|
|
115
|
+
]);
|
|
116
|
+
const out = (stdout + stderr).replace(/\r\n/g, '\n');
|
|
117
|
+
if (/passed|✓|ok\s+\d+\s+test/i.test(out) && !/failed|✗|Error:/i.test(out)) {
|
|
118
|
+
score = 100;
|
|
119
|
+
summary = 'Tests pass';
|
|
120
|
+
details.push('Last run: passed');
|
|
121
|
+
} else {
|
|
122
|
+
details.push('Run: npm test (some tests may have failed)');
|
|
123
|
+
fixes.push('Fix failing tests: npm test');
|
|
124
|
+
}
|
|
125
|
+
} catch (err) {
|
|
126
|
+
details.push('Test run failed or timed out: ' + (err.message || ''));
|
|
127
|
+
fixes.push('Run: npm test');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
status = score >= 80 ? 'ok' : score >= 50 ? 'warn' : 'fail';
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
name: 'Tests',
|
|
135
|
+
weight: WEIGHT,
|
|
136
|
+
status,
|
|
137
|
+
score: Math.min(100, score),
|
|
138
|
+
summary,
|
|
139
|
+
details,
|
|
140
|
+
fixes,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import scanFiles from '../utils/fileScanner.js';
|
|
2
|
+
import { readFile } from 'fs/promises';
|
|
3
|
+
|
|
4
|
+
const WEIGHT = 15;
|
|
5
|
+
const EXTENSIONS = ['.js', '.ts', '.jsx', '.tsx', '.vue', '.py'];
|
|
6
|
+
const PATTERN = /(?:^|\s)(TODO|FIXME|HACK|XXX|BUG)\s*:?/gi;
|
|
7
|
+
const PENALTY = { todo: 2, fixme: 5, hack: 8, xxx: 3, bug: 6 };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} projectPath
|
|
11
|
+
* @returns {Promise<import('./index.js').CheckerResult>}
|
|
12
|
+
*/
|
|
13
|
+
export default async function checkTodos(projectPath) {
|
|
14
|
+
let files;
|
|
15
|
+
try {
|
|
16
|
+
files = await scanFiles(projectPath, EXTENSIONS);
|
|
17
|
+
} catch (err) {
|
|
18
|
+
return {
|
|
19
|
+
name: 'Code Quality',
|
|
20
|
+
weight: WEIGHT,
|
|
21
|
+
status: 'error',
|
|
22
|
+
score: 0,
|
|
23
|
+
summary: 'File scan failed',
|
|
24
|
+
details: [],
|
|
25
|
+
fixes: [],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const byFile = new Map();
|
|
30
|
+
let totalDeduction = 0;
|
|
31
|
+
|
|
32
|
+
for (const filePath of files) {
|
|
33
|
+
let content;
|
|
34
|
+
try {
|
|
35
|
+
content = await readFile(filePath, 'utf-8');
|
|
36
|
+
} catch (_) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const counts = { todo: 0, fixme: 0, hack: 0, xxx: 0, bug: 0 };
|
|
40
|
+
let m;
|
|
41
|
+
const re = new RegExp(PATTERN.source, 'gi');
|
|
42
|
+
while ((m = re.exec(content)) !== null) {
|
|
43
|
+
const key = m[1].toLowerCase();
|
|
44
|
+
if (counts[key] !== undefined) {
|
|
45
|
+
counts[key]++;
|
|
46
|
+
totalDeduction += PENALTY[key] ?? 2;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
|
50
|
+
if (total > 0) {
|
|
51
|
+
const relative = filePath.replace(projectPath, '').replace(/^[/\\]/, '') || filePath;
|
|
52
|
+
byFile.set(relative, counts);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const score = Math.max(0, 100 - totalDeduction);
|
|
57
|
+
const status = score >= 80 ? 'ok' : score >= 50 ? 'warn' : 'fail';
|
|
58
|
+
const parts = [];
|
|
59
|
+
for (const [file, counts] of byFile) {
|
|
60
|
+
const segs = [];
|
|
61
|
+
if (counts.todo) segs.push(`${counts.todo} TODO(s)`);
|
|
62
|
+
if (counts.fixme) segs.push(`${counts.fixme} FIXME(s)`);
|
|
63
|
+
if (counts.hack) segs.push(`${counts.hack} HACK(s)`);
|
|
64
|
+
if (counts.xxx) segs.push(`${counts.xxx} XXX(s)`);
|
|
65
|
+
if (counts.bug) segs.push(`${counts.bug} BUG(s)`);
|
|
66
|
+
parts.push(`${file} — ${segs.join(', ')}`);
|
|
67
|
+
}
|
|
68
|
+
const totalItems = [...byFile.values()].reduce(
|
|
69
|
+
(a, c) => a + (c.todo + c.fixme + c.hack + c.xxx + c.bug),
|
|
70
|
+
0
|
|
71
|
+
);
|
|
72
|
+
const summary =
|
|
73
|
+
totalItems === 0
|
|
74
|
+
? 'No TODO/FIXME/HACK comments found'
|
|
75
|
+
: `${totalItems} comment(s) across ${byFile.size} file(s)`;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
name: 'Code Quality',
|
|
79
|
+
weight: WEIGHT,
|
|
80
|
+
status,
|
|
81
|
+
score,
|
|
82
|
+
summary,
|
|
83
|
+
details: parts,
|
|
84
|
+
fixes: totalItems > 0 ? ['Address TODO/FIXME/HACK comments or remove them'] : [],
|
|
85
|
+
};
|
|
86
|
+
}
|
package/src/engine.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import checkers from './checkers/index.js';
|
|
2
|
+
|
|
3
|
+
const TIMEOUT_MS = 30000;
|
|
4
|
+
|
|
5
|
+
function withTimeout(promise, ms, name) {
|
|
6
|
+
return Promise.race([
|
|
7
|
+
promise,
|
|
8
|
+
new Promise((_, reject) =>
|
|
9
|
+
setTimeout(() => reject(new Error(`Checker "${name}" timed out after ${ms}ms`)), ms)
|
|
10
|
+
),
|
|
11
|
+
]);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} projectPath
|
|
16
|
+
* @param {{ skipList?: string[], runTests?: boolean }} [options]
|
|
17
|
+
* @returns {Promise<import('./checkers/index.js').CheckerResult[]>}
|
|
18
|
+
*/
|
|
19
|
+
export async function runEngine(projectPath, options = {}) {
|
|
20
|
+
const { skipList = [], runTests = false } = options;
|
|
21
|
+
const skipSet = new Set(skipList.map((s) => s.toLowerCase().trim()));
|
|
22
|
+
|
|
23
|
+
const toRun = checkers.filter((c) => !skipSet.has(c.name.toLowerCase()));
|
|
24
|
+
|
|
25
|
+
const results = await Promise.allSettled(
|
|
26
|
+
toRun.map(({ name, check }) => {
|
|
27
|
+
const fn = () => check(projectPath, { runTests });
|
|
28
|
+
return withTimeout(fn(), TIMEOUT_MS, name);
|
|
29
|
+
})
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
return results.map((settled, i) => {
|
|
33
|
+
const { name } = toRun[i];
|
|
34
|
+
if (settled.status === 'fulfilled') {
|
|
35
|
+
return settled.value;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
name,
|
|
39
|
+
weight: 0,
|
|
40
|
+
status: 'error',
|
|
41
|
+
score: 0,
|
|
42
|
+
summary: 'Checker failed: ' + (settled.reason?.message || 'Unknown error'),
|
|
43
|
+
details: [],
|
|
44
|
+
fixes: [],
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
}
|
package/src/scorer.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {import('./checkers/index.js').CheckerResult[]} results
|
|
3
|
+
* @returns {{ score: number, grade: 'A'|'B'|'C'|'D'|'F', label: string }}
|
|
4
|
+
*/
|
|
5
|
+
export function calculateScore(results) {
|
|
6
|
+
const withWeight = results.filter((r) => r.status !== 'skip' && r.weight > 0);
|
|
7
|
+
const totalWeight = withWeight.reduce((sum, r) => sum + r.weight, 0);
|
|
8
|
+
if (totalWeight === 0) {
|
|
9
|
+
return { score: 0, grade: 'F', label: 'Critical' };
|
|
10
|
+
}
|
|
11
|
+
const weightedSum = withWeight.reduce((sum, r) => sum + r.score * r.weight, 0);
|
|
12
|
+
const score = Math.round(weightedSum / totalWeight);
|
|
13
|
+
const clamped = Math.max(0, Math.min(100, score));
|
|
14
|
+
|
|
15
|
+
let grade = 'F';
|
|
16
|
+
let label = 'Critical';
|
|
17
|
+
if (clamped >= 90) {
|
|
18
|
+
grade = 'A';
|
|
19
|
+
label = 'Excellent';
|
|
20
|
+
} else if (clamped >= 75) {
|
|
21
|
+
grade = 'B';
|
|
22
|
+
label = 'Good';
|
|
23
|
+
} else if (clamped >= 60) {
|
|
24
|
+
grade = 'C';
|
|
25
|
+
label = 'Fair';
|
|
26
|
+
} else if (clamped >= 45) {
|
|
27
|
+
grade = 'D';
|
|
28
|
+
label = 'Poor';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { score: clamped, grade, label };
|
|
32
|
+
}
|
package/src/ui/colors.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
export const colors = {
|
|
4
|
+
ok: chalk.green,
|
|
5
|
+
warn: chalk.yellow,
|
|
6
|
+
fail: chalk.red,
|
|
7
|
+
info: chalk.cyan,
|
|
8
|
+
dim: chalk.gray,
|
|
9
|
+
bold: chalk.bold,
|
|
10
|
+
title: chalk.bold.white,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const gradeColors = {
|
|
14
|
+
A: chalk.green,
|
|
15
|
+
B: chalk.cyan,
|
|
16
|
+
C: chalk.yellow,
|
|
17
|
+
D: chalk.magenta,
|
|
18
|
+
F: chalk.red,
|
|
19
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import boxen from 'boxen';
|
|
2
|
+
import Table from 'cli-table3';
|
|
3
|
+
import { colors, gradeColors } from './colors.js';
|
|
4
|
+
|
|
5
|
+
const STATUS_ICONS = { ok: '✅', warn: '⚠️', fail: '❌', skip: '⏭️', error: '❌' };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {{ results: import('../checkers/index.js').CheckerResult[], score: number, grade: string, label: string }} data
|
|
9
|
+
* @param {{ color?: boolean }} [opts]
|
|
10
|
+
*/
|
|
11
|
+
export function renderDashboard(data, opts = {}) {
|
|
12
|
+
const useColor = opts.color !== false;
|
|
13
|
+
const c = useColor ? colors : { ok: id, warn: id, fail: id, info: id, dim: id, bold: id, title: id };
|
|
14
|
+
const gc = useColor ? gradeColors : { A: id, B: id, C: id, D: id, F: id };
|
|
15
|
+
function id(x) {
|
|
16
|
+
return x;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { results, score, grade, label } = data;
|
|
20
|
+
|
|
21
|
+
const header = [
|
|
22
|
+
'',
|
|
23
|
+
c.title(' 🏥 PROJECT HEALTH REPORT '),
|
|
24
|
+
c.bold(` Score: ${score}/100 Grade: ${grade} `),
|
|
25
|
+
` Status: ${(gc[grade] || id)(label)} `,
|
|
26
|
+
'',
|
|
27
|
+
].join('\n');
|
|
28
|
+
|
|
29
|
+
console.log(
|
|
30
|
+
boxen(header, {
|
|
31
|
+
padding: 1,
|
|
32
|
+
margin: 1,
|
|
33
|
+
borderStyle: 'round',
|
|
34
|
+
borderColor: useColor ? 'gray' : undefined,
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const table = new Table({
|
|
39
|
+
head: [c.bold('Checker'), c.bold('Score'), c.bold('Status'), c.bold('Summary')],
|
|
40
|
+
colWidths: [18, 8, 10, 44],
|
|
41
|
+
wordWrap: true,
|
|
42
|
+
style: { head: [], border: useColor ? [] : [] },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
for (const r of results) {
|
|
46
|
+
const icon = STATUS_ICONS[r.status] || ' ';
|
|
47
|
+
const statusStr = `${icon} ${r.status}`;
|
|
48
|
+
const scoreStr = r.status === 'skip' ? '—' : `${r.score}`;
|
|
49
|
+
const rowColor = r.status === 'ok' ? c.ok : r.status === 'warn' ? c.warn : c.fail;
|
|
50
|
+
table.push([
|
|
51
|
+
rowColor(r.name),
|
|
52
|
+
scoreStr,
|
|
53
|
+
rowColor(statusStr),
|
|
54
|
+
rowColor(r.summary.length > 42 ? r.summary.slice(0, 39) + '...' : r.summary),
|
|
55
|
+
]);
|
|
56
|
+
}
|
|
57
|
+
console.log(table.toString());
|
|
58
|
+
|
|
59
|
+
const nonOk = results.filter((r) => r.status !== 'ok' && r.status !== 'skip' && r.details?.length);
|
|
60
|
+
if (nonOk.length > 0) {
|
|
61
|
+
console.log(c.bold('\n Details\n'));
|
|
62
|
+
for (const r of nonOk) {
|
|
63
|
+
console.log(c.dim(` ${r.name}:`));
|
|
64
|
+
for (const d of r.details) {
|
|
65
|
+
console.log(c.dim(' • ' + d));
|
|
66
|
+
}
|
|
67
|
+
console.log('');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const withFixes = results.filter((r) => r.fixes?.length);
|
|
72
|
+
if (withFixes.length > 0) {
|
|
73
|
+
console.log(c.bold(' Suggested fixes\n'));
|
|
74
|
+
for (const r of withFixes) {
|
|
75
|
+
console.log(c.info(` ${r.name}:`));
|
|
76
|
+
for (const f of r.fixes) {
|
|
77
|
+
console.log(c.dim(' ' + f));
|
|
78
|
+
}
|
|
79
|
+
console.log('');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.log(c.dim(' Run with --json to export results.\n'));
|
|
84
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
|
|
4
|
+
const execPromise = promisify(exec);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Promise wrapper for child_process.exec
|
|
8
|
+
* @param {string} command - Command to execute
|
|
9
|
+
* @param {string} [cwd] - Working directory
|
|
10
|
+
* @returns {Promise<{ stdout: string, stderr: string }>}
|
|
11
|
+
*/
|
|
12
|
+
export default async function execAsync(command, cwd) {
|
|
13
|
+
const options = cwd ? { cwd, maxBuffer: 1024 * 1024 } : { maxBuffer: 1024 * 1024 };
|
|
14
|
+
return execPromise(command, options);
|
|
15
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readdir } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { dirname } from 'path';
|
|
5
|
+
|
|
6
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage']);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Recursively scan a directory for files matching given extensions.
|
|
10
|
+
* Skips node_modules, .git, dist, build, coverage.
|
|
11
|
+
* @param {string} dir - Directory to scan (absolute path)
|
|
12
|
+
* @param {string[]} extensions - File extensions to match (e.g. ['.js', '.ts'])
|
|
13
|
+
* @returns {Promise<string[]>} Array of absolute file paths
|
|
14
|
+
*/
|
|
15
|
+
export default async function scanFiles(dir, extensions = []) {
|
|
16
|
+
const normalizedExt = new Set(
|
|
17
|
+
extensions.map((e) => (e.startsWith('.') ? e.toLowerCase() : `.${e.toLowerCase()}`))
|
|
18
|
+
);
|
|
19
|
+
const results = [];
|
|
20
|
+
|
|
21
|
+
async function walk(currentDir) {
|
|
22
|
+
let entries;
|
|
23
|
+
try {
|
|
24
|
+
entries = await readdir(currentDir, { withFileTypes: true });
|
|
25
|
+
} catch (err) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
const fullPath = join(currentDir, entry.name);
|
|
31
|
+
if (entry.isDirectory()) {
|
|
32
|
+
if (!SKIP_DIRS.has(entry.name)) {
|
|
33
|
+
await walk(fullPath);
|
|
34
|
+
}
|
|
35
|
+
} else if (entry.isFile()) {
|
|
36
|
+
const ext = entry.name.includes('.')
|
|
37
|
+
? `.${entry.name.split('.').pop().toLowerCase()}`
|
|
38
|
+
: '';
|
|
39
|
+
if (normalizedExt.size === 0 || normalizedExt.has(ext)) {
|
|
40
|
+
results.push(fullPath);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await walk(dir);
|
|
47
|
+
return results;
|
|
48
|
+
}
|