feat(runner): guarantee a no-build CLI and add the Linux display preflight (Task 2.2, part 1) - #8
Conversation
…light (Task 2.2, part 1) Node 22.18 or newer runs the TypeScript directly, so the CLI needs no build step. Fix the one construct that stopped the package loading that way (a constructor parameter property), enforce it with erasableSyntaxOnly, prove it with a test that loads the package under plain Node and expects no warning, raise the engines floor to 22.18 (verified against Node's changelog) and record the decision, including that a distributable would still need a build. Add classifyDisplay and a gatherer that reads the machine: a display is virtual only when the virtual server (Xvfb, Xvnc, Xdummy) serving that exact display is visible, real only when the session type says desktop, and otherwise unknown. It never decides from the operating system's name. Environment inspection now reports the display kind and adds a real-display capability that a virtual display does not satisfy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
3 issues found across 10 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="docs/decisions/tool-layout.md">
<violation number="1" location="docs/decisions/tool-layout.md:12">
P3: The new `engines.node` floor `>=22.18.0` makes README.md stale: it still states "Requires Node.js 22.12 or newer" (README.md:22). A user following the README could install 22.12–22.17, where type stripping is not on by default and silent, so the no-build load test (which asserts empty stderr) and the CLI would fail. Update the README requirement to match the new floor.</violation>
</file>
<file name="packages/qa/src/runner/environment.ts">
<violation number="1" location="packages/qa/src/runner/environment.ts:26">
P1: On macOS without `DISPLAY` or `WAYLAND_DISPLAY`, this default descriptor reports an `unknown` display, so `inspectEnvironment` incorrectly grants the `display` capability and can run GUI scenarios on a headless host. Preserve `none` when no display variable is present before applying the non-Linux fallback, or make `classifyDisplay` return `none` for that case.</violation>
</file>
<file name="packages/qa/package.json">
<violation number="1" location="packages/qa/package.json:8">
P3: The engines floor was bumped to >=22.18.0 in package.json, but package-lock.json was not regenerated: the root entry (line 15) and the `packages/qa` entry (line 1519) still record `"node": ">=22.12.0"`. The lockfile is the canonical record of the engine floor, and a subsequent `npm install` will silently rewrite these entries, so keeping them aligned here avoids stale, misleading metadata and a noisy diff later. Run `npm install` and commit the regenerated lockfile (both entries should read `>=22.18.0`).</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| return classifyDisplay(await readDisplayFacts()).kind !== 'none'; | ||
| }, | ||
| async describeDisplay() { | ||
| return classifyDisplay(await readDisplayFacts()); |
There was a problem hiding this comment.
P1: On macOS without DISPLAY or WAYLAND_DISPLAY, this default descriptor reports an unknown display, so inspectEnvironment incorrectly grants the display capability and can run GUI scenarios on a headless host. Preserve none when no display variable is present before applying the non-Linux fallback, or make classifyDisplay return none for that case.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/qa/src/runner/environment.ts, line 26:
<comment>On macOS without `DISPLAY` or `WAYLAND_DISPLAY`, this default descriptor reports an `unknown` display, so `inspectEnvironment` incorrectly grants the `display` capability and can run GUI scenarios on a headless host. Preserve `none` when no display variable is present before applying the non-Linux fallback, or make `classifyDisplay` return `none` for that case.</comment>
<file context>
@@ -3,26 +3,27 @@ import { readFile } from 'node:fs/promises';
+ return classifyDisplay(await readDisplayFacts()).kind !== 'none';
+ },
+ async describeDisplay() {
+ return classifyDisplay(await readDisplayFacts());
},
async audio() {
</file context>
There was a problem hiding this comment.
Fixed in 3c66a24. On any platform that's neither Linux nor Windows, no DISPLAY/WAYLAND_DISPLAY at all is now 'none'; either being set (but unclassifiable further) is still 'unknown'. Test: 'no display evidence at all is reported as none, not guessed as a working display'.
| | --- | --- | --- | | ||
| | Repository | `Frogbyte-io/release-qa`, public | Created and made public before Stage 0 | | ||
| | Runtime | Node.js 22. `.node-version` pins **22.23.2** exactly; `engines.node` is the floor `>=22.12.0` | The harness ran on Node 22.23.2 (Linux) and 24.13.0 (Windows). CI installs the version in `.node-version`; bump it deliberately | | ||
| | Runtime | Node.js 22. `.node-version` pins **22.23.2** exactly; `engines.node` is the floor `>=22.18.0` | The harness ran on Node 22.23.2 (Linux) and 24.13.0 (Windows). 22.18.0 is the first 22.x with type stripping on by default and without an experimental warning (per Node's changelog). CI installs the version in `.node-version`; bump it deliberately | |
There was a problem hiding this comment.
P3: The new engines.node floor >=22.18.0 makes README.md stale: it still states "Requires Node.js 22.12 or newer" (README.md:22). A user following the README could install 22.12–22.17, where type stripping is not on by default and silent, so the no-build load test (which asserts empty stderr) and the CLI would fail. Update the README requirement to match the new floor.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/decisions/tool-layout.md, line 12:
<comment>The new `engines.node` floor `>=22.18.0` makes README.md stale: it still states "Requires Node.js 22.12 or newer" (README.md:22). A user following the README could install 22.12–22.17, where type stripping is not on by default and silent, so the no-build load test (which asserts empty stderr) and the CLI would fail. Update the README requirement to match the new floor.</comment>
<file context>
@@ -9,7 +9,7 @@ These defaults come from the plan's proposals and the Stage 0 results ([native a
| --- | --- | --- |
| Repository | `Frogbyte-io/release-qa`, public | Created and made public before Stage 0 |
-| Runtime | Node.js 22. `.node-version` pins **22.23.2** exactly; `engines.node` is the floor `>=22.12.0` | The harness ran on Node 22.23.2 (Linux) and 24.13.0 (Windows). CI installs the version in `.node-version`; bump it deliberately |
+| Runtime | Node.js 22. `.node-version` pins **22.23.2** exactly; `engines.node` is the floor `>=22.18.0` | The harness ran on Node 22.23.2 (Linux) and 24.13.0 (Windows). 22.18.0 is the first 22.x with type stripping on by default and without an experimental warning (per Node's changelog). CI installs the version in `.node-version`; bump it deliberately |
| Package manager | npm workspaces, one root `package-lock.json` | Plan default; nothing in Stage 0 contradicted it |
| Language and test | TypeScript 7.0.2, Vitest 5.0.1, `@types/node` 22.20.4, all pinned exactly | Verified together in this change: `npm run typecheck` and `npm test` pass, and a deliberate type error fails the typecheck |
</file context>
There was a problem hiding this comment.
Fixed in 3c66a24. README now says 22.18 and mentions the no-build guarantee.
| "type": "module", | ||
| "engines": { | ||
| "node": ">=22.12.0" | ||
| "node": ">=22.18.0" |
There was a problem hiding this comment.
P3: The engines floor was bumped to >=22.18.0 in package.json, but package-lock.json was not regenerated: the root entry (line 15) and the packages/qa entry (line 1519) still record "node": ">=22.12.0". The lockfile is the canonical record of the engine floor, and a subsequent npm install will silently rewrite these entries, so keeping them aligned here avoids stale, misleading metadata and a noisy diff later. Run npm install and commit the regenerated lockfile (both entries should read >=22.18.0).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/qa/package.json, line 8:
<comment>The engines floor was bumped to >=22.18.0 in package.json, but package-lock.json was not regenerated: the root entry (line 15) and the `packages/qa` entry (line 1519) still record `"node": ">=22.12.0"`. The lockfile is the canonical record of the engine floor, and a subsequent `npm install` will silently rewrite these entries, so keeping them aligned here avoids stale, misleading metadata and a noisy diff later. Run `npm install` and commit the regenerated lockfile (both entries should read `>=22.18.0`).</comment>
<file context>
@@ -5,7 +5,7 @@
"type": "module",
"engines": {
- "node": ">=22.12.0"
+ "node": ">=22.18.0"
},
"scripts": {
</file context>
There was a problem hiding this comment.
Fixed in 3c66a24: ran npm install and verified only the two workspace entries' engines fields changed (root and packages/qa), both now >=22.18.0.
…n the late-ownership tests
"a late spawn/own from a hook that was cut off is refused" assumed prerequisites and phase entry always finish in a
few milliseconds (phaseMs: 30) before the hook's own 150 ms sleep elapses. On a loaded or slow CI machine that margin
is not guaranteed: prerequisites itself can exceed the phase deadline, so the install hook is never entered and the
late attempt never happens, leaving the test's "refused" assertion looking at undefined. Observed as a Windows CI
failure; not reproducible locally even under CPU load, consistent with I/O/scheduling jitter specific to that runner.
Both tests now wait for the install hook to actually start (eventually(() => calls.includes('install'))), then abort
the run directly and let the hook's own abort listener trigger the late attempt — the same pattern the neighbouring
"ignores cancellation" test already uses. This removes the wall-clock race entirely instead of loosening it.
Verified the tests still catch the regression: neutering the ownership check they exercise makes both fail.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 1 file (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/qa/test/runner/execute-hardening.test.ts">
<violation number="1" location="packages/qa/test/runner/execute-hardening.test.ts:203">
P3: With `controller.abort()` and `phaseMs` bumped to 2000, these two tests cut the hook off by cancelling the whole run instead of by the phase deadline, so the scenario they are named for — a hook cut off by the phase deadline while the rest of the run continues — is no longer covered. `assertActive()` in `execute.ts` only checks `signal.aborted`, and both mechanisms abort the same per-phase signal, so the assertions still pass, but the deadline-cut path for late `spawn`/`own` refusals is no longer exercised. The deterministic `eventually` wait means you can keep the deadline semantics: leave a small `phaseMs` and drop `controller.abort()` — the hook already waits for its own signal's abort, so the deadline now provides the cut and `await running` resolves with `interrupted`/`timeout`. That removes the flakiness and preserves what these tests verify.</violation>
</file>
…Xvfb by display number, scan all processes - On any platform that isn't Linux or Windows (in practice, macOS), no DISPLAY and no WAYLAND_DISPLAY at all is now "none", not "unknown": those variables aren't required by that platform's own GUI apps, so their presence is weak evidence at best, but their total absence must not grant a display capability the host cannot back up. - Matching a client's DISPLAY against the Xvfb/Xvnc/Xdummy process serving it now compares display numbers, ignoring any host prefix and any ".screen" suffix on either side: ":99.0" and ":99" name the same endpoint, and Xvfb's own argument never carries a screen number. - The scan of /proc for the serving virtual display server no longer stops after 5000 processes; missing the one process that matters would misclassify a virtual display as unknown or real. - Fixed a test that claimed to cover a rejecting display() probe but, because its fixture also provided describeDisplay(), only ever exercised that describeDisplay() rejecting — added the genuine case (no describeDisplay at all, display() itself rejects) and renamed the misleading one. - README and the tool-layout decision record now state the 22.18 floor and no-build guarantee accurately: no stale 22.12 mention, and no reference to a CLI entry path that does not exist yet. - Regenerated package-lock.json's engines metadata for the two workspace entries to match the raised floor (npm install; verified only those two lines changed). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
3 issues found across 6 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="docs/decisions/tool-layout.md">
<violation number="1" location="docs/decisions/tool-layout.md:34">
P3: The added sentence "which test/no-build.test.ts loads under plain Node on every CI runner and asserts prints no warning" has a grammar error: "asserts prints" (and "asserts no warning" is missing that). Rewrite as "asserts that no warning is printed". This is a factual, public decision record, so wording matters, though the underlying claim itself is accurate.</violation>
<violation number="2" location="docs/decisions/tool-layout.md:34">
P3: In a decision record where path precision is the point, `test/no-build.test.ts` is the only unpivoted path in this paragraph: `packages/qa/src/index.ts` and `packages/qa/tsconfig.json` are both prefixed, but the test is actually at `packages/qa/test/no-build.test.ts`. There is no `test/` directory at repo root, so a reader crossing to the file will not find it. Add the `packages/qa/` prefix.</violation>
</file>
<file name="packages/qa/src/runner/display.ts">
<violation number="1" location="packages/qa/src/runner/display.ts:75">
P3: Removing the `.slice(0, 5000)` bound makes `linuxCommandLines` fan out one `readFile` per PID in `/proc` through a single unbounded `Promise.all`. The probes `display()` and `describeDisplay()` in `src/runner/environment.ts` each call `readDisplayFacts()`, so every environment inspection now performs two full unbounded /proc scans; on hosts with thousands of processes (build farms, shared dev boxes) this adds visible latency, and under fd/resource pressure individual reads fail and are silently mapped to '' by the `.then(..., () => '')` guard — which then yields exactly the misclassification (missing the serving X server) this change is meant to prevent, with no signal that a read was lost. Keep the full scan but bound its concurrency in chunks so a large /proc cannot degrade the probes or drop reads.</violation>
</file>
… bound /proc read concurrency - The late-spawn/late-own tests were rewritten to wait for the hook to actually enter its phase and then let that phase's own 300 ms deadline (not an outer cancellation) cut it off. 300 ms, not the original 30 ms: prerequisites shares the same budget, measured at 1-4 ms idle, and 30 ms left too little margin for a loaded CI machine to finish prerequisites and enter install before the deadline fired — that margin, not the mechanism, was the actual cause of the Windows CI flakiness fixed a commit ago. Both tests now also assert the phase was reached and the result is a timeout, so the deadline path they are named for is genuinely exercised again. - linuxCommandLines reads /proc/<pid>/cmdline in bounded batches of 256 instead of one unbounded Promise.all over every process, so a host with many thousands of processes cannot exhaust file descriptors while still being scanned in full. - Fixed a grammar error and an unprefixed path in the tool-layout decision record. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Summary
Task 2.2 of the plan, part 1: guarantee the CLI needs no build step, and add the Linux display preflight the plan calls for before the CLI itself is built.
No-build guarantee
Node 22.18 or newer strips types and runs the TypeScript source directly, so
packages/qa/src/cli/*.ts(Task 2.2's next part) can be run with a plainnodeinvocation. That only holds while the source sticks to erasable syntax — no enums, namespaces, or constructor parameter properties.erasableSyntaxOnlyintsconfig.jsonenforces that at typecheck time,test/no-build.test.tsloads the package under plain Node on every CI run (both platforms) and asserts no warning is printed, andengines.nodeis raised to the22.18.0floor where stripping is on by default without an experimental warning (verified against Node's changelog).packages/qa/tsconfig.jsonkeepsnoEmit; this decision is scoped to running from a checkout. A published or installed distribution would still need a build or bundle, andbin/main/exportsstay out ofpackage.jsonuntil that is decided.Linux display preflight (
display.ts)classifyDisplaynever infers a real display from the operating system's name. It reports:virtualonly when it can see the specific virtual X server (Xvfb/Xvnc/Xdummy) serving the exactDISPLAYin question (matched by reading/proc/<pid>/cmdlinefor every process, not just checking a well-known name),realonly when the session type says a desktop session (XDG_SESSION_TYPE=x11/waylandon Linux, a named interactive session on Windows),unknownwhen something answers on a display but neither of the above can be shown,nonewhen there is no display at all.inspectEnvironmentnow reportsdisplay: { kind, detail }alongside the measured environment, and a scenario can require thereal-displaycapability (satisfied only bykind: 'real') as well as the existing looserdisplaycapability (satisfied by anything butnone).How it interacted with Task 2.1's hardening
This branch was built on top of the merged Task 2.1 branch and rebased onto
mainafter PR #7 merged. The two touched the same function (inspectEnvironment's probe-running): #7 made every probe safe against a synchronous throw (Promise.resolve().then(probe)), this branch added thedescribeDisplayprobe and its owntry/catch. Merged both behaviours —audioanddisplayare both invoked through the safe wrapper — and added a test that was missing before: adescribeDisplayhook that throws before returning a promise is still just "no display", not a crash.Test plan
npm ci,npm run typecheck,npm test: 466 passed, 2 skipped (Windows 11, Node 24.13); no leaked processes, temp directories, or home markersubuntu-24.04andwindows-2025(a genuinely flaky Windows test surfaced and was fixed test-first with a documented root cause, not just loosened; see review thread history)🤖 Generated with Claude Code
Summary by cubic
Guarantees the CLI runs straight from TypeScript source with no build step, adds a display preflight that distinguishes virtual, real, unknown, and absent displays, and removes a wall-clock race in two cancellation tests that flaked on Windows CI.
No-build CLI
node packages/qa/src/cli/bin.tswith no build or bundle.erasableSyntaxOnlyand a plain-Node load test enforce the erasable-syntax limit;engines.nodeis raised to>=22.18.0, where stripping is on by default without a warning.noEmitstays andbin/main/exportsremain absent, because a published or installed distribution would still need a build.Display preflight
classifyDisplaynames a displayvirtualonly when it sees a virtual X server (Xvfb/Xvnc/Xdummy) serving that exact display, matched by display number and ignoring any host prefix or screen suffix;realonly for desktop sessions (x11/wayland on Linux, interactive sessions on Windows);unknownwhen it cannot tell; andnonewhen nothing is set (including a headless macOS host).inspectEnvironmentnow reports the display kind and adds areal-displaycapability that virtual displays don't satisfy, alongside the existing looserdisplaycapability./procruns in bounded batches so a host with many thousands of processes cannot exhaust file descriptors.Written for commit 3c4aad4. Summary will update on new commits.