@lenne.tech/cli 1.43.0 → 1.44.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.
@@ -154,23 +154,46 @@ const DoctorCommand = {
154
154
  else if (gs.file && gs.hasDbReset) {
155
155
  line('OK', colors.green, 'global-setup allow-list is ticket + shard safe');
156
156
  }
157
- // 7.5 check.mjs drift: the root wrapper is canonical (bundled with the
157
+ // 7.5 check wrapper drift: the root wrapper is canonical (bundled with the
158
158
  // CLI, synced by `lt fullstack update`). A diverged copy silently
159
159
  // misses fixes (idle-watchdog, install hoisting, summed test
160
160
  // metrics) — surface it instead of letting copies drift apart.
161
+ //
162
+ // Checked over the WHOLE copy set, not just `check.mjs`: the wrapper
163
+ // imports siblings, so a missing or stale one makes `check` die with
164
+ // ERR_MODULE_NOT_FOUND (or an import mismatch) before running a single
165
+ // step. Reporting only on `check.mjs` meant doctor printed a green
166
+ // "matches the canonical CLI version" for a project whose `check` was
167
+ // completely broken — and doctor is the tool people reach for exactly
168
+ // then.
161
169
  try {
162
170
  const { readFileSync: read } = yield Promise.resolve().then(() => __importStar(require('fs')));
163
171
  const { join: j } = yield Promise.resolve().then(() => __importStar(require('path')));
164
- const projectCheck = j(layout.root, 'scripts', 'check.mjs');
172
+ const { resolveCopySet } = yield Promise.resolve().then(() => __importStar(require('../../lib/heal-check-wrapper')));
165
173
  const bundledCheck = j(__dirname, '..', '..', 'templates', 'check', 'check.mjs');
166
- if (filesystem.exists(projectCheck) && filesystem.exists(bundledCheck)) {
167
- if (read(projectCheck, 'utf8') === read(bundledCheck, 'utf8')) {
168
- line('OK', colors.green, 'scripts/check.mjs matches the canonical CLI version');
174
+ if (filesystem.exists(bundledCheck)) {
175
+ const missing = [];
176
+ const drifted = [];
177
+ for (const { rel, source } of resolveCopySet(bundledCheck)) {
178
+ const target = j(layout.root, rel);
179
+ if (!filesystem.exists(target)) {
180
+ missing.push(rel);
181
+ }
182
+ else if (read(target, 'utf8') !== read(source, 'utf8')) {
183
+ drifted.push(rel);
184
+ }
169
185
  }
170
- else {
171
- line('WARN', colors.yellow, 'scripts/check.mjs differs from the canonical CLI version');
186
+ if (missing.length > 0) {
187
+ line('ERROR', colors.red, `check wrapper incomplete missing ${missing.join(', ')}`);
188
+ line('ERROR', colors.red, ' `pnpm run check` cannot start; run `lt fullstack update` to install it');
189
+ }
190
+ else if (drifted.length > 0) {
191
+ line('WARN', colors.yellow, `${drifted.join(', ')} differs from the canonical CLI version`);
172
192
  line('WARN', colors.yellow, ' run `lt fullstack update` to sync it (skips uncommitted local edits)');
173
193
  }
194
+ else if (filesystem.exists(j(layout.root, 'scripts', 'check.mjs'))) {
195
+ line('OK', colors.green, 'check wrapper matches the canonical CLI version');
196
+ }
174
197
  }
175
198
  }
176
199
  catch (_a) {
@@ -198,7 +198,20 @@ const NewCommand = {
198
198
  const changedCheck = (0, heal_check_wrapper_1.healCheckWrapper)(cwd, checkAsset);
199
199
  if (changedCheck.length > 0) {
200
200
  info('');
201
- success(` Installed/updated the check wrapper: ${changedCheck.join(', ')}`);
201
+ // A skip entry is NOT a success — it means the wrapper stayed on its old
202
+ // version. Reporting the whole list through `success()` painted a refusal
203
+ // green.
204
+ const skipped = changedCheck.filter((entry) => entry.includes('skipped'));
205
+ const applied = changedCheck.filter((entry) => !entry.includes('skipped'));
206
+ if (applied.length > 0) {
207
+ success(` Installed/updated the check wrapper: ${applied.join(', ')}`);
208
+ info(' `check` now serialises build/typecheck against the test suites,');
209
+ info(' so it takes longer in wall-clock but no longer destabilises API e2e runs.');
210
+ info(' The wrapper imports its siblings — keep them together, or `check` will not start.');
211
+ }
212
+ for (const entry of skipped) {
213
+ warning(` Check wrapper NOT updated: ${entry}`);
214
+ }
202
215
  }
203
216
  // ── Self-heal: keep `.lt-dev/` out of git ──────────────────────────────
204
217
  //
@@ -209,6 +222,16 @@ const NewCommand = {
209
222
  info('');
210
223
  success(' Added `.lt-dev/` to .gitignore');
211
224
  }
225
+ // ── Self-heal: keep the check's isolated Nuxt build dir out of git ──────
226
+ //
227
+ // The check wrapper pins `NUXT_BUILD_DIR=.nuxt-check` so it never writes the
228
+ // `.nuxt/` a parked `nuxt dev` reads. Current starters already ignore it
229
+ // (via their `.nuxt-*` glob); projects scaffolded before that glob do not,
230
+ // and a build dir is a plausible place for a resolved runtimeConfig to be
231
+ // committed by accident. Idempotent.
232
+ if ((0, dev_patches_1.addToGitignore)(cwd, '.nuxt-check')) {
233
+ success(' Added `.nuxt-check` to .gitignore');
234
+ }
212
235
  // ── Self-heal: repair the vendor-mode migration store ──────────────────
213
236
  //
214
237
  // `migrations-utils/migrate.js` is written ONCE, at conversion time. Projects
@@ -1,24 +1,37 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.healCheckWrapper = healCheckWrapper;
4
+ exports.resolveCopySet = resolveCopySet;
4
5
  const child_process_1 = require("child_process");
5
6
  const fs_1 = require("fs");
6
7
  const path_1 = require("path");
7
8
  /** Marker value for the report-driven check wrapper. */
8
9
  const WRAPPER = 'node scripts/check.mjs';
10
+ /** Where the wrapper and its imports live inside a project. */
11
+ const SCRIPTS_DIR = 'scripts';
9
12
  /**
10
- * Idempotently install the report-driven `check.mjs` wrapper into a project.
13
+ * Idempotently install the report-driven check wrapper and every module it
14
+ * imports — into a project.
11
15
  *
12
- * Copies the bundled canonical wrapper to `<root>/scripts/check.mjs` and rewrites
13
- * the root `package.json` so that `check` runs the wrapper while the original
14
- * chain is preserved as `check:raw`. A no-op once already wired (so it is safe to
15
- * run on every `lt fullstack update`).
16
+ * Copies the bundled wrapper to `<root>/scripts/check.mjs`, copies its whole
17
+ * relative-import closure alongside it under the names the wrapper imports them
18
+ * by, and rewrites the root `package.json` so that `check` runs the wrapper
19
+ * while the original chain is preserved as `check:raw`. A no-op once already
20
+ * wired (so it is safe to run on every `lt fullstack update`).
16
21
  *
17
22
  * `lt fullstack init` already ships the wrapper via the template clone; this is
18
23
  * the MIGRATION path that brings it into pre-existing projects.
19
24
  *
25
+ * The copy set moves ATOMICALLY: if any member must be skipped, none are
26
+ * written. A partial update would leave `check.mjs` and a sibling on different
27
+ * versions, and the project's `check` then dies on an import mismatch before
28
+ * running a single step.
29
+ *
20
30
  * @param projectRoot Absolute path to the (workspace) project root.
21
- * @param assetPath Absolute path to the bundled canonical `check.mjs`.
31
+ * @param assetPath Absolute path to the bundled wrapper. Its DIRECTORY is
32
+ * also probed: every module the wrapper imports relatively
33
+ * (transitively) is shipped from there. The asset always
34
+ * lands as `scripts/check.mjs` regardless of its own name.
22
35
  * @returns The list of changed file paths (relative to `projectRoot`); empty when nothing changed.
23
36
  */
24
37
  function healCheckWrapper(projectRoot, assetPath) {
@@ -39,22 +52,35 @@ function healCheckWrapper(projectRoot, assetPath) {
39
52
  if (!scripts || typeof scripts.check !== 'string') {
40
53
  return changed;
41
54
  }
42
- // 1. Ensure scripts/check.mjs exists and matches the bundled canonical version.
43
- // GUARD: never overwrite a check.mjs with UNCOMMITTED local modifications —
44
- // that would silently destroy work that exists nowhere else. A committed
45
- // divergence is overwritten (recoverable via `git diff`/history, and the
46
- // canonical wrapper is the supported version); an uncommitted one is kept and
47
- // reported via the 'scripts/check.mjs (skipped: uncommitted changes)' entry.
48
- const targetScript = (0, path_1.join)(projectRoot, 'scripts', 'check.mjs');
49
- const bundled = (0, fs_1.readFileSync)(assetPath, 'utf8');
50
- if (!(0, fs_1.existsSync)(targetScript) || (0, fs_1.readFileSync)(targetScript, 'utf8') !== bundled) {
51
- if ((0, fs_1.existsSync)(targetScript) && hasUncommittedChanges(projectRoot, 'scripts/check.mjs')) {
52
- changed.push('scripts/check.mjs (skipped: uncommitted changes commit or discard them, then re-run)');
53
- }
54
- else {
55
- (0, fs_1.mkdirSync)((0, path_1.dirname)(targetScript), { recursive: true });
56
- (0, fs_1.copyFileSync)(assetPath, targetScript);
57
- changed.push('scripts/check.mjs');
55
+ // 1. Ensure the wrapper and everything it imports exists in the project
56
+ // and matches the canonical version.
57
+ //
58
+ // The set is derived from the wrapper's own import statements rather than a
59
+ // hard-coded name: the wrapper grew a sibling (`build-test-gate.mjs`, which
60
+ // serialises the CPU-heavy build against the API e2e suite), and copying only
61
+ // `check.mjs` installs a file whose very first import resolves to nothing —
62
+ // the project's `check` then dies with ERR_MODULE_NOT_FOUND before running a
63
+ // single step. Deriving it from the imports (not from "every .mjs in the
64
+ // directory") keeps the next sibling free of changes here while making sure a
65
+ // stray file in the asset dir never claims a path in the project's scripts/.
66
+ const copies = resolveCopySet(assetPath);
67
+ // Decide EVERY member before writing ANY of them — see the atomicity note in
68
+ // the doc block above.
69
+ const plans = copies.map((copy) => planCopy(projectRoot, copy));
70
+ const blocked = plans.filter((p) => p.action === 'skip');
71
+ if (blocked.length > 0) {
72
+ // One entry for the whole set: the set is what could not be updated, and
73
+ // naming only the blocking member would suggest the others did land.
74
+ const names = blocked.map((p) => p.rel).join(', ');
75
+ changed.push(`${copies.map((c) => c.rel).join(' + ')} (skipped: uncommitted changes in ${names} — commit or discard them, then re-run)`);
76
+ }
77
+ else {
78
+ for (const plan of plans) {
79
+ if (plan.action === 'up-to-date') {
80
+ continue;
81
+ }
82
+ writeCopy(projectRoot, plan);
83
+ changed.push(plan.rel);
58
84
  }
59
85
  }
60
86
  // 2. Wire package.json: `check` runs the wrapper; the original chain becomes `check:raw`.
@@ -69,11 +95,57 @@ function healCheckWrapper(projectRoot, assetPath) {
69
95
  return changed;
70
96
  }
71
97
  /**
72
- * True when `relPath` has UNCOMMITTED modifications in the project's git tree.
73
- * Overwriting such a file would destroy work that exists nowhere else — a
74
- * committed file is recoverable via git, an uncommitted edit is not. Non-git
75
- * projects (or git errors) return false: there the overwrite is the only way
76
- * to distribute fixes, and `git` cannot protect what it does not track.
98
+ * The wrapper plus the transitive closure of its relative imports.
99
+ *
100
+ * Matches both quote styles: the bundled `.mjs` files are formatted by the
101
+ * consuming project's formatter, not by this repo's, so their quote style is
102
+ * not ours to assume.
103
+ */
104
+ function resolveCopySet(assetPath) {
105
+ const assetDir = (0, path_1.dirname)(assetPath);
106
+ // Regular files only — a directory named `*.mjs` would otherwise reach
107
+ // copyFileSync and abort the whole migration with EISDIR.
108
+ const available = new Set((0, fs_1.readdirSync)(assetDir, { withFileTypes: true })
109
+ .filter((entry) => entry.isFile())
110
+ .map((entry) => entry.name));
111
+ const copies = [{ rel: `${SCRIPTS_DIR}/check.mjs`, source: assetPath }];
112
+ // Keyed by target rel, NOT by source basename: the asset lands as
113
+ // `scripts/check.mjs` whatever it is called, so a `check.mjs` sitting beside a
114
+ // differently-named asset must not claim that same path a second time.
115
+ const claimed = new Set(copies.map((c) => c.rel));
116
+ const queue = [assetPath];
117
+ const visited = new Set();
118
+ while (queue.length > 0) {
119
+ const file = queue.shift();
120
+ if (visited.has(file)) {
121
+ continue;
122
+ }
123
+ visited.add(file);
124
+ let source;
125
+ try {
126
+ source = (0, fs_1.readFileSync)(file, 'utf8');
127
+ }
128
+ catch (_a) {
129
+ continue;
130
+ }
131
+ for (const match of source.matchAll(/\bfrom\s+['"]\.\/([^'"/]+)['"]/g)) {
132
+ const name = match[1];
133
+ const rel = `${SCRIPTS_DIR}/${name}`;
134
+ if (claimed.has(rel) || !available.has(name)) {
135
+ continue;
136
+ }
137
+ claimed.add(rel);
138
+ const resolved = (0, path_1.join)(assetDir, name);
139
+ copies.push({ rel, source: resolved });
140
+ queue.push(resolved);
141
+ }
142
+ }
143
+ return copies;
144
+ }
145
+ /**
146
+ * True when the TRACKED `relPath` has uncommitted modifications. Overwriting
147
+ * such a file would destroy work that exists nowhere else. Only meaningful for
148
+ * a tracked path — see `isTracked`.
77
149
  */
78
150
  function hasUncommittedChanges(projectRoot, relPath) {
79
151
  try {
@@ -87,3 +159,93 @@ function hasUncommittedChanges(projectRoot, relPath) {
87
159
  return false;
88
160
  }
89
161
  }
162
+ /** True when `target` is a symlink (checked without following it). */
163
+ function isSymlink(target) {
164
+ try {
165
+ return (0, fs_1.lstatSync)(target).isSymbolicLink();
166
+ }
167
+ catch (_a) {
168
+ return false;
169
+ }
170
+ }
171
+ /**
172
+ * True when git tracks `relPath`, i.e. it holds a recoverable copy.
173
+ *
174
+ * An empty `git status --porcelain` alone does NOT establish that: it is also
175
+ * empty for an ignored file, and for a path in a directory git knows nothing
176
+ * about. Those are precisely the cases where an overwrite is unrecoverable.
177
+ */
178
+ function isTracked(projectRoot, relPath) {
179
+ try {
180
+ (0, child_process_1.execFileSync)('git', ['-C', projectRoot, 'ls-files', '--error-unmatch', '--', relPath], {
181
+ stdio: 'ignore',
182
+ });
183
+ return true;
184
+ }
185
+ catch (_a) {
186
+ return false;
187
+ }
188
+ }
189
+ /** Decide what should happen to one copy target, without touching the disk. */
190
+ function planCopy(projectRoot, copy) {
191
+ const target = (0, path_1.join)(projectRoot, copy.rel);
192
+ const plan = Object.assign(Object.assign({}, copy), { action: 'write', backup: false });
193
+ if (!(0, fs_1.existsSync)(target)) {
194
+ return plan;
195
+ }
196
+ // A symlink here resolves OUTSIDE the project, and git reports the (unchanged)
197
+ // link blob as clean, so the guard below cannot see it. Writing would silently
198
+ // modify a file somewhere else entirely.
199
+ if (isSymlink(target)) {
200
+ plan.action = 'skip';
201
+ return plan;
202
+ }
203
+ if ((0, fs_1.readFileSync)(target, 'utf8') === (0, fs_1.readFileSync)(copy.source, 'utf8')) {
204
+ plan.action = 'up-to-date';
205
+ return plan;
206
+ }
207
+ // A TRACKED file whose working copy diverges carries edits that exist nowhere
208
+ // else — never overwrite it. A tracked-and-clean file is safe to replace
209
+ // (git can restore it). Anything git does not track is not recoverable at
210
+ // all, so it gets a `.bak` instead of a refusal: refusing would be the worse
211
+ // outcome, because the wrapper's OWN previous output is untracked until the
212
+ // user commits it, and a refusal there permanently blocks the update.
213
+ if (isTracked(projectRoot, copy.rel)) {
214
+ if (hasUncommittedChanges(projectRoot, copy.rel)) {
215
+ plan.action = 'skip';
216
+ }
217
+ return plan;
218
+ }
219
+ plan.backup = true;
220
+ return plan;
221
+ }
222
+ /** Write one planned copy, backing up an unversioned target first. */
223
+ function writeCopy(projectRoot, plan) {
224
+ const target = (0, path_1.join)(projectRoot, plan.rel);
225
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(target), { recursive: true });
226
+ if (plan.backup && (0, fs_1.existsSync)(target)) {
227
+ const backup = `${target}.bak`;
228
+ // Keep the FIRST backup — a later run must not overwrite the original with
229
+ // an already-generated copy.
230
+ if (!(0, fs_1.existsSync)(backup)) {
231
+ (0, fs_1.copyFileSync)(target, backup);
232
+ }
233
+ }
234
+ // temp + rename so an interrupted run can never leave a half-written wrapper.
235
+ const tmp = `${target}.lt-tmp-${process.pid}`;
236
+ try {
237
+ (0, fs_1.copyFileSync)(plan.source, tmp);
238
+ (0, fs_1.renameSync)(tmp, target);
239
+ }
240
+ catch (error) {
241
+ try {
242
+ if ((0, fs_1.existsSync)(tmp)) {
243
+ (0, fs_1.unlinkSync)(tmp);
244
+ }
245
+ }
246
+ catch (_a) {
247
+ /* best effort */
248
+ }
249
+ throw error;
250
+ }
251
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Cross-group mutual exclusion between CPU-heavy steps and contention-sensitive
3
+ * test suites (DEV-2524, cause 2).
4
+ *
5
+ * The root check wrapper (scripts/check.mjs) runs each workspace project's step
6
+ * chain concurrently by default. The api group's `test` step is the API e2e
7
+ * suite, whose Better-Auth session validation is sensitive to CPU contention:
8
+ * it tips into intermittent 401/500 the moment another group saturates the
9
+ * machine — most reliably the app group's `nuxt build`. Measured in DEV-2524:
10
+ * the same commit was red under the parallel run and green (2418/2418) under
11
+ * `--sequential`. This gate guarantees a heavy step and a sensitive test step
12
+ * never run at the same time, while:
13
+ * - same-class steps may still overlap (two builds, or two test suites)
14
+ * → the parallel design is preserved elsewhere;
15
+ * - every other step kind (format, lint, server-start, …) never touches the
16
+ * gate at all and stays fully parallel.
17
+ *
18
+ * Which steps belong to which class is `gateClass()` in check.mjs, not this
19
+ * file: the gate is a generic two-class lock and does not know what a "build"
20
+ * is. Note that the classes are named "build" and "test" only because those are
21
+ * the labels the caller passes; the lock treats them as two opaque, mutually
22
+ * exclusive classes.
23
+ *
24
+ * It is a two-class fair lock: whichever class is active admits every waiter of
25
+ * that class; the other class waits until the active class fully drains, then
26
+ * takes over as a batch. Handover is FIFO between the two classes, so neither
27
+ * can starve the other.
28
+ *
29
+ * Starvation-freedom is workload-bounded, not unconditional: an arriving acquire
30
+ * of the ACTIVE class barges ahead of an already-queued opposite-class waiter, so
31
+ * an unbounded stream of same-class arrivals could in theory starve the other
32
+ * class. That cannot happen here — the check wrapper issues FINITELY MANY gated
33
+ * steps per group, so the active class always drains and the queued batch is then
34
+ * admitted. (Do not restate this as "one test then one build per group": the root
35
+ * group has a `test` and no build at all, and a chain's shape is the project's to
36
+ * choose. Finiteness is what the argument needs, and finiteness is what holds.)
37
+ */
38
+ export function createBuildTestGate() {
39
+ let activeClass = null; // 'test' | 'build' | null
40
+ let activeCount = 0;
41
+ const queue = []; // FIFO of { klass, resolve }
42
+
43
+ function admit(klass, resolve) {
44
+ activeClass = klass;
45
+ activeCount += 1;
46
+ resolve();
47
+ }
48
+
49
+ function acquire(klass) {
50
+ return new Promise((resolve) => {
51
+ if (activeClass === null || activeClass === klass) {
52
+ admit(klass, resolve);
53
+ } else {
54
+ queue.push({ klass, resolve });
55
+ }
56
+ });
57
+ }
58
+
59
+ function release() {
60
+ // Guard against an unbalanced release. Without it the counter goes negative
61
+ // and the lock stops excluding: with two holders and one stray release,
62
+ // `activeCount` reaches 0 while a holder is still running, so the opposite
63
+ // class is admitted alongside it — silently, and precisely when the machine
64
+ // is busiest. No current caller can double-release (runGroup acquires and
65
+ // releases exactly once per gated step), but this is shipped into every
66
+ // generated project and there are two release sites per acquire.
67
+ if (activeCount <= 0) return;
68
+
69
+ activeCount -= 1;
70
+ if (activeCount > 0) return;
71
+
72
+ activeClass = null;
73
+ if (queue.length === 0) return;
74
+
75
+ // Active class fully drained with waiters pending: hand over to the class of
76
+ // the oldest waiter, admitting every queued waiter of that class as a batch.
77
+ //
78
+ // Via the public API the queue only ever holds a SINGLE class at a time (a
79
+ // same-class acquire is admitted immediately and never queues, so only the
80
+ // opposite class waits while one class is active). The `carried` re-queue is
81
+ // therefore always empty in practice — kept as a defensive guard so the
82
+ // handover stays correct if the admission rule ever changes.
83
+ const nextClass = queue[0].klass;
84
+ const carried = [];
85
+ for (const waiter of queue) {
86
+ if (waiter.klass === nextClass) {
87
+ admit(waiter.klass, waiter.resolve);
88
+ } else {
89
+ carried.push(waiter);
90
+ }
91
+ }
92
+ queue.length = 0;
93
+ queue.push(...carried);
94
+ }
95
+
96
+ return {
97
+ acquire,
98
+ release,
99
+ // Read-only accessors, exposed for assertions / telemetry only.
100
+ get activeClass() {
101
+ return activeClass;
102
+ },
103
+ get activeCount() {
104
+ return activeCount;
105
+ },
106
+ };
107
+ }
@@ -26,10 +26,12 @@
26
26
  * lt-dev `running-check-script` skill relies on: non-zero === failed).
27
27
  */
28
28
  import { execSync, spawn } from "node:child_process";
29
- import { readdirSync, readFileSync } from "node:fs";
29
+ import { readdirSync, readFileSync, realpathSync } from "node:fs";
30
30
  import { dirname, join } from "node:path";
31
31
  import { fileURLToPath } from "node:url";
32
32
 
33
+ import { createBuildTestGate } from "./build-test-gate.mjs";
34
+
33
35
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
34
36
  const VERBOSE = process.argv.includes("--verbose") || process.argv.includes("-v");
35
37
  const SEQUENTIAL = process.argv.includes("--sequential") || process.argv.includes("--seq");
@@ -59,25 +61,118 @@ function fmtDuration(ms) {
59
61
  return `${m}m ${Math.round(s - m * 60)}s`;
60
62
  }
61
63
 
64
+ // ── Nuxt build-dir isolation for the check's OWN package-manager calls ───────
65
+ // The app's `build:check` / `typecheck:*` scripts pin `NUXT_BUILD_DIR` so a
66
+ // check never writes the `.nuxt/` a parked `nuxt dev` reads. One writer has no
67
+ // script to pin it in: `postinstall: nuxt prepare`. It inherits the env of
68
+ // whatever triggered the install — and this script triggers one on every run
69
+ // (the hoisted install below). Unpinned, that install rewrites
70
+ // `.nuxt/tsconfig.json` under a running dev server, which then type-checks
71
+ // without the `~`/`#` aliases and dies on code that is fine.
72
+ //
73
+ // Applied on TWO levels: as a textual prefix, so `buildGroups` stays a pure
74
+ // function a guard can assert against, AND as a real environment variable at
75
+ // spawn time. The prefix alone is not enough — a `VAR=value cmd` assignment
76
+ // binds only to the first simple command, so a step written with `;` or a
77
+ // leading `cd` would be reported as pinned and run unpinned. Both carry the
78
+ // same value, so they cannot disagree.
79
+ const CHECK_BUILD_DIR = ".nuxt-check";
80
+
81
+ // Deliberately narrow: a blanket prefix would override the dirs the package.json
82
+ // scripts pin themselves. These are the commands that run lifecycle hooks (or,
83
+ // for `audit`, may resolve after fixing) and so have no pin of their own.
84
+ //
85
+ // SINGLE SOURCE: `classify()` derives its install/audit branches from these very
86
+ // patterns, so "the pin predicate is at least as wide as the hoist predicate" is
87
+ // true BY CONSTRUCTION rather than by assertion. It used to be two hand-written
88
+ // regexes that were supposed to agree, and they did not: `classify()` matched a
89
+ // bare `\baudit\b`, which also catches `npx audit-ci`, `bash scripts/audit.sh`
90
+ // and `pnpm --filter api audit`. Those were hoisted (so no longer ordinary
91
+ // steps) but not pinned — and worse, `runAudit` appends ` --json` to whatever
92
+ // was labelled "audit", so the check failed on a flag it invented itself.
93
+ const PM = String.raw`(?:pnpm|npm|yarn|bun)`;
94
+ /** `<pm> install|ci|i` — including the `i` shorthand. */
95
+ export const PM_INSTALL = new RegExp(String.raw`\b${PM}\s+(?:install|ci|i)\b`);
96
+ /**
97
+ * A REAL package-manager audit (`pnpm audit`, `npm audit`, `yarn npm audit`).
98
+ *
99
+ * Only these are hoisted, because only these produce the JSON that `runAudit`
100
+ * parses — and it appends ` --json` to whatever it is handed. A project script
101
+ * that merely has "audit" in its name (`pnpm run audit:ci`) is a normal step:
102
+ * hoisting it fed it a flag it does not accept, so the check failed on an
103
+ * argument the wrapper invented.
104
+ */
105
+ export const PM_AUDIT = new RegExp(String.raw`\b${PM}\s+audit\b`);
106
+ /** A project script whose name mentions audit or install — an ordinary step that still needs the pin. */
107
+ const PM_RUN_SCRIPT = new RegExp(String.raw`\b${PM}\s+run\s+\S*(?:audit|install)\S*`);
108
+ /** 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
+ );
112
+
113
+ /**
114
+ * Prefix a package-manager command with the check's isolated Nuxt build dir.
115
+ *
116
+ * Idempotent, and never overrides a pin the command already carries — the
117
+ * existing-pin test is NOT anchored to the start of the string, because the
118
+ * shape the nuxt starter actually ships is `cross-env NUXT_BUILD_DIR=… pnpm …`,
119
+ * which a `^` anchor does not see.
120
+ */
121
+ export function pinCheckBuildDir(cmd) {
122
+ if (!PM_INVOCATION.test(cmd) || /(^|\s)NUXT_BUILD_DIR=/.test(cmd)) return cmd;
123
+ return `NUXT_BUILD_DIR=${CHECK_BUILD_DIR} ${cmd}`;
124
+ }
125
+
126
+ /**
127
+ * The environment a step needs beyond the inherited one.
128
+ *
129
+ * Mirrors the textual pin so a shell construct the prefix cannot reach (a `;`
130
+ * separator, a leading `cd`) still gets the isolated build dir. Only for
131
+ * commands the pin applies to — a step that pins itself keeps its own value,
132
+ * because the prefix check already declined to touch it.
133
+ */
134
+ function stepEnv(step) {
135
+ return /(^|\s)NUXT_BUILD_DIR=/.test(step.cmd) ? { NUXT_BUILD_DIR: CHECK_BUILD_DIR } : null;
136
+ }
137
+
62
138
  // ── step classification ────────────────────────────────────────────────────
63
139
  // Map a raw command from a `check` chain onto a stable kind + label so the
64
140
  // report stays readable regardless of the underlying tool (oxfmt/oxlint/tsc/…).
65
141
  function classify(cmd) {
66
142
  const c = cmd.toLowerCase();
67
- if (c.includes("vendor-freshness"))
68
- return { fatal: false, kind: "vendor", label: "vendor-freshness" };
69
143
  // Dependency install — hoisted to ONE workspace-level run (see buildGroups):
70
144
  // api and app chains both start with `pnpm install --frozen-lockfile`, and
71
145
  // running those CONCURRENTLY (parallel groups) mutates the same workspace
72
146
  // node_modules from two processes at once.
73
- if (/\b(pnpm|npm|yarn|bun)\s+(install|ci)\b/.test(c))
74
- return { fatal: true, kind: "install", label: "install" };
75
- if (c.includes("audit")) return { fatal: true, kind: "audit", label: "audit" };
147
+ //
148
+ // Checked BEFORE `vendor-freshness`: that branch is a plain substring test, so
149
+ // a command that mentions it anywhere (`pnpm install --filter vendor-freshness`)
150
+ // used to short-circuit past this one and land in the ordinary step list, where
151
+ // nothing pins NUXT_BUILD_DIR for it.
152
+ //
153
+ // Both predicates come from the pin patterns above, so a command can never be
154
+ // 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" };
76
159
  if (c.includes("format:check") || c.includes("oxfmt"))
77
160
  return { fatal: true, kind: "format", label: "format" };
78
161
  if (c.includes("lint")) return { fatal: true, kind: "lint", label: "lint" };
79
- if (/(^|&|\s)(pnpm\s+)?test(:|\s|$)|vitest|jest|test:unit|test:ci/.test(c))
162
+ // A unit-only run is named explicitly, is short, and is not contention
163
+ // sensitive — it carries `light` so the build⊥test gate lets it through (see
164
+ // GATE_CLASS). A bare `pnpm test` is NOT assumed to be light: in the starters
165
+ // 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))
80
169
  return { fatal: true, kind: "test", label: "test" };
170
+ // `typecheck` runs vue-tsc / tsc, which saturates the machine just like a
171
+ // build — and it does NOT contain the substrings "build" or "tsc", so it used
172
+ // to fall through to `other` and run ungated, fully concurrent with the API
173
+ // e2e suite. The gate then paid its serialisation cost while the second
174
+ // 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" };
81
176
  if (c.includes("build") || c.includes("nuxt build") || c.includes("tsc"))
82
177
  return { fatal: true, kind: "build", label: "build" };
83
178
  if (c.includes("check-server-start") || c.includes("server-start"))
@@ -85,6 +180,21 @@ function classify(cmd) {
85
180
  return { fatal: true, kind: "other", label: cmd.length > 32 ? `${cmd.slice(0, 29)}…` : cmd };
86
181
  }
87
182
 
183
+ /**
184
+ * Which mutual-exclusion class a step belongs to, or null when it is ungated.
185
+ *
186
+ * The gate keeps CPU-heavy work off the contention-sensitive API e2e suite. It
187
+ * is deliberately CONSERVATIVE: a bare `test` step is treated as sensitive even
188
+ * though a project's may be light, because the two failure directions are not
189
+ * symmetric — too wide costs wall-clock, too narrow costs the flaky-suite bug
190
+ * this gate exists to fix (DEV-2524).
191
+ */
192
+ export function gateClass(step) {
193
+ if (step.kind === "build") return "build";
194
+ if (step.kind === "test") return step.light ? null : "test";
195
+ return null;
196
+ }
197
+
88
198
  // Rewrite a check-only format/lint command into its auto-fixing variant, so a
89
199
  // `check` run repairs every fixable finding instead of only reporting it.
90
200
  function toFixCommand(kind, cmd) {
@@ -120,9 +230,22 @@ function sumMatches(clean, re) {
120
230
  // showed "16 passed" (unit only) while its 69 e2e tests ran unseen.
121
231
  function parseVitest(out) {
122
232
  const clean = stripAnsi(out);
123
- const passed = sumMatches(clean, /Tests\s+(?:\d+\s+failed[^\n]*?)?(\d+)\s+passed/gi);
233
+ let passed = sumMatches(clean, /Tests\s+(?:\d+\s+failed[^\n]*?)?(\d+)\s+passed/gi);
124
234
  const files = sumMatches(clean, /Test Files\s+(?:\d+\s+failed[^\n]*?)?(\d+)\s+passed/gi);
125
- const failed = sumMatches(clean, /Tests\s+(\d+)\s+failed/gi);
235
+ let failed = sumMatches(clean, /Tests\s+(\d+)\s+failed/gi);
236
+ // `node --test` (a chain may run one, e.g. over scripts/) reports in node:test format
237
+ // ("ℹ pass N" / "ℹ fail N", or "# pass N" under the TAP reporter), not Vitest's
238
+ // "Tests N passed". Without this fallback parseVitest returned null for it, so
239
+ // the gate tests went uncounted and a green run could show "Total 0 passed".
240
+ if (passed == null) {
241
+ passed = sumMatches(clean, /(?:^|\n)[^\S\n]*[#ℹ][^\S\n]+pass[^\S\n]+(\d+)\b/gi);
242
+ }
243
+ // Checked independently of `passed`: a node:test run that reports only
244
+ // failures has no `pass` line at all, and nesting this inside the branch above
245
+ // made those runs parse as "no tests" instead of as failures.
246
+ if (failed == null) {
247
+ failed = sumMatches(clean, /(?:^|\n)[^\S\n]*[#ℹ][^\S\n]+fail[^\S\n]+(\d+)\b/gi);
248
+ }
126
249
  if (passed == null && files == null) return null;
127
250
  return {
128
251
  failed: failed ?? 0,
@@ -148,17 +271,48 @@ const SEVERITIES = ["critical", "high", "moderate", "low", "info"];
148
271
  // the command's own exit code, so `check` blocks precisely when a bare
149
272
  // `<auditCmd>` would — never with a narrower scope than the chain. (The old
150
273
  // hardcoded `--prod` hid devDependency vulns for library packages.)
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`.
278
+ *
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.
283
+ *
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.
289
+ *
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.
293
+ */
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);
300
+ }
301
+
151
302
  async function runAudit(auditCmd) {
152
303
  const cmd = /(^|\s)--json(\s|$)/.test(auditCmd) ? auditCmd : `${auditCmd} --json`;
153
- const { code, out } = await capture(cmd, ROOT);
304
+ const { code, out } = await capture(cmd, ROOT, 0, { NUXT_BUILD_DIR: CHECK_BUILD_DIR });
154
305
  let counts = null;
306
+ let unlisted = 0;
155
307
  try {
156
- counts = JSON.parse(out.slice(out.indexOf("{")))?.metadata?.vulnerabilities ?? null;
308
+ const parsed = JSON.parse(out.slice(out.indexOf("{")));
309
+ counts = parsed?.metadata?.vulnerabilities ?? null;
310
+ unlisted = countUnlisted(parsed);
157
311
  } catch {
158
312
  /* fall through to raw reason */
159
313
  }
160
314
  const total = counts ? SEVERITIES.reduce((n, s) => n + (counts[s] || 0), 0) : 0;
161
- return { auditCmd, blocking: code !== 0, counts, reason: counts ? null : out, total };
315
+ return { auditCmd, blocking: code !== 0, counts, reason: counts ? null : out, total, unlisted };
162
316
  }
163
317
 
164
318
  // Watchdog: kill a TEST step whose child produces NO output for this long. A
@@ -220,9 +374,14 @@ function killTree(child, signal = "SIGTERM") {
220
374
  // idleTimeoutMs > 0 arms the no-output watchdog for this child; 0 (the default)
221
375
  // runs it unwatched. Only callers that KNOW the child streams progress (test
222
376
  // steps) should pass a timeout — see runGroup.
223
- function capture(cmd, cwd, idleTimeoutMs = 0) {
377
+ function capture(cmd, cwd, idleTimeoutMs = 0, extraEnv = null) {
224
378
  return new Promise((resolve) => {
225
- const child = spawn(cmd, { cwd, shell: true });
379
+ // `extraEnv` carries the build-dir pin as a real environment variable IN
380
+ // ADDITION to the textual prefix. A `VAR=value cmd` prefix binds only to the
381
+ // first simple command, so a step written with `;` or a leading `cd` would be
382
+ // reported as pinned and run unpinned. The env reaches every command in the
383
+ // 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 });
226
385
  RUNNING.add(child);
227
386
  let out = "";
228
387
  let idleTimer = null;
@@ -325,6 +484,42 @@ function statusLines(order, states) {
325
484
  // ── project discovery + step grouping ────────────────────────────────────────
326
485
  const IS_ORCHESTRATOR = (script) => !script || script.includes("check.mjs");
327
486
 
487
+ /**
488
+ * True when a command re-enters `check` across workspace members.
489
+ *
490
+ * Such a command must be stripped from the root chain: this wrapper ALREADY
491
+ * runs every member as its own group, so letting the fan-out through runs them
492
+ * a second time — and, because the root group runs under the same
493
+ * `Promise.all`, CONCURRENTLY with the wrapper's own member groups. That means
494
+ * two `pnpm install` against one node_modules (exactly what the install hoist
495
+ * exists to prevent), two builds writing the same build dir, and two API e2e
496
+ * suites sharing one database.
497
+ *
498
+ * A positive test for "re-enters check", not a match on one spelling: `run` is
499
+ * optional in pnpm (`pnpm -r check`), the scope may be given as `--filter`
500
+ * rather than `-r`, and npm/yarn/turbo/lerna/nx each spell it differently. The
501
+ * previous version required a literal `pnpm … run check` and let every other
502
+ * form survive.
503
+ */
504
+ export function isRecursiveCheck(cmd) {
505
+ const c = cmd.toLowerCase();
506
+ if (!/\bcheck\b/.test(c)) return false;
507
+ // Fans out over workspace members. NOTE the `(?:^|\s)` rather than `\b`: there
508
+ // is no word boundary between a space and a `-`, so `\b-r` never matches
509
+ // anything — the flag forms have to be anchored on whitespace.
510
+ if (/(?:^|\s)(?:-r|--recursive|--filter\S*|--workspaces?|foreach)(?:\s|=|$)/.test(c)) {
511
+ return true;
512
+ }
513
+ // … or delegates to a monorepo task runner, which does the same.
514
+ return /(?:^|\s)(?:turbo|lerna|nx)(?:\s|$)/.test(c);
515
+ }
516
+
517
+ /** True when this command is hoisted to a single workspace-level run. */
518
+ function isHoisted(cmd) {
519
+ const kind = classify(cmd).kind;
520
+ return kind === "install" || kind === "audit";
521
+ }
522
+
328
523
  // Read the `packages:` globs from pnpm-workspace.yaml (monorepos). A simple
329
524
  // value-list parse — enough for the globs lt projects use (e.g. `projects/*`).
330
525
  function workspaceGlobs() {
@@ -412,12 +607,30 @@ function discoverProjects() {
412
607
  if (chain) projects.push(asProject(rel, chain));
413
608
  }
414
609
  }
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);
415
614
  if (projects.length === 0) {
416
- const root = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
417
- const chain =
418
- root.scripts?.["check:raw"] ??
419
- (IS_ORCHESTRATOR(root.scripts?.check) ? null : root.scripts?.check);
420
- if (chain) projects.push(asProject(".", chain));
615
+ if (rootChain) projects.push(asProject(".", rootChain));
616
+ } else if (rootChain) {
617
+ // With members present, the root's own chain must not be dropped: beyond
618
+ // install/audit (hoisted later) and the member fan-out (replaced by the
619
+ // member expansion above) it may carry root-ONLY steps — in the assembled
620
+ // monorepo that is `check:workspace` / `check:pin`, which exist precisely
621
+ // for the case where members are present. Strip the fan-out command and
622
+ // keep whatever remains as a root project.
623
+ const ownSteps = rootChain
624
+ .split("&&")
625
+ .map((s) => s.trim())
626
+ .filter(Boolean)
627
+ .filter((c) => !isRecursiveCheck(c));
628
+ // Only when something is actually LEFT: a chain that reduces to nothing but
629
+ // hoisted steps would otherwise add an empty group that occupies a live-view
630
+ // row and reports a phantom success it never earned.
631
+ if (ownSteps.some((c) => !isHoisted(c))) {
632
+ projects.unshift(asProject(".", ownSteps.join(" && ")));
633
+ }
421
634
  }
422
635
  if (PROJECT_FILTERS.length)
423
636
  return projects.filter((p) =>
@@ -429,7 +642,7 @@ function discoverProjects() {
429
642
  // One group per project: its ordered, fix-mapped steps. The audit step is
430
643
  // hoisted to a single workspace-level run; its EXACT command (scope + level +
431
644
  // package manager) is captured so the run mirrors the chain's own audit.
432
- function buildGroups(projects) {
645
+ export function buildGroups(projects) {
433
646
  let auditCmd = null;
434
647
  let installCmd = null;
435
648
  const groups = projects.map((project) => {
@@ -439,19 +652,36 @@ function buildGroups(projects) {
439
652
  .map((s) => s.trim())
440
653
  .filter(Boolean)) {
441
654
  const meta = classify(raw);
655
+ const pinned = pinCheckBuildDir(raw);
656
+ // Both kinds are hoisted to ONE workspace-level run and every further
657
+ // occurrence is dropped — deliberately, and regardless of how it is
658
+ // spelled: in a pnpm workspace each member's install resolves the whole
659
+ // workspace anyway, so a second one is redundant, and running two
660
+ // concurrently races on the same node_modules. The same holds for the
661
+ // audit, which is a workspace-wide question.
662
+ //
663
+ // This is safe to drop silently ONLY because `classify` now hoists just
664
+ // the real `<pm> install` / `<pm> audit` forms. A project script that
665
+ // merely mentions audit in its name stays an ordinary step, so a chain can
666
+ // no longer lose a gate here without a trace.
442
667
  if (meta.kind === "audit") {
443
- if (!auditCmd) auditCmd = raw;
668
+ if (!auditCmd) auditCmd = pinned;
444
669
  continue;
445
670
  }
446
671
  if (meta.kind === "install") {
447
- // Hoisted like the audit: one workspace-level install BEFORE the
448
- // fan-out. In a pnpm workspace every member's install resolves the
449
- // whole workspace anyway, and two parallel installs race on the same
450
- // node_modules.
451
- if (!installCmd) installCmd = raw;
672
+ if (!installCmd) installCmd = pinned;
452
673
  continue;
453
674
  }
454
- steps.push({ ...meta, cmd: toFixCommand(meta.kind, raw), cwd: project.dir });
675
+ // Pinned here TOO, not only on the two hoists. classify() routes every
676
+ // install and audit into a hoist, so for those this is redundant — but the
677
+ // step list also carries the non-hoisted remainder above, and both layers
678
+ // are idempotent (pinCheckBuildDir never double-prefixes and never
679
+ // overrides an existing pin), so defending it costs nothing.
680
+ steps.push({
681
+ ...meta,
682
+ cmd: pinCheckBuildDir(toFixCommand(meta.kind, raw)),
683
+ cwd: project.dir,
684
+ });
455
685
  }
456
686
  return { project, steps };
457
687
  });
@@ -460,22 +690,70 @@ function buildGroups(projects) {
460
690
 
461
691
  // ── per-project runner ───────────────────────────────────────────────────────
462
692
  // Runs a group's steps in order, recording results + live state. Stops early
463
- // when another project already failed (abort.hit).
464
- async function runGroup(group, states, results, abort) {
693
+ // when another project already failed (abort.hit). The `gate` keeps CPU-heavy
694
+ // steps (build, typecheck) from overlapping a contention-sensitive test suite
695
+ // across groups (DEV-2524, cause 2) — see build-test-gate.mjs.
696
+ async function runGroup(group, states, results, abort, gate) {
465
697
  const rel = group.project.rel;
466
698
  const st = states.get(rel);
467
699
  const startedAt = Date.now();
468
700
  for (const step of group.steps) {
469
701
  if (abort.hit) return;
702
+ // A parallel `nuxt build` saturating the machine tips the API e2e suite's
703
+ // Better-Auth session validation into intermittent 401/500 (DEV-2524). Hold
704
+ // the two-class gate so heavy CPU work and a sensitive test suite never
705
+ // overlap across groups; same-class steps still run concurrently and every
706
+ // other step kind ignores the gate entirely.
707
+ const klass = gateClass(step);
708
+ let waited = 0;
709
+ if (klass) {
710
+ const queuedAt = Date.now();
711
+ st.current = `${step.label} (queued)`;
712
+ st.stepStart = queuedAt;
713
+ // Surface the wait in CI too: the step line below is only printed AFTER
714
+ // the acquire, so a gate-blocked group would otherwise emit nothing at all
715
+ // 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`);
717
+ await gate.acquire(klass);
718
+ waited = Date.now() - queuedAt;
719
+ // Another group may have failed while we waited — abort before starting.
720
+ //
721
+ // Not raced against an abort signal on purpose: the fatal path calls
722
+ // killAll(), which terminates the holder's process tree, so its capture()
723
+ // resolves, its finally releases, and this waiter is admitted within
724
+ // milliseconds. Racing would hand the queue a waiter that never releases
725
+ // its slot, which is the one way to actually deadlock the opposite class.
726
+ if (abort.hit) {
727
+ gate.release();
728
+ return;
729
+ }
730
+ }
470
731
  st.current = step.label;
471
732
  st.stepStart = Date.now();
472
733
  if (!TTY) process.stdout.write(` ${C.dim("→")} ${shortRel(rel)} · ${step.label}\n`);
473
- // Watchdog only on test steps (see IDLE_TIMEOUT_MS): a test runner streams
474
- // output continuously, so prolonged silence == deadlocked workers. Other
475
- // steps buffer their output and must run unwatched.
476
- const { code, out } = await capture(step.cmd, step.cwd, step.kind === "test" ? IDLE_TIMEOUT_MS : 0);
734
+ // Watchdog on every GATED step, not just tests. A test runner streams output
735
+ // continuously, so prolonged silence == deadlocked workers; a build is
736
+ // normally left unwatched because it buffers. But a gated build holds a slot
737
+ // that blocks every test step in every other group, so a wedged one now
738
+ // hangs the whole run rather than just its own chain — it needs the same
739
+ // watchdog. Ungated steps still run unwatched.
740
+ const watch = step.kind === "test" || klass ? IDLE_TIMEOUT_MS : 0;
741
+ let code;
742
+ let out;
743
+ try {
744
+ ({ code, out } = await capture(step.cmd, step.cwd, watch, stepEnv(step)));
745
+ } finally {
746
+ // Release on every exit path — normal completion or the fatal-failure
747
+ // return below. A leaked slot would deadlock the opposite class under
748
+ // Promise.all.
749
+ if (klass) gate.release();
750
+ }
477
751
  const dur = Date.now() - st.stepStart;
478
752
  const r = { dur, kind: step.kind, label: step.label, project: rel };
753
+ // Recorded separately from `dur`: the report must not hide where the
754
+ // wall-clock went. A step that waited 8 minutes behind another group's build
755
+ // and then ran for 2 is not a 2-minute step.
756
+ if (waited > 0) r.waited = waited;
479
757
  if (step.kind === "test") r.tests = parseVitest(out);
480
758
  if (step.kind === "lint") r.lint = parseLint(out);
481
759
  results.push(r);
@@ -534,7 +812,7 @@ async function main() {
534
812
  const t = Date.now();
535
813
  if (!TTY) process.stdout.write(` ${C.dim("→")} install\n`);
536
814
  else drawLive([`${C.cyan(FRAMES[0])} install`]);
537
- const { code, out } = await capture(installCmd, ROOT);
815
+ const { code, out } = await capture(installCmd, ROOT, 0, { NUXT_BUILD_DIR: CHECK_BUILD_DIR });
538
816
  const dur = Date.now() - t;
539
817
  if (code !== 0) {
540
818
  liveCount = 0; // the failure line must survive — nothing may overwrite it
@@ -558,18 +836,18 @@ async function main() {
558
836
  if (audit.blocking) {
559
837
  liveCount = 0; // the failure line must survive — nothing may overwrite it
560
838
  const summary = audit.counts
561
- ? `${audit.total} vuln (${renderVulnLine(audit.counts)})`
839
+ ? `${audit.total} vuln (${renderVulnLine(audit.counts, audit.unlisted, true)})`
562
840
  : "failed";
563
841
  console.log(`${C.red("✗")} audit ${C.red(summary)} ${C.dim(`(${fmtDuration(dur)})`)}`);
564
842
  return fail(
565
843
  `audit (${auditCmd})`,
566
- audit.counts ? renderVulnLine(audit.counts) : audit.reason,
844
+ audit.counts ? renderVulnLine(audit.counts, audit.unlisted, true) : audit.reason,
567
845
  started,
568
846
  );
569
847
  }
570
848
  if (!TTY) {
571
849
  process.stdout.write(
572
- ` ${C.green("✓")} audit ${audit.counts ? renderVulnLine(audit.counts) : C.dim("0")} ${C.dim(`(${fmtDuration(dur)})`)}\n`,
850
+ ` ${C.green("✓")} audit ${audit.counts ? renderVulnLine(audit.counts, audit.unlisted) : C.dim("0")} ${C.dim(`(${fmtDuration(dur)})`)}\n`,
573
851
  );
574
852
  }
575
853
  // TTY success: NO permanent line — the live status view overwrites the audit
@@ -583,16 +861,20 @@ async function main() {
583
861
  const order = groups.map((g) => g.project.rel);
584
862
  const states = new Map(order.map((rel) => [rel, { current: "queued" }]));
585
863
  const abort = { failure: null, hit: false };
864
+ // Serializes CPU-heavy `build` steps against the contention-sensitive `test`
865
+ // suites across groups so a parallel `nuxt build` can never destabilize the
866
+ // API-e2e run (DEV-2524). Inert in --sequential mode (steps never overlap).
867
+ const gate = createBuildTestGate();
586
868
  const ticker = TTY ? setInterval(() => drawLive(statusLines(order, states)), 80) : null;
587
869
  if (TTY) drawLive(statusLines(order, states));
588
870
 
589
871
  if (SEQUENTIAL) {
590
872
  for (const g of groups) {
591
- await runGroup(g, states, results, abort);
873
+ await runGroup(g, states, results, abort, gate);
592
874
  if (abort.hit) break;
593
875
  }
594
876
  } else {
595
- await Promise.all(groups.map((g) => runGroup(g, states, results, abort)));
877
+ await Promise.all(groups.map((g) => runGroup(g, states, results, abort, gate)));
596
878
  }
597
879
 
598
880
  if (ticker) clearInterval(ticker);
@@ -605,13 +887,27 @@ async function main() {
605
887
  }
606
888
 
607
889
  // ── rendering helpers ─────────────────────────────────────────────────────────
608
- function renderVulnLine(counts) {
609
- return SEVERITIES.map((s) => {
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) => {
610
904
  const n = counts[s] || 0;
611
905
  const txt = `${s} ${n}`;
612
- if (n > 0 && (s === "critical" || s === "high")) return C.red(txt);
613
- return n > 0 ? C.yellow(txt) : C.dim(txt);
906
+ if (n === 0 || allUnlisted) return C.dim(txt);
907
+ if (s === "critical" || s === "high") return C.red(txt);
908
+ return C.yellow(txt);
614
909
  }).join(C.dim(" · "));
910
+ return unlisted > 0 ? `${line}${C.dim(` (${unlisted} not listed)`)}` : line;
615
911
  }
616
912
 
617
913
  function metricSuffix(r) {
@@ -619,6 +915,11 @@ function metricSuffix(r) {
619
915
  const failed = r.tests.failed ? C.red(` / ${r.tests.failed} failed`) : "";
620
916
  return ` ${C.dim(`${r.tests.passed} passed${r.tests.files != null ? ` / ${r.tests.files} files` : ""}`)}${failed}`;
621
917
  }
918
+ if (r.waited != null && r.waited >= 1000) {
919
+ // The gate wait is NOT part of `dur`, so without this the report would show
920
+ // a two-minute step that actually occupied ten minutes of wall-clock.
921
+ return ` ${C.dim(`queued ${fmtDuration(r.waited)}`)}`;
922
+ }
622
923
  if (r.kind === "lint" && r.lint) {
623
924
  return r.lint.warnings > 0
624
925
  ? ` ${C.yellow(`${r.lint.warnings} warning${r.lint.warnings === 1 ? "" : "s"}`)}`
@@ -655,17 +956,36 @@ function report(started, results) {
655
956
  console.log(C.green(bar));
656
957
 
657
958
  console.log(`\n${C.bold("Steps")}`);
658
- for (const r of results.filter((x) => x.kind !== "audit")) {
659
- console.log(
660
- ` ${C.green("✓")} ${`${shortRel(r.project)} · ${r.label}`.padEnd(26)}${metricSuffix(r) || " "} ${C.dim(`(${fmtDuration(r.dur)})`)}`,
661
- );
959
+ const steps = results.filter((x) => x.kind !== "audit");
960
+ // Group by project when more than one is involved: workspace-level steps
961
+ // (hoisted install/audit, root-only checks) under "monorepo", then one block
962
+ // per member. Steps within a project run sequentially, so per-group order is
963
+ // chain order. A single-project run keeps the flat list — a header is noise.
964
+ const stepGroups = [...new Set(steps.map((r) => r.project))].sort((a, b) =>
965
+ a === "." ? -1 : b === "." ? 1 : shortRel(a).localeCompare(shortRel(b)),
966
+ );
967
+ if (stepGroups.length > 1) {
968
+ for (const project of stepGroups) {
969
+ console.log(` ${C.bold(project === "." ? "monorepo" : shortRel(project))}`);
970
+ for (const r of steps.filter((x) => x.project === project)) {
971
+ console.log(
972
+ ` ${C.green("✓")} ${r.label.padEnd(24)}${metricSuffix(r) || " "} ${C.dim(`(${fmtDuration(r.dur)})`)}`,
973
+ );
974
+ }
975
+ }
976
+ } else {
977
+ for (const r of steps) {
978
+ console.log(
979
+ ` ${C.green("✓")} ${`${shortRel(r.project)} · ${r.label}`.padEnd(26)}${metricSuffix(r) || " "} ${C.dim(`(${fmtDuration(r.dur)})`)}`,
980
+ );
981
+ }
662
982
  }
663
983
 
664
984
  console.log(
665
985
  `\n${C.bold("Vulnerabilities")} ${C.dim(audit ? `(${audit.auditCmd})` : "(no audit step)")}`,
666
986
  );
667
987
  console.log(
668
- ` ${audit?.counts ? renderVulnLine(audit.counts) : C.dim(audit ? "counts unavailable" : "—")}`,
988
+ ` ${audit?.counts ? renderVulnLine(audit.counts, audit.unlisted, audit.blocking) : C.dim(audit ? "counts unavailable" : "—")}`,
669
989
  );
670
990
 
671
991
  console.log(`\n${C.bold("Tests")}`);
@@ -691,7 +1011,57 @@ function report(started, results) {
691
1011
  console.log(`\n${C.green("All checks passed.")}\n`);
692
1012
  }
693
1013
 
694
- main().catch((err) => {
695
- console.error(C.red(`\ncheck.mjs crashed: ${err?.stack || err}`));
696
- process.exit(1);
697
- });
1014
+ // Run only when invoked as the CLI (`node scripts/check.mjs`). Importing this
1015
+ // module must never kick off a full check run — a sibling test does exactly
1016
+ // that to assert the pure helpers, where the project has one. (Do not name a
1017
+ // specific test file here: a project scaffolded by `lt fullstack init` ships
1018
+ // one, a project migrated by `lt fullstack update` does not, and naming it
1019
+ // tells half the readers to look for something that was never installed.)
1020
+ //
1021
+ // Split into a pure DECISION and its side effect on purpose. With the
1022
+ // `process.exit(1)` inlined, the fail-closed branch was unreachable from a test
1023
+ // (it would take the test process down with it), so nothing caught a regression
1024
+ // that turned it into a silent `return false` — which is exactly the "green gate
1025
+ // that never ran" this guard exists to prevent.
1026
+ export function resolveCliEntry(entry = process.argv[1], self = fileURLToPath(import.meta.url)) {
1027
+ if (!entry) return { isEntry: false };
1028
+ try {
1029
+ return { isEntry: realpathSync(entry) === realpathSync(self) };
1030
+ } catch (err) {
1031
+ // "Cannot tell" is NOT "not the entry" — the caller must fail closed.
1032
+ return { isEntry: false, unresolvable: err };
1033
+ }
1034
+ }
1035
+
1036
+ function isCliEntry() {
1037
+ const { isEntry, unresolvable } = resolveCliEntry();
1038
+ if (unresolvable) {
1039
+ // Fail CLOSED. Treating this as "not the CLI" would make `node
1040
+ // scripts/check.mjs` print nothing and exit 0 — a green gate that never ran.
1041
+ process.stderr.write(
1042
+ `[check] cannot resolve the CLI entry (${unresolvable?.code || unresolvable}) — refusing to report success\n`,
1043
+ );
1044
+ process.exit(1);
1045
+ }
1046
+ return isEntry;
1047
+ }
1048
+
1049
+ if (isCliEntry()) {
1050
+ // Never leave the child tree behind. Without this, Ctrl-C or a crash detaches
1051
+ // every running `pnpm test` / build / e2e fork pool: they keep the test
1052
+ // database and ports held, and the next run fails for a reason that has
1053
+ // nothing to do with the code.
1054
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
1055
+ process.on(signal, () => {
1056
+ killAll();
1057
+ // Conventional 128+n, and it makes the interruption distinguishable from
1058
+ // an ordinary failure.
1059
+ process.exit(signal === "SIGINT" ? 130 : 143);
1060
+ });
1061
+ }
1062
+ main().catch((err) => {
1063
+ killAll();
1064
+ console.error(C.red(`\ncheck.mjs crashed: ${err?.stack || err}`));
1065
+ process.exit(1);
1066
+ });
1067
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/cli",
3
- "version": "1.43.0",
3
+ "version": "1.44.0",
4
4
  "description": "lenne.Tech CLI: lt",
5
5
  "keywords": [
6
6
  "lenne.Tech",