@lenne.tech/cli 1.46.0 → 1.47.0

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.
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // @lt-check-wrapper 3.13.0
2
3
  /**
3
4
  * Quiet, report-driven wrapper around the project `check` pipeline.
4
5
  *
@@ -24,35 +25,42 @@
24
25
  *
25
26
  * Exit code: 0 when every step passed, 1 otherwise (preserves the contract the
26
27
  * lt-dev `running-check-script` skill relies on: non-zero === failed).
28
+ *
29
+ * Version marker: the `@lt-check-wrapper` line above names the lt-monorepo
30
+ * release this wrapper ships with. The lt CLI reads it so `lt fullstack update`
31
+ * never replaces a project's wrapper with an older one. `pnpm run release`
32
+ * bumps it (bumpFiles in .versionrc.json), and
33
+ * scripts/check-wrapper-version.test.mjs fails when it drifts from
34
+ * package.json. In a generated project it keeps the release it came from.
27
35
  */
28
- import { execSync, spawn } from "node:child_process";
29
- import { readdirSync, readFileSync, realpathSync } from "node:fs";
30
- import { dirname, join } from "node:path";
31
- import { fileURLToPath } from "node:url";
36
+ import { execSync, spawn } from 'node:child_process';
37
+ import { readFileSync, realpathSync } from 'node:fs';
38
+ import { dirname, join } from 'node:path';
39
+ import { fileURLToPath } from 'node:url';
32
40
 
33
- import { createBuildTestGate } from "./build-test-gate.mjs";
41
+ import { C, stripAnsi } from './lib/ansi.mjs';
42
+ import {
43
+ advisoryBulkUrl,
44
+ configuredRegistry,
45
+ countSuppressions,
46
+ countUnlistedBySeverity,
47
+ isAuditEndpointUnavailable,
48
+ isAuditResultAmbiguous,
49
+ renderVulnLine,
50
+ sumSeverities,
51
+ } from './lib/audit-report.mjs';
52
+ import { createBuildTestGate } from './build-test-gate.mjs';
53
+ import { expandGlob as expandWorkspaceGlob, workspaceGlobs as readWorkspaceGlobs } from './lib/workspace-packages.mjs';
34
54
 
35
- const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
36
- const VERBOSE = process.argv.includes("--verbose") || process.argv.includes("-v");
37
- const SEQUENTIAL = process.argv.includes("--sequential") || process.argv.includes("--seq");
38
- const NO_FIX = process.argv.includes("--no-fix");
39
- const PROJECT_FILTERS = process.argv
40
- .filter((a) => a.startsWith("--project="))
41
- .map((a) => a.slice("--project=".length));
55
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
56
+ const VERBOSE = process.argv.includes('--verbose') || process.argv.includes('-v');
57
+ const SEQUENTIAL = process.argv.includes('--sequential') || process.argv.includes('--seq');
58
+ const NO_FIX = process.argv.includes('--no-fix');
59
+ const PROJECT_FILTERS = process.argv.filter((a) => a.startsWith('--project=')).map((a) => a.slice('--project='.length));
42
60
  // Verbose streams raw output, so the in-place live view is disabled there.
43
61
  const TTY = Boolean(process.stdout.isTTY) && !VERBOSE;
44
62
 
