fullstack-critic 1.0.0 → 1.0.2

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 CHANGED
@@ -14,20 +14,17 @@ Claude Code, Gemini CLI, GitHub Copilot, Cursor, Qoder, Antigravity, or any assi
14
14
 
15
15
  ## Install & run (no AI required)
16
16
 
17
- > **Status: not yet on the public npm registry.** `npx fullstack-critic` and
18
- > `npm i -g fullstack-critic` will `404` until this repo is published (`npm publish`).
19
- > Until then, install **from this folder** — the commands below are tested and work.
17
+ Published on npm as **`fullstack-critic`** (v1.0.0). Install globally, or run with
18
+ zero install via `npx` (verified working):
20
19
 
21
20
  ```bash
22
- # From inside this folder — install the CLI globally so `critic` works in any project:
23
- npm install -g . # or: npm link
24
- # or install by path from anywhere:
25
- npm install -g C:\path\to\fullstack-critic
26
-
27
- # No install at all — run straight from the folder:
28
- node bin\fullstack-critic.js review . # Windows
29
- node bin/fullstack-critic.js review . # macOS / Linux
30
- npx --yes /path/to/fullstack-critic review . # npx against a local folder/git
21
+ npm install -g fullstack-critic # or: pnpm add -g / yarn global add / bun add -g
22
+ npx fullstack-critic review . # zero-install, one-shot
23
+
24
+ # Working from a clone instead (offline / developing the critic itself):
25
+ npm install -g . # from inside the cloned folder
26
+ node bin/fullstack-critic.js review . # no install at all
27
+ npx --yes /path/to/fullstack-critic review . # npx against a local folder/git
31
28
  ```
32
29
 
33
30
  ```bash
@@ -1,4 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
- const code = require('../src/cli').run(process.argv.slice(2));
4
- process.exitCode = Number.isFinite(code) ? code : 0;
3
+ const out = require('../src/cli').run(process.argv.slice(2));
4
+ if (out && typeof out.then === 'function') {
5
+ // Async commands (e.g. `watch --dash`) resolve after the process is already
6
+ // kept alive by the watcher; a pending promise simply never resolves.
7
+ out
8
+ .then((code) => { process.exitCode = Number.isFinite(code) ? code : 0; })
9
+ .catch((err) => { process.stderr.write(`${(err && err.stack) || err}\n`); process.exitCode = 1; });
10
+ } else {
11
+ process.exitCode = Number.isFinite(out) ? out : 0;
12
+ }
@@ -17,31 +17,23 @@ It ships two ways to use it:
17
17
 
18
18
  ## Install
19
19
 
20
- The CLI is a zero-dependency Node package (Node ≥ 18).
20
+ The CLI is a zero-dependency Node package (Node ≥ 18), published on npm as
21
+ **`fullstack-critic`** (v1.0.0).
21
22
 
22
- > **Not published to the public npm registry yet.** So `npx fullstack-critic` and
23
- > `npm i -g fullstack-critic` return **404** until this repo is published with `npm publish`.
24
- > Use one of the tested local methods below instead.
23
+ ```bash
24
+ # Recommended — from the public registry:
25
+ npm install -g fullstack-critic # or: pnpm add -g / yarn global add / bun add -g
26
+ npx fullstack-critic review . # zero-install, one-shot
27
+ ```
25
28
 
26
29
  ```bash
27
- # A) Global install from this folder — makes `critic` available in every project:
30
+ # Or work from a clone (offline / developing the critic itself):
28
31
  cd fullstack-critic
29
- npm install -g . # equivalent: npm link
30
-
31
- # B) Install by absolute path from anywhere:
32
- npm install -g C:\path\to\fullstack-critic
33
-
34
- # C) No install — run straight from the folder:
35
- node bin/fullstack-critic.js review .
36
-
37
- # D) pnpm / yarn equivalents:
38
- pnpm add -g ./fullstack-critic
39
- yarn global add file:./fullstack-critic
32
+ npm install -g . # equivalent: npm link
33
+ node bin/fullstack-critic.js review . # no install at all
34
+ pnpm add -g ./fullstack-critic # pnpm / yarn equivalents
40
35
  ```
41
36
 
42
- > To make `npx fullstack-critic` / `npm i -g fullstack-critic` work for everyone,
43
- > a maintainer must run `npm publish` from this folder first (see “Publishing”).
44
-
45
37
  Once installed it provides the `critic` (and `fullstack-critic`) command.
46
38
 
47
39
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstack-critic",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "A free, universal principal-engineer critic. Attach it to any project or run it as a background watcher over any in-progress workflow. Reviews 100% of resources — code, packages, and dependencies — across 12 dimensions and emits an evidence-based report with a fix / optimize / delete / add action plan.",
5
5
  "keywords": [
6
6
  "code-review",
package/src/analyzer.js CHANGED
@@ -42,6 +42,7 @@ function scanFile(file) {
42
42
  // Whole-file suppression: a `critic-ignore-file` marker in the header (for rule-definition files).
43
43
  if (/critic-ignore-file/.test(lines.slice(0, 2).join('\n'))) return { findings, lines: lines.length, scannable: true };
44
44
  const perRule = {};
45
+ const ctx = { rel: file.rel };
45
46
 
46
47
  for (const rule of LINE_RULES) {
47
48
  if (!ruleApplies(rule, file.ext)) continue;
@@ -50,7 +51,7 @@ function scanFile(file) {
50
51
  const line = lines[i];
51
52
  if (!rule.test.test(line)) continue;
52
53
  if (/critic-ignore/.test(line)) continue; // inline suppression: `// critic-ignore` (like eslint-disable-line)
53
- if (rule.skipIf && rule.skipIf(line)) continue;
54
+ if (rule.skipIf && rule.skipIf(line, ctx)) continue;
54
55
  perRule[rule.id]++;
55
56
  if (perRule[rule.id] > CAP_PER_RULE) continue;
56
57
  findings.push({
@@ -92,6 +93,22 @@ function scanFile(file) {
92
93
  fix: 'Split along responsibility boundaries.', verify: 'each module has one reason to change', confirmed: true,
93
94
  });
94
95
  }
96
+ // React: list rendering without a key prop (reconciliation + focus bugs).
97
+ if (['.jsx', '.tsx', '.js'].includes(file.ext)) {
98
+ for (let i = 0; i < lines.length; i++) {
99
+ const m = lines[i].match(/\.map\s*\(\s*\(?([A-Za-z$_][\w$]*)\)?\s*=>\s*\(?\s*<[A-Za-z]/);
100
+ if (m && !/\bkey\s*=|\{\.\.\.[a-z]|critic-ignore/.test(lines.slice(i, i + 4).join(' '))) {
101
+ findings.push({
102
+ ruleId: 'missing-list-key', severity: 'LOW', category: 'fix', dimension: 'Frontend',
103
+ title: 'List rendered without a key prop', file: file.rel, line: i + 1,
104
+ problem: `.map() returns JSX without an explicit key (var ${m[1]}).`,
105
+ evidence: snippet(lines[i]), impact: 'Wrong item reconciliation, lost focus/state on reorder.',
106
+ fix: 'Add key={item.id} (stable identity, not the array index).',
107
+ verify: 'React dev console shows no key warnings for this list.', confirmed: 'suspected',
108
+ });
109
+ }
110
+ }
111
+ }
95
112
  return { findings, lines: lines.length, scannable: true };
96
113
  }
97
114
 
@@ -157,6 +174,54 @@ function structuralFindings(root, files, ctx) {
157
174
  fix: 'Add purpose, stack, setup, run, and test instructions.', verify: 'a new contributor can run the app from README alone.', confirmed: true,
158
175
  });
159
176
  }
