fullstack-critic 1.0.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstack-critic",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
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/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
+ };
package/src/ui.js CHANGED
@@ -26,6 +26,7 @@ const net = require('net');
26
26
  const { DIMENSIONS } = require('./analyzer');
27
27
  const { execSummary } = require('./report');
28
28
  const { openBrowser } = require('./observatory');
29
+ const sysinfo = require('./sysinfo');
29
30
 
30
31
  const SEV_ORDER = ['BLOCKER', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'];
31
32
  const UI_BASE_PORT = 4781; // distinct from the observatory's 4780
@@ -81,6 +82,9 @@ function buildSnapshot(result, extra = {}) {
81
82
  file: f.file, line: f.line, severity: f.severity, category: f.category,
82
83
  dimension: f.dimension, title: f.title, problem: f.problem, fix: f.fix, evidence: f.evidence,
83
84
  })),
85
+ // OS-level live telemetry + expected-completion estimate (see sysinfo.js).
86
+ sys: extra.sys || null,
87
+ estimate: extra.estimate || null,
84
88
  };
85
89
  }
86
90
 
@@ -133,6 +137,36 @@ function renderHtml(data, opts = {}) {
133
137
  footer{color:var(--mut);font-size:11.5px;padding:20px 26px;border-top:1px solid var(--line);margin-top:24px}
134
138
  .pulse{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--ok);margin-right:6px;animation:p 1.4s infinite}
135
139
  @keyframes p{0%,100%{opacity:.3}50%{opacity:1}}
