fullstack-critic 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/src/rules.js ADDED
@@ -0,0 +1,182 @@
1
+ 'use strict';
2
+ /* critic-ignore-file — this file *defines* detection regexes; scanning it would match its own patterns. */
3
+ /**
4
+ * Line- and file-level heuristic rules mapped to the 12 CRITIC.md dimensions.
5
+ * Every rule is universal (no framework assumptions) and carries an action
6
+ * category: fix | optimize | delete | add.
7
+ */
8
+
9
+ // Words that mark an obviously fake / placeholder credential (avoid noisy secret alerts).
10
+ const PLACEHOLDER = /(example|placeholder|your[_-]|xxx+|<[^>]+>|changeme|change_me|dummy|redacted|dummy|sample|todo|process\.env|os\.environ|getenv|env\[|secrets\.|vault|\{\{|\$\{)/i;
11
+
12
+ const LINE_RULES = [
13
+ // ---- Security -----------------------------------------------------------
14
+ {
15
+ id: 'hardcoded-secret', severity: 'CRITICAL', category: 'fix', dimension: 'Security',
16
+ title: 'Possible hardcoded credential',
17
+ test: /\b(api[_-]?key|apikey|secret|passwd|password|access[_-]?token|auth[_-]?token|private[_-]?key|access[_-]?key[_-]?id)\b["']?\s*[:=]\s*["'][^"'\s]{12,}["']/i,
18
+ skipIf: (line) => PLACEHOLDER.test(line),
19
+ problem: 'A secret appears to be committed in source.',
20
+ impact: 'Anyone with repo read access obtains the credential; git history keeps it after deletion.',
21
+ fix: 'Move to a secret manager / runtime env var and rotate the exposed value.',
22
+ verify: 'grep confirms no literal remains; service accepts the rotated secret.',
23
+ },
24
+ {
25
+ id: 'aws-access-key', severity: 'CRITICAL', category: 'fix', dimension: 'Security',
26
+ title: 'AWS access key ID pattern',
27
+ test: /\bAKIA[0-9A-Z]{16}\b/,
28
+ problem: 'String matches the AWS access-key-id format.',
29
+ impact: 'Cloud account compromise if paired with a secret key.',
30
+ fix: 'Rotate immediately; load credentials from the environment or an instance role.',
31
+ verify: 'aws sts get-caller-identity with the old key fails.',
32
+ },
33
+ {
34
+ id: 'private-key', severity: 'CRITICAL', category: 'fix', dimension: 'Security',
35
+ title: 'Private key material committed',
36
+ test: /-----BEGIN (RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/,
37
+ problem: 'A private key block is embedded in a tracked file.',
38
+ impact: 'Full loss of the key trust boundary.',
39
+ fix: 'Remove from source, rotate the keypair, add the file to .gitignore.',
40
+ verify: 'openssl pkey -in <file> fails / file untracked.',
41
+ },
42
+ {
43
+ id: 'code-eval', severity: 'HIGH', category: 'fix', dimension: 'Security',
44
+ title: 'Dynamic code evaluation',
45
+ ext: ['.js', '.ts', '.mjs', '.cjs', '.jsx', '.py', '.rb', '.php'],
46
+ test: /(^|[^.\w])(eval|Function)\s*\(|\bexec\s*\(\s*f["']/,
47
+ problem: 'eval/Function executes arbitrary runtime-constructed code.',
48
+ impact: 'Remote code execution if any input reaches the evaluated string.',
49
+ fix: 'Replace with a lookup table, JSON.parse, or an AST-restricted evaluator.',
50
+ verify: 'no eval/Function call sites remain on untrusted input.',
51
+ },
52
+ {
53
+ id: 'command-injection', severity: 'HIGH', category: 'fix', dimension: 'Security',
54
+ title: 'Shell command built from string',
55
+ ext: ['.js', '.ts', '.mjs', '.cjs', '.py', '.rb', '.go', '.php'],
56
+ test: /(execSync|exec|spawn|child_process\.exec|os\.system|subprocess\.(run|call|Popen|check_output)|Runtime\.getRuntime\(\)\.exec)\s*\([^)]*(\$\{|%|["']\s*\+|f["']|shell\s*=\s*True)/,
57
+ problem: 'A shell command interpolates variables — classic command-injection vector.',
58
+ impact: 'Attacker-controlled input runs arbitrary shell commands.',
59
+ fix: 'Pass an argument array and never a shell string; validate against an allowlist.',
60
+ verify: 'injection payload in the input is treated as a literal argument.',
61
+ },
62
+ {
63
+ id: 'sql-injection', severity: 'HIGH', category: 'fix', dimension: 'Security',
64
+ title: 'SQL string built by concatenation/interpolation',
65
+ ext: ['.js', '.ts', '.mjs', '.cjs', '.py', '.rb', '.php', '.java', '.go'],
66
+ test: /\b(SELECT|INSERT\s+INTO|UPDATE|DELETE\s+FROM|WHERE)\b[^;]*\b\$\{|\+\s*\w+\s*\+\s*["'][^"']*["']|["'][^"']*\bWHERE\b[^"']*["']\s*%|f["'][^"']*\b(SELECT|WHERE)\b/i,
67
+ skipIf: (line) => /placeholder|\?\?|\$1|\bparams\b/i.test(line),
68
+ problem: 'Query text is assembled from variables instead of bound parameters.',
69
+ impact: 'SQL injection — data exfiltration or destruction.',
70
+ fix: 'Use parameterised queries / ORM bind params.',
71
+ verify: "quote-injection payload is bound as a value, not executed.",
72
+ },
73
+ {
74
+ id: 'xss-sink', severity: 'HIGH', category: 'fix', dimension: 'Security',
75
+ title: 'Untrusted HTML injection sink',
76
+ ext: ['.js', '.jsx', '.ts', '.tsx', '.html', '.vue', '.svelte'],
77
+ test: /(\.innerHTML\s*=|dangerouslySetInnerHTML|document\.write\(|\{\{\{.*\}\}\}|v-html=|\.html\()/,
78
+ problem: 'Markup is written from a value that may be user-controlled.',
79
+ impact: 'Cross-site scripting / DOM-based XSS.',
80
+ fix: 'Sanitise with a vetted library or render as text / auto-escaping template.',
81
+ verify: '<script> payload renders inert.',
82
+ },
83
+ {
84
+ id: 'jwt-verify-off', severity: 'HIGH', category: 'fix', dimension: 'Security',
85
+ title: 'JWT signature verification disabled or alg:none',
86
+ ext: ['.js', '.ts', '.mjs', '.cjs', '.py', '.go', '.java'],
87
+ test: /(verify\s*:\s*false|algorithms\s*[:=]\s*\[?\s*["']none["']|\.decode\([^,)]*\)\s*;?|jwt\.decode\([^)]*verify\s*=\s*False)/i,
88
+ problem: 'Token is decoded without verifying its signature.',
89
+ impact: 'Anyone can forge an accepted token — full auth bypass.',
90
+ fix: 'Verify signature with a fixed algorithm allowlist before trusting claims.',
91
+ verify: 'a token with alg:none is rejected.',
92
+ },
93
+ {
94
+ id: 'cors-wildcard', severity: 'MEDIUM', category: 'fix', dimension: 'Security',
95
+ title: 'CORS wildcard with credentials',
96
+ ext: ['.js', '.ts', '.py', '.go', '.java', '.rb'],
97
+ test: /(access[_-]?control[_-]?allow[_-]?origin["']?\s*[:,=]\s*["']\*["']|origin\s*:\s*["']\*["'][\s\S]{0,40}credentials\s*:\s*true|cors\(\s*\*?\s*\))/i,
98
+ problem: 'Either reflects all origins or pairs "*" with credentialed requests.',
99
+ impact: 'Cross-origin credentialed reads by any site.',
100
+ fix: 'Allowlist exact origins; never combine "*" with credentials.',
101
+ verify: 'request from a non-allowlisted origin gets no ACAO header.',
102
+ },
103
+
104
+ // ---- Correctness --------------------------------------------------------
105
+ {
106
+ id: 'swallowed-error', severity: 'HIGH', category: 'fix', dimension: 'Correctness',
107
+ title: 'Empty / no-op error handler (error swallowed)',
108
+ ext: ['.js', '.ts', '.mjs', '.cjs', '.jsx', '.tsx', '.java', '.cs', '.php', '.go', '.py', '.rb'],
109
+ test: /catch\s*(\([^)]*\))?\s*\{[\s\t]*\}|except[^:]*:\s*(pass|\.\.\.)\s*$|except\s*:\s*pass/,
110
+ problem: 'A caught error is discarded with no handling or logging.',
111
+ impact: 'Failures vanish silently; data corruption goes unnoticed.',
112
+ fix: 'Handle, log with context, or rethrow. Never leave an empty catch.',
113
+ verify: 'triggering the error path surfaces a log/rethrow.',
114
+ },
115
+ {
116
+ id: 'loose-equality', severity: 'LOW', category: 'optimize', dimension: 'Correctness',
117
+ ext: ['.js', '.ts', '.mjs', '.cjs', '.jsx', '.tsx'],
118
+ title: 'Loose equality (==) can coerce unexpectedly',
119
+ test: /[^=!<>]==[^=]|!=[^=]/,
120
+ skipIf: (line) => /=>|===|!==|==\s*null|!=\s*null/.test(line),
121
+ problem: '== / != apply type coercion.',
122
+ impact: 'Subtle logic bugs comparing 0, "", false, null.',
123
+ fix: 'Prefer === / !==.',
124
+ verify: 'strict comparison keeps behaviour for the intended operands.',
125
+ },
126
+
127
+ // ---- Code quality / cleanliness ----------------------------------------
128
+ {
129
+ id: 'todo-comment', severity: 'INFO', category: 'delete', dimension: 'Code quality',
130
+ title: 'Unresolved TODO/FIXME/HACK',
131
+ test: /\b(TODO|FIXME|HACK|XXX)\b[:\s]/,
132
+ problem: 'Undated, unassigned work marker left in code.',
133
+ impact: 'Known debt silently accumulates.',
134
+ fix: 'Resolve it or track it in the issue tracker and remove the marker.',
135
+ verify: 'marker gone or linked to a tracked issue.',
136
+ },
137
+ {
138
+ id: 'debug-leftover', severity: 'LOW', category: 'delete', dimension: 'Code quality',
139
+ ext: ['.js', '.ts', '.mjs', '.cjs', '.jsx', '.tsx'],
140
+ title: 'debugger statement left in code',
141
+ test: /^\s*debugger\s*;?\s*$/,
142
+ problem: 'A debugger breakpoint ships to source.',
143
+ impact: 'Halts execution when devtools are open.',
144
+ fix: 'Delete it.',
145
+ verify: 'grep -n debugger returns nothing.',
146
+ },
147
+ {
148
+ id: 'console-log', severity: 'INFO', category: 'delete', dimension: 'Code quality',
149
+ ext: ['.js', '.ts', '.mjs', '.cjs', '.jsx', '.tsx', '.py'],
150
+ title: 'Debug logging (console.log / print) in source',
151
+ test: /(^|[^.\w])(console\.(log|debug)|print|System\.out\.print)\s*\(/,
152
+ skipIf: (line) => /^\s*(#|\/\/|\*)/.test(line),
153
+ problem: 'Ad-hoc debug output left in committed code.',
154
+ impact: 'Noisy logs; can leak sensitive values to stdout.',
155
+ fix: 'Use the structured logger at the right level, or remove.',
156
+ verify: 'no console.log/print debug calls remain.',
157
+ },
158
+
159
+ // ---- Database -----------------------------------------------------------
160
+ {
161
+ id: 'select-star', severity: 'LOW', category: 'optimize', dimension: 'Database',
162
+ title: 'SELECT * fetches all columns',
163
+ test: /\bSELECT\s+\*\s+FROM\b/i,
164
+ problem: 'Reads every column regardless of need.',
165
+ impact: 'Wasted I/O and memory; breaks when schema adds columns.',
166
+ fix: 'Select only required columns; ensure covering indexes for hot queries.',
167
+ verify: 'query lists explicit columns.',
168
+ },
169
+ {
170
+ id: 'unbounded-query', severity: 'MEDIUM', category: 'optimize', dimension: 'Database',
171
+ title: 'Collection query without LIMIT/pagination',
172
+ ext: ['.js', '.ts', '.py', '.rb', '.go', '.java', '.php', '.sql'],
173
+ test: /\.(find|getAll|all|select|list)\s*\(\s*\)|\bSELECT\b[^;]*\bFROM\b(?![^;]*\b(LIMIT|TOP|fetch)\b)/i,
174
+ skipIf: (line) => /\b(count|exists|where|limit|id\s*=|primarykey)\b/i.test(line),
175
+ problem: 'A whole-collection read has no upper bound.',
176
+ impact: 'Memory/latency blowup as the table grows.',
177
+ fix: 'Add keyset/cursor pagination or an explicit LIMIT.',
178
+ verify: 'endpoint returns a bounded page size.',
179
+ },
180
+ ];
181
+
182
+ module.exports = { LINE_RULES, PLACEHOLDER };
package/src/util.js ADDED
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+ /**
3
+ * Shared filesystem helpers for the critic.
4
+ * Design goal: touch 100% of the project's resources — never silently skip a file.
5
+ * Every skipped file is counted and the reason is recorded so the report can prove coverage.
6
+ */
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ // Directories that are never first-party source. Skipped, but counted + reported.
11
+ const SKIP_DIRS = new Set([
12
+ 'node_modules', '.git', '.hg', '.svn', 'dist', 'build', '.next', '.nuxt',
13
+ 'out', 'coverage', '.nyc_output', 'vendor', '__pycache__', '.venv', 'venv',
14
+ '.tox', 'target', '.gradle', '.serverless', '.turbo', '.cache',
15
+ '.critic', '.critic-memory',
16
+ ]);
17
+
18
+ // Extensions that are definitely not scannable text.
19
+ const BINARY_EXT = new Set([
20
+ '.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.bmp', '.tiff', '.pdf',
21
+ '.zip', '.gz', '.tar', '.tgz', '.bz2', '.7z', '.rar', '.jar', '.war',
22
+ '.woff', '.woff2', '.ttf', '.eot', '.otf', '.mp3', '.mp4', '.wav', '.avi',
23
+ '.mov', '.webm', '.so', '.dylib', '.dll', '.exe', '.bin', '.obj', '.o', '.a',
24
+ '.class', '.pyc', '.pyo', '.lockb', '.wasm', '.pdb', '.dat',
25
+ ]);
26
+
27
+ const MAX_FILE_BYTES = 1024 * 1024; // 1 MB — larger files are counted, not line-scanned.
28
+
29
+ function isBinaryExt(ext) {
30
+ return BINARY_EXT.has(ext.toLowerCase());
31
+ }
32
+
33
+ /**
34
+ * Recursively walk a project. Returns { files, skipped }.
35
+ * files: [{ abs, rel, ext, size }] — every non-ignored, non-binary file (100% of scannable resources)
36
+ * skipped:[{ rel, reason }] — every resource deliberately not line-scanned (proof of coverage)
37
+ */
38
+ function walk(root) {
39
+ const files = [];
40
+ const skipped = [];
41
+
42
+ function recurse(dir) {
43
+ let entries;
44
+ try {
45
+ entries = fs.readdirSync(dir, { withFileTypes: true });
46
+ } catch (e) {
47
+ skipped.push({ rel: path.relative(root, dir) || '.', reason: `unreadable directory (${e.code})` });
48
+ return;
49
+ }
50
+ for (const ent of entries) {
51
+ const abs = path.join(dir, ent.name);
52
+ const rel = path.relative(root, abs).split(path.sep).join('/');
53
+ if (ent.isDirectory()) {
54
+ if (SKIP_DIRS.has(ent.name)) {
55
+ skipped.push({ rel: rel + '/', reason: `dependency / build / VCS directory (${ent.name})` });
56
+ continue;
57
+ }
58
+ recurse(abs);
59
+ continue;
60
+ }
61
+ if (ent.isSymbolicLink && ent.isSymbolicLink()) {
62
+ skipped.push({ rel, reason: 'symbolic link (not followed)' });
63
+ continue;
64
+ }
65
+ let st;
66
+ try { st = fs.statSync(abs); } catch (e) { skipped.push({ rel, reason: `stat failed (${e.code})` }); continue; }
67
+ const ext = path.extname(ent.name).toLowerCase();
68
+ if (isBinaryExt(ext)) {
69
+ skipped.push({ rel, reason: `binary asset (${ext})` });
70
+ continue;
71
+ }
72
+ if (st.size > MAX_FILE_BYTES) {
73
+ skipped.push({ rel, reason: `file larger than ${MAX_FILE_BYTES} bytes (counted, not line-scanned)` });
74
+ continue;
75
+ }
76
+ files.push({ abs, rel, ext, size: st.size });
77
+ }
78
+ }
79
+
80
+ recurse(root);
81
+ return { files, skipped };
82
+ }
83
+
84
+ function readText(abs) {
85
+ try {
86
+ const buf = fs.readFileSync(abs);
87
+ // A NUL byte in the first 800 bytes is the classic binary signature.
88
+ if (buf.subarray(0, 800).includes(0)) return null;
89
+ return buf.toString('utf8');
90
+ } catch (e) {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ function exists(p) {
96
+ try { fs.accessSync(p); return true; } catch { return false; }
97
+ }
98
+
99
+ module.exports = { walk, readText, exists, SKIP_DIRS, MAX_FILE_BYTES };
package/src/watcher.js ADDED
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+ /**
3
+ * Watch mode: attach to an in-progress workflow and keep reviewing in the background.
4
+ * On every change batch it re-runs the analyzer, diffs findings against the last pass,
5
+ * prints concise feedback (what appeared / what got resolved), and rewrites the report file.
6
+ * Cross-platform: watches each directory individually (no reliance on recursive:true).
7
+ */
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { analyze } = require('./analyzer');
11
+ const { renderConsole, renderMarkdown } = require('./report');
12
+ const { SKIP_DIRS } = require('./util');
13
+
14
+ const IGNORED_OUTPUT = new Set(['.critic-report.md', '.critic-review.json', 'RUN_STATE.md', 'REVIEW_HISTORY.md']);
15
+
16
+ function listDirs(root) {
17
+ const dirs = [root];
18
+ const stack = [root];
19
+ while (stack.length) {
20
+ const d = stack.pop();
21
+ let entries;
22
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { continue; }
23
+ for (const e of entries) {
24
+ if (!e.isDirectory()) continue;
25
+ if (SKIP_DIRS.has(e.name)) continue;
26
+ const full = path.join(d, e.name);
27
+ dirs.push(full);
28
+ stack.push(full);
29
+ }
30
+ }
31
+ return dirs;
32
+ }
33
+
34
+ function keyOf(f) {
35
+ // Key on rule + file + evidence text (NOT line) so inserting/deleting lines elsewhere
36
+ // does not make every shifted finding look simultaneously new and fixed.
37
+ return `${f.file}::${f.ruleId}::${(f.evidence || '').slice(0, 120)}`;
38
+ }
39
+
40
+ function watch(root, opts = {}) {
41
+ const debounceMs = opts.debounceMs || 400;
42
+ let last = new Map(); // key -> finding
43
+ let first = true;
44
+ let timer = null;
45
+ const pending = new Set();
46
+ const watchers = new Map();
47
+
48
+ function runPass() {
49
+ const result = analyze(root);
50
+ const current = new Map(result.findings.map((f) => [keyOf(f), f]));
51
+
52
+ if (first) {
53
+ process.stdout.write(renderConsole(result) + '\n');
54
+ first = false;
55
+ } else {
56
+ const added = [...current.values()].filter((f) => !last.has(keyOf(f)));
57
+ const resolved = [...last.values()].filter((f) => !current.has(keyOf(f)));
58
+ const time = new Date().toLocaleTimeString();
59
+ if (!added.length && !resolved.length) {
60
+ process.stdout.write(`\x1b[90m[${time}] no change in findings (${result.findings.length} total).\x1b[0m\n`);
61
+ } else {
62
+ process.stdout.write(`\x1b[36m[${time}] critic feedback\x1b[0m — ${result.findings.length} total\n`);
63
+ for (const f of added.slice(0, 15)) {
64
+ process.stdout.write(` \x1b[31m+ NEW ${f.severity}\x1b[0m ${f.file}:${f.line} ${f.title}\n`);
65
+ }
66
+ for (const f of resolved.slice(0, 15)) {
67
+ process.stdout.write(` \x1b[32m- FIXED\x1b[0m ${f.file}:${f.line} ${f.title}\n`);
68
+ }
69
+ if (added.length > 15) process.stdout.write(` … ${added.length - 15} more new\n`);
70
+ }
71
+ }
72
+ last = current;
73
+
74
+ if (opts.report) {
75
+ try { fs.writeFileSync(opts.report, renderMarkdown(result)); }
76
+ catch (e) { process.stderr.write(`could not write report ${opts.report}: ${e.message}\n`); }
77
+ }
78
+ if (opts.json) {
79
+ try { fs.writeFileSync(opts.json, JSON.stringify(result, null, 2)); }
80
+ catch (e) { process.stderr.write(`could not write json ${opts.json}: ${e.message}\n`); }
81
+ }
82
+ }
83
+
84
+ function schedule(file) {
85
+ if (file && IGNORED_OUTPUT.has(path.basename(file))) return; // ignore our own writes
86
+ pending.add(file || '.');
87
+ if (timer) clearTimeout(timer);
88
+ timer = setTimeout(() => {
89
+ pending.clear();
90
+ try { runPass(); } catch (e) { process.stderr.write(`critic watch error: ${e.message}\n`); }
91
+ }, debounceMs);
92
+ }
93
+
94
+ function addWatcher(dir) {
95
+ if (watchers.has(dir)) return;
96
+ try {
97
+ const w = fs.watch(dir, { persistent: true }, (_event, filename) => {
98
+ // A new subdirectory appearing: start watching it too.
99
+ if (filename) {
100
+ const maybe = path.join(dir, filename);
101
+ try { if (fs.statSync(maybe).isDirectory() && !SKIP_DIRS.has(filename)) addWatcher(maybe); } catch { /* path vanished mid-edit */ }
102
+ }
103
+ schedule(filename);
104
+ });
105
+ w.on('error', () => { /* dir removed while watching */ });
106
+ watchers.set(dir, w);
107
+ } catch { /* directory unreadable — skip watching it */ }
108
+ }
109
+
110
+ for (const d of listDirs(root)) addWatcher(d);
111
+ runPass();
112
+
113
+ process.stdout.write(
114
+ `\n\x1b[90mWatching ${path.resolve(root)} for changes — ${watchers.size} dir(s). ` +
115
+ `Report: ${opts.report || 'stdout only'}. Ctrl+C to stop.\x1b[0m\n`
116
+ );
117
+
118
+ return {
119
+ close() {
120
+ for (const w of watchers.values()) { try { w.close(); } catch { /* already closed */ } }
121
+ watchers.clear();
122
+ if (timer) clearTimeout(timer);
123
+ },
124
+ };
125
+ }
126
+
127
+ module.exports = { watch };
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+ /* critic-ignore-file — contains intentionally vulnerable fixture strings used to prove detection works. */
3
+ /**
4
+ * Critic self-tests (zero-dependency, runs on `node --test`).
5
+ * These prove the tool's own behaviour and satisfy its "add tests" finding for itself.
6
+ */
7
+ const test = require('node:test');
8
+ const assert = require('node:assert');
9
+ const fs = require('node:fs');
10
+ const os = require('node:os');
11
+ const path = require('node:path');
12
+
13
+ const { analyze } = require('../src/analyzer');
14
+ const { renderMarkdown, renderConsole } = require('../src/report');
15
+ const { analyzeDeps } = require('../src/deps');
16
+ const { walk } = require('../src/util');
17
+
18
+ function fixtureDir(files) {
19
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'critic-test-'));
20
+ for (const [rel, content] of Object.entries(files)) {
21
+ const abs = path.join(dir, rel);
22
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
23
+ fs.writeFileSync(abs, content);
24
+ }
25
+ return dir;
26
+ }
27
+
28
+ test('walk counts every non-ignored file (100% coverage proof)', () => {
29
+ const dir = fixtureDir({
30
+ 'a.js': 'const x = 1;\n',
31
+ 'src/b.py': 'print(1)\n',
32
+ 'node_modules/pkg/c.js': 'ignored\n',
33
+ });
34
+ const { files, skipped } = walk(dir);
35
+ const rels = files.map((f) => f.rel).sort();
36
+ assert.deepStrictEqual(rels, ['a.js', 'src/b.py']);
37
+ assert.ok(skipped.some((s) => s.rel.startsWith('node_modules/')), 'node_modules is counted as skipped, not silently dropped');
38
+ fs.rmSync(dir, { recursive: true, force: true });
39
+ });
40
+
41
+ test('analyzer detects the core vulnerability classes', () => {
42
+ const dir = fixtureDir({
43
+ 'app.js': [
44
+ 'const api_key = "sk-live-aaaaaaaaaaaaaaaaaaaaaa";', // hardcoded secret (CRITICAL)
45
+ 'require("child_process").execSync("ls " + user);', // command injection (HIGH)
46
+ 'try { f(); } catch (e) {}', // swallowed error (HIGH)
47
+ 'return "SELECT * FROM t WHERE id=" + id;', // sql concat + select star
48
+ ].join('\n'),
49
+ });
50
+ const r = analyze(dir);
51
+ const ids = new Set(r.findings.map((f) => f.ruleId));
52
+ assert.ok(ids.has('hardcoded-secret'), 'detects secret');
53
+ assert.ok(ids.has('command-injection'), 'detects command injection');
54
+ assert.ok(ids.has('swallowed-error'), 'detects swallowed error');
55
+ assert.ok(r.counts.CRITICAL >= 1, 'a CRITICAL is present');
56
+ fs.rmSync(dir, { recursive: true, force: true });
57
+ });
58
+
59
+ test('dependency analysis flags missing lockfile and unpinned ranges', () => {
60
+ const dir = fixtureDir({
61
+ 'package.json': JSON.stringify({ name: 'x', version: '1.0.0', dependencies: { leftpad: '*', express: '^4.0.0' } }),
62
+ });
63
+ const { ecosystems, findings } = analyzeDeps(dir);
64
+ assert.strictEqual(ecosystems[0].name, 'npm');
65
+ assert.strictEqual(ecosystems[0].lock, null, 'no lockfile detected');
66
+ assert.ok(findings.some((f) => /no lock file/i.test(f.title)), 'missing-lock finding raised');
67
+ assert.ok(findings.some((f) => /Unpinned dependency "leftpad"/.test(f.title)), 'unpinned * flagged');
68
+ assert.ok(!findings.some((f) => /Unpinned dependency "express"/.test(f.title)), 'caret range not flagged');
69
+ fs.rmSync(dir, { recursive: true, force: true });
70
+ });
71
+
72
+ test('report groups the action plan into fix / optimize / delete / add', () => {
73
+ const dir = fixtureDir({ 'app.js': 'try { f(); } catch (e) {}\n// TODO: x\nreturn "SELECT * FROM t";\n' });
74
+ const r = analyze(dir);
75
+ const md = renderMarkdown(r);
76
+ assert.match(md, /## Prioritised Action Plan/);
77
+ assert.match(md, /### FIX —/);
78
+ assert.match(md, /### DELETE —/);
79
+ assert.match(md, /### OPTIMIZE —/);
80
+ assert.match(md, /## Coverage \(100% of resources/);
81
+ for (const line of renderConsole(r).split('\n')) assert.ok(typeof line === 'string');
82
+ fs.rmSync(dir, { recursive: true, force: true });
83
+ });
84
+
85
+ test('findings always carry an exact location', () => {
86
+ const dir = fixtureDir({ 'app.js': 'const api_key = "sk-live-aaaaaaaaaaaaaaaaaaaaaa";\n' });
87
+ const r = analyze(dir);
88
+ assert.ok(r.findings.length > 0);
89
+ for (const f of r.findings) {
90
+ assert.ok(typeof f.file === 'string' && f.file.length > 0, 'finding has a file');
91
+ assert.ok(Number.isInteger(f.line) && f.line >= 1, 'finding has a line number');
92
+ assert.ok(['fix', 'optimize', 'delete', 'add'].includes(f.category), 'finding has a valid category');
93
+ }
94
+ fs.rmSync(dir, { recursive: true, force: true });
95
+ });