177
+
178
+ // Packaging hygiene (npm manifests).
179
+ const pkgPath = path.join(root, 'package.json');
180
+ if (exists(pkgPath)) {
181
+ let pkg = null;
182
+ try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); } catch { /* reported by deps parse */ }
183
+ if (pkg) {
184
+ if (!Array.isArray(pkg.files) && pkg.private !== true) {
185
+ F.push({
186
+ ruleId: 'pkg-no-files-field', severity: 'LOW', category: 'optimize', dimension: 'Infrastructure',
187
+ title: 'Publishable package has no "files" allowlist', file: 'package.json', line: 1,
188
+ problem: 'Without "files", npm packs everything not gitignored — tests, docs, env samples.',
189
+ evidence: `"files" missing; name=${pkg.name || '(unnamed)'}`, impact: 'Bloated tarballs, slower installs, accidental source leakage.',
190
+ fix: 'Add a "files" array listing dist/src/bin and other publishable paths.', verify: 'npm pack --dry-run lists only intended files.', confirmed: true,
191
+ });
192
+ }
193
+ if (!pkg.engines) {
194
+ F.push({
195
+ ruleId: 'pkg-no-engines', severity: 'INFO', category: 'add', dimension: 'Infrastructure',
196
+ title: 'No "engines" field in package.json', file: 'package.json', line: 1,
197
+ problem: 'The supported Node range is undocumented for consumers and CI.',
198
+ evidence: '"engines" missing', impact: 'Runs on unsupported runtimes; env drift.',
199
+ fix: 'Declare "engines": { "node": ">=18" } (or the real supported range).', verify: 'npm install warns on older Node.', confirmed: true,
200
+ });
201
+ }
202
+ }
203
+ }
204
+
205
+ // SPA / server routing: an Express-style server delivering an SPA without a
206
+ // history fallback 404s every deep link on hard refresh.
207
+ const serverRouters = files.filter((f) => /\.(js|ts|mjs|cjs)$/.test(f.ext) && /app\.(get|use)\s*\(|router\.(get|use)\s*\(/.test(readText(f.abs) || ''));
208
+ const hasApiRoutes = serverRouters.some((f) => /\b(api|\/api)\b|\.use\s*\(/.test(readText(f.abs) || ''));
209
+ const hasSpa = rels.has('index.html') || files.some((f) => /\.(jsx|tsx|vue|svelte)$/.test(f.ext)) || files.some((f) => /(^|\/)(src|app|pages)\/(index|App)\.(jsx?|tsx?|vue|svelte)$/i.test(f.rel));
210
+ const hasFallback = serverRouters.some((f) => {
211
+ const t = readText(f.abs) || '';
212
+ return /history\(\)|http\.createServerHandler|connect\(\)\.middleware|\*\s*['")]\s|notFound|sendFile\s*\(\s*(path|__dirname|require\.resolve)/i.test(t) && /app\.(get|use)\s*\(\s*['"]?\*|all\s*\(\s*['"]\*|history|fallback/i.test(t);
213
+ });
214
+ if (hasApiRoutes && hasSpa && !hasFallback && !isGo && !isRust) {
215
+ F.push({
216
+ ruleId: 'spa-no-history-fallback', severity: 'MEDIUM', category: 'fix', dimension: 'Architecture',
217
+ title: 'SPA served by an API server with no history fallback route', file: '.', line: 1,
218
+ problem: 'Client-side routes (/settings, /items/5) have no server handler — direct visits and refreshes 404.',
219
+ evidence: 'express-style routers with api routes + SPA entry files, no catch-all → index.html',
220
+ impact: 'Broken deep links, failed bookmarks, worse SEO and support load.',
221
+ fix: 'Mount a last-resort catch-all that serves index.html for non-GET/api paths (e.g. express-history-api-fallback or app.get("*")).',
222
+ verify: 'refreshing any client route returns 200 with the app.', confirmed: 'suspected',
223
+ });
224
+ }
160
225
  return F;
161
226
  }
162
227
 
package/src/cli.js CHANGED
@@ -11,6 +11,15 @@ const { renderConsole, renderMarkdown } = require('./report');
11
11
  const { watch } = require('./watcher');
12
12
  const { init } = require('./init');
13
13
  const { analyzeDeps } = require('./deps');
14
+ const ui = require('./ui');
15
+
16
+ // Auto-open the browser only when a human is watching: an interactive stdout and
17
+ // not a CI box. The UI file/server is still produced with --open to force it.
18
+ function shouldOpen(args) {
19
+ if (args.flags.open) return true;
20
+ if (args.flags['no-open']) return false;
21
+ return !!process.stdout.isTTY && !process.env.CI;
22
+ }
14
23
 
15
24
  // Map a --fail-on level to the worst-order index that still triggers exit 1.
16
25
  // order index: BLOCKER=0 CRITICAL=1 HIGH=2 MEDIUM=3 LOW=4 INFO=5
@@ -72,20 +81,68 @@ function cmdReview(args) {
72
81
  const p = args.flags.md || args.flags.o;
73
82
  safeWrite(typeof p === 'string' ? p : '.critic-report.md', renderMarkdown(result), 'Report');
74
83
  }
84
+ // Built-in per-run dashboard: a fresh, project-scoped HTML file opened in the
85
+ // browser. Skipped with --no-ui; browser-launch gated by TTY/CI (see shouldOpen).
86
+ if (!args.flags['no-ui']) {
87
+ const r = ui.writeStaticAndOpen(result, { open: shouldOpen(args) });
88
+ if (r.file) process.stdout.write(`\x1b[36mUI report:\x1b[0m ${r.file}\n`);
89
+ else if (r.error) process.stderr.write(`could not produce UI report: ${r.error}\n`);
90
+ }
75
91
  return exitCodeFor(result, args.flags['fail-on']);
76
92
  }
77
93
 
78
- function cmdWatch(args) {
94
+ async function cmdWatch(args) {
79
95
  const target = targetOf(args);
96
+ let observer = null;
97
+ if (args.flags.dash) {
98
+ const obs = require('./observatory');
99
+ const wanted = Number(args.flags.port) || obs.DEFAULT_PORT;
100
+ process.stdout.write(`\x1b[90m[observatory] ensuring dashboard on port ${wanted}…\x1b[0m\n`);
101
+ const { port, reused } = await obs.ensureServer({ port: wanted });
102
+ observer = obs.createAdapter({ port, root: target, cliVersion: readVersion() });
103
+ await observer.open();
104
+ if (shouldOpen(args)) obs.openBrowser(`http://127.0.0.1:${port}/`);
105
+ process.stdout.write(
106
+ `\x1b[36m[observatory] streaming this watch → http://127.0.0.1:${port}/ \u00b7 ${reused ? 'reused running dashboard' : 'started new dashboard'}\x1b[0m\n`
107
+ );
108
+ }
109
+ // Built-in per-run live dashboard (default). Single process, single project,
110
+ // re-rendered every pass — no shared history, so nothing to clear between runs.
111
+ let dash = null;
112
+ if (!args.flags['no-ui']) {
113
+ dash = await ui.startLive(target, { port: Number(args.flags['ui-port']) || undefined });
114
+ if (dash) {
115
+ if (shouldOpen(args)) require('./observatory').openBrowser(dash.url);
116
+ process.stdout.write(`\x1b[36mUI (live):\x1b[0m ${dash.url}\n`);
117
+ }
118
+ }
119
+ let passNo = 0;
120
+ const onPass = (info) => {
121
+ passNo += 1;
122
+ if (dash) { try { dash.set(info.result, { passNo, added: info.added, resolved: info.resolved }); } catch (e) { process.stderr.write(`ui set error: ${e.message}\n`); } }
123
+ if (observer) { try { observer.pass(info); } catch (e) { process.stderr.write(`observatory onPass error: ${e.message}\n`); } }
124
+ };
80
125
  const w = watch(target, {
81
126
  debounceMs: Number(args.flags.debounce) || 400,
82
127
  report: (args.flags['report-file'] != null) ? (typeof args.flags['report-file'] === 'string' ? args.flags['report-file'] : '.critic-report.md') : '.critic-report.md',
83
128
  json: args.flags.json ? (typeof args.flags.json === 'string' ? args.flags.json : '.critic-review.json') : null,
129
+ onPass: (dash || observer) ? onPass : null,
130
+ });
131
+ process.on('SIGINT', () => {
132
+ if (observer) observer.close('sigint');
133
+ if (dash) dash.close();
134
+ w.close();
135
+ process.stdout.write('\nstopped watching.\n');
136
+ process.exit(0);
84
137
  });
85
- process.on('SIGINT', () => { w.close(); process.stdout.write('\nstopped watching.\n'); process.exit(0); });
86
138
  return 0;
87
139
  }
88
140
 
141
+ function readVersion() {
142
+ try { return JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version; }
143
+ catch { return 'unknown'; }
144
+ }
145
+
89
146
  function cmdInit(args) {
90
147
  const target = path.resolve(args._[1] || '.');
91
148
  init(target);
@@ -134,11 +191,18 @@ Flags:
134
191
  --fail-on <level> Exit 1 at this severity: blocker|high|medium|low|never (review default: critical)
135
192
  --no-code Skip code line-scanning (dependencies + structure only)
136
193
  --debounce <ms> Watch mode settle time (default 400)
194
+ --dash Additionally stream this watch into the external 3D Session Observatory
195
+ --port <n> Session Observatory port when using --dash (default 4780)
196
+ --ui-port <n> Base port for the built-in live dashboard in watch mode (default 4781; walks to a free port)
197
+ --no-ui Do not launch the built-in dashboard (review/watch)
198
+ --open Force the browser to open even in non-TTY/CI contexts
199
+ --no-open Generate the dashboard but do not auto-open a browser window
137
200
  --quiet Print only the summary line
138
201
 
139
202
  Examples:
140
203
  npx fullstack-critic review .
141
204
  critic watch ./my-app --md CRITIC_REPORT.md
205
+ critic watch . --dash # live 3D mission control of the critic's work
142
206
  critic init && critic audit
143
207
  `);
144
208
  return 0;
@@ -0,0 +1,204 @@
1
+ 'use strict';
2
+ /**
3
+ * Session Observatory adapter — the opt-in bridge between `critic watch` and a
4
+ * local 3D Session Observatory dashboard (`--dash`).
5
+ *
6
+ * It turns every watch-loop pass into protocol events and streams them to the
7
+ * observatory server over HTTP `POST /ingest`, so a critic run shows up as a
8
+ * living orb whose orbital rings are the analysis passes and findings it just
9
+ * surfaced. Design rules (see plan):
10
+ * - Zero behavior change when `--dash` is absent (this module is only loaded
11
+ * when the flag is present).
12
+ * - Zero dependencies — only Node core (http / child_process / os).
13
+ * - Never block or break the workflow over telemetry: every ingest is
14
+ * fire-and-forget with errors swallowed.
15
+ * - Port policy: reuse a healthy server on the requested port, else spawn one
16
+ * (which itself walks to the next free port), and propagate that exact port.
17
+ */
18
+ const http = require('http');
19
+ const { spawn } = require('child_process');
20
+
21
+ const DEFAULT_PORT = 4780;
22
+ const PORT_SPAN = 10;
23
+ const MAX_FINDING_EVENTS = 60; // cap so a huge diff can't flood the bus
24
+
25
+ /* ------------------------------- transport -------------------------------- */
26
+
27
+ function postEvent(port, event) {
28
+ return new Promise((resolve) => {
29
+ let body;
30
+ try { body = JSON.stringify(event); } catch { return resolve(); }
31
+ const req = http.request(
32
+ {
33
+ host: '127.0.0.1', port, path: '/ingest', method: 'POST', agent: false,
34
+ headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) },
35
+ timeout: 2000,
36
+ },
37
+ (res) => { res.on('data', () => {}); res.on('end', () => resolve()); }
38
+ );
39
+ req.on('error', () => resolve()); // dashboard down → workflow unaffected
40
+ req.on('timeout', () => { req.destroy(); resolve(); });
41
+ req.end(body);
42
+ });
43
+ }
44
+
45
+ function healthProbe(port) {
46
+ return new Promise((resolve) => {
47
+ const req = http.get({ host: '127.0.0.1', port, path: '/health', timeout: 1000, agent: false }, (res) => {
48
+ let data = '';
49
+ res.on('data', (c) => { data += c; });
50
+ res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(null); } });
51
+ });
52
+ req.on('error', () => resolve(null));
53
+ req.on('timeout', () => { req.destroy(); resolve(null); });
54
+ });
55
+ }
56
+
57
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
58
+
59
+ /* ------------------------------ server launch ------------------------------ */
60
+
61
+ function resolveLauncher() {
62
+ // Prefer a locally installed copy; otherwise let npx fetch it on demand.
63
+ try {
64
+ const bin = require.resolve('session-observatory/bin/session-observatory.js');
65
+ return { cmd: process.execPath, prefix: [bin] };
66
+ } catch {
67
+ const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
68
+ return { cmd: npx, prefix: ['--yes', 'session-observatory'] };
69
+ }
70
+ }
71
+
72
+ function spawnServer(port) {
73
+ const { cmd, prefix } = resolveLauncher();
74
+ const args = [...prefix, 'serve', '--port', String(port), '--no-open'];
75
+ try {
76
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true });
77
+ child.on('error', () => { /* npx missing — the poll below just won't find it */ });
78
+ child.unref();
79
+ return child;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Ensure a dashboard is answering, honouring the port policy. Returns
87
+ * { port, reused, spawned }.
88
+ */
89
+ async function ensureServer(opts = {}) {
90
+ const base = Number(opts.port) || DEFAULT_PORT;
91
+ const existing = await healthProbe(base);
92
+ if (existing && existing.product === 'session-observatory') {
93
+ return { port: existing.port || base, reused: true, spawned: null };
94
+ }
95
+
96
+ const spawned = spawnServer(base);
97
+ // Poll a small window: a fresh server may walk up to a neighbouring free port.
98
+ for (let attempt = 0; attempt < 12; attempt++) {
99
+ await sleep(400);
100
+ for (let p = base; p <= base + PORT_SPAN; p++) {
101
+ const h = await healthProbe(p);
102
+ if (h && h.product === 'session-observatory') return { port: h.port || p, reused: false, spawned };
103
+ }
104
+ }
105
+ // Never found — still return the base port so ingest calls fail silently.
106
+ return { port: base, reused: false, spawned };
107
+ }
108
+
109
+ /* --------------------------------- adapter -------------------------------- */
110
+
111
+ function createAdapter(opts = {}) {
112
+ const port = Number(opts.port) || DEFAULT_PORT;
113
+ const root = opts.root || process.cwd();
114
+ const sessionId = opts.sessionId || `critic-${process.pid}`;
115
+ const cliVersion = opts.cliVersion || 'unknown';
116
+ const send = (type, payload, metrics) =>
117
+ postEvent(port, { ts: Date.now(), sessionId, source: 'critic', type, payload: payload || {}, metrics });
118
+
119
+ return {
120
+ sessionId,
121
+ port,
122
+
123
+ open() {
124
+ return send('session.open', {
125
+ label: 'fullstack-critic watch',
126
+ root,
127
+ pid: process.pid,
128
+ cli: cliVersion,
129
+ cwd: process.cwd(),
130
+ });
131
+ },
132
+
133
+ /**
134
+ * Called once per watch pass. Emits an analysis background batch, the
135
+ * finding-added / finding-fixed diff, and a metrics snapshot so the HUD can
136
+ * show severity counts and a live rss/cpu heartbeat.
137
+ */
138
+ pass(info = {}) {
139
+ const result = info.result;
140
+ if (!result) return Promise.resolve();
141
+ const events = [];
142
+ const meta = result.meta || {};
143
+ const cov = result.coverage || {};
144
+ events.push(send('bg.batch', {
145
+ category: 'analysis', count: 1,
146
+ durationMs: meta.durationMs || 0, filesScanned: cov.filesScanned || 0,
147
+ }));
148
+
149
+ const added = (info.added || []).slice(0, MAX_FINDING_EVENTS);
150
+ const resolved = (info.resolved || []).slice(0, MAX_FINDING_EVENTS);
151
+ for (const f of added) {
152
+ events.push(send('bg.batch', {
153
+ category: 'analysis', kind: 'finding-added', count: 1,
154
+ ruleId: f.ruleId, severity: f.severity, file: f.file, line: f.line, message: f.title,
155
+ }));
156
+ }
157
+ for (const f of resolved) {
158
+ events.push(send('bg.batch', {
159
+ category: 'analysis', kind: 'finding-fixed', count: 1,
160
+ ruleId: f.ruleId, severity: f.severity, file: f.file, line: f.line, message: f.title,
161
+ }));
162
+ }
163
+
164
+ const counts = result.counts || {};
165
+ const mem = process.memoryUsage();
166
+ events.push(send('metrics', {}, {
167
+ critical: (counts.CRITICAL || 0) + (counts.BLOCKER || 0),
168
+ high: counts.HIGH || 0,
169
+ medium: counts.MEDIUM || 0,
170
+ low: counts.LOW || 0,
171
+ info: counts.INFO || 0,
172
+ total: (result.findings || []).length,
173
+ filesScanned: cov.filesScanned || 0,
174
+ passMs: meta.durationMs || 0,
175
+ rssMb: Math.round(mem.rss / 1048576),
176
+ }));
177
+ return Promise.all(events);
178
+ },
179
+
180
+ close(reason) {
181
+ return send('session.close', { reason: reason || 'watch-stopped' });
182
+ },
183
+ };
184
+ }
185
+
186
+ /* ------------------------------ browser open ------------------------------ */
187
+
188
+ function openBrowser(url) {
189
+ try {
190
+ const platform = process.platform;
191
+ let cmd, args;
192
+ if (platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""', url.replace(/&/g, '^&')]; }
193
+ else if (platform === 'darwin') { cmd = 'open'; args = [url]; }
194
+ else { cmd = 'xdg-open'; args = [url]; }
195
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true });
196
+ child.on('error', () => {});
197
+ child.unref();
198
+ return true;
199
+ } catch {
200
+ return false;
201
+ }
202
+ }
203
+
204
+ module.exports = { ensureServer, createAdapter, openBrowser, postEvent, healthProbe, DEFAULT_PORT };
package/src/report.js CHANGED
@@ -47,11 +47,42 @@ function renderConsole(result) {
47
47
  lines.push('Action plan: ' +
48
48
  `${categoryCounts.fix} fix · ${categoryCounts.optimize} optimize · ${categoryCounts.delete} delete · ${categoryCounts.add} add`);
49
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) {
50
+
51
+ // Group every finding by dimension so all severity levels — not just blocking —
52
+ // are visible in the terminal (packaging, routing, API, validation, frontend, backend…).
53
+ const byDim = {};
54
+ for (const f of result.findings) (byDim[f.dimension] || (byDim[f.dimension] = [])).push(f);
55
+ const orderedDims = [
56
+ ...DIMENSIONS.filter((d) => byDim[d]),
57
+ ...Object.keys(byDim).filter((d) => !DIMENSIONS.includes(d)),
58
+ ];
59
+
60
+ if (orderedDims.length) {
61
+ lines.push('');
62
+ lines.push('Findings by dimension (all severities):');
63
+ for (const d of orderedDims) {
64
+ const ds = byDim[d];
65
+ const parts = SEV_ORDER.filter((s) => ds.some((f) => f.severity === s))
66
+ .map((s) => `${ds.filter((f) => f.severity === s).length} ${s}`)
67
+ .join(' · ');
68
+ lines.push(` ${d.padEnd(20)} ${parts}`);
69
+ }
70
+ }
71
+
72
+ const verbose = process.env.CRITIC_VERBOSE ? Number(process.env.CRITIC_VERBOSE) : 1;
73
+ const PER_DIM_CAP = verbose >= 2 ? Infinity : 15;
74
+ for (const d of orderedDims) {
75
+ const ds = byDim[d];
76
+ lines.push('');
77
+ lines.push(`${d} — ${ds.length} finding(s)${ds.length > PER_DIM_CAP ? ` (showing ${PER_DIM_CAP}, worst first; --md for all)` : ''}:`);
78
+ // Findings arrive pre-sorted by severity → file → line; keep that order.
79
+ for (const f of ds.slice(0, PER_DIM_CAP)) {
80
+ lines.push(` [${f.severity}] ${f.file}:${f.line} ${f.title}`);
81
+ }
82
+ }
83
+ if (result.findings.length) {
52
84
  lines.push('');
53
- lines.push('Top blocking findings:');
54
- for (const f of top) lines.push(` [${f.severity}] ${f.file}:${f.line} ${f.title}`);
85
+ lines.push('Run with --md <file> for the full evidence, impact & fix per finding.');
55
86
  }
56
87
  return lines.join('\n');
57
88
  }
package/src/rules.js CHANGED
@@ -177,6 +177,121 @@ const LINE_RULES = [
177
177
  fix: 'Add keyset/cursor pagination or an explicit LIMIT.',
178
178
  verify: 'endpoint returns a bounded page size.',
179
179
  },
180
+
181
+ // ---- API design ---------------------------------------------------------
182
+ {
183
+ id: 'api-numbered-endpoints', severity: 'INFO', category: 'add', dimension: 'API design',
184
+ title: 'Verb-in-URL endpoint (non-RESTful routing)',
185
+ ext: ['.js', '.ts', '.mjs', '.cjs', '.py', '.rb', '.php', '.java', '.go'],
186
+ test: /\.(get|post|put|patch|delete|route)\s*\(\s*["'][^"']*\/(create|update|delete|get|fetch|list|add|remove|edit)[A-Za-z]*/i,
187
+ problem: 'Endpoint path embeds the action verb instead of relying on the HTTP method.',
188
+ impact: 'Inconsistent contract; harder to version and document.',
189
+ fix: 'Use method + noun (POST /orders, PATCH /orders/:id, DELETE /orders/:id).',
190
+ verify: 'route list contains no verb segments.',
191
+ },
192
+ {
193
+ id: 'fetch-unhandled', severity: 'MEDIUM', category: 'fix', dimension: 'API design',
194
+ title: 'Network call without error handling (.ok/.catch missing)',
195
+ ext: ['.js', '.ts', '.jsx', '.tsx', '.mjs'],
196
+ test: /\bfetch\s*\(/,
197
+ skipIf: (line) => /\.ok\b|\.catch\s*\(|try\s*\{|await\s+safe|\bthen\s*\([^)]*\)\s*\.catch|critic-ignore/.test(line),
198
+ problem: 'fetch resolves on 4xx/5xx; without .ok or .catch failures are silently ignored.',
199
+ impact: 'Failed requests render empty/broken UIs instead of error states.',
200
+ fix: 'Check res.ok and wrap in try/catch, or route through one client that does.',
201
+ verify: 'a 500 response surfaces the error path in the UI/logs.',
202
+ },
203
+
204
+ // ---- Validation & routing (security-adjacent) ---------------------------
205
+ {
206
+ id: 'open-redirect', severity: 'MEDIUM', category: 'fix', dimension: 'Security',
207
+ title: 'Redirect target built from request input',
208
+ ext: ['.js', '.ts', '.mjs', '.cjs', '.py', '.rb', '.php'],
209
+ test: /redirect\s*\([^)]*(req\.(query|params|body)|location\.search|URLSearchParams|get\s*\(\s*["']next)/,
210
+ problem: 'A user-controlled value decides where the browser is sent.',
211
+ impact: 'Open redirect — phishing via a trusted domain.',
212
+ fix: 'Validate against an allowlist of internal paths; reject absolute URLs.',
213
+ verify: 'redirect to an external host is rejected.',
214
+ },
215
+ {
216
+ id: 'form-no-validation', severity: 'MEDIUM', category: 'fix', dimension: 'Correctness',
217
+ title: 'Form submit without validation',
218
+ ext: ['.jsx', '.tsx', '.js', '.ts', '.html', '.vue', '.svelte'],
219
+ test: /<form\b[^>]*\bonsubmit\s*=/i,
220
+ skipIf: (line) => /validate|isValid|\.checkValidity|zod|yup|joi|required/i.test(line),
221
+ problem: 'Submit path accepts whatever the fields contain.',
222
+ impact: 'Malformed or hostile data reaches the API; server must never be the only gate.',
223
+ fix: 'Validate on submit and surface field errors (or a schema library).',
224
+ verify: 'empty/invalid submit is blocked with messages.',
225
+ },
226
+ {
227
+ id: 'novalidate-form', severity: 'LOW', category: 'fix', dimension: 'Correctness',
228
+ title: 'Form disables browser validation',
229
+ test: /<form[^>]*novalidate/i,
230
+ skipIf: (line) => /customValidity|custom-validation|validateForm/i.test(line),
231
+ problem: 'novalidate turns off built-in required/type checks.',
232
+ impact: 'Users submit invalid values unless a custom validator fully replaces them.',
233
+ fix: 'Remove novalidate, or guarantee an equivalent custom validation path.',
234
+ verify: 'invalid field blocks submit.',
235
+ },
236
+
237
+ // ---- Frontend & a11y ------------------------------------------------------
238
+ {
239
+ id: 'a11y-clickable-div', severity: 'MEDIUM', category: 'fix', dimension: 'Frontend',
240
+ title: 'Click handler on a non-interactive element',
241
+ ext: ['.jsx', '.tsx', '.html', '.vue', '.svelte'],
242
+ test: /<(div|span|img|li|p)\b[^>]*\son(Click|KeyPress|KeyDown)\s*=/,
243
+ skipIf: (line) => /role\s*=|tabIndex/i.test(line),
244
+ problem: 'Interactive behaviour lives on an element keyboards and screen readers skip.',
245
+ impact: 'Primary actions unreachable for keyboard/AT users.',
246
+ fix: 'Use <button> or add role="button" + tabIndex + key handling.',
247
+ verify: 'the action is triggerable by Tab + Enter.',
248
+ },
249
+ {
250
+ id: 'img-missing-alt', severity: 'LOW', category: 'fix', dimension: 'Frontend',
251
+ title: 'Image without alt text',
252
+ ext: ['.jsx', '.tsx', '.html', '.vue', '.svelte'],
253
+ test: /<img\b(?![^>]*alt\s*=)[^>]*src=/i,
254
+ problem: '<img> lacks an alt attribute.',
255
+ impact: 'Screen readers announce nothing; Lighthouse a11y failures.',
256
+ fix: 'Add meaningful alt, or alt="" for decorative images.',
257
+ verify: 'axe/Lighthouse a11y passes the images rule.',
258
+ },
259
+ {
260
+ id: 'plain-img-next', severity: 'LOW', category: 'optimize', dimension: 'Frontend',
261
+ title: 'Plain <img> in Next.js (next/image preferred)',
262
+ ext: ['.jsx', '.tsx'],
263
+ test: /<img\s/,
264
+ skipIf: (line) => /next\/image|<Image\b/i.test(line),
265
+ problem: 'Hand-written <img> skips automatic resizing, lazy-loading and format negotiation.',
266
+ impact: 'Wasted bytes and worse LCP — the metric that decides search rank.',
267
+ fix: 'Use next/image with explicit width/height.',
268
+ verify: 'Lighthouse image credits improve.',
269
+ },
270
+ {
271
+ id: 'default-props-function', severity: 'LOW', category: 'optimize', dimension: 'Frontend',
272
+ title: 'defaultProps on function components (deprecated)',
273
+ ext: ['.jsx', '.tsx'],
274
+ test: /\.defaultProps\s*=/,
275
+ problem: 'React removed defaultProps support for function components.',
276
+ impact: 'Defaults silently stop applying on React 19+.',
277
+ fix: 'Use ES parameter defaults.',
278
+ verify: 'no .defaultProps assignments remain.',
279
+ },
280
+
281
+ // ---- Backend on async runtimes -------------------------------------------
282
+ {
283
+ id: 'sync-io-in-handler', severity: 'MEDIUM', category: 'fix', dimension: 'Backend',
284
+ title: 'Synchronous fs call inside a request handler',
285
+ ext: ['.js', '.ts', '.mjs', '.cjs'],
286
+ test: /fs\.(readFileSync|writeFileSync|appendFileSync|readdirSync|existsSync|statSync)\s*\(/,
287
+ // Only flag inside request-handler surfaces (routes/api/serverless handlers),
288
+ // where the block stalls every other in-flight request; scripts/CLI use sync fs freely.
289
+ skipIf: (line, ctx) => !(ctx && /(^|[\\/])(routes?|api|controllers?|handlers?|serverless|\.\/(pages|app)\/api|[\\/]pages\/api[\\/])/i.test(ctx.rel || '')),
290
+ problem: 'Blocking fs freezes the event loop for every other in-flight request.',
291
+ impact: 'Tail latency spikes and throughput collapse under load.',
292
+ fix: 'Use fs.promises / the async API, or preload at startup.',
293
+ verify: 'load test shows unchanged p95 while the file path is hit.',
294
+ },
180
295
  ];
181
296
 
182
297
  module.exports = { LINE_RULES, PLACEHOLDER };
package/src/ui.js ADDED
@@ -0,0 +1,297 @@
1
+ 'use strict';
2
+ /**
3
+ * Built-in per-run critic dashboard. Zero-dependency (Node core only).
4
+ *
5
+ * Why this exists: the only previous UI was the opt-in `--dash` bridge into an
6
+ * external, *shared* Session Observatory server that accumulates every session,
7
+ * so it visually mixes reports from other projects. This module is instead
8
+ * single-tenant: each `critic review`/`critic watch` process owns its own state,
9
+ * scoped to exactly one project. Starting a new run therefore always shows fresh
10
+ * data (there is nothing to clear because nothing is shared or persisted).
11
+ *
12
+ * - review -> writeStaticAndOpen(): one self-contained HTML file in a per-run
13
+ * temp path (unique + titled with the project), opened in the OS
14
+ * browser. No server left running; never written into the repo.
15
+ * - watch -> startLive(): an in-memory HTTP server on a verified free port
16
+ * that re-renders on every pass. Live updates; no file in the repo.
17
+ *
18
+ * Port policy: probe from a base port and walk to the next bindable port.
19
+ * Never block or break the workflow: any UI error is swallowed to a warning.
20
+ */
21
+ const http = require('http');
22
+ const fs = require('fs');
23
+ const os = require('os');
24
+ const path = require('path');
25
+ const net = require('net');
26
+ const { DIMENSIONS } = require('./analyzer');
27
+ const { execSummary } = require('./report');
28
+ const { openBrowser } = require('./observatory');
29
+
30
+ const SEV_ORDER = ['BLOCKER', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'];
31
+ const UI_BASE_PORT = 4781; // distinct from the observatory's 4780
32
+ const PORT_SPAN = 15;
33
+
34
+ function relName(root) {
35
+ const parts = String(root).replace(/[\\/]+$/, '').split(/[\\/]/);
36
+ return parts[parts.length - 1] || String(root);
37
+ }
38
+
39
+ /* ------------------------------ snapshot model ---------------------------- */
40
+
41
+ function buildSnapshot(result, extra = {}) {
42
+ const counts = result.counts || {};
43
+ const cat = result.categoryCounts || {};
44
+ const cov = result.coverage || {};
45
+ const findings = result.findings || [];
46
+
47
+ const byDim = {};
48
+ for (const f of findings) (byDim[f.dimension] || (byDim[f.dimension] = [])).push(f);
49
+ const dimensions = DIMENSIONS.map((d) => {
50
+ const ds = byDim[d] || [];
51
+ const worst = SEV_ORDER.find((s) => ds.some((f) => f.severity === s));
52
+ const status = !ds.length ? 'REVIEW'
53
+ : (worst === 'BLOCKER' || worst === 'CRITICAL' || worst === 'HIGH') ? 'FAIL'
54
+ : (worst === 'MEDIUM' || worst === 'LOW') ? 'WARN' : 'INFO';
55
+ return { dimension: d, status, count: ds.length, worst: worst || '-' };
56
+ });
57
+
58
+ return {
59
+ empty: false,
60
+ project: relName((result.meta && result.meta.root) || ''),
61
+ root: (result.meta && result.meta.root) || '',
62
+ generatedAt: new Date().toISOString(),
63
+ cliVersion: (result.meta && result.meta.cliVersion) || 'unknown',
64
+ durationMs: (result.meta && result.meta.durationMs) || 0,
65
+ passNo: extra.passNo || 1,
66
+ addedCount: (extra.added || []).length,
67
+ resolvedCount: (extra.resolved || []).length,
68
+ verdict: execSummary(result),
69
+ counts,
70
+ categoryCounts: cat,
71
+ coverage: {
72
+ filesScanned: cov.filesScanned || 0,
73
+ totalLines: cov.totalLines || 0,
74
+ filesSkipped: cov.filesSkipped || 0,
75
+ },
76
+ ecosystems: (result.dependencies || []).map((e) => ({
77
+ name: e.name, manifest: e.manifest, declaredCount: e.declaredCount, lock: e.lock || null,
78
+ })),
79
+ dimensions,
80
+ findings: findings.map((f) => ({
81
+ file: f.file, line: f.line, severity: f.severity, category: f.category,
82
+ dimension: f.dimension, title: f.title, problem: f.problem, fix: f.fix, evidence: f.evidence,
83
+ })),
84
+ };
85
+ }
86
+
87
+ function emptySnapshot(root) {
88
+ return {
89
+ empty: true, project: relName(root), root, generatedAt: new Date().toISOString(),
90
+ passNo: 0, counts: {}, categoryCounts: {}, findings: [], dimensions: [], ecosystems: [],
91
+ };
92
+ }
93
+
94
+ /* ------------------------------- HTML shell ------------------------------- */
95
+
96
+ // NOTE: the embedded client script intentionally avoids backticks/${} so it can
97
+ // live inside this file's template literals without clashing with Node's parser.
98
+ function renderHtml(data, opts = {}) {
99
+ const live = opts.live ? 'true' : 'false';
100
+ const payload = JSON.stringify(data).replace(/</g, '\\u003c');
101
+ return `<!doctype html>
102
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
103
+ <title>Full-Stack Critic — ${escAttr(data.project)}</title>
104
+ <link rel="icon" href="data:,">
105
+ <style>
106
+ :root{--bg:#0b1020;--card:#151b2e;--ink:#e6ecff;--mut:#93a0c2;--line:#26304a;--critic:#ff5470;--high:#ff9f43;--med:#ffd166;--low:#8bd450;--info:#63b3ed;--ok:#3ddc97;}
107
+ *{box-sizing:border-box}
108
+ body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 system-ui,Segoe UI,Roboto,Arial,sans-serif}
109
+ header{padding:22px 26px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#111834,#0b1020)}
110
+ h1{margin:0;font-size:20px;letter-spacing:.3px}
111
+ h1 .proj{color:var(--info)}
112
+ .sub{color:var(--mut);margin-top:6px;font-size:12.5px}
113
+ main{padding:22px 26px;max-width:1080px;margin:0 auto}
114
+ .verdict{font-size:16px;font-weight:600;padding:12px 16px;border-radius:10px;background:var(--card);border:1px solid var(--line);margin-bottom:18px}
115
+ .grid{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:18px}
116
+ .chip{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 14px;min-width:78px}
117
+ .chip b{display:block;font-size:22px}
118
+ .chip span{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.6px}
119
+ .sev-CRITICAL,.sev-BLOCKER{color:var(--critic)} .sev-HIGH{color:var(--high)} .sev-MEDIUM{color:var(--med)} .sev-LOW{color:var(--low)} .sev-INFO{color:var(--info)}
120
+ h2{font-size:14px;text-transform:uppercase;letter-spacing:.8px;color:var(--mut);margin:26px 0 10px}
121
+ table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
122
+ th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);vertical-align:top}
123
+ th{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.5px}
124
+ tr:last-child td{border-bottom:none}
125
+ code{background:#0c1226;border:1px solid var(--line);border-radius:5px;padding:1px 6px;color:#cdd8ff;font-size:12px}
126
+ .tag{display:inline-block;font-size:10.5px;font-weight:700;padding:2px 8px;border-radius:999px;border:1px solid var(--line);text-transform:uppercase;letter-spacing:.4px}
127
+ .cat-fix{color:var(--critic)} .cat-optimize{color:var(--high)} .cat-delete{color:var(--med)} .cat-add{color:var(--ok)}
128
+ .st-FAIL{background:rgba(255,84,112,.14);color:var(--critic)} .st-WARN{background:rgba(255,209,102,.14);color:var(--med)} .st-INFO{background:rgba(99,179,237,.14);color:var(--info)} .st-REVIEW{background:rgba(147,160,194,.14);color:var(--mut)}
129
+ details{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin-bottom:10px}
130
+ summary{cursor:pointer;font-weight:600}
131
+ .loc{color:var(--mut);font-size:12px;margin-left:8px}
132
+ .field{margin:8px 0} .field .k{color:var(--mut);font-size:11px;text-transform:uppercase}
133
+ footer{color:var(--mut);font-size:11.5px;padding:20px 26px;border-top:1px solid var(--line);margin-top:24px}
134
+ .pulse{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--ok);margin-right:6px;animation:p 1.4s infinite}
135
+ @keyframes p{0%,100%{opacity:.3}50%{opacity:1}}
136
+ </style></head>
137
+ <body>
138
+ <header>
139
+ <h1>Full-Stack Critic <span class="proj" id="h-proj"></span></h1>
140
+ <div class="sub" id="h-sub"></div>
141
+ </header>
142
+ <main>
143
+ <div class="verdict" id="verdict"></div>
144
+ <div class="grid" id="counts"></div>
145
+ <h2>12-Dimension Executive Summary</h2>
146
+ <table id="dims"><thead><tr><th>Dimension</th><th>Status</th><th>Findings</th><th>Worst</th></tr></thead><tbody></tbody></table>
147
+ <h2>Prioritised Action Plan</h2>
148
+ <div id="plan"></div>
149
+ <h2>All Findings</h2>
150
+ <div id="findings"></div>
151
+ <h2>Resources & Dependencies</h2>
152
+ <div id="deps"></div>
153
+ </main>
154
+ <footer id="foot"></footer>
155
+ <script>
156
+ var INITIAL = ${payload};
157
+ var LIVE = ${live};
158
+ var SEV = ['BLOCKER','CRITICAL','HIGH','MEDIUM','LOW','INFO'];
159
+ var CATS = [['fix','FIX — correctness & security'],['optimize','OPTIMIZE — performance & structure'],['delete','DELETE — dead code & debug'],['add','ADD — tests, config & docs']];
160
+ function el(t,c,x){var e=document.createElement(t);if(c)e.className=c;if(x!=null)e.textContent=x;return e;}
161
+ function render(d){
162
+ document.getElementById('h-proj').textContent = '— ' + (d.project||'');
163
+ var sub = (d.root||'') + ' · ' + (d.generatedAt? new Date(d.generatedAt).toLocaleString():'');
164
+ if(d.empty){ sub = 'waiting for first analysis pass…'; }
165
+ else if(LIVE){ sub += ' · live pass #' + d.passNo + ' (+'+d.addedCount+' new / -'+d.resolvedCount+' fixed)'; }
166
+ document.getElementById('h-sub').textContent = sub;
167
+
168
+ var v=document.getElementById('verdict'); v.textContent='';
169
+ if(d.empty){ v.appendChild(el('span',null,'Analysis starting…')); return; }
170
+ v.appendChild(el('span','pulse'));
171
+ v.appendChild(el('span',null,d.verdict||''));
172
+
173
+ var c=document.getElementById('counts'); c.textContent='';
174
+ SEV.forEach(function(s){ if(!d.counts[s]) return; var chip=el('div','chip'); chip.appendChild(el('b','sev-'+s,String(d.counts[s]))); chip.appendChild(el('span',null,s)); c.appendChild(chip); });
175
+ ['fix','optimize','delete','add'].forEach(function(k){ if(!d.categoryCounts[k]) return; var chip=el('div','chip'); chip.appendChild(el('b','cat-'+k,String(d.categoryCounts[k]))); chip.appendChild(el('span',null,'to '+k)); c.appendChild(chip); });
176
+
177
+ var tb=document.querySelector('#dims tbody'); tb.textContent='';
178
+ (d.dimensions||[]).forEach(function(x){ var tr=el('tr'); tr.appendChild(el('td',null,x.dimension)); var st=el('td'); st.appendChild(el('span','tag st-'+x.status,x.status)); tr.appendChild(st); tr.appendChild(el('td',null,String(x.count))); tr.appendChild(el('td','sev-'+(x.worst||''),''),x.worst==='-'?null:x.worst); if(x.worst==='-')tr.lastChild.textContent='—'; tb.appendChild(tr); });
179
+
180
+ var p=document.getElementById('plan'); p.textContent='';
181
+ CATS.forEach(function(pair){
182
+ var key=pair[0]; var items=(d.findings||[]).filter(function(f){return f.category===key;});
183
+ if(!items.length) return;
184
+ var h=el('h3','cat-'+key, pair[1]+' — '+items.length+' item(s)'); p.appendChild(h);
185
+ var t=el('table'); var thead=el('thead'); var hr=el('tr');
186
+ ['Sev','Where','Action','Fix'].forEach(function(x){hr.appendChild(el('th',null,x));}); thead.appendChild(hr); t.appendChild(thead);
187
+ var body=el('tbody');
188
+ items.slice(0,200).forEach(function(f){ var tr=el('tr'); tr.appendChild(el('td','sev-'+f.severity,f.severity)); tr.appendChild(el('td')).appendChild(el('code',null,f.file+':'+f.line)); tr.appendChild(el('td',null,f.title)); tr.appendChild(el('td',null,f.fix||f.problem||'')); body.appendChild(tr); });
189
+ t.appendChild(body); p.appendChild(t);
190
+ });
191
+
192
+ var fd=document.getElementById('findings'); fd.textContent='';
193
+ if(!(d.findings||[]).length){ fd.appendChild(el('p','sub','No automated findings.')); }
194
+ (d.findings||[]).slice(0,400).forEach(function(f){
195
+ var det=el('details'); var sum=el('summary'); sum.appendChild(el('span','sev-'+f.severity,'['+f.severity+'] ')); sum.appendChild(document.createTextNode(f.title)); sum.appendChild(el('span','loc',f.file+':'+f.line+' · '+f.dimension+' · '+f.category)); det.appendChild(sum);
196
+ [['Problem',f.problem],['Evidence',f.evidence],['Fix',f.fix]].forEach(function(pair){ if(!pair[1])return; var div=el('div','field'); div.appendChild(el('div','k',pair[0])); div.appendChild(el('code',null,pair[1])); det.appendChild(div); });
197
+ fd.appendChild(det);
198
+ });
199
+
200
+ var dp=document.getElementById('deps'); dp.textContent='';
201
+ var info=el('p','sub','Scanned '+d.coverage.filesScanned+' files · '+Number(d.coverage.totalLines).toLocaleString()+' lines · '+d.coverage.filesSkipped+' counted-but-skipped resources · '+(d.ecosystems||[]).length+' package ecosystem(s).');
202
+ dp.appendChild(info);
203
+ if((d.ecosystems||[]).length){ var t=el('table'); var body=el('tbody');
204
+ d.ecosystems.forEach(function(e){ var tr=el('tr'); tr.appendChild(el('td',null,e.name)); tr.appendChild(el('td')).appendChild(el('code',null,e.manifest)); tr.appendChild(el('td',null,String(e.declaredCount)+' declared')); var lk=el('td'); if(e.lock){lk.appendChild(el('code',null,e.lock));}else{lk.appendChild(el('span','tag st-FAIL','NO LOCKFILE'));} tr.appendChild(lk); body.appendChild(tr); });
205
+ t.appendChild(body); dp.appendChild(t); }
206
+
207
+ document.getElementById('foot').textContent = 'Full-Stack Critic v'+(d.cliVersion||'')+' · read-only REVIEW · this dashboard is scoped to this single run/project ('+(d.root||'')+') and holds no history from other projects.';
208
+ }
209
+ render(INITIAL);
210
+ function offline(){ var v=document.getElementById('verdict'); if(v){ v.textContent=''; v.appendChild(el('span',null,'Dashboard disconnected - the critic watch process is no longer serving. Restart it to resume live updates.')); } }
211
+ if(LIVE){ setInterval(function(){ fetch('/data',{cache:'no-store'}).then(function(r){return r.json();}).then(render).catch(offline); }, 1500); }
212
+ function esc(s){return String(s==null?'':s);}
213
+ </script>
214
+ </body></html>`;
215
+ }
216
+
217
+ function escAttr(s) {
218
+ return String(s == null ? '' : s).replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
219
+ }
220
+
221
+ /* ------------------------------ free port probe --------------------------- */
222
+
223
+ function pickFreePort(base, span) {
224
+ const b = Number(base) || UI_BASE_PORT;
225
+ const s = Number(span) || PORT_SPAN;
226
+ return new Promise((resolve) => {
227
+ let p = b;
228
+ (function test() {
229
+ if (p > b + s) return resolve(null);
230
+ const probe = net.createServer();
231
+ probe.once('error', () => { p++; test(); });
232
+ probe.once('listening', () => { probe.close(() => resolve(p)); });
233
+ probe.listen(p, '127.0.0.1');
234
+ })();
235
+ });
236
+ }
237
+
238
+ /* ------------------------------- static (review) -------------------------- */
239
+
240
+ function writeStaticAndOpen(result, opts = {}) {
241
+ try {
242
+ const data = buildSnapshot(result, opts.extra || {});
243
+ const dir = path.join(os.tmpdir(), 'fullstack-critic');
244
+ fs.mkdirSync(dir, { recursive: true });
245
+ const stamp = data.generatedAt.replace(/[:.]/g, '-');
246
+ const file = path.join(dir, `${data.project}-${stamp}.html`);
247
+ fs.writeFileSync(file, renderHtml(data, { live: false }));
248
+ const url = 'file:///' + file.replace(/\\/g, '/');
249
+ if (opts.open !== false) openBrowser(url);
250
+ return { file, url };
251
+ } catch (e) {
252
+ return { error: e.code || e.message };
253
+ }
254
+ }
255
+
256
+ /* --------------------------------- live (watch) --------------------------- */
257
+
258
+ function startLive(root, opts = {}) {
259
+ let data = emptySnapshot(root);
260
+ const server = http.createServer((req, res) => {
261
+ if (req.url === '/data') {
262
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
263
+ return res.end(JSON.stringify(data));
264
+ }
265
+ if (req.url === '/health') {
266
+ res.writeHead(200, { 'content-type': 'application/json' });
267
+ return res.end(JSON.stringify({ product: 'critic-ui', port: server.address() ? server.address().port : 0 }));
268
+ }
269
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
270
+ res.end(renderHtml(data, { live: true }));
271
+ });
272
+
273
+ return pickFreePort(opts.port).then((port) => {
274
+ if (!port) return null; // no free port in range → skip UI, never break the workflow
275
+ return new Promise((resolve) => {
276
+ server.once('error', (e) => {
277
+ process.stderr.write(`critic UI server unavailable (${e.code || e.message}); watch continues without the dashboard.\n`);
278
+ resolve(null);
279
+ });
280
+ server.once('listening', () => {
281
+ const bound = server.address().port;
282
+ resolve({
283
+ port: bound,
284
+ url: `http://127.0.0.1:${bound}/`,
285
+ set: (r, extra) => { data = buildSnapshot(r, extra); },
286
+ close: () => { try { server.close(); } catch { /* already closed */ } },
287
+ });
288
+ });
289
+ server.listen(port, '127.0.0.1');
290
+ });
291
+ });
292
+ }
293
+
294
+ module.exports = {
295
+ buildSnapshot, renderHtml, writeStaticAndOpen, startLive, pickFreePort,
296
+ UI_BASE_PORT, SEV_ORDER,
297
+ };
package/src/util.js CHANGED
@@ -15,6 +15,12 @@ const SKIP_DIRS = new Set([
15
15
  '.critic', '.critic-memory',
16
16
  ]);