45
- // ── tiny ANSI helpers ──────────────────────────────────────────────────────
46
- const C = {
47
- bold: (s) => `\x1b[1m${s}\x1b[0m`,
48
- cyan: (s) => `\x1b[36m${s}\x1b[0m`,
49
- dim: (s) => `\x1b[2m${s}\x1b[0m`,
50
- green: (s) => `\x1b[32m${s}\x1b[0m`,
51
- red: (s) => `\x1b[31m${s}\x1b[0m`,
52
- yellow: (s) => `\x1b[33m${s}\x1b[0m`,
53
- };
54
- const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
55
- const shortRel = (rel) => rel.replace(/^projects\//, "");
63
+ const shortRel = (rel) => rel.replace(/^projects\//, '');
56
64
 
57
65
  function fmtDuration(ms) {
58
66
  const s = ms / 1000;
@@ -76,7 +84,7 @@ function fmtDuration(ms) {
76
84
  // binds only to the first simple command, so a step written with `;` or a
77
85
  // leading `cd` would be reported as pinned and run unpinned. Both carry the
78
86
  // same value, so they cannot disagree.
79
- const CHECK_BUILD_DIR = ".nuxt-check";
87
+ const CHECK_BUILD_DIR = '.nuxt-check';
80
88
 
81
89
  // Deliberately narrow: a blanket prefix would override the dirs the package.json
82
90
  // scripts pin themselves. These are the commands that run lifecycle hooks (or,
@@ -106,9 +114,7 @@ export const PM_AUDIT = new RegExp(String.raw`\b${PM}\s+audit\b`);
106
114
  /** A project script whose name mentions audit or install — an ordinary step that still needs the pin. */
107
115
  const PM_RUN_SCRIPT = new RegExp(String.raw`\b${PM}\s+run\s+\S*(?:audit|install)\S*`);
108
116
  /** Any package-manager call that runs lifecycle hooks and carries no pin of its own. */
109
- export const PM_INVOCATION = new RegExp(
110
- `${PM_INSTALL.source}|${PM_AUDIT.source}|${PM_RUN_SCRIPT.source}`,
111
- );
117
+ export const PM_INVOCATION = new RegExp(`${PM_INSTALL.source}|${PM_AUDIT.source}|${PM_RUN_SCRIPT.source}`);
112
118
 
113
119
  /**
114
120
  * Prefix a package-manager command with the check's isolated Nuxt build dir.
@@ -152,32 +158,28 @@ function classify(cmd) {
152
158
  //
153
159
  // Both predicates come from the pin patterns above, so a command can never be
154
160
  // hoisted-but-unpinned. See the SINGLE SOURCE note there.
155
- if (PM_INSTALL.test(c)) return { fatal: true, kind: "install", label: "install" };
156
- if (PM_AUDIT.test(c)) return { fatal: true, kind: "audit", label: "audit" };
157
- if (c.includes("vendor-freshness"))
158
- return { fatal: false, kind: "vendor", label: "vendor-freshness" };
159
- if (c.includes("format:check") || c.includes("oxfmt"))
160
- return { fatal: true, kind: "format", label: "format" };
161
- if (c.includes("lint")) return { fatal: true, kind: "lint", label: "lint" };
161
+ if (PM_INSTALL.test(c)) return { fatal: true, kind: 'install', label: 'install' };
162
+ if (PM_AUDIT.test(c)) return { fatal: true, kind: 'audit', label: 'audit' };
163
+ if (c.includes('vendor-freshness')) return { fatal: false, kind: 'vendor', label: 'vendor-freshness' };
164
+ if (c.includes('format:check') || c.includes('oxfmt')) return { fatal: true, kind: 'format', label: 'format' };
165
+ if (c.includes('lint')) return { fatal: true, kind: 'lint', label: 'lint' };
162
166
  // A unit-only run is named explicitly, is short, and is not contention
163
167
  // sensitive — it carries `light` so the build⊥test gate lets it through (see
164
168
  // GATE_CLASS). A bare `pnpm test` is NOT assumed to be light: in the starters
165
169
  // it resolves to the API e2e suite, which is exactly what the gate protects.
166
- if (/(^|&|\s)(pnpm\s+)?test:unit(:|\s|$)/.test(c))
167
- return { fatal: true, kind: "test", label: "test", light: true };
168
- if (/(^|&|\s)(pnpm\s+)?test(:|\s|$)|vitest|jest|test:ci/.test(c))
169
- return { fatal: true, kind: "test", label: "test" };
170
+ if (/(^|&|\s)(pnpm\s+)?test:unit(:|\s|$)/.test(c)) return { fatal: true, kind: 'test', label: 'test', light: true };
171
+ if (/(^|&|\s)(pnpm\s+)?test(:|\s|$)|vitest|jest|test:ci/.test(c)) return { fatal: true, kind: 'test', label: 'test' };
170
172
  // `typecheck` runs vue-tsc / tsc, which saturates the machine just like a
171
173
  // build — and it does NOT contain the substrings "build" or "tsc", so it used
172
174
  // to fall through to `other` and run ungated, fully concurrent with the API
173
175
  // e2e suite. The gate then paid its serialisation cost while the second
174
176
  // heaviest CPU load in the chain still ran alongside the suite it protects.
175
- if (/\btypecheck\b/.test(c)) return { fatal: true, kind: "build", label: "typecheck" };
176
- if (c.includes("build") || c.includes("nuxt build") || c.includes("tsc"))
177
- return { fatal: true, kind: "build", label: "build" };
178
- if (c.includes("check-server-start") || c.includes("server-start"))
179
- return { fatal: true, kind: "server", label: "server-start" };
180
- return { fatal: true, kind: "other", label: cmd.length > 32 ? `${cmd.slice(0, 29)}…` : cmd };
177
+ if (/\btypecheck\b/.test(c)) return { fatal: true, kind: 'build', label: 'typecheck' };
178
+ if (c.includes('build') || c.includes('nuxt build') || c.includes('tsc'))
179
+ return { fatal: true, kind: 'build', label: 'build' };
180
+ if (c.includes('check-server-start') || c.includes('server-start'))
181
+ return { fatal: true, kind: 'server', label: 'server-start' };
182
+ return { fatal: true, kind: 'other', label: cmd.length > 32 ? `${cmd.slice(0, 29)}…` : cmd };
181
183
  }
182
184
 
183
185
  /**
@@ -190,8 +192,8 @@ function classify(cmd) {
190
192
  * this gate exists to fix (DEV-2524).
191
193
  */
192
194
  export function gateClass(step) {
193
- if (step.kind === "build") return "build";
194
- if (step.kind === "test") return step.light ? null : "test";
195
+ if (step.kind === 'build') return 'build';
196
+ if (step.kind === 'test') return step.light ? null : 'test';
195
197
  return null;
196
198
  }
197
199
 
@@ -199,15 +201,15 @@ export function gateClass(step) {
199
201
  // `check` run repairs every fixable finding instead of only reporting it.
200
202
  function toFixCommand(kind, cmd) {
201
203
  if (NO_FIX) return cmd;
202
- if (kind === "format") {
203
- if (/\bformat:check\b/.test(cmd)) return cmd.replace(/\bformat:check\b/, "format");
204
- if (/\boxfmt\b/.test(cmd)) return cmd.replace(/\s--check\b/, "");
204
+ if (kind === 'format') {
205
+ if (/\bformat:check\b/.test(cmd)) return cmd.replace(/\bformat:check\b/, 'format');
206
+ if (/\boxfmt\b/.test(cmd)) return cmd.replace(/\s--check\b/, '');
205
207
  return cmd;
206
208
  }
207
- if (kind === "lint") {
209
+ if (kind === 'lint') {
208
210
  if (/\blint:fix\b/.test(cmd) || /--fix\b/.test(cmd)) return cmd;
209
- if (/\brun\s+lint\b/.test(cmd)) return cmd.replace(/\brun\s+lint\b/, "run lint:fix");
210
- if (/\boxlint\b/.test(cmd)) return cmd.replace(/\boxlint\b/, "oxlint --fix --fix-suggestions");
211
+ if (/\brun\s+lint\b/.test(cmd)) return cmd.replace(/\brun\s+lint\b/, 'run lint:fix');
212
+ if (/\boxlint\b/.test(cmd)) return cmd.replace(/\boxlint\b/, 'oxlint --fix');
211
213
  return cmd;
212
214
  }
213
215
  return cmd;
@@ -264,55 +266,117 @@ function parseLint(out) {
264
266
  }
265
267
 
266
268
  // ── audit (faithful: runs the project's OWN audit command) ──────────────────
267
- const SEVERITIES = ["critical", "high", "moderate", "low", "info"];
268
-
269
269
  // Run the audit command exactly as the check chain defines it (same scope /
270
270
  // --prod / --audit-level), only appending --json for the counts. The gate is
271
271
  // the command's own exit code, so `check` blocks precisely when a bare
272
272
  // `<auditCmd>` would — never with a narrower scope than the chain. (The old
273
273
  // hardcoded `--prod` hid devDependency vulns for library packages.)
274
274
  /**
275
- * How many findings are counted in `metadata.vulnerabilities` but absent from
276
- * `advisories` — advisories suppressed via auditConfig.ignoreGhsas, plus (under
277
- * pnpm) findings below `--audit-level`.
275
+ * Was the advisory service actually reachable?
276
+ *
277
+ * Asked ONLY when the audit reported nothing (see `isAuditResultAmbiguous`), because that report
278
+ * is identical whether the tree is clean or the service was down — pnpm fails open and says
279
+ * "0 vulnerabilities" either way, with exit 0 and no error. A clean run then costs one HEAD-ish
280
+ * request; a run with findings costs nothing, because findings already prove it answered.
278
281
  *
279
- * `metadata.vulnerabilities` still counts suppressed advisories while
280
- * `advisories` drops them, and that difference is the only signal separating an
281
- * assessed advisory from a new one — without it the summary shows a permanent
282
- * red "high 1" next to a green gate.
282
+ * Unreachable is NOT treated as an error here. Offline, behind a proxy, or during an npm outage,
283
+ * "we could not check" is the honest answer — the caller degrades, which warns without blocking.
283
284
  *
284
- * Returns 0 when `advisories` is ABSENT rather than deriving from it. npm 7+
285
- * emits `auditReportVersion: 2` with a `vulnerabilities` map and no `advisories`
286
- * key at all, so deriving there made every finding — including a real,
287
- * unassessed critical — look suppressed. That is exactly the confusion this
288
- * accounting exists to prevent, produced in reverse.
285
+ * Measured cost, so nobody has to guess at it. A clean repository IS the ambiguous shape, so every
286
+ * healthy run pays one request: 0.67s against a reachable registry. Offline it is 0.07s, not the
287
+ * 8s ceiling — DNS and connection refusals fail immediately, and the timeout only bites on a
288
+ * connection that hangs. The visible cost is therefore the warning, not the wait: from here on an
289
+ * offline check reports "vulnerabilities NOT checked" where it used to show a green audit. That is
290
+ * the point of the change and its main annoyance in the same sentence.
291
+ */
292
+ async function advisoryServiceReachable() {
293
+ // The registry pnpm uses, not npmjs.org — see advisoryBulkUrl for why that distinction is
294
+ // the difference between a safeguard and a second false all-clear.
295
+ const url = advisoryBulkUrl(configuredRegistry());
296
+ const controller = new AbortController();
297
+ const timer = setTimeout(() => controller.abort(), 8000);
298
+ try {
299
+ const res = await fetch(url, {
300
+ body: '{}',
301
+ headers: { 'content-type': 'application/json' },
302
+ method: 'POST',
303
+ signal: controller.signal,
304
+ });
305
+ // Any HTTP answer proves the service is up. A 4xx to an empty body is still an answer.
306
+ return res.status > 0;
307
+ } catch {
308
+ return false;
309
+ } finally {
310
+ clearTimeout(timer);
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Why the audit established nothing — named, because the two causes need different actions.
289
316
  *
290
- * Named "unlisted", not "ignored": under pnpm the number also contains
291
- * below-threshold findings nobody assessed. It is "counted but not listed" — an
292
- * observation, not a claim about anyone's judgement.
317
+ * "unreachable" means wait: a foreign service is down and nothing here fixes it. "unreadable" means
318
+ * look here: the command returned success while saying nothing a report could be built from, which
319
+ * is usually local (a version mismatch, a wrapper eating the output). Collapsing both into one
320
+ * message buries the actionable case under the one nobody can act on.
293
321
  */
294
- export function countUnlisted(parsed) {
295
- const counts = parsed?.metadata?.vulnerabilities ?? null;
296
- if (!parsed?.advisories) return 0;
297
- const listed = Object.keys(parsed.advisories).length;
298
- const counted = counts ? SEVERITIES.reduce((n, s) => n + (counts[s] || 0), 0) : 0;
299
- return Math.max(0, counted - listed);
322
+ function auditDegradedText(audit) {
323
+ return audit.degradedCause === 'unreadable'
324
+ ? 'exited 0 but emitted no readable result — vulnerabilities NOT checked (look at the audit command itself)'
325
+ : 'could not run — the advisory service was unreachable; vulnerabilities NOT checked';
300
326
  }
301
327
 
302
328
  async function runAudit(auditCmd) {
303
329
  const cmd = /(^|\s)--json(\s|$)/.test(auditCmd) ? auditCmd : `${auditCmd} --json`;
304
330
  const { code, out } = await capture(cmd, ROOT, 0, { NUXT_BUILD_DIR: CHECK_BUILD_DIR });
305
331
  let counts = null;
306
- let unlisted = 0;
332
+ let unlisted = {};
333
+ let ambiguous = false;
307
334
  try {
308
- const parsed = JSON.parse(out.slice(out.indexOf("{")));
335
+ const parsed = JSON.parse(out.slice(out.indexOf('{')));
309
336
  counts = parsed?.metadata?.vulnerabilities ?? null;
310
- unlisted = countUnlisted(parsed);
337
+ unlisted = countUnlistedBySeverity(parsed);
338
+ ambiguous = isAuditResultAmbiguous(parsed);
311
339
  } catch {
312
340
  /* fall through to raw reason */
313
341
  }
314
- const total = counts ? SEVERITIES.reduce((n, s) => n + (counts[s] || 0), 0) : 0;
315
- return { auditCmd, blocking: code !== 0, counts, reason: counts ? null : out, total, unlisted };
342
+ // Two different ways an audit fails to establish anything, and they look nothing alike.
343
+ //
344
+ // 1. It errored out. Non-zero exit, no parseable counts, and a known infrastructure signature.
345
+ // All three required — a non-zero exit on its own stays fatal.
346
+ // 2. It reported a clean tree WITHOUT having reached the advisory service. Exit 0, zero counts,
347
+ // empty `advisories`, no error — byte-identical to a genuinely clean run, so the report cannot
348
+ // be asked about itself. The service is probed instead, and only when the result is ambiguous:
349
+ // a run with findings has proven the service answered.
350
+ // 3. It exited 0 and emitted nothing readable. Not a finding — findings parse. Not a failure —
351
+ // the exit code says so. It established NOTHING, and it used to render as `✓ audit 0`: the
352
+ // green tick plus a literal zero, which is the false all-clear in its purest form. Observed:
353
+ // pnpm answering a version mismatch on stderr with exit 0 and no JSON at all.
354
+ //
355
+ // Degraded WITHOUT probing, unlike case 2. A reachable service says nothing about a tally
356
+ // that was never parsed — the probe would answer a question this failure did not ask.
357
+ //
358
+ // The `code !== 0` branch stays FIRST and stays narrow on purpose. Folding these into one
359
+ // `!counts` test is the obvious simplification and it is wrong: it would turn every genuine
360
+ // audit failure into a warning, which is a worse bug than the one being fixed.
361
+ let degradedCause;
362
+ if (code !== 0 && !counts && isAuditEndpointUnavailable(out)) degradedCause = 'unreachable';
363
+ else if (code === 0 && !counts) degradedCause = 'unreadable';
364
+ else if (code === 0 && ambiguous && !(await advisoryServiceReachable())) degradedCause = 'unreachable';
365
+ const degraded = Boolean(degradedCause);
366
+ return {
367
+ auditCmd,
368
+ blocking: code !== 0 && !degraded,
369
+ counts,
370
+ degraded,
371
+ degradedCause,
372
+ reason: counts ? null : out,
373
+ // Read from the workspace, not from the report: a suppressed advisory leaves NOTHING in the
374
+ // JSON that says it was suppressed, so `ignoreGhsas` is the only evidence that a human ever
375
+ // assessed an unlisted finding. Without it, dimming would claim a judgement nobody made.
376
+ suppressions: countSuppressions(ROOT),
377
+ total: sumSeverities(counts),
378
+ unlisted,
379
+ };
316
380
  }
317
381
 
318
382
  // Watchdog: kill a TEST step whose child produces NO output for this long. A
@@ -325,10 +389,10 @@ async function runAudit(auditCmd) {
325
389
  // Override with --idle-timeout=<seconds> or CHECK_IDLE_TIMEOUT (seconds); 0
326
390
  // disables it.
327
391
  const IDLE_TIMEOUT_MS = (() => {
328
- const flag = process.argv.find((a) => a.startsWith("--idle-timeout="));
329
- const raw = flag ? flag.slice("--idle-timeout=".length) : process.env.CHECK_IDLE_TIMEOUT;
392
+ const flag = process.argv.find((a) => a.startsWith('--idle-timeout='));
393
+ const raw = flag ? flag.slice('--idle-timeout='.length) : process.env.CHECK_IDLE_TIMEOUT;
330
394
  const DEFAULT_MS = 300 * 1000;
331
- if (raw === undefined || raw === "") return DEFAULT_MS;
395
+ if (raw === undefined || raw === '') return DEFAULT_MS;
332
396
  const seconds = Number(raw);
333
397
  if (seconds === 0) return 0; // explicit opt-out
334
398
  // Invalid value (typo, unit suffix, negative) → keep the protection at its
@@ -347,19 +411,19 @@ const RUNNING = new Set();
347
411
  // fork workers). Killing only the direct child orphans the tree — exactly the
348
412
  // zombie workers a deadlock leaves behind. Children are collected via pgrep
349
413
  // and killed leaves-first.
350
- function killTree(child, signal = "SIGTERM") {
414
+ function killTree(child, signal = 'SIGTERM') {
351
415
  const pids = [];
352
416
  const collect = (pid) => {
353
417
  pids.push(pid);
354
- let out = "";
418
+ let out = '';
355
419
  try {
356
- out = execSync(`pgrep -P ${pid}`, { stdio: ["ignore", "pipe", "ignore"] })
420
+ out = execSync(`pgrep -P ${pid}`, { stdio: ['ignore', 'pipe', 'ignore'] })
357
421
  .toString()
358
422
  .trim();
359
423
  } catch {
360
424
  /* no children */
361
425
  }
362
- if (out) for (const p of out.split("\n")) collect(Number(p));
426
+ if (out) for (const p of out.split('\n')) collect(Number(p));
363
427
  };
364
428
  collect(child.pid);
365
429
  for (const pid of pids.reverse()) {
@@ -381,9 +445,13 @@ function capture(cmd, cwd, idleTimeoutMs = 0, extraEnv = null) {
381
445
  // first simple command, so a step written with `;` or a leading `cd` would be
382
446
  // reported as pinned and run unpinned. The env reaches every command in the
383
447
  // string, and the prefix still wins where both apply (same value).
384
- const child = spawn(cmd, { cwd, env: extraEnv ? { ...process.env, ...extraEnv } : process.env, shell: true });
448
+ const child = spawn(cmd, {
449
+ cwd,
450
+ env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
451
+ shell: true,
452
+ });
385
453
  RUNNING.add(child);
386
- let out = "";
454
+ let out = '';
387
455
  let idleTimer = null;
388
456
  let killTimer = null;
389
457
  let watchdogHit = false;
@@ -396,7 +464,7 @@ function capture(cmd, cwd, idleTimeoutMs = 0, extraEnv = null) {
396
464
  idleTimer = setTimeout(() => {
397
465
  watchdogHit = true;
398
466
  killTree(child);
399
- killTimer = setTimeout(() => killTree(child, "SIGKILL"), 5000);
467
+ killTimer = setTimeout(() => killTree(child, 'SIGKILL'), 5000);
400
468
  killTimer.unref();
401
469
  }, idleTimeoutMs);
402
470
  };
@@ -406,8 +474,8 @@ function capture(cmd, cwd, idleTimeoutMs = 0, extraEnv = null) {
406
474
  if (VERBOSE) process.stdout.write(d);
407
475
  };
408
476
  armWatchdog();
409
- child.stdout.on("data", onData);
410
- child.stderr.on("data", onData);
477
+ child.stdout.on('data', onData);
478
+ child.stderr.on('data', onData);
411
479
  const done = (code, extra) => {
412
480
  clearTimeout(idleTimer);
413
481
  clearTimeout(killTimer);
@@ -415,14 +483,14 @@ function capture(cmd, cwd, idleTimeoutMs = 0, extraEnv = null) {
415
483
  if (watchdogHit) {
416
484
  const note =
417
485
  `[watchdog] step produced no output for ${Math.round(idleTimeoutMs / 1000)}s — ` +
418
- "process tree killed as deadlocked. This is a hang (workers idle at 0% CPU), " +
486
+ 'process tree killed as deadlocked. This is a hang (workers idle at 0% CPU), ' +
419
487
  `not a slow run. Re-run the step directly to debug: \`${cmd}\``;
420
488
  return resolve({ code: 1, out: `${out}\n${note}` });
421
489
  }
422
490
  resolve({ code, out: extra ? `${out}\n${extra}` : out });
423
491
  };
424
- child.on("close", (code) => done(code ?? 1));
425
- child.on("error", (err) => done(1, err.message));
492
+ child.on('close', (code) => done(code ?? 1));
493
+ child.on('error', (err) => done(1, err.message));
426
494
  });
427
495
  }
428
496
  function killAll() {
@@ -450,16 +518,16 @@ function signalExitHint(out) {
450
518
  if (/\[watchdog\]/.test(clean)) return null;
451
519
  const m = clean.match(/Command failed with exit code (137|143)\b/);
452
520
  if (!m) return null;
453
- const sig = m[1] === "143" ? "SIGTERM" : "SIGKILL";
521
+ const sig = m[1] === '143' ? 'SIGTERM' : 'SIGKILL';
454
522
  return (
455
523
  `[check] step ended via ${sig} (exit ${m[1]}) — the process was killed, not an assertion failure. ` +
456
- "Usual cause: resource pressure (parallel checks/builds swapping) or an external kill. " +
524
+ 'Usual cause: resource pressure (parallel checks/builds swapping) or an external kill. ' +
457
525
  "Re-run this project's check alone to confirm."
458
526
  );
459
527
  }
460
528
 
461
529
  // ── live multi-line status (one line per running project) ────────────────────
462
- const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
530
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
463
531
  let liveCount = 0;
464
532
  let frame = 0;
465
533
  function drawLive(lines) {
@@ -472,17 +540,16 @@ function statusLines(order, states) {
472
540
  frame += 1;
473
541
  return order.map((rel) => {
474
542
  const s = states.get(rel);
475
- if (s.failed) return `${C.red("✗")} ${shortRel(rel).padEnd(5)} ${C.red(`${s.failed} FAILED`)}`;
476
- if (s.done)
477
- return `${C.green("✓")} ${shortRel(rel).padEnd(5)} ${C.dim(`done (${fmtDuration(s.total)})`)}`;
543
+ if (s.failed) return `${C.red('✗')} ${shortRel(rel).padEnd(5)} ${C.red(`${s.failed} FAILED`)}`;
544
+ if (s.done) return `${C.green('✓')} ${shortRel(rel).padEnd(5)} ${C.dim(`done (${fmtDuration(s.total)})`)}`;
478
545
  const spin = C.cyan(FRAMES[frame % FRAMES.length]);
479
- const el = s.stepStart ? C.dim(` (${fmtDuration(Date.now() - s.stepStart)})`) : "";
480
- return `${spin} ${shortRel(rel).padEnd(5)} ${s.current || "queued"}${el}`;
546
+ const el = s.stepStart ? C.dim(` (${fmtDuration(Date.now() - s.stepStart)})`) : '';
547
+ return `${spin} ${shortRel(rel).padEnd(5)} ${s.current || 'queued'}${el}`;
481
548
  });
482
549
  }
483
550
 
484
551
  // ── project discovery + step grouping ────────────────────────────────────────
485
- const IS_ORCHESTRATOR = (script) => !script || script.includes("check.mjs");
552
+ const IS_ORCHESTRATOR = (script) => !script || script.includes('check.mjs');
486
553
 
487
554
  /**
488
555
  * True when a command re-enters `check` across workspace members.
@@ -517,63 +584,25 @@ export function isRecursiveCheck(cmd) {
517
584
  /** True when this command is hoisted to a single workspace-level run. */
518
585
  function isHoisted(cmd) {
519
586
  const kind = classify(cmd).kind;
520
- return kind === "install" || kind === "audit";
587
+ return kind === 'install' || kind === 'audit';
521
588
  }
522
589
 
523
- // Read the `packages:` globs from pnpm-workspace.yaml (monorepos). A simple
524
- // value-list parse — enough for the globs lt projects use (e.g. `projects/*`).
525
- function workspaceGlobs() {
526
- let text;
527
- try {
528
- text = readFileSync(join(ROOT, "pnpm-workspace.yaml"), "utf8");
529
- } catch {
530
- return [];
531
- }
532
- const globs = [];
533
- let inPackages = false;
534
- for (const raw of text.split("\n")) {
535
- const line = raw.replace(/#.*$/, "");
536
- if (/^packages:\s*$/.test(line)) {
537
- inPackages = true;
538
- continue;
539
- }
540
- if (inPackages) {
541
- const m = line.match(/^\s*-\s*['"]?([^'"]+?)['"]?\s*$/);
542
- if (m) globs.push(m[1]);
543
- else if (line.trim() && !/^\s/.test(line)) break; // next top-level key
544
- }
545
- }
546
- return globs;
547
- }
548
-
549
- // Expand a workspace glob to concrete directories (handles `dir/*` and literals).
550
- function expandGlob(glob) {
551
- if (glob.endsWith("/*")) {
552
- const base = glob.slice(0, -2);
553
- try {
554
- return readdirSync(join(ROOT, base), { withFileTypes: true })
555
- .filter((d) => d.isDirectory())
556
- .map((d) => join(base, d.name));
557
- } catch {
558
- return [];
559
- }
560
- }
561
- return [glob];
562
- }
590
+ // Workspace discovery lives in ./lib/workspace-packages.mjs — one reader shared with
591
+ // check-workspace-consistency.mjs and check-ci-consistency.mjs. It had been copied into
592
+ // all three and the copies had already drifted; the shared one also counts SYMLINKED
593
+ // members, which `lt fullstack init --api-link/--frontend-link` creates and which a
594
+ // plain `isDirectory()` filter reported as absent, making a linked workspace look empty.
595
+ const workspaceGlobs = () => readWorkspaceGlobs(ROOT);
596
+ const expandGlob = (glob) => expandWorkspaceGlob(ROOT, glob);
563
597
 
564
598
  function asProject(rel, check) {
565
599
  let pkg = {};
566
600
  try {
567
- pkg = JSON.parse(
568
- readFileSync(
569
- rel === "." ? join(ROOT, "package.json") : join(ROOT, rel, "package.json"),
570
- "utf8",
571
- ),
572
- );
601
+ pkg = JSON.parse(readFileSync(rel === '.' ? join(ROOT, 'package.json') : join(ROOT, rel, 'package.json'), 'utf8'));
573
602
  } catch {
574
603
  /* keep defaults */
575
604
  }
576
- return { check, dir: rel === "." ? ROOT : join(ROOT, rel), name: pkg.name || rel, rel };
605
+ return { check, dir: rel === '.' ? ROOT : join(ROOT, rel), name: pkg.name || rel, rel };
577
606
  }
578
607
 
579
608
  // Workspace sub-projects and their real check chain; if there are none (a
@@ -590,7 +619,7 @@ function asProject(rel, check) {
590
619
  // wrapper `check` means the real chain lives in `check:raw`.
591
620
  function realChain(pkg) {
592
621
  if (!IS_ORCHESTRATOR(pkg.scripts?.check)) return pkg.scripts?.check ?? null;
593
- return pkg.scripts?.["check:raw"] ?? null;
622
+ return pkg.scripts?.['check:raw'] ?? null;
594
623
  }
595
624
 
596
625
  function discoverProjects() {
@@ -599,7 +628,7 @@ function discoverProjects() {
599
628
  for (const rel of expandGlob(glob)) {
600
629
  let pkg;
601
630
  try {
602
- pkg = JSON.parse(readFileSync(join(ROOT, rel, "package.json"), "utf8"));
631
+ pkg = JSON.parse(readFileSync(join(ROOT, rel, 'package.json'), 'utf8'));
603
632
  } catch {
604
633
  continue;
605
634
  }
@@ -607,12 +636,10 @@ function discoverProjects() {
607
636
  if (chain) projects.push(asProject(rel, chain));
608
637
  }
609
638
  }
610
- const root = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
611
- const rootChain =
612
- root.scripts?.["check:raw"] ??
613
- (IS_ORCHESTRATOR(root.scripts?.check) ? null : root.scripts?.check);
639
+ const root = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
640
+ const rootChain = root.scripts?.['check:raw'] ?? (IS_ORCHESTRATOR(root.scripts?.check) ? null : root.scripts?.check);
614
641
  if (projects.length === 0) {
615
- if (rootChain) projects.push(asProject(".", rootChain));
642
+ if (rootChain) projects.push(asProject('.', rootChain));
616
643
  } else if (rootChain) {
617
644
  // With members present, the root's own chain must not be dropped: beyond
618
645
  // install/audit (hoisted later) and the member fan-out (replaced by the
@@ -621,7 +648,7 @@ function discoverProjects() {
621
648
  // for the case where members are present. Strip the fan-out command and
622
649
  // keep whatever remains as a root project.
623
650
  const ownSteps = rootChain
624
- .split("&&")
651
+ .split('&&')
625
652
  .map((s) => s.trim())
626
653
  .filter(Boolean)
627
654
  .filter((c) => !isRecursiveCheck(c));
@@ -629,13 +656,11 @@ function discoverProjects() {
629
656
  // hoisted steps would otherwise add an empty group that occupies a live-view
630
657
  // row and reports a phantom success it never earned.
631
658
  if (ownSteps.some((c) => !isHoisted(c))) {
632
- projects.unshift(asProject(".", ownSteps.join(" && ")));
659
+ projects.unshift(asProject('.', ownSteps.join(' && ')));
633
660
  }
634
661
  }
635
662
  if (PROJECT_FILTERS.length)
636
- return projects.filter((p) =>
637
- PROJECT_FILTERS.some((f) => p.rel.includes(f) || p.name.includes(f)),
638
- );
663
+ return projects.filter((p) => PROJECT_FILTERS.some((f) => p.rel.includes(f) || p.name.includes(f)));
639
664
  return projects;
640
665
  }
641
666
 
@@ -648,7 +673,7 @@ export function buildGroups(projects) {
648
673
  const groups = projects.map((project) => {
649
674
  const steps = [];
650
675
  for (const raw of project.check
651
- .split("&&")
676
+ .split('&&')
652
677
  .map((s) => s.trim())
653
678
  .filter(Boolean)) {
654
679
  const meta = classify(raw);
@@ -664,11 +689,11 @@ export function buildGroups(projects) {
664
689
  // the real `<pm> install` / `<pm> audit` forms. A project script that
665
690
  // merely mentions audit in its name stays an ordinary step, so a chain can
666
691
  // no longer lose a gate here without a trace.
667
- if (meta.kind === "audit") {
692
+ if (meta.kind === 'audit') {
668
693
  if (!auditCmd) auditCmd = pinned;
669
694
  continue;
670
695
  }
671
- if (meta.kind === "install") {
696
+ if (meta.kind === 'install') {
672
697
  if (!installCmd) installCmd = pinned;
673
698
  continue;
674
699
  }
@@ -713,7 +738,7 @@ async function runGroup(group, states, results, abort, gate) {
713
738
  // Surface the wait in CI too: the step line below is only printed AFTER
714
739
  // the acquire, so a gate-blocked group would otherwise emit nothing at all
715
740
  // for the length of a full build and read like a hang.
716
- if (!TTY) process.stdout.write(` ${C.dim("⋯")} ${shortRel(rel)} · ${step.label} ${C.dim("(queued)")}\n`);
741
+ if (!TTY) process.stdout.write(` ${C.dim('⋯')} ${shortRel(rel)} · ${step.label} ${C.dim('(queued)')}\n`);
717
742
  await gate.acquire(klass);
718
743
  waited = Date.now() - queuedAt;
719
744
  // Another group may have failed while we waited — abort before starting.
@@ -730,14 +755,14 @@ async function runGroup(group, states, results, abort, gate) {
730
755
  }
731
756
  st.current = step.label;
732
757
  st.stepStart = Date.now();
733
- if (!TTY) process.stdout.write(` ${C.dim("→")} ${shortRel(rel)} · ${step.label}\n`);
758
+ if (!TTY) process.stdout.write(` ${C.dim('→')} ${shortRel(rel)} · ${step.label}\n`);
734
759
  // Watchdog on every GATED step, not just tests. A test runner streams output
735
760
  // continuously, so prolonged silence == deadlocked workers; a build is
736
761
  // normally left unwatched because it buffers. But a gated build holds a slot
737
762
  // that blocks every test step in every other group, so a wedged one now
738
763
  // hangs the whole run rather than just its own chain — it needs the same
739
764
  // watchdog. Ungated steps still run unwatched.
740
- const watch = step.kind === "test" || klass ? IDLE_TIMEOUT_MS : 0;
765
+ const watch = step.kind === 'test' || klass ? IDLE_TIMEOUT_MS : 0;
741
766
  let code;
742
767
  let out;
743
768
  try {
@@ -754,8 +779,8 @@ async function runGroup(group, states, results, abort, gate) {
754
779
  // wall-clock went. A step that waited 8 minutes behind another group's build
755
780
  // and then ran for 2 is not a 2-minute step.
756
781
  if (waited > 0) r.waited = waited;
757
- if (step.kind === "test") r.tests = parseVitest(out);
758
- if (step.kind === "lint") r.lint = parseLint(out);
782
+ if (step.kind === 'test') r.tests = parseVitest(out);
783
+ if (step.kind === 'lint') r.lint = parseLint(out);
759
784
  results.push(r);
760
785
  if (code !== 0 && step.fatal) {
761
786
  st.failed = step.label;
@@ -773,7 +798,7 @@ async function runGroup(group, states, results, abort, gate) {
773
798
  }
774
799
  if (!TTY)
775
800
  process.stdout.write(
776
- ` ${C.green("✓")} ${shortRel(rel)} · ${step.label}${metricSuffix(r)} ${C.dim(`(${fmtDuration(dur)})`)}\n`,
801
+ ` ${C.green('✓')} ${shortRel(rel)} · ${step.label}${metricSuffix(r)} ${C.dim(`(${fmtDuration(dur)})`)}\n`,
777
802
  );
778
803
  }
779
804
  st.done = true;
@@ -785,20 +810,19 @@ async function main() {
785
810
  const started = Date.now();
786
811
  const projects = discoverProjects();
787
812
  if (projects.length === 0) {
788
- console.error(C.red("No workspace projects with a `check` script found."));
813
+ console.error(C.red('No workspace projects with a `check` script found.'));
789
814
  process.exit(1);
790
815
  }
791
816
  const { auditCmd, groups, installCmd } = buildGroups(projects);
792
- const stepCount =
793
- groups.reduce((n, g) => n + g.steps.length, 0) + (auditCmd ? 1 : 0) + (installCmd ? 1 : 0);
794
- const mode = SEQUENTIAL ? "sequential" : "parallel";
795
- const pkgName = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).name;
817
+ const stepCount = groups.reduce((n, g) => n + g.steps.length, 0) + (auditCmd ? 1 : 0) + (installCmd ? 1 : 0);
818
+ const mode = SEQUENTIAL ? 'sequential' : 'parallel';
819
+ const pkgName = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).name;
796
820
 
797
821
  console.log(C.bold(`\nRunning checks for ${C.cyan(pkgName)}`));
798
822
  console.log(
799
823
  C.dim(
800
- `${projects.length} project(s) · ${stepCount} steps · ${mode} · audit: ${auditCmd ?? "none"}` +
801
- `${NO_FIX ? "" : " · auto-fix format+lint"}${VERBOSE ? " · verbose" : ""}\n`,
824
+ `${projects.length} project(s) · ${stepCount} steps · ${mode} · audit: ${auditCmd ?? 'none'}` +
825
+ `${NO_FIX ? '' : ' · auto-fix format+lint'}${VERBOSE ? ' · verbose' : ''}\n`,
802
826
  ),
803
827
  );
804
828
 
@@ -810,18 +834,18 @@ async function main() {
810
834
  // two parallel installs mutating the same node_modules.
811
835
  if (installCmd) {
812
836
  const t = Date.now();
813
- if (!TTY) process.stdout.write(` ${C.dim("→")} install\n`);
837
+ if (!TTY) process.stdout.write(` ${C.dim('→')} install\n`);
814
838
  else drawLive([`${C.cyan(FRAMES[0])} install`]);
815
839
  const { code, out } = await capture(installCmd, ROOT, 0, { NUXT_BUILD_DIR: CHECK_BUILD_DIR });
816
840
  const dur = Date.now() - t;
817
841
  if (code !== 0) {
818
842
  liveCount = 0; // the failure line must survive — nothing may overwrite it
819
- console.log(`${C.red("✗")} install ${C.dim(`(${fmtDuration(dur)})`)}`);
843
+ console.log(`${C.red('✗')} install ${C.dim(`(${fmtDuration(dur)})`)}`);
820
844
  return fail(`install (${installCmd})`, out, started);
821
845
  }
822
- if (!TTY) process.stdout.write(` ${C.green("✓")} install ${C.dim(`(${fmtDuration(dur)})`)}\n`);
846
+ if (!TTY) process.stdout.write(` ${C.green('✓')} install ${C.dim(`(${fmtDuration(dur)})`)}\n`);
823
847
  // TTY success: no permanent line — see the audit block below.
824
- results.push({ dur, kind: "step", label: "install", project: "." });
848
+ results.push({ dur, kind: 'step', label: 'install', project: '.' });
825
849
  }
826
850
 
827
851
  // Step 0 — single workspace audit (blocking gate, runs before the fan-out).
@@ -829,37 +853,40 @@ async function main() {
829
853
  // the chain has no audit step.
830
854
  if (auditCmd) {
831
855
  const t = Date.now();
832
- if (!TTY) process.stdout.write(` ${C.dim("→")} audit\n`);
856
+ if (!TTY) process.stdout.write(` ${C.dim('→')} audit\n`);
833
857
  else drawLive([`${C.cyan(FRAMES[0])} audit`]);
834
858
  const audit = await runAudit(auditCmd);
835
859
  const dur = Date.now() - t;
836
860
  if (audit.blocking) {
837
861
  liveCount = 0; // the failure line must survive — nothing may overwrite it
838
- const summary = audit.counts
839
- ? `${audit.total} vuln (${renderVulnLine(audit.counts, audit.unlisted, true)})`
840
- : "failed";
841
- console.log(`${C.red("✗")} audit ${C.red(summary)} ${C.dim(`(${fmtDuration(dur)})`)}`);
842
- return fail(
843
- `audit (${auditCmd})`,
844
- audit.counts ? renderVulnLine(audit.counts, audit.unlisted, true) : audit.reason,
845
- started,
846
- );
862
+ const summary = audit.counts ? `${audit.total} vuln (${renderVulnLine(audit)})` : 'failed';
863
+ console.log(`${C.red('✗')} audit ${C.red(summary)} ${C.dim(`(${fmtDuration(dur)})`)}`);
864
+ return fail(`audit (${auditCmd})`, audit.counts ? renderVulnLine(audit) : audit.reason, started);
865
+ }
866
+ if (audit.degraded) {
867
+ // Loud, and never rendered as a tick. A green ✓ here would claim "no vulnerabilities",
868
+ // which is exactly what was NOT established — the service could not be reached. The run
869
+ // continues because an outage is not a finding and nothing in this repo fixes it.
870
+ liveCount = 0;
871
+ console.log(`${C.yellow('⚠')} audit ${C.yellow(auditDegradedText(audit))} ${C.dim(`(${fmtDuration(dur)})`)}`);
847
872
  }
848
- if (!TTY) {
873
+ // `!audit.degraded`: the warning above already printed, and a ✓ underneath it would say
874
+ // "no vulnerabilities" about a run that established nothing.
875
+ if (!TTY && !audit.degraded) {
849
876
  process.stdout.write(
850
- ` ${C.green("✓")} audit ${audit.counts ? renderVulnLine(audit.counts, audit.unlisted) : C.dim("0")} ${C.dim(`(${fmtDuration(dur)})`)}\n`,
877
+ ` ${C.green('✓')} audit ${audit.counts ? renderVulnLine(audit) : C.dim('0')} ${C.dim(`(${fmtDuration(dur)})`)}\n`,
851
878
  );
852
879
  }
853
880
  // TTY success: NO permanent line — the live status view overwrites the audit
854
881
  // row (like every other step); the result lands in the report twice: the
855
882
  // Steps list (entry below) and the Vulnerabilities section.
856
- results.push({ audit, kind: "audit" });
857
- results.push({ dur, kind: "step", label: "audit", project: "." });
883
+ results.push({ audit, kind: 'audit' });
884
+ results.push({ dur, kind: 'step', label: 'audit', project: '.' });
858
885
  }
859
886
 
860
887
  // Per-project steps — parallel by default, serial with --sequential.
861
888
  const order = groups.map((g) => g.project.rel);
862
- const states = new Map(order.map((rel) => [rel, { current: "queued" }]));
889
+ const states = new Map(order.map((rel) => [rel, { current: 'queued' }]));
863
890
  const abort = { failure: null, hit: false };
864
891
  // Serializes CPU-heavy `build` steps against the contention-sensitive `test`
865
892
  // suites across groups so a parallel `nuxt build` can never destabilize the
@@ -887,128 +914,103 @@ async function main() {
887
914
  }
888
915
 
889
916
  // ── rendering helpers ─────────────────────────────────────────────────────────
890
- // `unlisted` = counted in `metadata.vulnerabilities` but absent from `advisories`
891
- // — advisories suppressed via auditConfig.ignoreGhsas, and (under pnpm) findings
892
- // below `--audit-level`. Without accounting for them the line reads as an
893
- // unresolved finding forever.
894
- //
895
- // `blocking` is what decides whether dimming is allowed at all. Dimming says "you
896
- // already looked at this"; on a run the gate is FAILING, that is exactly the wrong
897
- // thing to say, and it used to be said — a real critical rendered grey and
898
- // labelled. When the gate fails, the numbers stay loud whatever the derivation
899
- // suggests.
900
- function renderVulnLine(counts, unlisted = 0, blocking = false) {
901
- const total = SEVERITIES.reduce((n, s) => n + (counts[s] || 0), 0);
902
- const allUnlisted = !blocking && unlisted > 0 && unlisted >= total;
903
- const line = SEVERITIES.map((s) => {
904
- const n = counts[s] || 0;
905
- const txt = `${s} ${n}`;
906
- if (n === 0 || allUnlisted) return C.dim(txt);
907
- if (s === "critical" || s === "high") return C.red(txt);
908
- return C.yellow(txt);
909
- }).join(C.dim(" · "));
910
- return unlisted > 0 ? `${line}${C.dim(` (${unlisted} not listed)`)}` : line;
911
- }
912
917
 
913
918
  function metricSuffix(r) {
914
- if (r.kind === "test" && r.tests?.passed != null) {
915
- const failed = r.tests.failed ? C.red(` / ${r.tests.failed} failed`) : "";
916
- return ` ${C.dim(`${r.tests.passed} passed${r.tests.files != null ? ` / ${r.tests.files} files` : ""}`)}${failed}`;
919
+ if (r.kind === 'test' && r.tests?.passed != null) {
920
+ const failed = r.tests.failed ? C.red(` / ${r.tests.failed} failed`) : '';
921
+ return ` ${C.dim(`${r.tests.passed} passed${r.tests.files != null ? ` / ${r.tests.files} files` : ''}`)}${failed}`;
917
922
  }
918
923
  if (r.waited != null && r.waited >= 1000) {
919
924
  // The gate wait is NOT part of `dur`, so without this the report would show
920
925
  // a two-minute step that actually occupied ten minutes of wall-clock.
921
926
  return ` ${C.dim(`queued ${fmtDuration(r.waited)}`)}`;
922
927
  }
923
- if (r.kind === "lint" && r.lint) {
928
+ if (r.kind === 'lint' && r.lint) {
924
929
  return r.lint.warnings > 0
925
- ? ` ${C.yellow(`${r.lint.warnings} warning${r.lint.warnings === 1 ? "" : "s"}`)}`
926
- : ` ${C.dim("clean")}`;
930
+ ? ` ${C.yellow(`${r.lint.warnings} warning${r.lint.warnings === 1 ? '' : 's'}`)}`
931
+ : ` ${C.dim('clean')}`;
927
932
  }
928
- return "";
933
+ return '';
929
934
  }
930
935
 
931
936
  function fail(stepLabel, reason, started) {
932
937
  console.log(`\n${C.red(`──── reason · ${stepLabel} ────`)}`);
933
- console.log(stripAnsi(String(reason)).trimEnd().split("\n").slice(-40).join("\n"));
934
- console.log(C.red("────────────────────────────────────────\n"));
935
- console.log(
936
- C.bold(
937
- C.red(`✗ Check FAILED at step "${stepLabel}" after ${fmtDuration(Date.now() - started)}.`),
938
- ),
939
- );
940
- console.log(C.dim("Re-run with --verbose for the full output of every step."));
938
+ console.log(stripAnsi(String(reason)).trimEnd().split('\n').slice(-40).join('\n'));
939
+ console.log(C.red('────────────────────────────────────────\n'));
940
+ console.log(C.bold(C.red(`✗ Check FAILED at step "${stepLabel}" after ${fmtDuration(Date.now() - started)}.`)));
941
+ console.log(C.dim('Re-run with --verbose for the full output of every step.'));
941
942
  process.exit(1);
942
943
  }
943
944
 
944
945
  function report(started, results) {
945
- const audit = results.find((r) => r.kind === "audit")?.audit;
946
- const tests = results.filter((r) => r.kind === "test");
947
- const unit = tests.find((r) => r.project?.includes("app"))?.tests;
948
- const api = tests.find((r) => r.project?.includes("api"))?.tests;
946
+ const audit = results.find((r) => r.kind === 'audit')?.audit;
947
+ const tests = results.filter((r) => r.kind === 'test');
948
+ const unit = tests.find((r) => r.project?.includes('app'))?.tests;
949
+ const api = tests.find((r) => r.project?.includes('api'))?.tests;
949
950
  const totalPassed = tests.reduce((n, r) => n + (r.tests?.passed || 0), 0);
950
951
 
951
- const bar = "═".repeat(52);
952
+ const bar = '═'.repeat(52);
952
953
  console.log(`\n${C.green(bar)}`);
953
- console.log(
954
- C.bold(` ${C.green("✓ Check PASSED")} ${C.dim(`(${fmtDuration(Date.now() - started)})`)}`),
955
- );
954
+ console.log(C.bold(` ${C.green('✓ Check PASSED')} ${C.dim(`(${fmtDuration(Date.now() - started)})`)}`));
956
955
  console.log(C.green(bar));
957
956
 
958
- console.log(`\n${C.bold("Steps")}`);
959
- const steps = results.filter((x) => x.kind !== "audit");
957
+ console.log(`\n${C.bold('Steps')}`);
958
+ const steps = results.filter((x) => x.kind !== 'audit');
960
959
  // Group by project when more than one is involved: workspace-level steps
961
960
  // (hoisted install/audit, root-only checks) under "monorepo", then one block
962
961
  // per member. Steps within a project run sequentially, so per-group order is
963
962
  // chain order. A single-project run keeps the flat list — a header is noise.
964
963
  const stepGroups = [...new Set(steps.map((r) => r.project))].sort((a, b) =>
965
- a === "." ? -1 : b === "." ? 1 : shortRel(a).localeCompare(shortRel(b)),
964
+ a === '.' ? -1 : b === '.' ? 1 : shortRel(a).localeCompare(shortRel(b)),
966
965
  );
967
966
  if (stepGroups.length > 1) {
968
967
  for (const project of stepGroups) {
969
- console.log(` ${C.bold(project === "." ? "monorepo" : shortRel(project))}`);
968
+ console.log(` ${C.bold(project === '.' ? 'monorepo' : shortRel(project))}`);
970
969
  for (const r of steps.filter((x) => x.project === project)) {
971
970
  console.log(
972
- ` ${C.green("✓")} ${r.label.padEnd(24)}${metricSuffix(r) || " "} ${C.dim(`(${fmtDuration(r.dur)})`)}`,
971
+ ` ${C.green('✓')} ${r.label.padEnd(24)}${metricSuffix(r) || ' '} ${C.dim(`(${fmtDuration(r.dur)})`)}`,
973
972
  );
974
973
  }
975
974
  }
976
975
  } else {
977
976
  for (const r of steps) {
978
977
  console.log(
979
- ` ${C.green("✓")} ${`${shortRel(r.project)} · ${r.label}`.padEnd(26)}${metricSuffix(r) || " "} ${C.dim(`(${fmtDuration(r.dur)})`)}`,
978
+ ` ${C.green('✓')} ${`${shortRel(r.project)} · ${r.label}`.padEnd(26)}${metricSuffix(r) || ' '} ${C.dim(`(${fmtDuration(r.dur)})`)}`,
980
979
  );
981
980
  }
982
981
  }
983
982
 
983
+ console.log(`\n${C.bold('Vulnerabilities')} ${C.dim(audit ? `(${audit.auditCmd})` : '(no audit step)')}`);
984
984
  console.log(
985
- `\n${C.bold("Vulnerabilities")} ${C.dim(audit ? `(${audit.auditCmd})` : "(no audit step)")}`,
986
- );
987
- console.log(
988
- ` ${audit?.counts ? renderVulnLine(audit.counts, audit.unlisted, audit.blocking) : C.dim(audit ? "counts unavailable" : "—")}`,
985
+ ` ${
986
+ audit?.counts
987
+ ? renderVulnLine(audit)
988
+ : audit?.degraded
989
+ ? // Named, not blank. "counts unavailable" reads like a formatting hiccup; the reader
990
+ // has to know the tree was never checked, or a green summary above means more than
991
+ // it should.
992
+ C.yellow(auditDegradedText(audit))
993
+ : C.dim(audit ? 'counts unavailable' : '—')
994
+ }`,
989
995
  );
990
996
 
991
- console.log(`\n${C.bold("Tests")}`);
997
+ console.log(`\n${C.bold('Tests')}`);
992
998
  if (unit || api) {
993
999
  // Monorepo with app and/or api projects → the canonical area breakdown.
994
- console.log(
995
- ` ${"Unit (app)".padEnd(18)}${unit?.passed != null ? `${unit.passed} passed` : C.dim("—")}`,
996
- );
997
- console.log(
998
- ` ${"API (api)".padEnd(18)}${api?.passed != null ? `${api.passed} passed` : C.dim("—")}`,
999
- );
1000
- console.log(` ${"Playwright".padEnd(18)}${C.dim("— (run via `lt dev test` / CI)")}`);
1000
+ console.log(` ${'Unit (app)'.padEnd(18)}${unit?.passed != null ? `${unit.passed} passed` : C.dim('—')}`);
1001
+ console.log(` ${'API (api)'.padEnd(18)}${api?.passed != null ? `${api.passed} passed` : C.dim('—')}`);
1002
+ console.log(` ${'Playwright'.padEnd(18)}${C.dim('— (run via `lt dev test` / CI)')}`);
1001
1003
  } else {
1002
1004
  // Single-package repo → one line per test-bearing project.
1003
1005
  for (const r of tests)
1004
1006
  console.log(
1005
- ` ${shortRel(r.project).padEnd(18)}${r.tests?.passed != null ? `${r.tests.passed} passed` : C.dim("—")}`,
1007
+ ` ${shortRel(r.project).padEnd(18)}${r.tests?.passed != null ? `${r.tests.passed} passed` : C.dim('—')}`,
1006
1008
  );
1007
- if (tests.length === 0) console.log(` ${C.dim("no test step")}`);
1009
+ if (tests.length === 0) console.log(` ${C.dim('no test step')}`);
1008
1010
  }
1009
- console.log(` ${C.bold("Total".padEnd(18))}${C.bold(`${totalPassed} passed`)}`);
1011
+ console.log(` ${C.bold('Total'.padEnd(18))}${C.bold(`${totalPassed} passed`)}`);
1010
1012
 
1011
- console.log(`\n${C.green("All checks passed.")}\n`);
1013
+ console.log(`\n${C.green('All checks passed.')}\n`);
1012
1014
  }
1013
1015
 
1014
1016
  // Run only when invoked as the CLI (`node scripts/check.mjs`). Importing this
@@ -1051,12 +1053,12 @@ if (isCliEntry()) {
1051
1053
  // every running `pnpm test` / build / e2e fork pool: they keep the test
1052
1054
  // database and ports held, and the next run fails for a reason that has
1053
1055
  // nothing to do with the code.
1054
- for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
1056
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
1055
1057
  process.on(signal, () => {
1056
1058
  killAll();
1057
1059
  // Conventional 128+n, and it makes the interruption distinguishable from
1058
1060
  // an ordinary failure.
1059
- process.exit(signal === "SIGINT" ? 130 : 143);
1061
+ process.exit(signal === 'SIGINT' ? 130 : 143);
1060
1062
  });
1061
1063
  }
1062
1064
  main().catch((err) => {