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/ui.js ADDED
@@ -0,0 +1,443 @@
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
+ const sysinfo = require('./sysinfo');
30
+
31
+ const SEV_ORDER = ['BLOCKER', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'];
32
+ const UI_BASE_PORT = 4781; // distinct from the observatory's 4780
33
+ const PORT_SPAN = 15;
34
+
35
+ function relName(root) {
36
+ const parts = String(root).replace(/[\\/]+$/, '').split(/[\\/]/);
37
+ return parts[parts.length - 1] || String(root);
38
+ }
39
+
40
+ /* ------------------------------ snapshot model ---------------------------- */
41
+
42
+ function buildSnapshot(result, extra = {}) {
43
+ const counts = result.counts || {};
44
+ const cat = result.categoryCounts || {};
45
+ const cov = result.coverage || {};
46
+ const findings = result.findings || [];
47
+
48
+ const byDim = {};
49
+ for (const f of findings) (byDim[f.dimension] || (byDim[f.dimension] = [])).push(f);
50
+ const dimensions = DIMENSIONS.map((d) => {
51
+ const ds = byDim[d] || [];
52
+ const worst = SEV_ORDER.find((s) => ds.some((f) => f.severity === s));
53
+ const status = !ds.length ? 'REVIEW'
54
+ : (worst === 'BLOCKER' || worst === 'CRITICAL' || worst === 'HIGH') ? 'FAIL'
55
+ : (worst === 'MEDIUM' || worst === 'LOW') ? 'WARN' : 'INFO';
56
+ return { dimension: d, status, count: ds.length, worst: worst || '-' };
57
+ });
58
+
59
+ return {
60
+ empty: false,
61
+ project: relName((result.meta && result.meta.root) || ''),
62
+ root: (result.meta && result.meta.root) || '',
63
+ generatedAt: new Date().toISOString(),
64
+ cliVersion: (result.meta && result.meta.cliVersion) || 'unknown',
65
+ durationMs: (result.meta && result.meta.durationMs) || 0,
66
+ passNo: extra.passNo || 1,
67
+ addedCount: (extra.added || []).length,
68
+ resolvedCount: (extra.resolved || []).length,
69
+ verdict: execSummary(result),
70
+ counts,
71
+ categoryCounts: cat,
72
+ coverage: {
73
+ filesScanned: cov.filesScanned || 0,
74
+ totalLines: cov.totalLines || 0,
75
+ filesSkipped: cov.filesSkipped || 0,
76
+ },
77
+ ecosystems: (result.dependencies || []).map((e) => ({
78
+ name: e.name, manifest: e.manifest, declaredCount: e.declaredCount, lock: e.lock || null,
79
+ })),
80
+ dimensions,
81
+ findings: findings.map((f) => ({
82
+ file: f.file, line: f.line, severity: f.severity, category: f.category,
83
+ dimension: f.dimension, title: f.title, problem: f.problem, fix: f.fix, evidence: f.evidence,
84
+ })),
85
+ // OS-level live telemetry + expected-completion estimate (see sysinfo.js).
86
+ sys: extra.sys || null,
87
+ estimate: extra.estimate || null,
88
+ };
89
+ }
90
+
91
+ function emptySnapshot(root) {
92
+ return {
93
+ empty: true, project: relName(root), root, generatedAt: new Date().toISOString(),
94
+ passNo: 0, counts: {}, categoryCounts: {}, findings: [], dimensions: [], ecosystems: [],
95
+ };
96
+ }
97
+
98
+ /* ------------------------------- HTML shell ------------------------------- */
99
+
100
+ // NOTE: the embedded client script intentionally avoids backticks/${} so it can
101
+ // live inside this file's template literals without clashing with Node's parser.
102
+ function renderHtml(data, opts = {}) {
103
+ const live = opts.live ? 'true' : 'false';
104
+ const payload = JSON.stringify(data).replace(/</g, '\\u003c');
105
+ return `<!doctype html>
106
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
107
+ <title>Full-Stack Critic — ${escAttr(data.project)}</title>
108
+ <link rel="icon" href="data:,">
109
+ <style>
110
+ :root{--bg:#0b1020;--card:#151b2e;--ink:#e6ecff;--mut:#93a0c2;--line:#26304a;--critic:#ff5470;--high:#ff9f43;--med:#ffd166;--low:#8bd450;--info:#63b3ed;--ok:#3ddc97;}
111
+ *{box-sizing:border-box}
112
+ body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 system-ui,Segoe UI,Roboto,Arial,sans-serif}
113
+ header{padding:22px 26px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#111834,#0b1020)}
114
+ h1{margin:0;font-size:20px;letter-spacing:.3px}
115
+ h1 .proj{color:var(--info)}
116
+ .sub{color:var(--mut);margin-top:6px;font-size:12.5px}
117
+ main{padding:22px 26px;max-width:1080px;margin:0 auto}
118
+ .verdict{font-size:16px;font-weight:600;padding:12px 16px;border-radius:10px;background:var(--card);border:1px solid var(--line);margin-bottom:18px}
119
+ .grid{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:18px}
120
+ .chip{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 14px;min-width:78px}
121
+ .chip b{display:block;font-size:22px}
122
+ .chip span{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.6px}
123
+ .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)}
124
+ h2{font-size:14px;text-transform:uppercase;letter-spacing:.8px;color:var(--mut);margin:26px 0 10px}
125
+ table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
126
+ th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);vertical-align:top}
127
+ th{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.5px}
128
+ tr:last-child td{border-bottom:none}
129
+ code{background:#0c1226;border:1px solid var(--line);border-radius:5px;padding:1px 6px;color:#cdd8ff;font-size:12px}
130
+ .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}
131
+ .cat-fix{color:var(--critic)} .cat-optimize{color:var(--high)} .cat-delete{color:var(--med)} .cat-add{color:var(--ok)}
132
+ .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)}
133
+ details{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin-bottom:10px}
134
+ summary{cursor:pointer;font-weight:600}
135
+ .loc{color:var(--mut);font-size:12px;margin-left:8px}
136
+ .field{margin:8px 0} .field .k{color:var(--mut);font-size:11px;text-transform:uppercase}
137
+ footer{color:var(--mut);font-size:11.5px;padding:20px 26px;border-top:1px solid var(--line);margin-top:24px}
138
+ .pulse{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--ok);margin-right:6px;animation:p 1.4s infinite}
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)}
170
+ </style></head>
171
+ <body>
172
+ <header>
173
+ <h1>Full-Stack Critic <span class="proj" id="h-proj"></span></h1>
174
+ <div class="sub" id="h-sub"></div>
175
+ </header>
176
+ <main>
177
+ <div class="verdict" id="verdict"></div>
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>
185
+ <h2>12-Dimension Executive Summary</h2>
186
+ <table id="dims"><thead><tr><th>Dimension</th><th>Status</th><th>Findings</th><th>Worst</th></tr></thead><tbody></tbody></table>
187
+ <h2>Prioritised Action Plan</h2>
188
+ <div id="plan"></div>
189
+ <h2>All Findings</h2>
190
+ <div id="findings"></div>
191
+ <h2>Resources & Dependencies</h2>
192
+ <div id="deps"></div>
193
+ </main>
194
+ <footer id="foot"></footer>
195
+ <script>
196
+ var INITIAL = ${payload};
197
+ var LIVE = ${live};
198
+ var SEV = ['BLOCKER','CRITICAL','HIGH','MEDIUM','LOW','INFO'];
199
+ var CATS = [['fix','FIX — correctness & security'],['optimize','OPTIMIZE — performance & structure'],['delete','DELETE — dead code & debug'],['add','ADD — tests, config & docs']];
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';}
246
+ function render(d){
247
+ document.getElementById('h-proj').textContent = '— ' + (d.project||'');
248
+ var sub = (d.root||'') + ' · ' + (d.generatedAt? new Date(d.generatedAt).toLocaleString():'');
249
+ if(d.empty){ sub = 'waiting for first analysis pass…'; }
250
+ else if(LIVE){ sub += ' · live pass #' + d.passNo + ' (+'+d.addedCount+' new / -'+d.resolvedCount+' fixed)'; }
251
+ document.getElementById('h-sub').textContent = sub;
252
+
253
+ renderEta(d.estimate,d); renderOs(d.sys); renderTasks(d.sys);
254
+
255
+ var v=document.getElementById('verdict'); v.textContent='';
256
+ if(d.empty){ v.appendChild(el('span',null,'Analysis starting…')); return; }
257
+ v.appendChild(el('span','pulse'));
258
+ v.appendChild(el('span',null,d.verdict||''));
259
+
260
+ var c=document.getElementById('counts'); c.textContent='';
261
+ 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); });
262
+ ['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); });
263
+
264
+ var tb=document.querySelector('#dims tbody'); tb.textContent='';
265
+ (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); });
266
+
267
+ var p=document.getElementById('plan'); p.textContent='';
268
+ CATS.forEach(function(pair){
269
+ var key=pair[0]; var items=(d.findings||[]).filter(function(f){return f.category===key;});
270
+ if(!items.length) return;
271
+ var h=el('h3','cat-'+key, pair[1]+' — '+items.length+' item(s)'); p.appendChild(h);
272
+ var t=el('table'); var thead=el('thead'); var hr=el('tr');
273
+ ['Sev','Where','Action','Fix'].forEach(function(x){hr.appendChild(el('th',null,x));}); thead.appendChild(hr); t.appendChild(thead);
274
+ var body=el('tbody');
275
+ 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); });
276
+ t.appendChild(body); p.appendChild(t);
277
+ });
278
+
279
+ var fd=document.getElementById('findings'); fd.textContent='';
280
+ if(!(d.findings||[]).length){ fd.appendChild(el('p','sub','No automated findings.')); }
281
+ (d.findings||[]).slice(0,400).forEach(function(f){
282
+ 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);
283
+ [['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); });
284
+ fd.appendChild(det);
285
+ });
286
+
287
+ var dp=document.getElementById('deps'); dp.textContent='';
288
+ 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).');
289
+ dp.appendChild(info);
290
+ if((d.ecosystems||[]).length){ var t=el('table'); var body=el('tbody');
291
+ 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); });
292
+ t.appendChild(body); dp.appendChild(t); }
293
+
294
+ 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.';
295
+ }
296
+ render(INITIAL);
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.')); } }
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);
300
+ function esc(s){return String(s==null?'':s);}
301
+ </script>
302
+ </body></html>`;
303
+ }
304
+
305
+ function escAttr(s) {
306
+ return String(s == null ? '' : s).replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
307
+ }
308
+
309
+ /* ------------------------------ free port probe --------------------------- */
310
+
311
+ function pickFreePort(base, span) {
312
+ const b = Number(base) || UI_BASE_PORT;
313
+ const s = Number(span) || PORT_SPAN;
314
+ return new Promise((resolve) => {
315
+ let p = b;
316
+ (function test() {
317
+ if (p > b + s) return resolve(null);
318
+ const probe = net.createServer();
319
+ probe.once('error', () => { p++; test(); });
320
+ probe.once('listening', () => { probe.close(() => resolve(p)); });
321
+ probe.listen(p, '127.0.0.1');
322
+ })();
323
+ });
324
+ }
325
+
326
+ /* ------------------------------- static (review) -------------------------- */
327
+
328
+ function writeStaticAndOpen(result, opts = {}) {
329
+ try {
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 || {}));
343
+ const dir = path.join(os.tmpdir(), 'fullstack-critic');
344
+ fs.mkdirSync(dir, { recursive: true });
345
+ const stamp = data.generatedAt.replace(/[:.]/g, '-');
346
+ const file = path.join(dir, `${data.project}-${stamp}.html`);
347
+ fs.writeFileSync(file, renderHtml(data, { live: false }));
348
+ const url = 'file:///' + file.replace(/\\/g, '/');
349
+ if (opts.open !== false) openBrowser(url);
350
+ return { file, url };
351
+ } catch (e) {
352
+ return { error: e.code || e.message };
353
+ }
354
+ }
355
+
356
+ /* --------------------------------- live (watch) --------------------------- */
357
+
358
+ function startLive(root, opts = {}) {
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
+
391
+ const server = http.createServer((req, res) => {
392
+ if (req.url === '/data') {
393
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
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) }));
400
+ }
401
+ if (req.url === '/health') {
402
+ res.writeHead(200, { 'content-type': 'application/json' });
403
+ return res.end(JSON.stringify({ product: 'critic-ui', port: server.address() ? server.address().port : 0 }));
404
+ }
405
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
406
+ res.end(renderHtml(withSys(data), { live: true }));
407
+ });
408
+
409
+ return pickFreePort(opts.port).then((port) => {
410
+ if (!port) { for (const t of timers) clearInterval(t); return null; } // no free port → skip UI, never break the workflow
411
+ return new Promise((resolve) => {
412
+ server.once('error', (e) => {
413
+ for (const t of timers) clearInterval(t);
414
+ process.stderr.write(`critic UI server unavailable (${e.code || e.message}); watch continues without the dashboard.\n`);
415
+ resolve(null);
416
+ });
417
+ server.once('listening', () => {
418
+ const bound = server.address().port;
419
+ resolve({
420
+ port: bound,
421
+ url: `http://127.0.0.1:${bound}/`,
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
+ },
433
+ });
434
+ });
435
+ server.listen(port, '127.0.0.1');
436
+ });
437
+ });
438
+ }
439
+
440
+ module.exports = {
441
+ buildSnapshot, renderHtml, writeStaticAndOpen, startLive, pickFreePort,
442
+ UI_BASE_PORT, SEV_ORDER,
443
+ };
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
+ });