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/cli.js ADDED
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+ /**
3
+ * Command router for the `critic` / `fullstack-critic` binary.
4
+ * Commands: review · watch · init · audit · help · version
5
+ */
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const { spawnSync } = require('child_process');
9
+ const { analyze } = require('./analyzer');
10
+ const { renderConsole, renderMarkdown } = require('./report');
11
+ const { watch } = require('./watcher');
12
+ const { init } = require('./init');
13
+ const { analyzeDeps } = require('./deps');
14
+
15
+ // Map a --fail-on level to the worst-order index that still triggers exit 1.
16
+ // order index: BLOCKER=0 CRITICAL=1 HIGH=2 MEDIUM=3 LOW=4 INFO=5
17
+ const FAIL_LEVELS = { never: -1, info: 5, low: 4, medium: 3, high: 2, critical: 1, blocker: 0 };
18
+
19
+ function parseArgs(argv) {
20
+ const args = { _: [] , flags: {} };
21
+ for (let i = 0; i < argv.length; i++) {
22
+ const a = argv[i];
23
+ if (a === '--') { args._.push(...argv.slice(i + 1)); break; }
24
+ if (a.startsWith('--')) {
25
+ const key = a.slice(2);
26
+ if (key.includes('=')) { const [k, v] = key.split('='); args.flags[k] = v; }
27
+ else if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) args.flags[key] = argv[++i];
28
+ else args.flags[key] = true;
29
+ } else if (a.startsWith('-') && a.length > 1) {
30
+ const key = a.slice(1);
31
+ if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) args.flags[key] = argv[++i];
32
+ else args.flags[key] = true;
33
+ } else args._.push(a);
34
+ }
35
+ return args;
36
+ }
37
+
38
+ function targetOf(args) {
39
+ // _[0] is the subcommand; the optional path is _[1].
40
+ const t = args.flags.target || args._[1] || '.';
41
+ return path.resolve(t);
42
+ }
43
+
44
+ function exitCodeFor(result, failOn) {
45
+ const rank = FAIL_LEVELS[String(failOn || 'critical').toLowerCase()];
46
+ if (rank == null) return 0;
47
+ const order = ['BLOCKER', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'];
48
+ for (let i = 0; i < order.length; i++) {
49
+ if (result.counts[order[i]] > 0) return i <= rank ? 1 : 0;
50
+ }
51
+ return 0;
52
+ }
53
+
54
+ function safeWrite(p, content, label) {
55
+ try { fs.writeFileSync(p, content); process.stdout.write(`${label} written to ${p}\n`); return true; }
56
+ catch (e) { process.stderr.write(`could not write ${label} to ${p}: ${e.code || e.message}\n`); return false; }
57
+ }
58
+
59
+ function cmdReview(args) {
60
+ const target = targetOf(args);
61
+ const result = analyze(target, { skipCode: !!args.flags['no-code'] });
62
+ if (args.flags.json) {
63
+ const p = typeof args.flags.json === 'string' ? args.flags.json : '.critic-review.json';
64
+ safeWrite(p, JSON.stringify(result, null, 2), 'JSON');
65
+ } else if (args.flags.quiet) {
66
+ // print nothing but the verdict line
67
+ process.stdout.write(`${result.counts.CRITICAL + result.counts.BLOCKER} blocking · ${result.counts.HIGH} high · ${result.findings.length} total\n`);
68
+ } else {
69
+ process.stdout.write(renderConsole(result) + '\n');
70
+ }
71
+ if (args.flags.md || args.flags.o) {
72
+ const p = args.flags.md || args.flags.o;
73
+ safeWrite(typeof p === 'string' ? p : '.critic-report.md', renderMarkdown(result), 'Report');
74
+ }
75
+ return exitCodeFor(result, args.flags['fail-on']);
76
+ }
77
+
78
+ function cmdWatch(args) {
79
+ const target = targetOf(args);
80
+ const w = watch(target, {
81
+ debounceMs: Number(args.flags.debounce) || 400,
82
+ report: (args.flags['report-file'] != null) ? (typeof args.flags['report-file'] === 'string' ? args.flags['report-file'] : '.critic-report.md') : '.critic-report.md',
83
+ json: args.flags.json ? (typeof args.flags.json === 'string' ? args.flags.json : '.critic-review.json') : null,
84
+ });
85
+ process.on('SIGINT', () => { w.close(); process.stdout.write('\nstopped watching.\n'); process.exit(0); });
86
+ return 0;
87
+ }
88
+
89
+ function cmdInit(args) {
90
+ const target = path.resolve(args._[1] || '.');
91
+ init(target);
92
+ return 0;
93
+ }
94
+
95
+ function cmdAudit(args) {
96
+ const target = targetOf(args);
97
+ const { ecosystems } = analyzeDeps(target);
98
+ if (!ecosystems.length) { process.stdout.write('No recognised dependency manifests.\n'); return 0; }
99
+ for (const eco of ecosystems) {
100
+ const cmd = eco.auditCmd;
101
+ process.stdout.write(`\n=== ${eco.name} · ${cmd} ===\n`);
102
+ const r = spawnSafe(cmd, target);
103
+ if (r == null) { process.stdout.write(` (skipped: \`${cmd.split(' ')[0]}\` not on PATH)\n`); continue; }
104
+ const out = (r.stdout || '') + (r.stderr || '');
105
+ process.stdout.write(abbrev(out, 4000) + '\n');
106
+ if (r.status != null) process.stdout.write(` exit ${r.status}\n`);
107
+ }
108
+ return 0;
109
+ }
110
+
111
+ function spawnSafe(cmd, cwd) {
112
+ try {
113
+ const r = spawnSync(cmd, { cwd, shell: true, encoding: 'utf8', timeout: 60000 });
114
+ if (r.error) return null;
115
+ if (r.status === 1 && /is not recognized|command not found|not found/i.test((r.stderr || '') + (r.stdout || ''))) return null;
116
+ return r;
117
+ } catch { return null; }
118
+ }
119
+ function abbrev(s, n) { return s && s.length > n ? s.slice(0, n) + `\n… (${s.length - n} more chars)` : s; }
120
+
121
+ function help() {
122
+ process.stdout.write(`
123
+ Full-Stack Critic — universal engineering critic, runs anywhere, reviews 100% of resources.
124
+
125
+ Usage:
126
+ critic review [path] [flags] One-shot full review across all 12 dimensions
127
+ critic watch [path] [flags] Attach to an in-progress workflow; continuous feedback on changes
128
+ critic init [path] Attach the critic to any project (.critic/ + .critic-memory/ + PROJECT_REVIEW.md)
129
+ critic audit [path] Run each detected package manager's native audit (npm audit, pip-audit, govulncheck…)
130
+
131
+ Flags:
132
+ --md <path> Write the full markdown report (default in watch: .critic-report.md)
133
+ --json <path> Emit machine-readable findings
134
+ --fail-on <level> Exit 1 at this severity: blocker|high|medium|low|never (review default: critical)
135
+ --no-code Skip code line-scanning (dependencies + structure only)
136
+ --debounce <ms> Watch mode settle time (default 400)
137
+ --quiet Print only the summary line
138
+
139
+ Examples:
140
+ npx fullstack-critic review .
141
+ critic watch ./my-app --md CRITIC_REPORT.md
142
+ critic init && critic audit
143
+ `);
144
+ return 0;
145
+ }
146
+
147
+ function run(argv) {
148
+ const args = parseArgs(argv);
149
+ const cmd = (args._[0] || 'help').toLowerCase();
150
+ switch (cmd) {
151
+ case 'review': return cmdReview(args);
152
+ case 'watch': return cmdWatch(args);
153
+ case 'init': return cmdInit(args);
154
+ case 'audit': return cmdAudit(args);
155
+ case 'version': case '--version': case '-v':
156
+ process.stdout.write(JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version + '\n'); return 0;
157
+ case 'help': case '--help': case undefined: return help();
158
+ default:
159
+ process.stderr.write(`Unknown command: ${cmd}\n`); return help() || 1;
160
+ }
161
+ }
162
+
163
+ module.exports = { run, parseArgs };
package/src/deps.js ADDED
@@ -0,0 +1,200 @@
1
+ 'use strict';
2
+ /**
3
+ * Dependency and package-manifest analysis.
4
+ * Covers every mainstream ecosystem so "100% of resources — packages, dependencies"
5
+ * is actually inspected, not assumed. Findings carry a category: fix | optimize | delete | add.
6
+ */
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { exists, readText } = require('./util');
10
+
11
+ const ECOSYSTEMS = [
12
+ {
13
+ name: 'npm',
14
+ manifest: 'package.json',
15
+ locks: ['package-lock.json', 'npm-shrinkwrap.json'],
16
+ altLocks: { yarn: 'yarn.lock', pnpm: 'pnpm-lock.yaml', bun: 'bun.lockb' },
17
+ auditCmd: 'npm audit --omit=dev',
18
+ outdatedCmd: 'npm outdated',
19
+ },
20
+ { name: 'pip', manifest: 'requirements.txt', lockGlob: /^requirements.*\.(txt|lock)$/i, auditCmd: 'pip-audit', loose: true },
21
+ { name: 'poetry', manifest: 'pyproject.toml', locks: ['poetry.lock'], auditCmd: 'poetry show --outdated' },
22
+ { name: 'pipenv', manifest: 'Pipfile', locks: ['Pipfile.lock'], auditCmd: 'pipenv check' },
23
+ { name: 'go', manifest: 'go.mod', locks: ['go.sum'], auditCmd: 'govulncheck ./...' },
24
+ { name: 'cargo', manifest: 'Cargo.toml', locks: ['Cargo.lock'], auditCmd: 'cargo audit', bin: 'cargo' },
25
+ { name: 'composer', manifest: 'composer.json', locks: ['composer.lock'], auditCmd: 'composer audit' },
26
+ { name: 'bundler', manifest: 'Gemfile', locks: ['Gemfile.lock'], auditCmd: 'bundle audit' },
27
+ { name: 'maven', manifest: 'pom.xml', locks: [], auditCmd: 'mvn dependency:analyze', loose: true },
28
+ { name: 'gradle', manifest: 'build.gradle', locks: ['gradle.lockfile'], auditCmd: './gradlew dependencyUpdates', loose: true },
29
+ ];
30
+
31
+ function parseJson(abs) {
32
+ try { return JSON.parse(readText(abs)); } catch { return null; }
33
+ }
34
+
35
+ function npmDeps(pkg) {
36
+ const out = [];
37
+ for (const section of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
38
+ const map = pkg[section] || {};
39
+ for (const [name, range] of Object.entries(map)) {
40
+ out.push({ name, range, section });
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /**
47
+ * Analyse dependency manifests present under root.
48
+ * Returns { ecosystems: [...], findings: [...] }.
49
+ */
50
+ function analyzeDeps(root) {
51
+ const ecosystems = [];
52
+ const findings = [];
53
+ const F = (f) => findings.push(f);
54
+
55
+ for (const eco of ECOSYSTEMS) {
56
+ const manifestPath = path.join(root, eco.manifest);
57
+ if (!exists(manifestPath)) continue;
58
+
59
+ let declared = [];
60
+ let scripts = {};
61
+ let hasScriptsField = false;
62
+ if (eco.name === 'npm') {
63
+ const pkg = parseJson(manifestPath);
64
+ if (pkg) {
65
+ declared = npmDeps(pkg);
66
+ scripts = pkg.scripts || {};
67
+ hasScriptsField = Object.prototype.hasOwnProperty.call(pkg, 'scripts');
68
+ }
69
+ } else {
70
+ const text = readText(manifestPath) || '';
71
+ declared = countLoose(eco.name, text);
72
+ }
73
+
74
+ const lockPresent = (eco.locks || []).filter((l) => exists(path.join(root, l)));
75
+ const altPresent = eco.altLocks
76
+ ? Object.entries(eco.altLocks).filter(([, f]) => exists(path.join(root, f))).map(([mgr]) => mgr)
77
+ : [];
78
+ const hasLock = lockPresent.length > 0 || altPresent.length > 0;
79
+
80
+ ecosystems.push({
81
+ name: eco.name,
82
+ manifest: eco.manifest,
83
+ declaredCount: declared.length,
84
+ lock: lockPresent[0] || altPresent[0] || null,
85
+ auditCmd: eco.auditCmd,
86
+ outdatedCmd: eco.outdatedCmd || null,
87
+ });
88
+
89
+ // --- Findings -----------------------------------------------------------
90
+ if (declared.length > 0 && !hasLock && !eco.loose) {
91
+ F({
92
+ severity: 'HIGH', category: 'add', dimension: 'Infrastructure',
93
+ title: `${eco.name}: dependencies declared but no lock file committed`,
94
+ file: eco.manifest, line: 1,
95
+ problem: 'Without a lock file, installs are not reproducible across machines, CI, and prod.',
96
+ evidence: `${declared.length} declared dependencies, no ${eco.locks} present.`,
97
+ impact: 'Different installs produce different trees; a single transitive compromise propagates everywhere.',
98
+ fix: `Run the install once and commit the generated lock file (${eco.auditCmd ? `then \`${eco.auditCmd}\`` : ''}).`,
99
+ verify: `Fresh clone then install; confirm the lock file is present and tracked.`,
100
+ });
101
+ }
102
+
103
+ if (eco.name === 'npm') {
104
+ for (const d of declared) {
105
+ const unpinned = d.range === '*' || d.range === 'latest' || d.range === '';
106
+ const gitOrLocal = /^(git|file:|link:|workspace:)/.test(d.range);
107
+ if (unpinned) {
108
+ F({
109
+ severity: 'MEDIUM', category: 'fix', dimension: 'Infrastructure',
110
+ title: `Unpinned dependency "${d.name}" (range "${d.range || 'empty'}")`,
111
+ file: 'package.json', line: 1,
112
+ problem: 'A wildcard/latest range can pull a breaking or compromised version on any install.',
113
+ evidence: `"${d.name}": "${d.range}" in ${d.section}`,
114
+ impact: 'Silent behaviour change or supply-chain exposure at any time.',
115
+ fix: `Pin "${d.name}" to an exact or caret range of a reviewed version.`,
116
+ verify: 'npm ls ' + d.name,
117
+ });
118
+ } else if (gitOrLocal && d.section === 'dependencies') {
119
+ F({
120
+ severity: 'LOW', category: 'fix', dimension: 'Infrastructure',
121
+ title: `Production dependency "${d.name}" resolves to a non-registry source`,
122
+ file: 'package.json', line: 1,
123
+ problem: 'git/file/workspace deps in production bypass registry integrity checks.',
124
+ evidence: `"${d.name}": "${d.range}"`,
125
+ impact: 'Cannot be verified by registry audits; may break fresh installs.',
126
+ fix: 'Publish to a registry or vendor the code; avoid git/file refs in production deps.',
127
+ verify: 'npm ci on a clean checkout',
128
+ });
129
+ }
130
+ }
131
+ if (exists(path.join(root, 'package-lock.json')) && exists(path.join(root, 'yarn.lock'))) {
132
+ F({
133
+ severity: 'LOW', category: 'delete', dimension: 'Infrastructure',
134
+ title: 'Multiple package-manager lock files present',
135
+ file: '.', line: 1,
136
+ problem: 'Both npm and yarn locks exist; installs can diverge by manager.',
137
+ evidence: 'package-lock.json and yarn.lock coexist',
138
+ impact: 'Team/CI drift between resolution trees.',
139
+ fix: 'Standardise on one manager and delete the other lock.',
140
+ verify: 'Only one lock file remains.',
141
+ });
142
+ }
143
+ }
144
+ }
145
+
146
+ // Scripts completeness for npm projects.
147
+ const npkg = exists(path.join(root, 'package.json')) ? parseJson(path.join(root, 'package.json')) : null;
148
+ if (npkg && hasScriptGate(npkg)) {
149
+ const s = npkg.scripts || {};
150
+ const has = (re) => Object.keys(s).some((k) => re.test(k));
151
+ if (!has(/^test$/i)) {
152
+ F({ severity: 'MEDIUM', category: 'add', dimension: 'Testing', title: 'No "test" script defined', file: 'package.json', line: 1,
153
+ problem: 'CI and the critic cannot run tests.', evidence: 'scripts.test missing', impact: 'Regressions ship unverified.',
154
+ fix: 'Add a test runner and a "test" script.', verify: 'npm test' });
155
+ }
156
+ if (!has(/^(lint|lint:.*)$/i)) {
157
+ F({ severity: 'LOW', category: 'add', dimension: 'Code quality', title: 'No "lint" script defined', file: 'package.json', line: 1,
158
+ problem: 'No automated style/quality gate.', evidence: 'scripts.lint missing', impact: 'Style and simple-bug drift.',
159
+ fix: 'Add an ESLint/Ruff-equivalent lint script.', verify: 'npm run lint' });
160
+ }
161
+ }
162
+
163
+ return { ecosystems, findings };
164
+ }
165
+
166
+ function hasScriptGate(pkg) { return Object.prototype.hasOwnProperty.call(pkg, 'scripts'); }
167
+
168
+ // Best-effort declared-dependency count for non-npm manifests.
169
+ function countLoose(name, text) {
170
+ const lines = text.split(/\r?\n/);
171
+ let count = 0;
172
+ if (name === 'pip') {
173
+ count = lines.filter((l) => /^[A-Za-z0-9_.\-\[\]<>=!~]/.test(l.trim()) && !l.trim().startsWith('#') && /.*/.test(l) && l.trim()).length;
174
+ } else if (name === 'poetry' || name === 'pipenv') {
175
+ let inSec = false;
176
+ for (const l of lines) {
177
+ if (/^\s*\[.*dependencies.*\]/i.test(l)) { inSec = true; continue; }
178
+ if (/^\s*\[/.test(l)) { inSec = false; continue; }
179
+ if (inSec && /^\s*[A-Za-z0-9_.\-]+\s*=/.test(l)) count++;
180
+ }
181
+ } else if (name === 'go') {
182
+ let inReq = false;
183
+ for (const l of lines) {
184
+ if (/require\s*\(/.test(l)) { inReq = true; continue; }
185
+ if (inReq && /^\)/.test(l)) { inReq = false; continue; }
186
+ if (inReq && /\S+\s+v[\d.]/.test(l)) count++;
187
+ if (!inReq && /^require\s+\S/.test(l)) count++;
188
+ }
189
+ } else if (name === 'cargo' || name === 'composer') {
190
+ let inSec = false;
191
+ for (const l of lines) {
192
+ if (/^\s*\[dependencies\]/i.test(l)) { inSec = true; continue; }
193
+ if (/^\s*\[/.test(l)) { inSec = false; continue; }
194
+ if (inSec && /^\s*[A-Za-z0-9_.\-]+\s*=/.test(l)) count++;
195
+ }
196
+ }
197
+ return count;
198
+ }
199
+
200
+ module.exports = { analyzeDeps, ECOSYSTEMS };
package/src/index.js ADDED
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+ /**
3
+ * Programmatic API so the critic can also be embedded in build scripts, test suites, or CI.
4
+ * const critic = require('fullstack-critic');
5
+ * const result = critic.analyze('./my-app');
6
+ * require('fs').writeFileSync('report.md', critic.renderMarkdown(result));
7
+ */
8
+ module.exports = {
9
+ analyze: require('./analyzer').analyze,
10
+ renderMarkdown: require('./report').renderMarkdown,
11
+ renderConsole: require('./report').renderConsole,
12
+ execSummary: require('./report').execSummary,
13
+ analyzeDeps: require('./deps').analyzeDeps,
14
+ watch: require('./watcher').watch,
15
+ init: require('./init').init,
16
+ DIMENSIONS: require('./analyzer').DIMENSIONS,
17
+ rules: require('./rules').LINE_RULES,
18
+ };
package/src/init.js ADDED
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+ /**
3
+ * Attach the critic to any project.
4
+ * - copies the rubric/knowledge base into ./<project>/.critic/
5
+ * - scaffolds ./.project>/.critic-memory/ from the memory templates (session memory)
6
+ * - writes a fill-in PROJECT_REVIEW.md at the project root if none exists
7
+ * Read-only toward the project's own source — it only adds files under the critic's own folders.
8
+ */
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { exists } = require('./util');
12
+
13
+ const PKG_ROOT = path.join(__dirname, '..');
14
+ const RUBRIC_FILES = ['CRITIC.md', 'AGENTS.md', 'CLAUDE.md', 'GEMINI.md', 'PROJECT_REVIEW.md'];
15
+ const RUBRIC_DIRS = ['layers', 'memory', 'agent', 'prompts'];
16
+
17
+ function copyFile(src, dest) {
18
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
19
+ fs.copyFileSync(src, dest);
20
+ }
21
+
22
+ function copyDir(src, dest) {
23
+ const entries = fs.readdirSync(src, { withFileTypes: true });
24
+ fs.mkdirSync(dest, { recursive: true });
25
+ for (const e of entries) {
26
+ const s = path.join(src, e.name);
27
+ const d = path.join(dest, e.name);
28
+ if (e.isDirectory()) copyDir(s, d); else copyFile(s, d);
29
+ }
30
+ }
31
+
32
+ function init(target, opts = {}) {
33
+ target = path.resolve(target || '.');
34
+ const created = [];
35
+ const criticDir = path.join(target, '.critic');
36
+
37
+ fs.mkdirSync(criticDir, { recursive: true });
38
+ for (const f of RUBRIC_FILES) {
39
+ const src = path.join(PKG_ROOT, f);
40
+ if (exists(src)) { copyFile(src, path.join(criticDir, f)); created.push(path.relative(target, path.join(criticDir, f))); }
41
+ }
42
+ for (const d of RUBRIC_DIRS) {
43
+ const src = path.join(PKG_ROOT, d);
44
+ if (exists(src)) { copyDir(src, path.join(criticDir, d)); }
45
+ }
46
+
47
+ // Session memory scaffold from templates.
48
+ const memDir = path.join(target, '.critic-memory');
49
+ const tmplDir = path.join(PKG_ROOT, 'memory', 'templates');
50
+ if (!exists(memDir) && exists(tmplDir)) {
51
+ fs.mkdirSync(memDir, { recursive: true });
52
+ for (const t of fs.readdirSync(tmplDir)) {
53
+ if (!t.endsWith('.md')) continue;
54
+ const dest = path.join(memDir, t);
55
+ if (!exists(dest)) { copyFile(path.join(tmplDir, t), dest); created.push(path.relative(target, dest)); }
56
+ }
57
+ }
58
+
59
+ // Root PROJECT_REVIEW.md (only if the project has none).
60
+ const rootPR = path.join(target, 'PROJECT_REVIEW.md');
61
+ if (!exists(rootPR) && exists(path.join(PKG_ROOT, 'PROJECT_REVIEW.md'))) {
62
+ copyFile(path.join(PKG_ROOT, 'PROJECT_REVIEW.md'), rootPR);
63
+ created.push('PROJECT_REVIEW.md');
64
+ }
65
+
66
+ // .gitignore hint so the attach is clean.
67
+ if (!opts.skipGitignoreHint) {
68
+ process.stdout.write(
69
+ `\nAttached critic to ${target}\n` +
70
+ ` - rubric copied into ./.critic/\n` +
71
+ ` - memory scaffolded into ./.critic-memory/ (${created.length} file(s) written)\n\n` +
72
+ `Next: add ".critic/" and ".critic-memory/" to .gitignore (or commit them), fill PROJECT_REVIEW.md,\n` +
73
+ `then tell your AI: "Read .critic/CRITIC.md and do a full review of this project."\n` +
74
+ `Or run continuous checks now: critic watch ${target === process.cwd() ? '.' : ''}\n`
75
+ );
76
+ }
77
+ return { target, created };
78
+ }
79
+
80
+ module.exports = { init };
package/src/report.js ADDED
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+ /**
3
+ * Renders the analyzer result into (a) a console summary and (b) a full
4
+ * markdown report whose action plan is grouped by FIX / OPTIMIZE / DELETE / ADD.
5
+ */
6
+ const { DIMENSIONS } = require('./analyzer');
7
+
8
+ const SEV_ORDER = ['BLOCKER', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'];
9
+ const CATEGORY_LABEL = {
10
+ fix: 'FIX — correctness & security defects',
11
+ optimize: 'OPTIMIZE — performance, size & structure',
12
+ delete: 'DELETE — dead code, debug, duplicates',
13
+ add: 'ADD — missing tests, config & docs',
14
+ };
15
+
16
+ function statusFor(dimName, findings) {
17
+ const ds = findings.filter((f) => f.dimension === dimName);
18
+ if (!ds.length) return { status: 'REVIEW', note: 'no automated signal — AI/manual pass required' };
19
+ const worst = SEV_ORDER.find((s) => ds.some((f) => f.severity === s)) || 'INFO';
20
+ const status = (worst === 'BLOCKER' || worst === 'CRITICAL' || worst === 'HIGH') ? 'FAIL'
21
+ : (worst === 'MEDIUM' || worst === 'LOW') ? 'WARN' : 'INFO';
22
+ return { status, note: `${ds.length} finding(s), worst ${worst}` };
23
+ }
24
+
25
+ function execSummary(result) {
26
+ const { counts } = result;
27
+ const blocking = counts.BLOCKER + counts.CRITICAL;
28
+ if (blocking > 0) return 'Not ready — BLOCKER/CRITICAL findings present.';
29
+ if (counts.HIGH > 0) return 'Ready with required fixes — HIGH findings must be addressed before ship.';
30
+ if (counts.MEDIUM > 0) return 'Ready — resolve MEDIUM items on the normal cycle.';
31
+ return 'Clean — no findings above LOW.';
32
+ }
33
+
34
+ function renderConsole(result) {
35
+ const { counts, categoryCounts, coverage } = result;
36
+ const lines = [];
37
+ lines.push('');
38
+ lines.push(`Full-Stack Critic — ${result.coverage ? relName(result.meta.root) : ''}`);
39
+ lines.push('─'.repeat(64));
40
+ lines.push(
41
+ `Scanned ${coverage.filesScanned} files (${coverage.totalLines.toLocaleString()} lines) · ` +
42
+ `${coverage.filesSkipped} skipped (counted) · ${result.dependencies.length} package ecosystem(s)`
43
+ );
44
+ lines.push('');
45
+ const sevRow = SEV_ORDER.filter((s) => counts[s]).map((s) => `${counts[s]} ${s}`).join(' · ');
46
+ lines.push('Findings: ' + (sevRow || 'none'));
47
+ lines.push('Action plan: ' +
48
+ `${categoryCounts.fix} fix · ${categoryCounts.optimize} optimize · ${categoryCounts.delete} delete · ${categoryCounts.add} add`);
49
+ lines.push('Verdict: ' + execSummary(result));
50
+ const top = result.findings.filter((f) => ['BLOCKER', 'CRITICAL', 'HIGH'].includes(f.severity)).slice(0, 12);
51
+ if (top.length) {
52
+ lines.push('');
53
+ lines.push('Top blocking findings:');
54
+ for (const f of top) lines.push(` [${f.severity}] ${f.file}:${f.line} ${f.title}`);
55
+ }
56
+ return lines.join('\n');
57
+ }
58
+
59
+ function renderMarkdown(result) {
60
+ const { findings, counts, categoryCounts, coverage, dependencies } = result;
61
+ const out = [];
62
+ out.push(`# Engineering Review — ${relName(result.meta.root)}`);
63
+ out.push('');
64
+ out.push(`_Generated by Full-Stack Critic v${result.meta.cliVersion} · scanned ${coverage.filesScanned} files / ${coverage.totalLines.toLocaleString()} lines · ${coverage.filesSkipped} resources counted-but-skipped · ${result.meta.durationMs} ms._`);
65
+ out.push('');
66
+ out.push('## Executive Summary');
67
+ out.push('');
68
+ out.push('| Dimension | Status | Note |');
69
+ out.push('|-----------|--------|------|');
70
+ for (const d of DIMENSIONS) {
71
+ const s = statusFor(d, findings);
72
+ out.push(`| ${d} | ${s.status} | ${s.note} |`);
73
+ }
74
+ out.push(`| **Overall** | **${verdictStatus(result)}** | ${execSummary(result)} |`);
75
+ out.push('');
76
+
77
+ // Coverage proof — nothing skipped silently.
78
+ out.push('## Coverage (100% of resources, explicitly accounted)');
79
+ out.push('');
80
+ out.push(`- Files line-scanned: **${coverage.filesScanned}** · total lines: **${coverage.totalLines.toLocaleString()}**`);
81
+ out.push(`- Resources counted but not line-scanned: **${coverage.filesSkipped}** (dependencies, build output, VCS, binaries, oversized — see below)`);
82
+ if (dependencies.length) {
83
+ out.push('- Package ecosystems analysed:');
84
+ for (const e of dependencies) {
85
+ out.push(` - \`${e.name}\` via \`${e.manifest}\` — ${e.declaredCount} declared, lock: ${e.lock ? '`' + e.lock + '`' : '**MISSING**'}, audit: \`${e.auditCmd}\``);
86
+ }
87
+ }
88
+ if (coverage.skipped && coverage.skipped.length) {
89
+ out.push('- Skipped (sample, up to 50):');
90
+ for (const s of coverage.skipped.slice(0, 50)) out.push(` - \`${s.rel}\` — ${s.reason}`);
91
+ }
92
+ const skippedDims = coverage.dimensions.filter((d) => !d.touched).map((d) => d.dimension);
93
+ if (skippedDims.length) {
94
+ out.push(`- Dimensions with no automated signal (require the AI/manual pass from \`CRITIC.md\`): ${skippedDims.join(', ')}`);
95
+ }
96
+ out.push('');
97
+
98
+ // Detailed findings grouped by dimension.
99
+ out.push('## Detailed Findings');
100
+ out.push('');
101
+ if (!findings.length) {
102
+ out.push('No automated findings. Run the AI review pass (`critic init` then “Review this project”) for the qualitative dimensions.');
103
+ }
104
+ for (const sev of SEV_ORDER) {
105
+ const group = findings.filter((f) => f.severity === sev);
106
+ if (!group.length) continue;
107
+ out.push(`### ${sev} (${group.length})`);
108
+ out.push('');
109
+ for (const f of group) {
110
+ out.push(`#### ${f.category.toUpperCase()} — ${f.title}`);
111
+ out.push('');
112
+ out.push('| Field | Detail |');
113
+ out.push('|-------|--------|');
114
+ out.push(`| **Location** | \`${f.file}:${f.line}\` |`);
115
+ out.push(`| **Dimension** | ${f.dimension} |`);
116
+ out.push(`| **Confirmed?** | ${f.confirmed === true ? 'confirmed (code seen)' : (f.confirmed === 'suspected' ? 'suspected (pattern-inferred)' : String(f.confirmed))} |`);
117
+ out.push(`| **Problem** | ${f.problem} |`);
118
+ out.push(`| **Evidence** | \`${escapeCell(f.evidence)}\` |`);
119
+ out.push(`| **Impact** | ${f.impact} |`);
120
+ out.push(`| **Fix** | ${f.fix} |`);
121
+ out.push(`| **Verify** | ${f.verify} |`);
122
+ out.push('');
123
+ }
124
+ }
125
+
126
+ // Action plan — the "what to fix / optimize / delete / add" deliverable.
127
+ out.push('## Prioritised Action Plan');
128
+ out.push('');
129
+ for (const cat of ['fix', 'optimize', 'delete', 'add']) {
130
+ const group = findings.filter((f) => f.category === cat);
131
+ if (!group.length) continue;
132
+ out.push(`### ${CATEGORY_LABEL[cat]} — ${group.length} item(s)`);
133
+ out.push('');
134
+ out.push('| # | Sev | Where | Action | Verify |');
135
+ out.push('|---|-----|-------|--------|--------|');
136
+ group.forEach((f, i) => {
137
+ out.push(`| ${i + 1} | ${f.severity} | \`${f.file}:${f.line}\` | ${escapeCell(f.title)} → ${escapeCell(f.fix)} | ${escapeCell(f.verify)} |`);
138
+ });
139
+ out.push('');
140
+ }
141
+
142
+ out.push('## Final Verdict');
143
+ out.push('');
144
+ out.push(verdictLine(result));
145
+ out.push('');
146
+ out.push('_REVIEW is read-only. To apply a category on request, run the AI in FIX mode against a specific finding block above._');
147
+ return out.join('\n');
148
+ }
149
+
150
+ function verdictStatus(result) {
151
+ const { counts } = result;
152
+ if (counts.BLOCKER + counts.CRITICAL > 0) return 'FAIL';
153
+ if (counts.HIGH > 0) return 'WARN';
154
+ return 'PASS';
155
+ }
156
+ function verdictLine(result) {
157
+ const { counts } = result;
158
+ if (counts.BLOCKER + counts.CRITICAL > 0) return '**Not ready for production** — clear BLOCKER/CRITICAL findings above (mostly security/correctness).';
159
+ if (counts.HIGH > 0) return '**Ready with required fixes** — resolve the HIGH findings before shipping.';
160
+ if (counts.MEDIUM > 0) return '**Ready** — MEDIUM/LOW items scheduled as normal.';
161
+ return '**Clean** — no significant automated findings; run the AI qualitative pass for full 12-dimension coverage.';
162
+ }
163
+ function escapeCell(s) {
164
+ return String(s == null ? '' : s).replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
165
+ }
166
+ function relName(root) {
167
+ const parts = String(root).replace(/[\\/]+$/, '').split(/[\\/]/);
168
+ return parts[parts.length - 1] || root;
169
+ }
170
+
171
+ module.exports = { renderConsole, renderMarkdown, execSummary };