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/README.md +9 -12
- package/bin/fullstack-critic.js +10 -2
- package/docs/GETTING_STARTED.md +11 -19
- package/package.json +1 -1
- package/src/analyzer.js +66 -1
- package/src/cli.js +66 -2
- package/src/observatory.js +204 -0
- package/src/report.js +35 -4
- package/src/rules.js +115 -0
- package/src/sysinfo.js +395 -0
- package/src/ui.js +443 -0
- package/src/util.js +11 -1
- package/src/watcher.js +12 -2
- package/tests/observatory.test.js +111 -0
- package/tests/sysinfo.test.js +121 -0
package/README.md
CHANGED
|
@@ -14,20 +14,17 @@ Claude Code, Gemini CLI, GitHub Copilot, Cursor, Qoder, Antigravity, or any assi
|
|
|
14
14
|
|
|
15
15
|
## Install & run (no AI required)
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
> Until then, install **from this folder** — the commands below are tested and work.
|
|
17
|
+
Published on npm as **`fullstack-critic`** (v1.0.0). Install globally, or run with
|
|
18
|
+
zero install via `npx` (verified working):
|
|
20
19
|
|
|
21
20
|
```bash
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
node bin/fullstack-critic.js review . # macOS / Linux
|
|
30
|
-
npx --yes /path/to/fullstack-critic review . # npx against a local folder/git
|
|
21
|
+
npm install -g fullstack-critic # or: pnpm add -g / yarn global add / bun add -g
|
|
22
|
+
npx fullstack-critic review . # zero-install, one-shot
|
|
23
|
+
|
|
24
|
+
# Working from a clone instead (offline / developing the critic itself):
|
|
25
|
+
npm install -g . # from inside the cloned folder
|
|
26
|
+
node bin/fullstack-critic.js review . # no install at all
|
|
27
|
+
npx --yes /path/to/fullstack-critic review . # npx against a local folder/git
|
|
31
28
|
```
|
|
32
29
|
|
|
33
30
|
```bash
|
package/bin/fullstack-critic.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
|
-
const
|
|
4
|
-
|
|
3
|
+
const out = require('../src/cli').run(process.argv.slice(2));
|
|
4
|
+
if (out && typeof out.then === 'function') {
|
|
5
|
+
// Async commands (e.g. `watch --dash`) resolve after the process is already
|
|
6
|
+
// kept alive by the watcher; a pending promise simply never resolves.
|
|
7
|
+
out
|
|
8
|
+
.then((code) => { process.exitCode = Number.isFinite(code) ? code : 0; })
|
|
9
|
+
.catch((err) => { process.stderr.write(`${(err && err.stack) || err}\n`); process.exitCode = 1; });
|
|
10
|
+
} else {
|
|
11
|
+
process.exitCode = Number.isFinite(out) ? out : 0;
|
|
12
|
+
}
|
package/docs/GETTING_STARTED.md
CHANGED
|
@@ -17,31 +17,23 @@ It ships two ways to use it:
|
|
|
17
17
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
20
|
-
The CLI is a zero-dependency Node package (Node ≥ 18)
|
|
20
|
+
The CLI is a zero-dependency Node package (Node ≥ 18), published on npm as
|
|
21
|
+
**`fullstack-critic`** (v1.0.0).
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
```bash
|
|
24
|
+
# Recommended — from the public registry:
|
|
25
|
+
npm install -g fullstack-critic # or: pnpm add -g / yarn global add / bun add -g
|
|
26
|
+
npx fullstack-critic review . # zero-install, one-shot
|
|
27
|
+
```
|
|
25
28
|
|
|
26
29
|
```bash
|
|
27
|
-
#
|
|
30
|
+
# Or work from a clone (offline / developing the critic itself):
|
|
28
31
|
cd fullstack-critic
|
|
29
|
-
npm install -g .
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
npm install -g C:\path\to\fullstack-critic
|
|
33
|
-
|
|
34
|
-
# C) No install — run straight from the folder:
|
|
35
|
-
node bin/fullstack-critic.js review .
|
|
36
|
-
|
|
37
|
-
# D) pnpm / yarn equivalents:
|
|
38
|
-
pnpm add -g ./fullstack-critic
|
|
39
|
-
yarn global add file:./fullstack-critic
|
|
32
|
+
npm install -g . # equivalent: npm link
|
|
33
|
+
node bin/fullstack-critic.js review . # no install at all
|
|
34
|
+
pnpm add -g ./fullstack-critic # pnpm / yarn equivalents
|
|
40
35
|
```
|
|
41
36
|
|
|
42
|
-
> To make `npx fullstack-critic` / `npm i -g fullstack-critic` work for everyone,
|
|
43
|
-
> a maintainer must run `npm publish` from this folder first (see “Publishing”).
|
|
44
|
-
|
|
45
37
|
Once installed it provides the `critic` (and `fullstack-critic`) command.
|
|
46
38
|
|
|
47
39
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstack-critic",
|
|
3
|
-
"version": "1.0.
|
|
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/analyzer.js
CHANGED
|
@@ -42,6 +42,7 @@ function scanFile(file) {
|
|
|
42
42
|
// Whole-file suppression: a `critic-ignore-file` marker in the header (for rule-definition files).
|
|
43
43
|
if (/critic-ignore-file/.test(lines.slice(0, 2).join('\n'))) return { findings, lines: lines.length, scannable: true };
|
|
44
44
|
const perRule = {};
|
|
45
|
+
const ctx = { rel: file.rel };
|
|
45
46
|
|
|
46
47
|
for (const rule of LINE_RULES) {
|
|
47
48
|
if (!ruleApplies(rule, file.ext)) continue;
|
|
@@ -50,7 +51,7 @@ function scanFile(file) {
|
|
|
50
51
|
const line = lines[i];
|
|
51
52
|
if (!rule.test.test(line)) continue;
|
|
52
53
|
if (/critic-ignore/.test(line)) continue; // inline suppression: `// critic-ignore` (like eslint-disable-line)
|
|
53
|
-
if (rule.skipIf && rule.skipIf(line)) continue;
|
|
54
|
+
if (rule.skipIf && rule.skipIf(line, ctx)) continue;
|
|
54
55
|
perRule[rule.id]++;
|
|
55
56
|
if (perRule[rule.id] > CAP_PER_RULE) continue;
|
|
56
57
|
findings.push({
|
|
@@ -92,6 +93,22 @@ function scanFile(file) {
|
|
|
92
93
|
fix: 'Split along responsibility boundaries.', verify: 'each module has one reason to change', confirmed: true,
|
|
93
94
|
});
|
|
94
95
|
}
|
|
96
|
+
// React: list rendering without a key prop (reconciliation + focus bugs).
|
|
97
|
+
if (['.jsx', '.tsx', '.js'].includes(file.ext)) {
|
|
98
|
+
for (let i = 0; i < lines.length; i++) {
|
|
99
|
+
const m = lines[i].match(/\.map\s*\(\s*\(?([A-Za-z$_][\w$]*)\)?\s*=>\s*\(?\s*<[A-Za-z]/);
|
|
100
|
+
if (m && !/\bkey\s*=|\{\.\.\.[a-z]|critic-ignore/.test(lines.slice(i, i + 4).join(' '))) {
|
|
101
|
+
findings.push({
|
|
102
|
+
ruleId: 'missing-list-key', severity: 'LOW', category: 'fix', dimension: 'Frontend',
|
|
103
|
+
title: 'List rendered without a key prop', file: file.rel, line: i + 1,
|
|
104
|
+
problem: `.map() returns JSX without an explicit key (var ${m[1]}).`,
|
|
105
|
+
evidence: snippet(lines[i]), impact: 'Wrong item reconciliation, lost focus/state on reorder.',
|
|
106
|
+
fix: 'Add key={item.id} (stable identity, not the array index).',
|
|
107
|
+
verify: 'React dev console shows no key warnings for this list.', confirmed: 'suspected',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
95
112
|
return { findings, lines: lines.length, scannable: true };
|
|
96
113
|
}
|
|
97
114
|
|
|
@@ -157,6 +174,54 @@ function structuralFindings(root, files, ctx) {
|
|
|
157
174
|
fix: 'Add purpose, stack, setup, run, and test instructions.', verify: 'a new contributor can run the app from README alone.', confirmed: true,
|
|
158
175
|
});
|
|
159
176
|
}
|
|
177
|
+
|
|
178
|
+
// Packaging hygiene (npm manifests).
|
|
179
|
+
const pkgPath = path.join(root, 'package.json');
|
|
180
|
+
if (exists(pkgPath)) {
|
|
181
|
+
let pkg = null;
|
|
182
|
+
try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); } catch { /* reported by deps parse */ }
|
|
183
|
+
if (pkg) {
|
|
184
|
+
if (!Array.isArray(pkg.files) && pkg.private !== true) {
|
|
185
|
+
F.push({
|
|
186
|
+
ruleId: 'pkg-no-files-field', severity: 'LOW', category: 'optimize', dimension: 'Infrastructure',
|
|
187
|
+
title: 'Publishable package has no "files" allowlist', file: 'package.json', line: 1,
|
|
188
|
+
problem: 'Without "files", npm packs everything not gitignored — tests, docs, env samples.',
|
|
189
|
+
evidence: `"files" missing; name=${pkg.name || '(unnamed)'}`, impact: 'Bloated tarballs, slower installs, accidental source leakage.',
|
|
190
|
+
fix: 'Add a "files" array listing dist/src/bin and other publishable paths.', verify: 'npm pack --dry-run lists only intended files.', confirmed: true,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (!pkg.engines) {
|
|
194
|
+
F.push({
|
|
195
|
+
ruleId: 'pkg-no-engines', severity: 'INFO', category: 'add', dimension: 'Infrastructure',
|
|
196
|
+
title: 'No "engines" field in package.json', file: 'package.json', line: 1,
|
|
197
|
+
problem: 'The supported Node range is undocumented for consumers and CI.',
|
|
198
|
+
evidence: '"engines" missing', impact: 'Runs on unsupported runtimes; env drift.',
|
|
199
|
+
fix: 'Declare "engines": { "node": ">=18" } (or the real supported range).', verify: 'npm install warns on older Node.', confirmed: true,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// SPA / server routing: an Express-style server delivering an SPA without a
|
|
206
|
+
// history fallback 404s every deep link on hard refresh.
|
|
207
|
+
const serverRouters = files.filter((f) => /\.(js|ts|mjs|cjs)$/.test(f.ext) && /app\.(get|use)\s*\(|router\.(get|use)\s*\(/.test(readText(f.abs) || ''));
|
|
208
|
+
const hasApiRoutes = serverRouters.some((f) => /\b(api|\/api)\b|\.use\s*\(/.test(readText(f.abs) || ''));
|
|
209
|
+
const hasSpa = rels.has('index.html') || files.some((f) => /\.(jsx|tsx|vue|svelte)$/.test(f.ext)) || files.some((f) => /(^|\/)(src|app|pages)\/(index|App)\.(jsx?|tsx?|vue|svelte)$/i.test(f.rel));
|
|
210
|
+
const hasFallback = serverRouters.some((f) => {
|
|
211
|
+
const t = readText(f.abs) || '';
|
|
212
|
+
return /history\(\)|http\.createServerHandler|connect\(\)\.middleware|\*\s*['")]\s|notFound|sendFile\s*\(\s*(path|__dirname|require\.resolve)/i.test(t) && /app\.(get|use)\s*\(\s*['"]?\*|all\s*\(\s*['"]\*|history|fallback/i.test(t);
|
|
213
|
+
});
|
|
214
|
+
if (hasApiRoutes && hasSpa && !hasFallback && !isGo && !isRust) {
|
|
215
|
+
F.push({
|
|
216
|
+
ruleId: 'spa-no-history-fallback', severity: 'MEDIUM', category: 'fix', dimension: 'Architecture',
|
|
217
|
+
title: 'SPA served by an API server with no history fallback route', file: '.', line: 1,
|
|
218
|
+
problem: 'Client-side routes (/settings, /items/5) have no server handler — direct visits and refreshes 404.',
|
|
219
|
+
evidence: 'express-style routers with api routes + SPA entry files, no catch-all → index.html',
|
|
220
|
+
impact: 'Broken deep links, failed bookmarks, worse SEO and support load.',
|
|
221
|
+
fix: 'Mount a last-resort catch-all that serves index.html for non-GET/api paths (e.g. express-history-api-fallback or app.get("*")).',
|
|
222
|
+
verify: 'refreshing any client route returns 200 with the app.', confirmed: 'suspected',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
160
225
|
return F;
|
|
161
226
|
}
|
|
162
227
|
|
package/src/cli.js
CHANGED
|
@@ -11,6 +11,15 @@ const { renderConsole, renderMarkdown } = require('./report');
|
|
|
11
11
|
const { watch } = require('./watcher');
|
|
12
12
|
const { init } = require('./init');
|
|
13
13
|
const { analyzeDeps } = require('./deps');
|
|
14
|
+
const ui = require('./ui');
|
|
15
|
+
|
|
16
|
+
// Auto-open the browser only when a human is watching: an interactive stdout and
|
|
17
|
+
// not a CI box. The UI file/server is still produced with --open to force it.
|
|
18
|
+
function shouldOpen(args) {
|
|
19
|
+
if (args.flags.open) return true;
|
|
20
|
+
if (args.flags['no-open']) return false;
|
|
21
|
+
return !!process.stdout.isTTY && !process.env.CI;
|
|
22
|
+
}
|
|
14
23
|
|
|
15
24
|
// Map a --fail-on level to the worst-order index that still triggers exit 1.
|
|
16
25
|
// order index: BLOCKER=0 CRITICAL=1 HIGH=2 MEDIUM=3 LOW=4 INFO=5
|
|
@@ -72,20 +81,68 @@ function cmdReview(args) {
|
|
|
72
81
|
const p = args.flags.md || args.flags.o;
|
|
73
82
|
safeWrite(typeof p === 'string' ? p : '.critic-report.md', renderMarkdown(result), 'Report');
|
|
74
83
|
}
|
|
84
|
+
// Built-in per-run dashboard: a fresh, project-scoped HTML file opened in the
|
|
85
|
+
// browser. Skipped with --no-ui; browser-launch gated by TTY/CI (see shouldOpen).
|
|
86
|
+
if (!args.flags['no-ui']) {
|
|
87
|
+
const r = ui.writeStaticAndOpen(result, { open: shouldOpen(args) });
|
|
88
|
+
if (r.file) process.stdout.write(`\x1b[36mUI report:\x1b[0m ${r.file}\n`);
|
|
89
|
+
else if (r.error) process.stderr.write(`could not produce UI report: ${r.error}\n`);
|
|
90
|
+
}
|
|
75
91
|
return exitCodeFor(result, args.flags['fail-on']);
|
|
76
92
|
}
|
|
77
93
|
|
|
78
|
-
function cmdWatch(args) {
|
|
94
|
+
async function cmdWatch(args) {
|
|
79
95
|
const target = targetOf(args);
|
|
96
|
+
let observer = null;
|
|
97
|
+
if (args.flags.dash) {
|
|
98
|
+
const obs = require('./observatory');
|
|
99
|
+
const wanted = Number(args.flags.port) || obs.DEFAULT_PORT;
|
|
100
|
+
process.stdout.write(`\x1b[90m[observatory] ensuring dashboard on port ${wanted}…\x1b[0m\n`);
|
|
101
|
+
const { port, reused } = await obs.ensureServer({ port: wanted });
|
|
102
|
+
observer = obs.createAdapter({ port, root: target, cliVersion: readVersion() });
|
|
103
|
+
await observer.open();
|
|
104
|
+
if (shouldOpen(args)) obs.openBrowser(`http://127.0.0.1:${port}/`);
|
|
105
|
+
process.stdout.write(
|
|
106
|
+
`\x1b[36m[observatory] streaming this watch → http://127.0.0.1:${port}/ \u00b7 ${reused ? 'reused running dashboard' : 'started new dashboard'}\x1b[0m\n`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
// Built-in per-run live dashboard (default). Single process, single project,
|
|
110
|
+
// re-rendered every pass — no shared history, so nothing to clear between runs.
|
|
111
|
+
let dash = null;
|
|
112
|
+
if (!args.flags['no-ui']) {
|
|
113
|
+
dash = await ui.startLive(target, { port: Number(args.flags['ui-port']) || undefined });
|
|
114
|
+
if (dash) {
|
|
115
|
+
if (shouldOpen(args)) require('./observatory').openBrowser(dash.url);
|
|
116
|
+
process.stdout.write(`\x1b[36mUI (live):\x1b[0m ${dash.url}\n`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
let passNo = 0;
|
|
120
|
+
const onPass = (info) => {
|
|
121
|
+
passNo += 1;
|
|
122
|
+
if (dash) { try { dash.set(info.result, { passNo, added: info.added, resolved: info.resolved }); } catch (e) { process.stderr.write(`ui set error: ${e.message}\n`); } }
|
|
123
|
+
if (observer) { try { observer.pass(info); } catch (e) { process.stderr.write(`observatory onPass error: ${e.message}\n`); } }
|
|
124
|
+
};
|
|
80
125
|
const w = watch(target, {
|
|
81
126
|
debounceMs: Number(args.flags.debounce) || 400,
|
|
82
127
|
report: (args.flags['report-file'] != null) ? (typeof args.flags['report-file'] === 'string' ? args.flags['report-file'] : '.critic-report.md') : '.critic-report.md',
|
|
83
128
|
json: args.flags.json ? (typeof args.flags.json === 'string' ? args.flags.json : '.critic-review.json') : null,
|
|
129
|
+
onPass: (dash || observer) ? onPass : null,
|
|
130
|
+
});
|
|
131
|
+
process.on('SIGINT', () => {
|
|
132
|
+
if (observer) observer.close('sigint');
|
|
133
|
+
if (dash) dash.close();
|
|
134
|
+
w.close();
|
|
135
|
+
process.stdout.write('\nstopped watching.\n');
|
|
136
|
+
process.exit(0);
|
|
84
137
|
});
|
|
85
|
-
process.on('SIGINT', () => { w.close(); process.stdout.write('\nstopped watching.\n'); process.exit(0); });
|
|
86
138
|
return 0;
|
|
87
139
|
}
|
|
88
140
|
|
|
141
|
+
function readVersion() {
|
|
142
|
+
try { return JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version; }
|
|
143
|
+
catch { return 'unknown'; }
|
|
144
|
+
}
|
|
145
|
+
|
|
89
146
|
function cmdInit(args) {
|
|
90
147
|
const target = path.resolve(args._[1] || '.');
|
|
91
148
|
init(target);
|
|
@@ -134,11 +191,18 @@ Flags:
|
|
|
134
191
|
--fail-on <level> Exit 1 at this severity: blocker|high|medium|low|never (review default: critical)
|
|
135
192
|
--no-code Skip code line-scanning (dependencies + structure only)
|
|
136
193
|
--debounce <ms> Watch mode settle time (default 400)
|
|
194
|
+
--dash Additionally stream this watch into the external 3D Session Observatory
|
|
195
|
+
--port <n> Session Observatory port when using --dash (default 4780)
|
|
196
|
+
--ui-port <n> Base port for the built-in live dashboard in watch mode (default 4781; walks to a free port)
|
|
197
|
+
--no-ui Do not launch the built-in dashboard (review/watch)
|
|
198
|
+
--open Force the browser to open even in non-TTY/CI contexts
|
|
199
|
+
--no-open Generate the dashboard but do not auto-open a browser window
|
|
137
200
|
--quiet Print only the summary line
|
|
138
201
|
|
|
139
202
|
Examples:
|
|
140
203
|
npx fullstack-critic review .
|
|
141
204
|
critic watch ./my-app --md CRITIC_REPORT.md
|
|
205
|
+
critic watch . --dash # live 3D mission control of the critic's work
|
|
142
206
|
critic init && critic audit
|
|
143
207
|
`);
|
|
144
208
|
return 0;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Session Observatory adapter — the opt-in bridge between `critic watch` and a
|
|
4
|
+
* local 3D Session Observatory dashboard (`--dash`).
|
|
5
|
+
*
|
|
6
|
+
* It turns every watch-loop pass into protocol events and streams them to the
|
|
7
|
+
* observatory server over HTTP `POST /ingest`, so a critic run shows up as a
|
|
8
|
+
* living orb whose orbital rings are the analysis passes and findings it just
|
|
9
|
+
* surfaced. Design rules (see plan):
|
|
10
|
+
* - Zero behavior change when `--dash` is absent (this module is only loaded
|
|
11
|
+
* when the flag is present).
|
|
12
|
+
* - Zero dependencies — only Node core (http / child_process / os).
|
|
13
|
+
* - Never block or break the workflow over telemetry: every ingest is
|
|
14
|
+
* fire-and-forget with errors swallowed.
|
|
15
|
+
* - Port policy: reuse a healthy server on the requested port, else spawn one
|
|
16
|
+
* (which itself walks to the next free port), and propagate that exact port.
|
|
17
|
+
*/
|
|
18
|
+
const http = require('http');
|
|
19
|
+
const { spawn } = require('child_process');
|
|
20
|
+
|
|
21
|
+
const DEFAULT_PORT = 4780;
|
|
22
|
+
const PORT_SPAN = 10;
|
|
23
|
+
const MAX_FINDING_EVENTS = 60; // cap so a huge diff can't flood the bus
|
|
24
|
+
|
|
25
|
+
/* ------------------------------- transport -------------------------------- */
|
|
26
|
+
|
|
27
|
+
function postEvent(port, event) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
let body;
|
|
30
|
+
try { body = JSON.stringify(event); } catch { return resolve(); }
|
|
31
|
+
const req = http.request(
|
|
32
|
+
{
|
|
33
|
+
host: '127.0.0.1', port, path: '/ingest', method: 'POST', agent: false,
|
|
34
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) },
|
|
35
|
+
timeout: 2000,
|
|
36
|
+
},
|
|
37
|
+
(res) => { res.on('data', () => {}); res.on('end', () => resolve()); }
|
|
38
|
+
);
|
|
39
|
+
req.on('error', () => resolve()); // dashboard down → workflow unaffected
|
|
40
|
+
req.on('timeout', () => { req.destroy(); resolve(); });
|
|
41
|
+
req.end(body);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function healthProbe(port) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
const req = http.get({ host: '127.0.0.1', port, path: '/health', timeout: 1000, agent: false }, (res) => {
|
|
48
|
+
let data = '';
|
|
49
|
+
res.on('data', (c) => { data += c; });
|
|
50
|
+
res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(null); } });
|
|
51
|
+
});
|
|
52
|
+
req.on('error', () => resolve(null));
|
|
53
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
58
|
+
|
|
59
|
+
/* ------------------------------ server launch ------------------------------ */
|
|
60
|
+
|
|
61
|
+
function resolveLauncher() {
|
|
62
|
+
// Prefer a locally installed copy; otherwise let npx fetch it on demand.
|
|
63
|
+
try {
|
|
64
|
+
const bin = require.resolve('session-observatory/bin/session-observatory.js');
|
|
65
|
+
return { cmd: process.execPath, prefix: [bin] };
|
|
66
|
+
} catch {
|
|
67
|
+
const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
68
|
+
return { cmd: npx, prefix: ['--yes', 'session-observatory'] };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function spawnServer(port) {
|
|
73
|
+
const { cmd, prefix } = resolveLauncher();
|
|
74
|
+
const args = [...prefix, 'serve', '--port', String(port), '--no-open'];
|
|
75
|
+
try {
|
|
76
|
+
const child = spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
77
|
+
child.on('error', () => { /* npx missing — the poll below just won't find it */ });
|
|
78
|
+
child.unref();
|
|
79
|
+
return child;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Ensure a dashboard is answering, honouring the port policy. Returns
|
|
87
|
+
* { port, reused, spawned }.
|
|
88
|
+
*/
|
|
89
|
+
async function ensureServer(opts = {}) {
|
|
90
|
+
const base = Number(opts.port) || DEFAULT_PORT;
|
|
91
|
+
const existing = await healthProbe(base);
|
|
92
|
+
if (existing && existing.product === 'session-observatory') {
|
|
93
|
+
return { port: existing.port || base, reused: true, spawned: null };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const spawned = spawnServer(base);
|
|
97
|
+
// Poll a small window: a fresh server may walk up to a neighbouring free port.
|
|
98
|
+
for (let attempt = 0; attempt < 12; attempt++) {
|
|
99
|
+
await sleep(400);
|
|
100
|
+
for (let p = base; p <= base + PORT_SPAN; p++) {
|
|
101
|
+
const h = await healthProbe(p);
|
|
102
|
+
if (h && h.product === 'session-observatory') return { port: h.port || p, reused: false, spawned };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Never found — still return the base port so ingest calls fail silently.
|
|
106
|
+
return { port: base, reused: false, spawned };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* --------------------------------- adapter -------------------------------- */
|
|
110
|
+
|
|
111
|
+
function createAdapter(opts = {}) {
|
|
112
|
+
const port = Number(opts.port) || DEFAULT_PORT;
|
|
113
|
+
const root = opts.root || process.cwd();
|
|
114
|
+
const sessionId = opts.sessionId || `critic-${process.pid}`;
|
|
115
|
+
const cliVersion = opts.cliVersion || 'unknown';
|
|
116
|
+
const send = (type, payload, metrics) =>
|
|
117
|
+
postEvent(port, { ts: Date.now(), sessionId, source: 'critic', type, payload: payload || {}, metrics });
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
sessionId,
|
|
121
|
+
port,
|
|
122
|
+
|
|
123
|
+
open() {
|
|
124
|
+
return send('session.open', {
|
|
125
|
+
label: 'fullstack-critic watch',
|
|
126
|
+
root,
|
|
127
|
+
pid: process.pid,
|
|
128
|
+
cli: cliVersion,
|
|
129
|
+
cwd: process.cwd(),
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Called once per watch pass. Emits an analysis background batch, the
|
|
135
|
+
* finding-added / finding-fixed diff, and a metrics snapshot so the HUD can
|
|
136
|
+
* show severity counts and a live rss/cpu heartbeat.
|
|
137
|
+
*/
|
|
138
|
+
pass(info = {}) {
|
|
139
|
+
const result = info.result;
|
|
140
|
+
if (!result) return Promise.resolve();
|
|
141
|
+
const events = [];
|
|
142
|
+
const meta = result.meta || {};
|
|
143
|
+
const cov = result.coverage || {};
|
|
144
|
+
events.push(send('bg.batch', {
|
|
145
|
+
category: 'analysis', count: 1,
|
|
146
|
+
durationMs: meta.durationMs || 0, filesScanned: cov.filesScanned || 0,
|
|
147
|
+
}));
|
|
148
|
+
|
|
149
|
+
const added = (info.added || []).slice(0, MAX_FINDING_EVENTS);
|
|
150
|
+
const resolved = (info.resolved || []).slice(0, MAX_FINDING_EVENTS);
|
|
151
|
+
for (const f of added) {
|
|
152
|
+
events.push(send('bg.batch', {
|
|
153
|
+
category: 'analysis', kind: 'finding-added', count: 1,
|
|
154
|
+
ruleId: f.ruleId, severity: f.severity, file: f.file, line: f.line, message: f.title,
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
for (const f of resolved) {
|
|
158
|
+
events.push(send('bg.batch', {
|
|
159
|
+
category: 'analysis', kind: 'finding-fixed', count: 1,
|
|
160
|
+
ruleId: f.ruleId, severity: f.severity, file: f.file, line: f.line, message: f.title,
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const counts = result.counts || {};
|
|
165
|
+
const mem = process.memoryUsage();
|
|
166
|
+
events.push(send('metrics', {}, {
|
|
167
|
+
critical: (counts.CRITICAL || 0) + (counts.BLOCKER || 0),
|
|
168
|
+
high: counts.HIGH || 0,
|
|
169
|
+
medium: counts.MEDIUM || 0,
|
|
170
|
+
low: counts.LOW || 0,
|
|
171
|
+
info: counts.INFO || 0,
|
|
172
|
+
total: (result.findings || []).length,
|
|
173
|
+
filesScanned: cov.filesScanned || 0,
|
|
174
|
+
passMs: meta.durationMs || 0,
|
|
175
|
+
rssMb: Math.round(mem.rss / 1048576),
|
|
176
|
+
}));
|
|
177
|
+
return Promise.all(events);
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
close(reason) {
|
|
181
|
+
return send('session.close', { reason: reason || 'watch-stopped' });
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* ------------------------------ browser open ------------------------------ */
|
|
187
|
+
|
|
188
|
+
function openBrowser(url) {
|
|
189
|
+
try {
|
|
190
|
+
const platform = process.platform;
|
|
191
|
+
let cmd, args;
|
|
192
|
+
if (platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""', url.replace(/&/g, '^&')]; }
|
|
193
|
+
else if (platform === 'darwin') { cmd = 'open'; args = [url]; }
|
|
194
|
+
else { cmd = 'xdg-open'; args = [url]; }
|
|
195
|
+
const child = spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
196
|
+
child.on('error', () => {});
|
|
197
|
+
child.unref();
|
|
198
|
+
return true;
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = { ensureServer, createAdapter, openBrowser, postEvent, healthProbe, DEFAULT_PORT };
|
package/src/report.js
CHANGED
|
@@ -47,11 +47,42 @@ function renderConsole(result) {
|
|
|
47
47
|
lines.push('Action plan: ' +
|
|
48
48
|
`${categoryCounts.fix} fix · ${categoryCounts.optimize} optimize · ${categoryCounts.delete} delete · ${categoryCounts.add} add`);
|
|
49
49
|
lines.push('Verdict: ' + execSummary(result));
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
|
|
51
|
+
// Group every finding by dimension so all severity levels — not just blocking —
|
|
52
|
+
// are visible in the terminal (packaging, routing, API, validation, frontend, backend…).
|
|
53
|
+
const byDim = {};
|
|
54
|
+
for (const f of result.findings) (byDim[f.dimension] || (byDim[f.dimension] = [])).push(f);
|
|
55
|
+
const orderedDims = [
|
|
56
|
+
...DIMENSIONS.filter((d) => byDim[d]),
|
|
57
|
+
...Object.keys(byDim).filter((d) => !DIMENSIONS.includes(d)),
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
if (orderedDims.length) {
|
|
61
|
+
lines.push('');
|
|
62
|
+
lines.push('Findings by dimension (all severities):');
|
|
63
|
+
for (const d of orderedDims) {
|
|
64
|
+
const ds = byDim[d];
|
|
65
|
+
const parts = SEV_ORDER.filter((s) => ds.some((f) => f.severity === s))
|
|
66
|
+
.map((s) => `${ds.filter((f) => f.severity === s).length} ${s}`)
|
|
67
|
+
.join(' · ');
|
|
68
|
+
lines.push(` ${d.padEnd(20)} ${parts}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const verbose = process.env.CRITIC_VERBOSE ? Number(process.env.CRITIC_VERBOSE) : 1;
|
|
73
|
+
const PER_DIM_CAP = verbose >= 2 ? Infinity : 15;
|
|
74
|
+
for (const d of orderedDims) {
|
|
75
|
+
const ds = byDim[d];
|
|
76
|
+
lines.push('');
|
|
77
|
+
lines.push(`${d} — ${ds.length} finding(s)${ds.length > PER_DIM_CAP ? ` (showing ${PER_DIM_CAP}, worst first; --md for all)` : ''}:`);
|
|
78
|
+
// Findings arrive pre-sorted by severity → file → line; keep that order.
|
|
79
|
+
for (const f of ds.slice(0, PER_DIM_CAP)) {
|
|
80
|
+
lines.push(` [${f.severity}] ${f.file}:${f.line} ${f.title}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (result.findings.length) {
|
|
52
84
|
lines.push('');
|
|
53
|
-
lines.push('
|
|
54
|
-
for (const f of top) lines.push(` [${f.severity}] ${f.file}:${f.line} ${f.title}`);
|
|
85
|
+
lines.push('Run with --md <file> for the full evidence, impact & fix per finding.');
|
|
55
86
|
}
|
|
56
87
|
return lines.join('\n');
|
|
57
88
|
}
|