fullstack-critic 1.0.0 → 1.0.3

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 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/sysinfo.js ADDED
@@ -0,0 +1,395 @@
1
+ 'use strict';
2
+ /**
3
+ * Zero-dependency (Node core only) OS-level telemetry for the built-in critic
4
+ * dashboard. It answers one question the report never could: "what is actually
5
+ * running on this machine right now, app-by-app, and when will the critic finish?"
6
+ *
7
+ * Design rules (same discipline as the rest of the tool):
8
+ * - Never crash the workflow. Every external probe is wrapped; a platform or
9
+ * tool we cannot read degrades to an empty/partial snapshot, not an error.
10
+ * - Cross-platform: Windows (Get-Process), macOS/Linux (ps). CPU% per process
11
+ * uses deltas between samples so the numbers are real, not lifetime averages.
12
+ * - "Project / app wise" grouping: processes are folded into apps and tagged
13
+ * with a coarse category (project, runtime, browser, terminal, database,
14
+ * system, other) so the live-task table is structured, not a raw dump.
15
+ * - The critic's OWN process is highlighted — that is the concrete "live task"
16
+ * this app is running — with its CPU/RSS and a scan-rate-based ETA.
17
+ */
18
+ const os = require('os');
19
+ const { spawnSync, exec } = require('child_process');
20
+
21
+ const IS_WIN = process.platform === 'win32';
22
+
23
+ // The machine-wide per-process probe is expensive on Windows (PowerShell cold
24
+ // start ~3s) — it must therefore run ASYNC + throttled in the live dashboard and
25
+ // is skipped entirely for one-shot `review`. System/self CPU, by contrast, is
26
+ // read from Node core and is always instant.
27
+
28
+ /* ------------------------------- primitives ------------------------------- */
29
+
30
+ // Block the thread for `ms` without busy-spinning (used only for the one-shot
31
+ // static snapshot, where there is no sample history to diff CPU against).
32
+ function sleepMs(ms) {
33
+ try {
34
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
35
+ } catch { /* SharedArrayBuffer disabled → just skip the wait */ }
36
+ }
37
+
38
+ function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
39
+ function round(n, d) { const m = Math.pow(10, d || 0); return Math.round((Number(n) || 0) * m) / m; }
40
+
41
+ // Aggregate instantaneous system CPU% from two os.cpus() readings.
42
+ function cpuPctFrom(prev, cur) {
43
+ if (!prev || !cur || prev.length !== cur.length) return null;
44
+ let prevIdle = 0, prevTotal = 0, curIdle = 0, curTotal = 0;
45
+ for (let i = 0; i < cur.length; i++) {
46
+ const p = prev[i].times, c = cur[i].times;
47
+ prevIdle += p.idle; curIdle += c.idle;
48
+ prevTotal += p.user + p.nice + p.sys + p.idle + p.irq;
49
+ curTotal += c.user + c.nice + c.sys + c.idle + c.irq;
50
+ }
51
+ const dt = curTotal - prevTotal;
52
+ if (dt <= 0) return null;
53
+ return clamp((1 - (curIdle - prevIdle) / dt) * 100, 0, 100);
54
+ }
55
+
56
+ function memInfo() {
57
+ const total = os.totalmem();
58
+ const free = os.freemem();
59
+ const used = Math.max(0, total - free);
60
+ return { memTotal: total, memFree: free, memUsed: used, memPct: total ? round((used / total) * 100, 1) : 0 };
61
+ }
62
+
63
+ function systemStatic() {
64
+ const cpus = os.cpus();
65
+ const load = os.loadavg ? os.loadavg() : [0, 0, 0];
66
+ return {
67
+ cores: cpus.length,
68
+ cpuModel: (cpus[0] && cpus[0].model ? cpus[0].model : '').trim(),
69
+ load1: round(load[0] || 0, 2),
70
+ uptimeSec: Math.round(os.uptime() || 0),
71
+ hostname: os.hostname(),
72
+ platform: process.platform,
73
+ arch: os.arch(),
74
+ nodeVersion: process.version,
75
+ pid: process.pid,
76
+ };
77
+ }
78
+
79
+ /* ------------------------------ process list ------------------------------ */
80
+
81
+ // Coarse, honest categorisation so the live-task view is structured.
82
+ const CAT_RULES = [
83
+ ['project', /fullstack-critic|critic[\\/]|bin\/fullstack/i],
84
+ ['runtime', /^(node|deno|bun|python|python3|java|ruby|php|dotnet|go|rustc|cargo|perl|jl)\b/i],
85
+ ['package', /^(npm|pnpm|yarn|npx|pip|pipenv|poetry|composer|bundle|gem|nuget)\b/i],
86
+ ['browser', /(chrome|chromium|msedge|edge|firefox|safari|brave|opera|vivaldi)/i],
87
+ ['terminal', /(powershell|pwsh|\bcmd\b|conhost|wt\b|terminal|iterm|\bbash\b|\bzsh\b|\bfish\b|codium)/i],
88
+ ['editor', /(code|cursor|idea|webstorm|pycharm|goland|clion|rubymine|phpstorm|sublime|atom|nvim|\bvim\b|emacs|eclipse|devenv)/i],
89
+ ['database', /(postgres|psql|mysqld|\bmariadb\b|mongod|redis|sqlite|clickhouse|influx|etcd)/i],
90
+ ['container', /(docker|com\.docker|kubelet|kubectl|containerd|podman|orbstack)/i],
91
+ ['vcs', /\bgit\b|git-lfs/i],
92
+ ['system', /(svchost|csrss|services|lsass|wininit|winlogon|dwm|explorer|ntoskrnl|system\b|\bsmss\b|\bwin32\b|taskhostw|\bsearch\b|fontdrvhost|memory\.diagnostic|registry|wmiprvse)/i],
93
+ ];
94
+ function classify(name) {
95
+ const n = String(name || '');
96
+ for (const [cat, re] of CAT_RULES) if (re.test(n)) return cat;
97
+ return 'other';
98
+ }
99
+
100
+ function execCapture(cmd, args, timeoutMs) {
101
+ try {
102
+ const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: timeoutMs || 1500, windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
103
+ if (r.error || r.status == null) return null;
104
+ return r.stdout || '';
105
+ } catch { return null; }
106
+ }
107
+
108
+ // Return raw process rows: { pid, name, memBytes, cpuSec, path }. cpuSec is total
109
+ // CPU-seconds consumed (POSIX returns an instantaneous % which we pass through as
110
+ // cpuPctRaw instead). Callers diff successive samples to get a real CPU%.
111
+ function processes() {
112
+ return IS_WIN ? winProcesses() : posixProcesses();
113
+ }
114
+
115
+ // Non-blocking process probe. cb(err, rows) — rows is [] on any failure so the
116
+ // live dashboard simply keeps showing the last successful app table.
117
+ function processesAsync(cb) {
118
+ if (IS_WIN) return runAsync('powershell.exe', WIN_ARGS, parseWinJson, cb);
119
+ return runAsync('ps', ['-axo', 'pid=,pcpu=,rss=,comm='], parsePosixText, cb);
120
+ }
121
+
122
+ const WIN_SCRIPT =
123
+ '$ErrorActionPreference="SilentlyContinue";' +
124
+ 'Get-Process | ForEach-Object {' +
125
+ '$cpu=0; try{$cpu=$_.CPU}catch{$cpu=0}' +
126
+ '$ws=0; try{$ws=$_.WS}catch{$ws=0}' +
127
+ '[pscustomobject]@{Id=$_.Id;Name=$_.ProcessName;WS=$ws;CPU=$cpu}' +
128
+ '} | ConvertTo-Json -Compress';
129
+ const WIN_ARGS = ['-NoProfile', '-NonInteractive', '-Command', WIN_SCRIPT];
130
+
131
+ function runAsync(cmd, args, parser, cb) {
132
+ const { spawn } = require('child_process');
133
+ let child;
134
+ try { child = spawn(cmd, args, { windowsHide: true }); } catch (e) { return cb(e, []); }
135
+ let out = '';
136
+ let done = false;
137
+ const finish = (err) => { if (done) return; done = true; clearTimeout(timer); cb(err, err ? [] : parseSafe(parser, out)); };
138
+ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } finish(new Error('probe-timeout')); }, 15000);
139
+ child.stdout.on('data', (d) => { out += d; });
140
+ child.on('error', (e) => finish(e));
141
+ child.on('close', () => finish(null));
142
+ }
143
+ function parseSafe(parser, out) { try { return parser(out) || []; } catch { return []; } }
144
+
145
+ function parseWinJson(stdout) {
146
+ if (!stdout) return [];
147
+ let rows; try { rows = JSON.parse(stdout); } catch { return []; }
148
+ if (!Array.isArray(rows)) rows = [rows];
149
+ return rows.filter((r) => r && r.Id != null).map((r) => ({
150
+ pid: Number(r.Id), name: String(r.Name || '?'),
151
+ memBytes: Number(r.WS) || 0, cpuSec: Number(r.CPU) || 0, path: '',
152
+ }));
153
+ }
154
+ function parsePosixText(stdout) { return parsePsText(stdout); }
155
+
156
+ // Shared ps -text parser (POSIX). pcpu is an instantaneous-ish %; rss is KB.
157
+ function parsePsText(stdout) {
158
+ if (!stdout) return [];
159
+ const out = [];
160
+ for (const line of stdout.split('\n')) {
161
+ const m = line.match(/^\s*(\d+)\s+([\d.]+)\s+(\d+)\s+(.*)$/);
162
+ if (!m) continue;
163
+ out.push({
164
+ pid: Number(m[1]),
165
+ name: String(m[4]).trim().split(/[\\/]/).pop(),
166
+ memBytes: (Number(m[3]) || 0) * 1024,
167
+ cpuSec: 0,
168
+ cpuPctRaw: Number(m[2]) || 0,
169
+ path: '',
170
+ });
171
+ }
172
+ return out;
173
+ }
174
+
175
+ // Synchronous variants — used only by tests and anywhere a blocking read is fine.
176
+ function winProcesses() {
177
+ const stdout = execCapture('powershell.exe', WIN_ARGS, 6000);
178
+ return parseWinJson(stdout);
179
+ }
180
+ function posixProcesses() {
181
+ const stdout = execCapture('ps', ['-axo', 'pid=,pcpu=,rss=,comm='], 1500);
182
+ return parsePsText(stdout);
183
+ }
184
+
185
+ // Fold processes into apps and compute a real CPU% from the previous sample.
186
+ // prevByPid: Map<pid,{cpuSec,ts}> | null. nowMs: current epoch ms.
187
+ function toAppRows(procs, prevByPid, nowMs, cores) {
188
+ const apps = new Map();
189
+ const updated = new Map();
190
+ for (const p of procs) {
191
+ let cpuPct = 0;
192
+ if (typeof p.cpuPctRaw === 'number') {
193
+ cpuPct = clamp(p.cpuPctRaw, 0, (cores || 1) * 100);
194
+ } else if (prevByPid) {
195
+ const prev = prevByPid.get(p.pid);
196
+ if (prev && nowMs > prev.ts) {
197
+ const dtSec = (nowMs - prev.ts) / 1000;
198
+ cpuPct = clamp(((p.cpuSec - prev.cpuSec) / dtSec) * 100, 0, (cores || 1) * 100);
199
+ }
200
+ }
201
+ updated.set(p.pid, { cpuSec: p.cpuSec, ts: nowMs });
202
+
203
+ const key = (p.name || '?').toLowerCase();
204
+ let a = apps.get(key);
205
+ if (!a) { a = { app: p.name || '?', category: classify(p.name), count: 0, cpuPct: 0, memBytes: 0, pids: [] }; apps.set(key, a); }
206
+ a.count++;
207
+ a.cpuPct += cpuPct;
208
+ a.memBytes += p.memBytes;
209
+ if (a.pids.length < 8) a.pids.push(p.pid);
210
+ if (!a.category || a.category === 'other') a.category = classify(p.name);
211
+ }
212
+ const rows = [...apps.values()].map((a) => ({ ...a, cpuPct: round(a.cpuPct, 1), memBytes: a.memBytes }));
213
+ return { rows, prevByPid: updated };
214
+ }
215
+
216
+ function byCategory(rows) {
217
+ const out = {};
218
+ for (const r of rows) {
219
+ const c = (out[r.category] || (out[r.category] = { count: 0, apps: 0, cpuPct: 0, memBytes: 0 }));
220
+ c.apps += 1; c.count += r.count; c.cpuPct += r.cpuPct; c.memBytes += r.memBytes;
221
+ }
222
+ for (const k of Object.keys(out)) { out[k].cpuPct = round(out[k].cpuPct, 1); }
223
+ return out;
224
+ }
225
+
226
+ /* ------------------------------ self (critic) ----------------------------- */
227
+
228
+ // CPU% of THIS critic process between samples, plus its resident memory.
229
+ function selfUsage(prev) {
230
+ const cpu = process.cpuUsage(prev && prev.cpuUsage); // microseconds since last
231
+ const wallMs = prev ? Date.now() - prev.ts : 0;
232
+ const cpuUs = (cpu.user || 0) + (cpu.system || 0);
233
+ const cpuPct = wallMs > 0 ? clamp((cpuUs / 1000 / (wallMs / 1000)) * 100, 0, (os.cpus().length || 1) * 100) : 0;
234
+ const mem = process.memoryUsage();
235
+ return {
236
+ cpuPct: round(cpuPct, 1),
237
+ rss: Math.round(mem.rss || 0),
238
+ heapUsed: Math.round(mem.heapUsed || 0),
239
+ rssMb: round((mem.rss || 0) / 1048576, 1),
240
+ next: { cpuUsage: process.cpuUsage(), ts: Date.now() },
241
+ };
242
+ }
243
+
244
+ /* -------------------------------- estimate -------------------------------- */
245
+
246
+ /**
247
+ * Expected-completion math for the critic's own scan. Fully pure & testable:
248
+ * it turns the analyzer's measured throughput and pass history into an honest
249
+ * ETA — and refuses to overclaim when there is not enough signal yet.
250
+ */
251
+ function computeEstimate(input) {
252
+ const o = input || {};
253
+ const now = o.now || Date.now();
254
+ const cores = o.cores || os.cpus().length || 1;
255
+ const passNo = Number(o.passNo) || 0;
256
+ const durationsMs = Array.isArray(o.durationsMs) ? o.durationsMs.filter((x) => x > 0) : [];
257
+ const lastPassMs = Number(o.lastPassMs) || (durationsMs.length ? durationsMs[durationsMs.length - 1] : 0);
258
+ const avgPassMs = durationsMs.length ? Math.round(durationsMs.reduce((s, x) => s + x, 0) / durationsMs.length) : lastPassMs;
259
+ const filesTotal = Number(o.filesTotal) || 0;
260
+ const totalLines = Number(o.totalLines) || 0;
261
+
262
+ const scanRateFps = lastPassMs > 0 ? round(filesTotal / (lastPassMs / 1000), 1) : 0;
263
+ const linesPerSec = lastPassMs > 0 ? round(totalLines / (lastPassMs / 1000), 0) : 0;
264
+ const timeOnTaskSec = o.startedAt ? round((now - o.startedAt) / 1000, 1) : 0;
265
+
266
+ // Under heavy system load the next pass will stretch; scale the estimate.
267
+ const loadFactor = o.cpuPct != null ? round(1 + Math.max(0, (o.cpuPct - 60) / 100), 2) : 1;
268
+ const estPassMs = avgPassMs ? Math.round(avgPassMs * loadFactor) : 0;
269
+
270
+ let etaLabel;
271
+ let complete = true;
272
+ if (o.mode === 'watch') {
273
+ const nextSec = round((estPassMs / 1000) + (Number(o.debounceMs) || 400) / 1000, 1);
274
+ etaLabel = `each re-scan completes in ~${nextSec}s after a change`;
275
+ complete = false; // watch never "finishes"; it waits for the next edit
276
+ } else if (estPassMs > 0) {
277
+ etaLabel = `full review completed in ~${round(estPassMs / 1000, 1)}s`;
278
+ } else {
279
+ etaLabel = 'measuring throughput…';
280
+ }
281
+
282
+ return {
283
+ mode: o.mode || 'review',
284
+ passNo, cores, filesTotal, totalLines,
285
+ lastPassMs, avgPassMs, estPassMs,
286
+ scanRateFps, linesPerSec, loadFactor,
287
+ timeOnTaskSec, serverNow: now, startedAt: o.startedAt || null,
288
+ etaLabel, complete,
289
+ };
290
+ }
291
+
292
+ /* -------------------------------- monitor --------------------------------- */
293
+
294
+ // Stateful sampler used by the live dashboard: keeps system-CPU, per-process-CPU
295
+ // and critic-process deltas between calls, plus a rolling history for the charts.
296
+ function createMonitor(root) {
297
+ const state = {
298
+ root: root || process.cwd(),
299
+ prevCpus: os.cpus(),
300
+ prevTs: Date.now(),
301
+ prevByPid: null,
302
+ prevSelf: { cpuUsage: process.cpuUsage(), ts: Date.now() },
303
+ history: [],
304
+ maxHistory: 60,
305
+ lastRows: [],
306
+ lastProcs: 0,
307
+ lastErr: null,
308
+ };
309
+
310
+ // Cheap, always-current reading: system CPU% (Node-core delta), memory, and
311
+ // this critic process. Safe to call on every poll / history tick — never blocks.
312
+ function sample() {
313
+ const now = Date.now();
314
+ const sysStatic = systemStatic();
315
+ let cpuPct = cpuPctFrom(state.prevCpus, os.cpus());
316
+ if (cpuPct == null) cpuPct = state.history.length ? state.history[state.history.length - 1].cpu : 0;
317
+ state.prevCpus = os.cpus();
318
+ state.prevTs = now;
319
+ const mem = memInfo();
320
+ const self = selfUsage(state.prevSelf);
321
+ state.prevSelf = self.next;
322
+ delete self.next;
323
+ state.history.push({ t: now, cpu: round(cpuPct, 1), mem: mem.memPct, procCount: state.lastProcs, selfCpu: self.cpuPct });
324
+ if (state.history.length > state.maxHistory) state.history.shift();
325
+ return buildSnapshot(sysStatic, cpuPct, mem, self, now);
326
+ }
327
+
328
+ function buildSnapshot(sysStatic, cpuPct, mem, self, now) {
329
+ const byCpu = state.lastRows.slice().sort((a, b) => b.cpuPct - a.cpuPct || b.memBytes - a.memBytes);
330
+ const topMem = state.lastRows.slice().sort((a, b) => b.memBytes - a.memBytes).slice(0, 8);
331
+ return {
332
+ ts: now,
333
+ system: { ...sysStatic, cpuPct: round(cpuPct, 1), ...mem },
334
+ self,
335
+ apps: byCpu.slice(0, 24),
336
+ totals: {
337
+ procCount: state.lastProcs, appCount: state.lastRows.length, topMem,
338
+ byCategory: byCategory(state.lastRows), refreshing: !!state.refreshing,
339
+ },
340
+ history: state.history.slice(),
341
+ error: state.lastErr || null,
342
+ };
343
+ }
344
+
345
+ // Expensive: the machine-wide per-process table. Runs async so it never blocks
346
+ // the HTTP server or the watch loop; cb() fires once the rows are cached.
347
+ function refresh(cb) {
348
+ if (state.refreshing) { if (cb) cb(null); return; }
349
+ state.refreshing = true;
350
+ processesAsync((err, procs) => {
351
+ state.refreshing = false;
352
+ const list = Array.isArray(procs) ? procs : [];
353
+ if (err) { state.lastErr = err.message; if (cb) cb(err); return; }
354
+ const now = Date.now();
355
+ const folded = toAppRows(list, state.prevByPid, now, systemStatic().cores);
356
+ state.prevByPid = folded.prevByPid;
357
+ state.lastRows = folded.rows;
358
+ state.lastProcs = list.length;
359
+ state.lastErr = null;
360
+ if (cb) cb(null);
361
+ });
362
+ }
363
+
364
+ return { sample, refresh, get historyLen() { return state.history.length; } };
365
+ }
366
+
367
+ // One-shot snapshot for a single (non-looping) `critic review`. Only the cheap,
368
+ // always-available readings (system CPU over a 60ms window, memory, this
369
+ // process). The machine-wide per-app table is deliberately omitted here because
370
+ // it needs the slow async probe — that lives in the live `watch` dashboard.
371
+ function snapshotOnce(root) {
372
+ const p1 = os.cpus();
373
+ const self1 = { cpuUsage: process.cpuUsage(), ts: Date.now() };
374
+ sleepMs(60);
375
+ const cpuPct = cpuPctFrom(p1, os.cpus()) || 0;
376
+ const selfRaw = selfUsage(self1);
377
+ const self = { cpuPct: selfRaw.cpuPct, rss: selfRaw.rss, heapUsed: selfRaw.heapUsed, rssMb: selfRaw.rssMb };
378
+ const mem = memInfo();
379
+ const now = Date.now();
380
+ return {
381
+ ts: now,
382
+ system: { ...systemStatic(), cpuPct: round(cpuPct, 1), ...mem },
383
+ self,
384
+ apps: [],
385
+ totals: { procCount: 0, appCount: 0, topMem: [], byCategory: {}, refreshing: false },
386
+ history: [{ t: now, cpu: round(cpuPct, 1), mem: mem.memPct, procCount: 0, selfCpu: self.cpuPct }],
387
+ live: false,
388
+ error: null,
389
+ };
390
+ }
391
+
392
+ module.exports = {
393
+ createMonitor, snapshotOnce, processes, processesAsync, toAppRows, byCategory, classify,
394
+ selfUsage, computeEstimate, cpuPctFrom, systemStatic, memInfo, sleepMs, IS_WIN,
395
+ };