Running the check Script
This skill is the single source of truth for executing the package.json check script in lt-dev workflows. Every reviewer, rebaser, and orchestrator that needs to guarantee project runnability must follow this procedure verbatim — duplicating the rules across agents leads to drift.
Goal: A truly green
checkrun (exit 0) is a non-negotiable prerequisite for any review or rebase to be considered complete. The only acceptable residual is an upstream dependency vulnerability where the full fix escalation ladder has been exhausted.
When to Use This Skill
| Caller | Phase | Trigger | Mode |
|---|---|---|---|
/lt-dev:review | Phase 1.5 | Before spawning any specialized reviewer | Advisory |
lt-dev:code-reviewer | Phase 1.5 | Before single-pass review (skip if orchestrator already ran it) | Advisory |
lt-dev:test-reviewer | (input briefing) | Honors the skip semantics defined here | Advisory |
/lt-dev:take-ticket | STEP 8 | After all test pillars are green | Blocking |
/lt-dev:git:ship | STEP 1, STEP 4b | Pre-flight, and after the post-rebase re-verify | Blocking |
/lt-dev:ticket-cycle | via take-ticket + git:ship | Whole cycle | Blocking |
lt-dev:branch-rebaser | Phase 6.5 | After lint/format, before tests | Blocking |
Caller modes
The procedure is identical in both modes. Only what happens with an unresolved error differs, and that difference is the whole point of the distinction:
- Advisory — the caller's job is to report. A red
checkis surfaced as a Critical finding and the workflow continues, because the user still wants the rest of the review. - Blocking — the caller's job is to ship. A red
checkstops the workflow. Nothing gets pushed, no MR/PR is created, nothing is merged.
Every path that leads to an MR/PR or a merge runs in Blocking mode. A red check reaching the base branch costs the whole team: CI goes red for everyone, the next take-ticket branches off broken code, and the person who eventually fixes it has no idea which change caused it. That is why the ladder in Step 3 has no iteration cap and why Step 5's residual list is as narrow as it is.
Procedure
Step 1 — Discover check scripts
Use the dedicated helper script (located in the lt-dev plugin):
bash "${CLAUDE_PLUGIN_ROOT}/scripts/discover-check-scripts.sh"
Output is TSV, one project per line:
<package.json path>\t<check script>\t<includes_tests:no|unit+api|yes>\t<package manager>
includes_tests values — callers decide what to do with each, the discovery itself prescribes no behavior:
| Value | Meaning | Playwright coverage |
|---|---|---|
no | No test runner detected in the check chain. | Not covered. |
unit+api | The check chain delegates to the lt check.mjs wrapper, which runs Unit (app) + API (api) test steps and deliberately skips Playwright (Playwright runs via lt dev test / CI — see scripts/check.mjs). | Not covered — Playwright must still be run separately if needed. |
yes | Direct pattern match on test, vitest, jest, or playwright in the check chain (transitively, one level of composite resolution). Scope is whatever the script defines. | Unknown — callers should verify locally if Playwright coverage is required. |
The helper:
- Uses
git ls-files "package.json" "**/package.json"so it respects.gitignoreand skipsnode_modules - Parses JSON via
jq→node -e→grep+sedfallback chain (works even withoutjqinstalled) - Resolves single-level composite scripts (
"check": "pnpm run ci"→ looks upci) - Detects the package manager per project via lockfile (
pnpm-lock.yaml→pnpm, etc.; defaultpnpm)
If the helper returns nothing → no project defines check → skip the rest of this skill.
Step 1a — No oxlint suggestion fixes
oxlint --fix-suggestions applies fixes oxlint itself marks as possibly behaviour-changing: its no-console
suggestion deletes the call. --fix-dangerously goes further. Current templates pass neither, but projects from
older ones still do, in the check wrapper, lint:fix, lint-staged or a project's own scripts/check.mjs.
lt fullstack update removes them at the workspace root and in the app (tracked, clean files only); the API project
keeps its own files, so a hit there is removed by hand or by taking the current nest-server-starter file. Observed in nest-server (2026-09, claude-code PR #10): a
commit hook turned a console.log into an empty function body and still reported "passed". Before Step 2, look for a use of either flag;
a mention in a comment does not count:
git grep -n -E "(^|[[:space:]\"'])--fix-(suggestions|dangerously)" -- '*package.json' '*.lintstagedrc*' '*lint-staged.config.*' '*scripts/check.mjs' \
| grep -v -E '^[^:]+:[0-9]+:[[:space:]]*(//|/?\*|#)'
The second filter drops comment lines: the current nest-server-starter wrapper explains in a comment why it passes
plain --fix only.
On a hit, the check runs without auto-fix:
- Run the lt wrapper as its read-only gate:
<pm> run check --no-fix(npm:npm run check -- --no-fix). Every wrapper that still passes one of the flags already knows--no-fix. Where a project's own chain callslint:fix, or its ownscripts/check.mjsis one of the hits, run that project's steps withlintinstead oflint:fix. - Fix lint findings by hand or with plain
oxlint --fix; format with the project's formatter as usual. - Name every hit (
file:line) in the Step 8 report with its remedy:lt fullstack updatefor the root and the app, removing the flag by hand (or the current nest-server-starter file) for the API project.
Step 2 — Run check per project
For each discovered project, cd into its directory and run the check script with the detected package manager:
cd "$(dirname <package.json path>)"
<package manager> run check
Capture stdout, stderr, and exit code. Track an iteration counter starting at 1.
Step 3 — Auto-fix loop (iterate until truly green)
If a project's check exits non-zero:
- Parse all errors from the current run (typecheck, lint, build, missing imports, type mismatches, unused symbols, audit findings, etc.)
- Fix every error at the root cause via
Read+Edit. Pre-existing errors are fixed too — whether an error came from this diff or was already there makes no difference to whether the project runs, and a redcheckblocks the next person exactly as hard either way. Scope boundaries do not apply to runnability. The one question that decides anything here is "can this be fixed?", and while the answer is yes, it gets fixed. - Re-run
checkfrom scratch. Never trust partial state. - Continue iterating until one of these terminal conditions:
- (a) GREEN —
checkexits 0 → done for this project - (b) STALLED — a full iteration produced no net reduction in error count
- (a) GREEN —
- No hard iteration cap. As long as each iteration strictly reduces the error count, keep going. The goal is true green.
One narrow exception, and it is about collision rather than scope. Point 2 stands: a pre-existing error gets fixed, because a red check blocks the next person regardless of who wrote it. What point 2 does not cover is an error inside uncommitted work another live session is mid-slice on — reshaping that is not a scope question, it is two sessions editing one file, and the peer returns to a rewritten refactor having lost more time than the fix saved. Where the tree is dirty and bash "${CLAUDE_PLUGIN_ROOT}/scripts/change-provenance.sh" attributes the failing path to a live peer: fix it if it is trivial and self-contained (a missing import, a format violation, a stray console.log), and otherwise report it as a blocker naming the author, plus one SOLVED carrying the diagnosis so they can fix their own work in one step. Nobody live, or nothing attributable: point 2 applies unchanged.
STALLED is a signal to change approach, not permission to stop. The count stops falling when the same fix is being retried, so the next iteration has to attack the error differently: read the failing file in full rather than patching around the reported line, check whether two errors share one cause, look at how a working sibling module solves the same thing, run the failing step alone to see output the aggregate run swallowed, check whether it is one of the known hazards in Steps 6.5 to 6.8. Only when a genuinely different approach has been tried and the count still holds does the residual classification in Step 5 apply — and in Blocking mode, everything except an exhausted-ladder dependency vulnerability still stops the workflow.
Step 4 — Audit findings: mandatory fix escalation ladder
When pnpm audit / npm audit / yarn audit (invoked by check) reports a vulnerability, exhaust this ladder before classifying the finding as Accepted. Re-run check after every step.
| # | Step | Command (pnpm / npm / yarn) |
|---|---|---|
| 1 | Update to latest compatible | pnpm update <pkg> / npm update <pkg> / yarn upgrade <pkg> |
| 2 | Automatic remediation | pnpm audit --fix / npm audit fix / yarn audit --fix |
| 3 | Force semver-major upgrade | pnpm audit --fix --force / npm audit fix --force |
| 4 | Bump direct dep to next major if advisory lists fix there | pnpm add <pkg>@<major> / npm install <pkg>@<major> / yarn add <pkg>@<major> |
| 5 | Force a transitive dep version | pnpm.overrides / resolutions (yarn) / overrides (npm) block in package.json, then re-install |
| 6 | Raise the CONSUMER, not the vulnerable package | the direct dependency that pulls it, and the lt framework if the framework pins it (@lenne.tech/nest-server / @lenne.tech/nuxt-extensions) — a full /lt-dev:maintenance:maintain is the thorough form |
| 7 | Patch the consumer — only under the conditions below | pnpm patch <pkg> → patchedDependencies |
| 8 | Replace the package | Switch to a maintained alternative if abandoned |
Step 6 is the rung that gets skipped, and it is the one that most often works. Steps 1 to 5 all push the vulnerable package forward. When that fails, the reason is usually not the vulnerable package at all — it is the consumer holding it back, by pinning a vulnerable range or by using an API the patched major removed. So the question to ask is "who keeps this version here?", and the answer is a direct dependency, or a framework whose pin an override cannot beat. An override is a pin, and a pin is strictly worse than a real version bump: it diverges the tree from what upstream tests, and it rots silently. Take the bump where one exists.
Step 7 (patching) is narrow on purpose, and the narrowness is the point. When the patched major only fails to load — a removed default export, a renamed entry point — a one-line patch looks like it closes the advisory properly rather than suppressing it. Sometimes it does. Three conditions have to hold first, and the third is the one that usually fails:
- The patch targets a stable path in the vulnerable package, and is pinned to one exact version. What breaks a patch is not
dist/as such — published packages shipdist/and nothing else — it is a path that moves between releases.dist/chunks/<hash>.mjsis renamed by the next build and the patch then fails for everyone on the team;dist/index.mjsis the package's own entry point and stays. A patch that names a minified symbol is still acceptable with the version pin, because the pin turns the risk into a loudpnpm installfailure instead of a silent mis-patch. Patch the vulnerable dependency, not a consumer that happens to import it — see the measured case below, where the first assessment looked at the consumer's chunk and rejected the rung on that basis. - Weigh the production closure against the maintenance cost. A patch is a standing obligation on every future bump, so for a dev-tooling path that cannot reach production it may well spend more than it buys — in a single project. A TEMPLATE flips that arithmetic: every generated project inherits one patch, while a residual would have to be re-argued in each of them. This is a judgement, not a veto; conditions 1 and 3 are the hard ones.
- Loading is not the same as working. A major that removed an export usually changed behaviour too, so a patched import makes the module import while leaving the consumer running against an API it was never tested on. If you cannot say what else changed in that major and why it does not matter here, the patch is a guess wearing the costume of a fix.
Patch the VULNERABLE package, not the consumer. This is the distinction that decides whether the rung works, and it is easy to get wrong, because the error points at the consumer. Measured, lt-crm 2026-10-06: @nuxt/[email protected] does import Git from 'simple-git', and raising simple-git to the only fixed line (4.x, which dropped that default export) made nuxt prepare abort with does not provide an export named 'default'. The first assessment looked at devtools' own dist/chunks/module-main.mjs, found the import there, and rejected patching on condition 1 — a compiled chunk — which was the right verdict about the wrong file. The patch belongs one level down, in simple-git's own dist/index.mjs, where a single added line re-exports as default the very factory the module already exports as simpleGit. Nothing about the hardened code changes; only the module's surface comes back.
With that, all three conditions hold: it is the vulnerable package rather than a consumer artefact, it is pinned to one exact version (patchedDependencies: { '[email protected]': … } plus the override target), and the API devtools actually calls — branch, revparse, status — is core API in 4.x, so there is an answer to "what else did the major change". One CRITICAL and two HIGH advisories closed instead of suppressed; pnpm audit went from critical 1 · high 5 to critical 0 · high 3 on that tree, and the ignoreGhsas list shrank from six entries to three.
The pin is what makes a minified symbol acceptable: the patch names an internal name from that exact build, so any other version renames it and pnpm install fails LOUDLY rather than silently patching the wrong binding. That failure is the signal to check whether the consumer has moved to the named import — at which point the PATCH goes. The override has its own exit condition and usually outlives it: it can go only once nothing in the tree resolves the vulnerable line any more (here: once nuxt requests a @nuxt/devtools that no longer depends on simple-git 3). simple-git 3.36.0 already has the named export, so a consumer that switches to it may still ask for the vulnerable 3.x.
nuxt-base-starter and nuxt-extensions carry the identical patch for the identical path, and a project generated from the template should keep it rather than "fix" it into a residual. The template also accepts the standing maintenance cost more readily than a single project would, because every generated project inherits it while a residual would have to be re-argued in each one.
So the order is: patch the vulnerable package, and only then declare a residual. Condition 3 still rules out a patch whose behavioural effect you cannot state, and condition 2 still argues against taking on a patch for a dev-only path in a project of your own — but "the consumer cannot use the fix" is a claim about the CONSUMER, and it does not survive a one-line, security-neutral patch in the dependency. Record that you evaluated patching and what you concluded, either way, or the next person pays to rediscover it.
A finding may only be classified as Accepted after every applicable step has been tried and verified. Two shapes qualify, and they carry different evidence and different follow-up:
| Shape | What must be documented | Re-checked when |
|---|---|---|
| No patched version exists | the advisory's stated fix range, and proof that no version in it was ever published (registry listing, not the advisory's own claim) | on every check — a publish closes it |
| A patched version exists but the consumer cannot use it | which consumer holds it, what breaks on the patched version as measured (the error, not a guess), that raising or replacing the consumer was tried (Step 6), and that the vulnerable path is outside the production closure | on every check — the consumer can adapt at any time, and this shape rots faster than the first |
Verify the fix range against the registry, never against the advisory. An advisory naming a patched version that was never published is not a rarity: measured twice in lt-crm — node-forge 1.4.1 (advisory says patched, npm's latest is the vulnerable 1.4.0) and simple-git 3.36.1 (the 3.36 line ends at 3.36.0, which the same advisories mark vulnerable). npm view <pkg> versions settles it in one call, and an override pointed at a version that does not exist, or at one that is itself vulnerable, is worse than no override: the guard reports the finding as handled.
The second shape is the one to be strict about, because "the consumer cannot use it" is also what it looks like when nobody tried hard enough — and the simple-git case above is the cautionary tale, not the model. Everything on its record was true: the CRITICAL needs >=4.0.1, simple-git 4 removed the default export devtools imports, the newest 3.x devtools still imports it that way, and devtools is a hard dependencies entry of nuxt so it cannot be dropped. The documented residual looked honest and still was not the answer, because one rung had been assessed against the wrong file. A record that complete is exactly what makes a residual hard to re-open later, so the time to look again is before writing it.
What earns this shape is therefore narrower than it reads: no patch in the vulnerable package can restore what the consumer needs, and that was tested rather than assumed. The two entries nest-server carries today qualify because their advisories have no published fix at all — a different claim, and the one that does not depend on a consumer's import style.
Write the residual down where the guard can re-check it, not only in prose. Since nest-server
11.42.5 the second shape has a machine-readable counterpart next to the ignoreGhsas entry, and
check:overrides re-examines it on every run:
auditConfig:
ignoreGhsas:
- GHSA-x6jw-m9v5-85vh
unusableFixConsumers:
GHSA-x6jw-m9v5-85vh: '@nuxt/[email protected] cannot use 4.0.1'
The entry shows the syntax only. For this particular advisory the patch above is the answer, so do not copy the line as it stands.
Three parts, and all three are load-bearing: the consumer, the version it was assessed against, and the patched version that was rejected. Each can change without anybody revisiting the entry — a consumer bump, a backport into a line the consumer can use, a second package starting to pull the same vulnerable version — and each of those turns the printed "cannot use it" into a false statement. That is what gets re-checked; the entry expires by itself rather than aging into a permanent exception.
Two situations where this declaration is the wrong answer, both of which the guard rejects:
- The project itself depends on the affected package, in the root
package.jsonor in a workspace package. Then the ladder is not exhausted at all: a direct dependency can simply be raised, so the advisory was closable the whole time. A declaration here does not record a dead end, it hides a version bump nobody made. - The declared consumer is not actually the one holding the version. Naming a plausible package
that does not pull the affected one at all reads like a considered assessment and is not one. Get
the parent from the lockfile (
pnpm why <pkg>), not from memory.
CI parity — the local audit must match the CI security gate. A project's local check may run pnpm audit at a lower severity threshold (or narrower scope) than the CI gate — e.g. local pnpm audit --prod --audit-level=critical while a CI job runs pnpm audit --prod --audit-level=high (allow_failure: false). A green local check then hides findings that fail CI: the pipeline goes red on a "pre-existing" HIGH CVE the local loop never even surfaced. Before trusting a green local check, confirm its --audit-level and --prod/scope match the strictest audit gate in .gitlab-ci.yml / .github/workflows. If they diverge, raise the local check audit-level to match CI (so the local loop becomes the single source of truth that catches exactly what CI enforces), then run the ladder above on whatever new findings surface. An audit-level mismatch is a silent local↔CI parity bug — never an Accepted Residual.
Step 4a — Claim a cross-cutting finding before fixing it
An audit finding is not this branch's problem. It is the repository's, so every parallel session running check sees the same one and, left alone, every one of them walks the ladder above. That is wasted work in the best case; in the normal case it is four sessions rewriting the same lockfile and four rebase conflicts nobody needed. The same holds for any other finding that belongs to the repo rather than to the diff: a broken CI config, a corrupted lockfile, a broken shared test setup, an audit-level mismatch with CI.
So before starting the ladder on a finding of that kind:
- Read the ledger.
bash "${CLAUDE_PLUGIN_ROOT}/scripts/peer-ledger.sh" readcosts nothing and disturbs nobody. It also answers what a message cannot: whether a session that is no longer running already claimed this, and whether somebody already recorded the cause.- The finding shows as
[held]→ do not fix it. Record it as claimed in the report block and carry on with the rest ofcheck. - It shows as
[stale]→ the claiming session is gone. Take it over. - A
notealready names the cause → start from it instead of diagnosing it again.
- The finding shows as
- Claim it:
peer-ledger.sh claim "audit:GHSA-xxxx" "<what it moves>". The command refuses a topic another live session holds and names that session, so the claim is decided rather than merely announced. - Then
ListAgents. A live peer in this repository that is about to runcheckgets oneCLAIMmessage on top (format incoordinating-peer-sessions). The ledger already covers everyone else, including sessions that start later. - Fix it, then
peer-ledger.sh releasenaming the ticket or commit that carries the fix, so the topic frees up for good rather than only until this session closes.
If a CLAIM for the same finding arrives from a peer first, do not fix it. Classify it as claimed in the report block, name the peer, and carry on with the rest of check. Contest the claim only when you are already mid-ladder on it, and then one of the two backs off; two sessions on one lockfile is the outcome the claim exists to prevent. None of this reaches the user: who fixes a shared finding is coordination, and the ledger settles it.
This does not soften Step 5. A claimed finding is not an Accepted Residual and not a reason to call check green: it is still open, it is just open in a different session. If it blocks this branch's gate, the session waits for the peer (one ASK, or notify_when_idle) rather than fixing it in parallel.
The other direction is worth more than the claim. Most check failures are the repository's, not the diff's, so the diagnosis you just paid for is one a peer is about to pay for again. When you find the cause of something environmental or shared (a shared test database, a toolchain or SWC config, an audit-level mismatch with CI, a check-server-start port hazard), record it with peer-ledger.sh note "<topic>" "cause: … fix: …" and send one SOLVED to any peer live right now. The note is the part that lasts: it turns the next session's half hour into two lines, including a session that starts long after yours has closed. Record the cause, not the symptom.
Step 5 — Residual classification
Only after a project has STALLED (and, for audit findings, only after the escalation ladder is exhausted):
| Residual type | Treatment |
|---|---|
| Vulnerable dependency where the full ladder has been tried and no patched version exists (verified against the REGISTRY's version list, not the advisory's claim) | Accepted Residual — document with package name, advisory ID, ladder steps tried, why each failed, evidence of unfixability. NOT a blocker. |
| Vulnerable dependency where a patched version exists but the consumer provably cannot use it (Step 6 tried in both directions, the breakage measured, the path outside the production closure) | Accepted Residual, with a shorter leash — same documentation plus the consumer's name and the measured error, and it is re-examined on every run rather than treated as settled. |
| Vulnerable dependency where the ladder has NOT been fully tried | Critical blocker — the loop is not allowed to terminate until the ladder is exhausted. |
| Any other residual (typecheck, lint, build, test, import, etc.) | Critical blocker — report it as a Critical finding in the caller's report (in /lt-dev:review: a row of its findings table). |
Step 6 — Bypass policy (hard rules)
Never use any of the following to silence errors:
git commit --no-verify@ts-ignore,@ts-expect-error,@ts-nocheckeslint-disable,eslint-disable-next-line,eslint-disable-line/* istanbul ignore */- Lint rule downgrades in config files
- Commenting out broken code
- Deleting failing tests
it.skip(...),describe.skip(...),test.skip(...)in test files--passWithNoTestsflags on the test command- Adding
oxlint-disabledirectives to suppress real findings
The ONLY permitted "non-fix" is an upstream dependency vulnerability where the escalation ladder has been fully exhausted. Everything else must be fixed at the root.
Step 6.5 — server-start step failures (Nitro/Nest port hazards)
The starter check pipeline ends by booting the production build, waiting for its readiness line, and rendering one request. Since September 2026 both starters do that with node scripts/check-server-start.mjs (Node built-ins only, runs on Windows); the app calls it without arguments, the API as pnpm run check:server-start with --entry=dist/src/main.js --port-env=NSC__PORT --ready="Server starte[dt] at". Projects generated earlier still end in bash scripts/check-server-start.sh. Which one you face decides the triage:
- Node script: it allocates ports in-process, so hazards 2 and 3 below cannot occur. A failure prints the reason — boot timeout, early exit, a non-2xx/3xx answer — plus the last 30 lines of server output. Read those first.
Server booted but GET / did not renderis the mis-traced-bundle case: the server listens, then throws on every render. - Bash script: all three hazards below apply. They share one symptom,
ERR_SOCKET_BAD_PORTfromnode:net, with different causes. If the project has to run on Windows, port it to the Node script instead of patching the bash one (nuxt-base-startermigration guide2.26.0-windows-check-chain.md).
-
Nitro
PORT-string bug (App side, both scripts): the port must go intoNITRO_PORT, neverPORT. Some Nitro versions readprocess.env.PORTwithoutparseIntand crash;NITRO_PORTgoes through Nitro's own env loader and is coerced to number. The Node script defaults toNITRO_PORT. Nest does not have this issue —NSC__PORTis fine on the API side. -
lerna/nx ANSI-injection (bash only, when
checkruns under a workspace runner): the runner may inject ANSI color escapes (\x1b[33m...\x1b[39m) into captured output, soFREE_PORT=$(node -e "...console.log(p)")captures the codes too. A naivetr -cd '0-9'makes it worse — the codes contain digits (33, 39), producing ports like 335454639. Strip the escape pattern withsed:FREE_PORT=$(node -e "..." | sed $'s/\x1b\\[[0-9;]*m//g' | tr -d '[:space:]') -
Phantom Unix-domain-sockets named
[33m12345[39mnext to the package.json (modesrwx, bash only): leftovers from runs that hit hazard 2. Nest's port parser fell through to "treat as a socket path" and bound a file. Delete them, then re-runcheck:rm -f $'\x1b[33m'*$'\x1b[39m'
Details live in the modernizing-toolchain skill (Phase 6).
Step 6.6 — API tests fail only inside check? Suspect a shared test database
Symptom: check fails at api · test with one or two failures, but the very same suite is green when run on its own, and the failing spec moves between runs — a missing admin user in one run, a missing season in the next, always in a prepare / beforeAll step (Cannot read properties of null (reading '_id'), 404 on a record the setup just created).
This is not flakiness and not load. It is a second test run — from another working copy — dropping the database out from under this one. The API e2e global setup drops its database on start; if two checkouts resolve to the same database name, whoever starts second wipes the first one mid-run. Parallel checkouts are routine (lt ticket slots, a second clone, another agent session), so this hits often and reads as random.
Triage, in this order:
- Run the failing suite alone. Green in isolation + wandering failure inside
check⇒ this bug, not a real defect. Do not "fix" the spec. - Check for a competing run:
ps aux | grep [v]itest. A run in another directory of the same project is the smoking gun. - Confirm the database name is shared: it must contain a per-run (or at least per-working-copy) component. A constant like
myproject-e2eis the bug.
The fix belongs in the project, not in the spec — and it very likely already exists upstream. nest-server / nest-server-starter give every test RUN its own database (<base>-e2e-run-<timestamp>-p<pid>, set in tests/global-setup.ts before the workers fork) plus a db-lifecycle.reporter.ts that drops it on green, keeps it on red for debugging, and collects the leftovers of crashed runs. A project still on a fixed database name has simply not adopted it — port the base-repo solution rather than inventing a local one (see the contributing-to-lt-framework skill).
Things to preserve when porting:
- The drop guard. A test setup that drops "whatever the URI points at" is dangerous, because a running
lt devsession exportsNSC__MONGOOSE__URI/MONGODB_URIpointing at the development database — a suite started in that shell would wipe the developer's data. Refuse to drop any database whose name does not look disposable (/(e2e|ci|test|acctest)/i). - Derived databases. A spec that needs its own database must derive it from the run's database (
deriveTestDbUri(...)), never invent a global name: a fixed name is shared by concurrent runs, and its leftovers are collected by nothing — an aborted run skips the spec's own cleanup, and the databases pile up (one real machine had accumulated ~140). - The startup sweep + run governor (11.29.0+).
tests/global-setup.tsadditionally (a) sweeps stale leftover DBs of this project BEFORE the run (dead-PID/age guarded) — this is what survives SIGKILL and--reporteroverrides, and (b) acquires a machine-wide e2e slot (tests/e2e-run-slots.ts,<tmpdir>/lt-e2e-run-slots) so concurrentcheckruns from parallel sessions queue instead of starving each other. A run printing[e2e-governor] waiting for a free e2e slotevery 15s is QUEUED, not hung — do not kill it and do not misread it as a deadlock. The config also drops to low-resource mode (reduced forks) when another run is active.
Step 6.7 — api · test looks deadlocked (0% CPU, no output)? It is usually a retry grind
A spec file whose app/socket state broke (historically: resource starvation from overlapping runs) grinds through (1+retry) attempts × testTimeout × tests-per-file — with retry: 5 that was up to an hour at 0% CPU, which the watchdog kills as "workers idle". Base repos now ship retry: 2 and the run governor removes the starvation trigger. If you still see it: check pgrep -f vitest for a single surviving fork at 0% CPU, read which spec file last logged, and re-run that file alone. Never raise retry to paper over it.
Step 6.8 — check fails on a fully green test run? Console-intercept teardown race
Symptom: check fails at a test step while every single test passed:
EnvironmentTeardownError: [vitest-worker]: Closing rpc while "onUserConsoleLog" was pending
Test Files 108 passed (108) Tests 2300 passed Errors 1 error
It is sporadic — roughly one run in ten — and a rerun is usually green, which makes it read as flakiness.
Cause. vitest replaces globalThis.console in every worker and ships each write to the main process as an onUserConsoleLog RPC call. On teardown, execute() does not await those calls, it rejects them. A console write from the last test in a file therefore races its own cleanup and loses. Structural, not load-dependent — which is why "rerun it" makes it disappear without fixing anything.
Only console.* can trigger this. vitest replaces globalThis.console and nothing else. A Nest Logger (and anything else writing straight to process.stdout/stderr) never becomes an onUserConsoleLog and cannot cause the race — so in an API suite the loudest lines are usually the irrelevant ones, and silencing the logger is the obvious move that does nothing. Hunt for console.* instead; in nest-server-starter the entire problem was one line, dist/core/common/helpers/config.helper.js:78.
The switch is disableConsoleIntercept: true in the vitest config: it skips setupConsoleLogSpy(), after which rpc.onUserConsoleLog has exactly one caller left — sendLog() in the very console that is no longer installed. Confirm it in the installed version rather than trusting the docs:
grep -rn "disableConsoleIntercept" node_modules/vitest/dist/chunks/base.*.js
# vitest 4.x → if (!config.disableConsoleIntercept) await setupConsoleLogSpy();
Never set it prophylactically. Measure first — and measure by diffing two runs, not by grepping one.
vitest run > /tmp/int-off.log 2>&1 # flag off
# flip disableConsoleIntercept in the CONFIG FILE — a CLI override does
# not win against a value set there — then run again:
vitest run > /tmp/int-on.log 2>&1 # flag on
diff /tmp/int-off.log /tmp/int-on.log
The difference between the two runs is the answer, for exposure and cost alike: everything the flag adds or removes shows up there and nowhere else. A diff that is empty bar timestamps and durations means there are no console writes at all — the flag is inert and the race cannot occur in that suite.
A grep is a quick pre-check, never the verdict:
vitest run 2>&1 | grep -cE '^(stdout|stderr) \| ' # lower bound only
It can only ever undercount, and it has already lied in two independent ways in this stack: the default reporter swallows passing tests' output in a piped run, and a content pattern tuned to one repo's wording (Configured for|No local config) reported 1 where 8 lines were waiting — seven of them phrased differently. Every repo words its logs its own way, so a pattern that catches everything in one catches a third of it in the next.
Check that the run executed tests at all before believing any number. A run that dies on startup — an unresolvable plugin, a config pointing at the wrong root — prints no console lines either and reports a clean 0. Read the test-file and test counts, not just the tail.
At this measurement, "nothing found" looks identical whether nothing was there or the instrument was not looking. Three variants of that have already cost time here: the reporter that swallows passing output, the crashed run that reports 0, and the pattern that was too narrow. All three say "nothing" where something is.
| Exposure | Cost | What it means | Action |
|---|---|---|---|
| 0 | 0 | There are no console writes at all. | Flag is inert, the race cannot occur here. The cause is something else — keep looking. |
| 0 | >0 | Writes exist; the default reporter swallows the output of passing tests in a piped run. | The exposure reading was a false negative. Decide on cost. |
| >0 | either | Writes are visible. | The flag applies; cost decides. |
Exposure 0 on its own proves nothing — the grep measures what the reporter prints, not what the workers write. Only cost 0 establishes that no writes happen, and only then may a report claim the race is structurally impossible.
Cost runs in both directions, so it has to be measured per suite rather than reasoned about. Measured across the base repos:
| Suite | Exposure | Cost (visible lines) | Decision |
|---|---|---|---|
nest-server unit | real | 0 → 8–9 | flag set |
nest-server-starter unit | 114 writes/run | 0 → 114 | writes silenced at source |
nest-server-starter e2e | real | volume roughly halved (direction only) | flag kept |
nuxt-base-template unit | 0 | 9 → 9 (identical) | not set |
In a unit config the flag tends to make output appear (without the intercept the reporter can no longer swallow the output of passing tests); in an e2e config it tends to make output shorter (the intercept prints an extra header line per write). Same flag, opposite balance. Record a range wherever the count moves between runs — three of nest-server's lines are the same rate-limit warning, and how many appear depends on how many counters a run happens to create. A single number invites the next reader to treat ordinary variance as a regression and go hunting for it.
The e2e row deliberately gives a direction instead of a count: its figures come from a file comment (vitest-e2e.config.ts:96) that nobody re-ran, and it is not even clear whether they covered the whole suite or a single spec. Where a number has not been measured, state the direction and stop. The direction is what carries that row anyway — an e2e config is where the balance inverts.
The nest-server-starter unit row is the cautionary one, and it is worth reading twice. It was first recorded as "exposure 0" because the grep said so — and that reading was the artefact this step exists to prevent: 114 console.info writes per run had been going over the RPC the whole time, invisible because the reporter swallows passing tests' output in a piped run. What settled it in the end was neither the flag nor any config line, but a single vi.spyOn(console, 'info') beside the one spec that produced them. Exposure and cost both read 0 there today because the writes are gone, not because they never existed.
Where the flag belongs: in the project that shows the symptom, never one level up in a starter or template. A config line in a starter travels into every generated project, where it costs attribution on day one — lines lose their stdout | <file> prefix and interleave under parallel runs — while paying off only in the few projects that actually have the symptom.
silent: true is not a middle ground. Without the intercept vitest no longer owns the console and has nothing left to suppress.
If the lines turn out to be unwanted, clean up the logs instead of setting the flag. A targeted vi.spyOn(console, 'debug').mockImplementation(() => {}) in the test setup silences only the noisy channels and leaves every other file its attribution — that is exactly why nuxt-base-template measures 0 on both numbers. Keep the flag only where the lines are wanted diagnostics (an e2e suite that gets read on failure) and the cost balance is favourable.
Step 7 — Test-duplication avoidance
Tests must not run twice if check already covered them on an unchanged working tree.
After each project completes Step 3 with a GREEN result, record a post-check baseline:
git -C <project-dir> rev-parse HEAD # commit baseline
git -C <project-dir> status --porcelain # working-tree baseline
A subsequent test phase (e.g. branch-rebaser Phase 7, code-reviewer Phase 5, test-reviewer) may skip running tests for a project when ALL of the following hold:
- The discovery output marked the project's
checkas covering the test category in question (see table below) - The project ended Step 3 in GREEN status (or YELLOW with only Accepted Residuals)
git rev-parse HEADmatches the baseline (no new commits)git status --porcelainmatches the baseline (no working-tree changes since)
Skip eligibility per includes_tests value:
includes_tests | Unit/API test phase | Playwright/E2E test phase |
|---|---|---|
no | Run normally | Run normally |
unit+api | Skip-eligible (conditions 2–4 still apply) | Run normally — check.mjs does not execute Playwright |
yes | Skip-eligible (conditions 2–4 still apply) | Skip-eligible only if the caller has independently verified Playwright is part of the project's check chain; otherwise run normally |
If any condition fails → run tests as normal.
Step 8 — Report block
Every caller must include this block in its final report:
### Check Script Results
| Project | Script | Iterations | Initial Errors | Auto-Fixed | Accepted | Final Status |
|---------|--------|------------|----------------|------------|----------|--------------|
| projects/api | pnpm run check | 3 | 7 | 6 | 1 | ⚠️ (1 accepted) |
| projects/app | pnpm run check | 1 | 0 | 0 | 0 | ✅ |
**Fixes applied:**
- `projects/api/src/modules/user/user.service.ts:42` — Removed unused `LoggerService` import (pre-existing)
- `projects/api/src/modules/auth/auth.controller.ts:18` — Fixed implicit `any` on `req` parameter (introduced in diff)
**Suggestion fixes off** (Step 1a; omit the line when there was no hit):
- `projects/app/package.json:14` — `lint:fix` passes `--fix-suggestions`; ran with `--no-fix`. `lt fullstack update` removes the flag
- `projects/api/package.json:31` — `lint:fix` passes `--fix-suggestions`; ran with `--no-fix`. Remove the flag by hand: the update leaves the API project alone
**Check Script — Accepted Residuals** (escalation ladder exhausted):
- `projects/api`: `[email protected]` — GHSA-xxxx-yyyy-zzzz (Moderate)
- Ladder steps tried: (1) `pnpm update` — no newer version, (2) `audit --fix` — no fix available, (3) `--force` — same, (4) next major doesn't exist, (5) no override target, (6) no maintained alternative
- Evidence: registry shows latest = 1.2.3, no advisory fix listed
**Check Script — Unresolved** (Critical blockers):
- _(none)_
Step 9 — Gating
Both modes share the green case and split on the red one:
| Outcome | Advisory caller | Blocking caller |
|---|---|---|
| All projects GREEN | Continue | Continue |
| Only Accepted Residuals (exhausted-ladder dependency CVEs) | Continue | Continue, residuals named in the summary |
| Any Unresolved blocker | Continue the workflow so the user still gets the rest of the review; list the blockers as Critical findings in the caller's report and surface them in its verdict line | Stop. Nothing is pushed, no MR/PR is created, nothing is merged |
What a Blocking caller does on red: print which project failed, which errors remain, which fixes were attempted, and which approaches Step 3 already ruled out — then stop and hand it to the user. The branch stays local and intact, so nothing is lost and the work can resume the moment the cause is understood.
The green requirement covers every discovered project. A monorepo where projects/api is green and projects/app is red is red: CI runs both, so shipping on a partial green just moves the failure to the pipeline, where it costs a full round-trip instead of one more local iteration.
Skip Coordination Between Callers
When /lt-dev:review delegates a small diff to lt-dev:code-reviewer, the orchestrator has already executed this skill in Phase 1.5. To avoid a duplicate run, the orchestrator passes:
SKIP running-check-script — orchestrator already ran it. Pre-computed Check Script Results block:
<paste full block from Step 8 verbatim>
The agent then skips Steps 1–7 and pastes the block verbatim into its report.
Related Skills
| Element | Relationship |
|---|---|
Script: scripts/discover-check-scripts.sh | Discovery helper (Step 1) |
Command: /lt-dev:review | Orchestrator caller (Phase 1.5) |
Agent: code-reviewer | Single-pass caller (Phase 1.5) |
Agent: branch-rebaser | Rebase caller (Phase 6.5) |
Agent: test-reviewer | Honors skip semantics from Step 7 |
Skill: maintaining-npm-packages | Owns the broader package maintenance ladder; this skill borrows Step 4 from there |
Skill: rebasing-branches | Strategy for rebases; defers check execution to this skill |
Skill: coordinating-peer-sessions | Claim protocol for cross-cutting findings when parallel sessions share the repo (Step 4a) |
Skill: validating-ci-pipelines-locally | Reproduces the CI pipeline itself; reach for it when check is green locally but CI fails |
Skill: validating-production-readiness | Release gate that requires a green check before it runs |
Skill: maintaining-lt-stack | Stack release; every base repo's recipe needs check green before tagging |
Skill: modernizing-toolchain | Toolchain migrations whose ERR_SOCKET_BAD_PORT / ANSI-strip hazards surface in check |