17
17
 
18
+ // Files the critic itself generates. Never re-scanned: otherwise a previous
19
+ // report's example findings would echo back as stale "findings" forever.
20
+ const OUTPUT_FILES = new Set([
21
+ '.critic-report.md', '.critic-review.json', '.critic-report.html', '.critic-report.json',
22
+ ]);
23
+
18
24
  // Extensions that are definitely not scannable text.
19
25
  const BINARY_EXT = new Set([
20
26
  '.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.bmp', '.tiff', '.pdf',
@@ -62,6 +68,10 @@ function walk(root) {
62
68
  skipped.push({ rel, reason: 'symbolic link (not followed)' });
63
69
  continue;
64
70
  }
71
+ if (OUTPUT_FILES.has(ent.name)) {
72
+ skipped.push({ rel, reason: 'critic output artifact (self-report, not re-scanned)' });
73
+ continue;
74
+ }
65
75
  let st;
66
76
  try { st = fs.statSync(abs); } catch (e) { skipped.push({ rel, reason: `stat failed (${e.code})` }); continue; }
67
77
  const ext = path.extname(ent.name).toLowerCase();
@@ -96,4 +106,4 @@ function exists(p) {
96
106
  try { fs.accessSync(p); return true; } catch { return false; }
97
107
  }
98
108
 
99
- module.exports = { walk, readText, exists, SKIP_DIRS, MAX_FILE_BYTES };
109
+ module.exports = { walk, readText, exists, SKIP_DIRS, OUTPUT_FILES, MAX_FILE_BYTES };
package/src/watcher.js CHANGED
@@ -49,12 +49,15 @@ function watch(root, opts = {}) {
49
49
  const result = analyze(root);
50
50
  const current = new Map(result.findings.map((f) => [keyOf(f), f]));
51
51
 
52
+ let added = [];
53
+ let resolved = [];
52
54
  if (first) {
53
55
  process.stdout.write(renderConsole(result) + '\n');
56
+ added = [...current.values()]; // initial surfacing of everything open
54
57
  first = false;
55
58
  } else {
56
- const added = [...current.values()].filter((f) => !last.has(keyOf(f)));
57
- const resolved = [...last.values()].filter((f) => !current.has(keyOf(f)));
59
+ added = [...current.values()].filter((f) => !last.has(keyOf(f)));
60
+ resolved = [...last.values()].filter((f) => !current.has(keyOf(f)));
58
61
  const time = new Date().toLocaleTimeString();
59
62
  if (!added.length && !resolved.length) {
60
63
  process.stdout.write(`\x1b[90m[${time}] no change in findings (${result.findings.length} total).\x1b[0m\n`);
@@ -71,6 +74,13 @@ function watch(root, opts = {}) {
71
74
  }
72
75
  last = current;
73
76
 
77
+ // Optional observatory hook (only present with `--dash`); never let it
78
+ // break the watch loop.
79
+ if (opts.onPass) {
80
+ try { opts.onPass({ result, added, resolved }); }
81
+ catch (e) { process.stderr.write(`observatory onPass error: ${e.message}\n`); }
82
+ }
83
+
74
84
  if (opts.report) {
75
85
  try { fs.writeFileSync(opts.report, renderMarkdown(result)); }
76
86
  catch (e) { process.stderr.write(`could not write report ${opts.report}: ${e.message}\n`); }
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+ /**
3
+ * Session Observatory adapter self-tests (zero-dependency, `node --test`).
4
+ * A throwaway HTTP collector stands in for the observatory server so we can
5
+ * assert the exact protocol events a `critic watch --dash` run emits — the
6
+ * mapping contract between the two packages — without importing the server.
7
+ */
8
+ const test = require('node:test');
9
+ const assert = require('node:assert');
10
+ const http = require('node:http');
11
+
12
+ const { createAdapter, healthProbe, DEFAULT_PORT } = require('../src/observatory');
13
+
14
+ function startCollector() {
15
+ const events = [];
16
+ const server = http.createServer((req, res) => {
17
+ if (req.method === 'POST' && req.url === '/ingest') {
18
+ let body = '';
19
+ req.on('data', (c) => { body += c; });
20
+ req.on('end', () => {
21
+ try { events.push(JSON.parse(body)); } catch { /* ignore malformed */ }
22
+ res.writeHead(202, { 'content-type': 'application/json' });
23
+ res.end('{"accepted":1}');
24
+ });
25
+ return;
26
+ }
27
+ res.writeHead(404); res.end();
28
+ });
29
+ return new Promise((resolve) => {
30
+ server.listen(0, '127.0.0.1', () => {
31
+ resolve({
32
+ port: server.address().port,
33
+ events,
34
+ close: () => new Promise((r) => server.close(r)),
35
+ });
36
+ });
37
+ });
38
+ }
39
+
40
+ const fakeResult = (n) => ({
41
+ meta: { durationMs: 42 },
42
+ coverage: { filesScanned: n },
43
+ counts: { BLOCKER: 0, CRITICAL: 1, HIGH: 2, MEDIUM: 3, LOW: 4, INFO: 5 },
44
+ findings: [{ file: 'a.js', line: 3, ruleId: 'hardcoded-secret', severity: 'CRITICAL', title: 'secret' }],
45
+ });
46
+
47
+ test('adapter emits session.open with critic source and root', async () => {
48
+ const col = await startCollector();
49
+ const a = createAdapter({ port: col.port, root: '/tmp/proj' });
50
+ await a.open();
51
+ assert.strictEqual(col.events.length, 1);
52
+ const ev = col.events[0];
53
+ assert.strictEqual(ev.type, 'session.open');
54
+ assert.strictEqual(ev.source, 'critic');
55
+ assert.strictEqual(ev.payload.root, '/tmp/proj');
56
+ assert.ok(ev.sessionId.startsWith('critic-'));
57
+ await col.close();
58
+ });
59
+
60
+ test('pass() maps an analysis batch, the finding diff, and severity metrics', async () => {
61
+ const col = await startCollector();
62
+ const a = createAdapter({ port: col.port });
63
+ await a.pass({
64
+ result: fakeResult(12),
65
+ added: [{ file: 'a.js', line: 3, ruleId: 'hardcoded-secret', severity: 'CRITICAL', title: 'secret' }],
66
+ resolved: [{ file: 'b.js', line: 9, ruleId: 'swallowed-error', severity: 'HIGH', title: 'catch' }],
67
+ });
68
+ const types = col.events.map((e) => e.type);
69
+ assert.ok(types.includes('bg.batch') && types.includes('metrics'));
70
+
71
+ const analysis = col.events.find((e) => e.type === 'bg.batch' && e.payload.category === 'analysis' && !e.payload.kind);
72
+ assert.strictEqual(analysis.payload.filesScanned, 12);
73
+ assert.strictEqual(analysis.payload.durationMs, 42);
74
+
75
+ const added = col.events.find((e) => e.payload.kind === 'finding-added');
76
+ assert.strictEqual(added.payload.ruleId, 'hardcoded-secret');
77
+ assert.strictEqual(added.payload.severity, 'CRITICAL'); // bus lowercases on ingest
78
+ const fixed = col.events.find((e) => e.payload.kind === 'finding-fixed');
79
+ assert.strictEqual(fixed.payload.file, 'b.js');
80
+
81
+ const metrics = col.events.find((e) => e.type === 'metrics');
82
+ assert.strictEqual(metrics.metrics.critical, 1); // CRITICAL + BLOCKER
83
+ assert.strictEqual(metrics.metrics.high, 2);
84
+ assert.strictEqual(metrics.metrics.filesScanned, 12);
85
+ assert.ok(Number.isFinite(metrics.metrics.rssMb) && metrics.metrics.rssMb > 0, 'rss heartbeat present');
86
+ await col.close();
87
+ });
88
+
89
+ test('close() emits session.close with a reason', async () => {
90
+ const col = await startCollector();
91
+ const a = createAdapter({ port: col.port });
92
+ await a.close('sigint');
93
+ const ev = col.events[col.events.length - 1];
94
+ assert.strictEqual(ev.type, 'session.close');
95
+ assert.strictEqual(ev.payload.reason, 'sigint');
96
+ await col.close();
97
+ });
98
+
99
+ test('ingest failures never reject the producer (dashboard down)', async () => {
100
+ const a = createAdapter({ port: 1 }); // nothing listening
101
+ // Should resolve quietly rather than throw.
102
+ await a.open();
103
+ await a.pass({ result: fakeResult(1), added: [], resolved: [] });
104
+ assert.ok(true);
105
+ });
106
+
107
+ test('healthProbe returns null when no observatory answers', async () => {
108
+ const h = await healthProbe(1);
109
+ assert.strictEqual(h, null);
110
+ assert.strictEqual(DEFAULT_PORT, 4780);
111
+ });