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 ADDED
@@ -0,0 +1,114 @@
1
+ # check-project-health
2
+
3
+ [![npm version](https://img.shields.io/npm/v/check-project-health.svg)](https://www.npmjs.com/package/check-project-health)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node version](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen.svg)](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** (&lt;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
+ }
@@ -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,10 @@
1
+ import ora from 'ora';
2
+
3
+ /**
4
+ * Create a loading spinner for CLI feedback
5
+ * @param {string} [text] - Initial text
6
+ * @returns {ora.Ora}
7
+ */
8
+ export function createSpinner(text = 'Loading...') {
9
+ return ora(text);
10
+ }
@@ -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
+ }