140
+ /* --- OS live-task + ETA panels --- */
141
+ .row2{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:18px}
142
+ @media(max-width:860px){.row2{grid-template-columns:1fr}}
143
+ .card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
144
+ .card h2{margin-top:0}
145
+ .gauges{display:flex;gap:18px;flex-wrap:wrap;margin-bottom:8px}
146
+ .gauge{min-width:120px;flex:1}
147
+ .gauge .top{display:flex;justify-content:space-between;align-items:baseline;color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.5px}
148
+ .gauge .top b{font-size:22px;color:var(--ink)}
149
+ .track{height:8px;background:#0c1226;border:1px solid var(--line);border-radius:999px;margin-top:6px;overflow:hidden}
150
+ .track>i{display:block;height:100%;border-radius:999px;transition:width .5s ease}
151
+ #charts{display:grid;grid-template-columns:1fr;gap:10px;margin-top:10px}
152
+ .chart{background:#0c1226;border:1px solid var(--line);border-radius:8px;padding:8px 10px}
153
+ .chart .ct{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}
154
+ .chart svg{display:block;width:100%;height:46px}
155
+ .eta-barbox{height:14px;background:#0c1226;border:1px solid var(--line);border-radius:999px;overflow:hidden;margin-bottom:8px}
156
+ .eta-bar{height:100%;width:0;background:linear-gradient(90deg,var(--ok),var(--info));transition:width .15s linear}
157
+ .eta-line{font-size:15px;font-weight:600}
158
+ .grid2{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-top:12px}
159
+ @media(max-width:860px){.grid2{grid-template-columns:1fr 1fr}}
160
+ .kv{background:#0c1226;border:1px solid var(--line);border-radius:8px;padding:8px 10px}
161
+ .kv .k{color:var(--mut);font-size:10.5px;text-transform:uppercase;letter-spacing:.5px}
162
+ .kv .v{font-size:14px;font-weight:600;margin-top:2px}
163
+ .bar{position:relative;height:20px;min-width:90px;background:#0c1226;border:1px solid var(--line);border-radius:6px;overflow:hidden}
164
+ .bar>i{display:block;height:100%;opacity:.5;transition:width .5s ease}
165
+ .bar>span{position:absolute;left:6px;top:0;line-height:20px;font-size:11.5px;color:#fff;text-shadow:0 0 3px rgba(0,0,0,.85)}
166
+ th.sortable{cursor:pointer} th.sortable:hover{color:var(--ink)} th.act{color:var(--ink)}
167
+ .c-project{color:var(--ok)} .c-runtime{color:var(--info)} .c-browser{color:var(--high)} .c-terminal{color:var(--med)}
168
+ .c-editor{color:#c792ea} .c-database{color:var(--critic)} .c-container{color:#7fdbca} .c-package{color:#ffd166}
169
+ .c-vcs{color:#93a0c2} .c-system{color:var(--mut)} .c-other{color:var(--mut)}
136
170
  </style></head>
137
171
  <body>
138
172
  <header>
@@ -142,6 +176,12 @@ function renderHtml(data, opts = {}) {
142
176
  <main>
143
177
  <div class="verdict" id="verdict"></div>
144
178
  <div class="grid" id="counts"></div>
179
+ <div class="row2">
180
+ <section class="card"><h2>Estimated Completion</h2><div id="eta"></div></section>
181
+ <section class="card"><h2>OS Activity <span class="sub" id="os-meta"></span></h2><div id="gauges"></div><div id="charts"></div></section>
182
+ </div>
183
+ <h2>Live Tasks — running processes, grouped app-wise</h2>
184
+ <div id="tasks" class="card"></div>
145
185
  <h2>12-Dimension Executive Summary</h2>
146
186
  <table id="dims"><thead><tr><th>Dimension</th><th>Status</th><th>Findings</th><th>Worst</th></tr></thead><tbody></tbody></table>
147
187
  <h2>Prioritised Action Plan</h2>
@@ -158,6 +198,51 @@ var LIVE = ${live};
158
198
  var SEV = ['BLOCKER','CRITICAL','HIGH','MEDIUM','LOW','INFO'];
159
199
  var CATS = [['fix','FIX — correctness & security'],['optimize','OPTIMIZE — performance & structure'],['delete','DELETE — dead code & debug'],['add','ADD — tests, config & docs']];
160
200
  function el(t,c,x){var e=document.createElement(t);if(c)e.className=c;if(x!=null)e.textContent=x;return e;}
201
+
202
+ /* ---- OS live-task + expected-completion rendering (zero-dep SVG charts) ---- */
203
+ var LASTSYS=null; var SORT='cpu'; var LASTEST=null; var PASS={no:-1,start:0};
204
+ var SVGNS='http://www.w3.org/2000/svg';
205
+ function fmtBytes(n){n=Number(n)||0;if(n>=1073741824)return (n/1073741824).toFixed(1)+' GB';if(n>=1048576)return (n/1048576).toFixed(0)+' MB';if(n>=1024)return (n/1024).toFixed(0)+' KB';return n+' B';}
206
+ function fmtPct(n){return (Math.round((Number(n)||0)*10)/10)+'%';}
207
+ function round1(n){return Math.round((Number(n)||0)*10)/10;}
208
+ function dur(sec){sec=Math.max(0,Math.floor(Number(sec)||0));var h=Math.floor(sec/3600),m=Math.floor((sec%3600)/60),s=sec%60;if(h)return h+'h '+m+'m';if(m)return m+'m '+s+'s';return s+'s';}
209
+ function maxOf(hist,key){var mx=0;for(var i=0;i<hist.length;i++){var v=Number(hist[i][key])||0;if(v>mx)mx=v;}return mx;}
210
+ function bar(val,max,color,text){var b=el('div','bar');var f=el('i');f.style.width=(max>0?Math.min(100,(val/max)*100):0)+'%';if(color)f.style.background=color;b.appendChild(f);if(text!=null)b.appendChild(el('span',null,text));return b;}
211
+ function kv(k,v){var d=el('div','kv');d.appendChild(el('div','k',k));d.appendChild(el('div','v',String(v)));return d;}
212
+ function gaugeCard(label,pct,color){var g=el('div','gauge');var top=el('div','top');top.appendChild(el('span',null,label));top.appendChild(el('b',null,fmtPct(pct)));g.appendChild(top);var tr=el('div','track');var f=el('i');f.style.width=Math.min(100,Math.max(0,Number(pct)||0))+'%';f.style.background=color;tr.appendChild(f);g.appendChild(tr);return g;}
213
+ function chart(hist,key,label,color,max){var box=el('div','chart');box.appendChild(el('div','ct',label));var W=300,H=46;var svg=document.createElementNS(SVGNS,'svg');svg.setAttribute('viewBox','0 0 '+W+' '+H);svg.setAttribute('preserveAspectRatio','none');box.appendChild(svg);var n=hist.length;if(!n||n<2){box.appendChild(el('div','sub','collecting samples…'));return box;}var mx=max||maxOf(hist,key)||1;var line='';var area='0,'+H+' ';for(var i=0;i<n;i++){var v=Number(hist[i][key])||0;var x=(i/(n-1))*W;var y=H-(Math.min(v,mx)/mx)*H;line+=(i?' ':'')+x.toFixed(1)+','+y.toFixed(1);area+=x.toFixed(1)+','+y.toFixed(1)+' ';}area+=W+','+H;var pg=document.createElementNS(SVGNS,'polygon');pg.setAttribute('points',area);pg.setAttribute('fill',color);pg.setAttribute('opacity','0.12');var pl=document.createElementNS(SVGNS,'polyline');pl.setAttribute('points',line);pl.setAttribute('fill','none');pl.setAttribute('stroke',color);pl.setAttribute('stroke-width','1.6');svg.appendChild(pg);svg.appendChild(pl);var cap=el('div','sub');cap.textContent='now '+fmtPct(hist[n-1][key])+' · peak '+fmtPct(maxOf(hist,key))+' · scale '+round1(mx)+'%';box.appendChild(cap);return box;}
214
+ function renderOs(sys){var box=document.getElementById('gauges');var ch=document.getElementById('charts');if(!box)return;box.textContent='';if(ch)ch.textContent='';if(!sys||!sys.system){box.appendChild(el('span','sub','OS telemetry unavailable'));return;}
215
+ var meta=document.getElementById('os-meta');if(meta)meta.textContent=sys.system.platform+'/'+sys.system.arch+' · '+sys.system.cores+' cores · '+sys.system.hostname;
216
+ var g=el('div','gauges');g.appendChild(gaugeCard('System CPU',sys.system.cpuPct,'var(--high)'));g.appendChild(gaugeCard('Memory',sys.system.memPct,'var(--info)'));g.appendChild(gaugeCard('Critic CPU',sys.self?sys.self.cpuPct:0,'var(--critic)'));box.appendChild(g);
217
+ var st=el('div','sub');st.textContent='Processes: '+sys.totals.procCount+' · Apps: '+sys.totals.appCount+' · RAM '+fmtBytes(sys.system.memUsed)+' / '+fmtBytes(sys.system.memTotal)+' · Critic RSS '+(sys.self?fmtBytes(sys.self.rss):'?')+' · uptime '+dur(sys.system.uptimeSec);box.appendChild(st);
218
+ if(ch){if(sys.history)ch.appendChild(chart(sys.history,'cpu','CPU %','var(--high)',100));if(sys.history)ch.appendChild(chart(sys.history,'mem','Memory %','var(--info)',100));if(sys.history)ch.appendChild(chart(sys.history,'selfCpu','Critic CPU %','var(--critic)',Math.max(40,maxOf(sys.history,'selfCpu'))));}
219
+ }
220
+ function th(label,key){var t=el('th','sortable'+(SORT===key?' act':''),label);if(key)t.onclick=function(){SORT=key;renderTasks(LASTSYS);};return t;}
221
+ function renderTasks(sys){var box=document.getElementById('tasks');if(!box)return;box.textContent='';LASTSYS=sys;if(!sys||!sys.apps||!sys.apps.length){box.appendChild(el('p','sub',(sys&&sys.totals&&sys.totals.refreshing)?'sampling running processes…':'Per-app live tasks stream in here a few seconds after the dashboard starts.'));return;}
222
+ var maxcpu=1,maxmem=1;sys.apps.forEach(function(a){if(a.cpuPct>maxcpu)maxcpu=a.cpuPct;if(a.memBytes>maxmem)maxmem=a.memBytes;});
223
+ var rows=sys.apps.slice();if(SORT==='mem')rows.sort(function(a,b){return b.memBytes-a.memBytes;});else if(SORT==='name')rows.sort(function(a,b){return String(a.app).localeCompare(String(b.app));});else rows.sort(function(a,b){return b.cpuPct-a.cpuPct;});
224
+ var t=el('table');var thead=el('thead');var hr=el('tr');hr.appendChild(th('App / Process','name'));hr.appendChild(th('Category',''));hr.appendChild(th('Instances',''));hr.appendChild(th('CPU %','cpu'));hr.appendChild(th('Memory','mem'));thead.appendChild(hr);t.appendChild(thead);
225
+ var body=el('tbody');rows.forEach(function(a){var tr=el('tr');var nm=el('td');nm.appendChild(el('code',null,a.app));tr.appendChild(nm);tr.appendChild(el('td')).appendChild(el('span','tag c-'+a.category,a.category));tr.appendChild(el('td',null,String(a.count)));var cc=el('td');cc.appendChild(bar(a.cpuPct,maxcpu,'var(--high)',fmtPct(a.cpuPct)));tr.appendChild(cc);var mc=el('td');mc.appendChild(bar(a.memBytes,maxmem,'var(--info)',fmtBytes(a.memBytes)));tr.appendChild(mc);body.appendChild(tr);});
226
+ t.appendChild(body);box.appendChild(t);
227
+ var cats=Object.keys(sys.totals.byCategory||{});if(cats.length){var leg=el('div','sub');leg.style.marginTop='10px';leg.textContent='By category: '+cats.sort().map(function(k){return k+' ('+(sys.totals.byCategory[k].count)+' proc, '+fmtBytes(sys.totals.byCategory[k].memBytes)+')';}).join(' · ');box.appendChild(leg);}
228
+ }
229
+ function renderEta(e,d){var box=document.getElementById('eta');if(!box)return;box.textContent='';if(!e){box.appendChild(el('p','sub','Estimating expected completion…'));return;}LASTEST=e;if(e.passNo!==PASS.no){PASS.no=e.passNo;PASS.start=Date.now();}
230
+ var bb=el('div','eta-barbox');var b=el('div','eta-bar');b.id='eta-bar';if(e.complete)b.style.width='100%';bb.appendChild(b);box.appendChild(bb);
231
+ box.appendChild(el('div','eta-line',e.etaLabel||''));
232
+ var cnt=el('div','sub');cnt.id='eta-count';box.appendChild(cnt);
233
+ var g=el('div','grid2');
234
+ g.appendChild(kv('Scan rate',(e.scanRateFps||0)+' files/s'));
235
+ g.appendChild(kv('Lines/s',(e.linesPerSec||0).toLocaleString?String((e.linesPerSec||0).toLocaleString()):String(e.linesPerSec||0)));
236
+ g.appendChild(kv('Files scanned',String(e.filesTotal||0)));
237
+ g.appendChild(kv('Last pass',(e.lastPassMs||0)+' ms'));
238
+ g.appendChild(kv('Avg pass',(e.avgPassMs||0)+' ms'));
239
+ g.appendChild(kv('Passes','×'+(e.passNo||0)));
240
+ g.appendChild(kv('Time on task',dur(e.timeOnTaskSec)));
241
+ g.appendChild(kv('System load','×'+(e.loadFactor||1)));
242
+ g.appendChild(kv('Cores',String(e.cores||0)));
243
+ box.appendChild(g);tickEta();
244
+ }
245
+ function tickEta(){if(!LASTEST)return;var e=LASTEST;var b=document.getElementById('eta-bar');var c=document.getElementById('eta-count');var total=(e.estPassMs||0)/1000;if(e.complete){if(b)b.style.width='100%';if(c)c.textContent=(e.mode==='watch')?'idle — watching for your next edit':('done in ~'+round1(total)+'s');return;}var secs=(Date.now()-PASS.start)/1000;var pct=total>0?Math.min(100,(secs/total)*100):0;if(b)b.style.width=pct+'%';if(c)c.textContent='this pass: '+round1(secs)+'s / ~'+round1(total)+'s expected';}
161
246
  function render(d){
162
247
  document.getElementById('h-proj').textContent = '— ' + (d.project||'');
163
248
  var sub = (d.root||'') + ' · ' + (d.generatedAt? new Date(d.generatedAt).toLocaleString():'');
@@ -165,6 +250,8 @@ function render(d){
165
250
  else if(LIVE){ sub += ' · live pass #' + d.passNo + ' (+'+d.addedCount+' new / -'+d.resolvedCount+' fixed)'; }
166
251
  document.getElementById('h-sub').textContent = sub;
167
252
 
253
+ renderEta(d.estimate,d); renderOs(d.sys); renderTasks(d.sys);
254
+
168
255
  var v=document.getElementById('verdict'); v.textContent='';
169
256
  if(d.empty){ v.appendChild(el('span',null,'Analysis starting…')); return; }
170
257
  v.appendChild(el('span','pulse'));
@@ -209,6 +296,7 @@ function render(d){
209
296
  render(INITIAL);
210
297
  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
298
  if(LIVE){ setInterval(function(){ fetch('/data',{cache:'no-store'}).then(function(r){return r.json();}).then(render).catch(offline); }, 1500); }
299
+ setInterval(tickEta, 150);
212
300
  function esc(s){return String(s==null?'':s);}
213
301
  </script>
214
302
  </body></html>`;
@@ -239,7 +327,19 @@ function pickFreePort(base, span) {
239
327
 
240
328
  function writeStaticAndOpen(result, opts = {}) {
241
329
  try {
242
- const data = buildSnapshot(result, opts.extra || {});
330
+ const cov = result.coverage || {};
331
+ let sys = null;
332
+ let estimate = null;
333
+ try {
334
+ sys = sysinfo.snapshotOnce((result.meta && result.meta.root) || process.cwd());
335
+ estimate = sysinfo.computeEstimate({
336
+ mode: 'review', passNo: 1,
337
+ lastPassMs: (result.meta && result.meta.durationMs) || 0,
338
+ filesTotal: cov.filesScanned || 0, totalLines: cov.totalLines || 0,
339
+ cores: sys.system.cores, cpuPct: sys.system.cpuPct,
340
+ });
341
+ } catch { /* OS probe is best-effort; the report still renders without it */ }
342
+ const data = buildSnapshot(result, Object.assign({ sys, estimate }, opts.extra || {}));
243
343
  const dir = path.join(os.tmpdir(), 'fullstack-critic');
244
344
  fs.mkdirSync(dir, { recursive: true });
245
345
  const stamp = data.generatedAt.replace(/[:.]/g, '-');
@@ -257,23 +357,60 @@ function writeStaticAndOpen(result, opts = {}) {
257
357
 
258
358
  function startLive(root, opts = {}) {
259
359
  let data = emptySnapshot(root);
360
+
361
+ // Live OS telemetry. The cheap system/self reading advances on a timer so the
362
+ // charts keep moving between HTTP polls; the expensive machine-wide process
363
+ // table refreshes async on its own slower cadence and never blocks requests.
364
+ const monitor = sysinfo.createMonitor(root);
365
+ const startedAt = Date.now();
366
+ const timing = { passNo: 0, lastPassMs: 0, durations: [] };
367
+ try { monitor.sample(); monitor.refresh(() => {}); } catch { /* best-effort */ }
368
+ const timers = [
369
+ setInterval(() => { try { monitor.sample(); } catch { /* ignore */ } }, 1200),
370
+ setInterval(() => { try { monitor.refresh(() => {}); } catch { /* ignore */ } }, 3500),
371
+ ];
372
+
373
+ function currentEstimate(sys) {
374
+ const cov = data.coverage || {};
375
+ try {
376
+ return sysinfo.computeEstimate({
377
+ mode: 'watch', passNo: timing.passNo, lastPassMs: timing.lastPassMs, durationsMs: timing.durations,
378
+ filesTotal: cov.filesScanned || 0, totalLines: cov.totalLines || 0,
379
+ cores: sys && sys.system ? sys.system.cores : undefined, cpuPct: sys && sys.system ? sys.system.cpuPct : null,
380
+ startedAt, now: Date.now(), debounceMs: opts.debounceMs,
381
+ });
382
+ } catch { return null; }
383
+ }
384
+
385
+ function withSys(d) {
386
+ let sys = null;
387
+ try { sys = monitor.sample(); } catch { /* ignore */ }
388
+ return Object.assign({}, d, { sys, estimate: currentEstimate(sys), startedAt, passNo: timing.passNo });
389
+ }
390
+
260
391
  const server = http.createServer((req, res) => {
261
392
  if (req.url === '/data') {
262
393
  res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
263
- return res.end(JSON.stringify(data));
394
+ return res.end(JSON.stringify(withSys(data)));
395
+ }
396
+ if (req.url === '/sys') {
397
+ let sys = null; try { sys = monitor.sample(); } catch { /* ignore */ }
398
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
399
+ return res.end(JSON.stringify({ sys, estimate: currentEstimate(sys) }));
264
400
  }
265
401
  if (req.url === '/health') {
266
402
  res.writeHead(200, { 'content-type': 'application/json' });
267
403
  return res.end(JSON.stringify({ product: 'critic-ui', port: server.address() ? server.address().port : 0 }));
268
404
  }
269
405
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
270
- res.end(renderHtml(data, { live: true }));
406
+ res.end(renderHtml(withSys(data), { live: true }));
271
407
  });
272
408
 
273
409
  return pickFreePort(opts.port).then((port) => {
274
- if (!port) return null; // no free port in range → skip UI, never break the workflow
410
+ if (!port) { for (const t of timers) clearInterval(t); return null; } // no free port → skip UI, never break the workflow
275
411
  return new Promise((resolve) => {
276
412
  server.once('error', (e) => {
413
+ for (const t of timers) clearInterval(t);
277
414
  process.stderr.write(`critic UI server unavailable (${e.code || e.message}); watch continues without the dashboard.\n`);
278
415
  resolve(null);
279
416
  });
@@ -282,8 +419,17 @@ function startLive(root, opts = {}) {
282
419
  resolve({
283
420
  port: bound,
284
421
  url: `http://127.0.0.1:${bound}/`,
285
- set: (r, extra) => { data = buildSnapshot(r, extra); },
286
- close: () => { try { server.close(); } catch { /* already closed */ } },
422
+ set: (r, extra) => {
423
+ data = buildSnapshot(r, extra);
424
+ timing.passNo = (extra && extra.passNo) || timing.passNo + 1;
425
+ const d = (r.meta && r.meta.durationMs) || 0;
426
+ timing.lastPassMs = d;
427
+ if (d > 0) { timing.durations.push(d); if (timing.durations.length > 12) timing.durations.shift(); }
428
+ },
429
+ close: () => {
430
+ for (const t of timers) clearInterval(t);
431
+ try { server.close(); } catch { /* already closed */ }
432
+ },
287
433
  });
288
434
  });
289
435
  server.listen(port, '127.0.0.1');
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+ /**
3
+ * sysinfo unit tests — deliberately exercise the PURE, deterministic parts of the
4
+ * OS telemetry (grouping, CPU-delta, categorisation, ETA math) so they pass on
5
+ * any machine, plus a bounded check that the live process probe never throws.
6
+ */
7
+ const test = require('node:test');
8
+ const assert = require('node:assert');
9
+
10
+ const sys = require('../src/sysinfo');
11
+
12
+ test('classify() folds process names into structured categories', () => {
13
+ assert.strictEqual(sys.classify('node'), 'runtime');
14
+ assert.strictEqual(sys.classify('chrome'), 'browser');
15
+ assert.strictEqual(sys.classify('MicrosoftEdge'), 'browser');
16
+ assert.strictEqual(sys.classify('powershell'), 'terminal');
17
+ assert.strictEqual(sys.classify('Code'), 'editor');
18
+ assert.strictEqual(sys.classify('postgres'), 'database');
19
+ assert.strictEqual(sys.classify('com.docker.backend'), 'container');
20
+ assert.strictEqual(sys.classify('git'), 'vcs');
21
+ assert.strictEqual(sys.classify('svchost'), 'system');
22
+ assert.strictEqual(sys.classify('mystery-thing'), 'other');
23
+ });
24
+
25
+ test('toAppRows() computes real per-app CPU% from a sample delta', () => {
26
+ const procs = [
27
+ { pid: 1, name: 'node', cpuSec: 2, memBytes: 1000 },
28
+ { pid: 2, name: 'node', cpuSec: 1, memBytes: 500 },
29
+ { pid: 3, name: 'chrome', cpuSec: 0.5, memBytes: 4000 },
30
+ ];
31
+ // One second earlier: node pid1 used 1s CPU, pid2 1s, chrome 0.5s (no change).
32
+ const prev = new Map([
33
+ [1, { cpuSec: 1, ts: 0 }],
34
+ [2, { cpuSec: 1, ts: 0 }],
35
+ [3, { cpuSec: 0.5, ts: 0 }],
36
+ ]);
37
+ const { rows } = sys.toAppRows(procs, prev, 1000, /* cores */ 8);
38
+ const node = rows.find((r) => r.app === 'node');
39
+ const chrome = rows.find((r) => r.app === 'chrome');
40
+ assert.strictEqual(node.count, 2, 'two node processes folded into one app');
41
+ assert.ok(Math.abs(node.cpuPct - 100) < 1, 'pid1 grew 1 CPU-sec in 1 wall-sec = ~100% (pid2 flat)');
42
+ assert.strictEqual(chrome.cpuPct, 0, 'no CPU-sec delta → 0%');
43
+ assert.strictEqual(node.category, 'runtime');
44
+ });
45
+
46
+ test('toAppRows() uses ps instantaneous % on POSIX-style rows and clamps to cores', () => {
47
+ const procs = [{ pid: 9, name: 'python', memBytes: 10, cpuPctRaw: 1600 }]; // absurd value
48
+ const { rows } = sys.toAppRows(procs, null, 1000, /* cores */ 8);
49
+ assert.strictEqual(rows[0].cpuPct, 800, 'clamped to cores*100');
50
+ });
51
+
52
+ test('byCategory() aggregates counts and memory per category', () => {
53
+ const rows = [
54
+ { app: 'node', category: 'runtime', count: 2, cpuPct: 10, memBytes: 1000 },
55
+ { app: 'deno', category: 'runtime', count: 1, cpuPct: 5, memBytes: 1000 },
56
+ { app: 'chrome', category: 'browser', count: 4, cpuPct: 20, memBytes: 9999 },
57
+ ];
58
+ const by = sys.byCategory(rows);
59
+ assert.strictEqual(by.runtime.count, 3);
60
+ assert.strictEqual(by.runtime.apps, 2);
61
+ assert.strictEqual(by.browser.memBytes, 9999);
62
+ });
63
+
64
+ test('cpuPctFrom() turns two os.cpus() readings into a 0-100 percentage', () => {
65
+ const prev = [{ times: { user: 0, nice: 0, sys: 0, idle: 100, irq: 0 } }];
66
+ const cur = [{ times: { user: 50, nice: 0, sys: 0, idle: 150, irq: 0 } }];
67
+ assert.strictEqual(sys.cpuPctFrom(prev, cur), 50);
68
+ assert.strictEqual(sys.cpuPctFrom(null, cur), null, 'no previous reading → null (caller degrades)');
69
+ });
70
+
71
+ test('computeEstimate(): a finished review pass reports throughput + a completed ETA', () => {
72
+ const e = sys.computeEstimate({ mode: 'review', passNo: 1, lastPassMs: 420, filesTotal: 30, totalLines: 1500, cores: 8 });
73
+ assert.strictEqual(e.complete, true);
74
+ assert.match(e.etaLabel, /completed in/);
75
+ assert.ok(e.scanRateFps > 70 && e.scanRateFps < 72, 'files/sec from measured duration (' + e.scanRateFps + ')');
76
+ assert.strictEqual(e.lastPassMs, 420);
77
+ });
78
+
79
+ test('computeEstimate(): watch mode never claims completion and stretches under load', () => {
80
+ const calm = sys.computeEstimate({ mode: 'watch', passNo: 3, durationsMs: [200, 300, 400], filesTotal: 50, cores: 4, cpuPct: 20 });
81
+ assert.strictEqual(calm.complete, false);
82
+ assert.match(calm.etaLabel, /re-scan/);
83
+ assert.strictEqual(calm.avgPassMs, 300, 'rolling average of prior passes');
84
+ assert.strictEqual(calm.loadFactor, 1, 'no load penalty below 60% CPU');
85
+
86
+ const hot = sys.computeEstimate({ mode: 'watch', passNo: 3, durationsMs: [300], filesTotal: 50, cores: 4, cpuPct: 100 });
87
+ assert.strictEqual(hot.loadFactor, 1.4, 'ETA scales up under heavy system load');
88
+ });
89
+
90
+ test('systemStatic() / memInfo() always return sane, bounded numbers', () => {
91
+ const s = sys.systemStatic();
92
+ assert.ok(s.cores >= 1);
93
+ assert.ok(typeof s.platform === 'string' && s.platform.length);
94
+ const m = sys.memInfo();
95
+ assert.ok(m.memPct >= 0 && m.memPct <= 100, 'memory % bounded');
96
+ assert.ok(m.memUsed >= 0 && m.memUsed <= m.memTotal);
97
+ });
98
+
99
+ test('createMonitor().sample() advances a bounded rolling history', () => {
100
+ const mon = sys.createMonitor('.');
101
+ const a = mon.sample();
102
+ const b = mon.sample();
103
+ assert.ok(a.system.cpuPct >= 0 && a.system.cpuPct <= 100);
104
+ assert.ok(b.history.length >= a.history.length, 'each sample appends a point');
105
+ assert.ok(mon.historyLen <= 60, 'history capped');
106
+ assert.ok(a.self && typeof a.self.rss === 'number' && a.self.rss > 0, 'critic self memory is real');
107
+ });
108
+
109
+ test('processesAsync() resolves with an array and never throws (bounded, offline-safe)', (_t, done) => {
110
+ const to = setTimeout(() => assert.fail('processesAsync did not call back in 12s'), 12000);
111
+ sys.processesAsync((err, rows) => {
112
+ clearTimeout(to);
113
+ assert.ok(Array.isArray(rows) || err, 'either an array (possibly empty on a locked-down box) or an err');
114
+ if (Array.isArray(rows) && rows.length) {
115
+ const p = rows[0];
116
+ assert.ok(Number.isInteger(p.pid) && p.pid > 0, 'a process row has an integer pid');
117
+ assert.ok(typeof p.name === 'string' && p.name.length, 'and a name');
118
+ }
119
+ done();
120
+ });
121
+ });