Skip to content

feat(runner): guarantee a no-build CLI and add the Linux display preflight (Task 2.2, part 1) - #8

Merged
Andreas-Froyland merged 4 commits into
mainfrom
task-2.2a-cli
Sep 22, 2026
Merged

Andreas-Froyland merged 4 commits into
mainfrom
task-2.2a-cli

Conversation

@Andreas-Froyland

@Andreas-Froyland Andreas-Froyland commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

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 plain node invocation. That only holds while the source sticks to erasable syntax — no enums, namespaces, or constructor parameter properties. erasableSyntaxOnly in tsconfig.json enforces that at typecheck time, test/no-build.test.ts loads the package under plain Node on every CI run (both platforms) and asserts no warning is printed, and engines.node is raised to the 22.18.0 floor where stripping is on by default without an experimental warning (verified against Node's changelog).

packages/qa/tsconfig.json keeps noEmit; this decision is scoped to running from a checkout. A published or installed distribution would still need a build or bundle, and bin/main/exports stay out of package.json until that is decided.

Linux display preflight (display.ts)

classifyDisplay never infers a real display from the operating system's name. It reports:

  • virtual only when it can see the specific virtual X server (Xvfb/Xvnc/Xdummy) serving the exact DISPLAY in question (matched by reading /proc/<pid>/cmdline for every process, not just checking a well-known name),
  • real only when the session type says a desktop session (XDG_SESSION_TYPE=x11/wayland on Linux, a named interactive session on Windows),
  • unknown when something answers on a display but neither of the above can be shown,
  • none when there is no display at all.

inspectEnvironment now reports display: { kind, detail } alongside the measured environment, and a scenario can require the real-display capability (satisfied only by kind: 'real') as well as the existing looser display capability (satisfied by anything but none).

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 main after 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 the describeDisplay probe and its own try/catch. Merged both behaviours — audio and display are both invoked through the safe wrapper — and added a test that was missing before: a describeDisplay hook that throws before returning a promise is still just "no display", not a crash.

Test plan

  • Clean npm ci, npm run typecheck, npm test: 466 passed, 2 skipped (Windows 11, Node 24.13); no leaked processes, temp directories, or home markers
  • CI on ubuntu-24.04 and windows-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 22.18 or newer strips types, so the CLI can run from a checkout via node packages/qa/src/cli/bin.ts with no build or bundle.
  • erasableSyntaxOnly and a plain-Node load test enforce the erasable-syntax limit; engines.node is raised to >=22.18.0, where stripping is on by default without a warning.
  • noEmit stays and bin/main/exports remain absent, because a published or installed distribution would still need a build.

Display preflight

  • classifyDisplay names a display virtual only 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; real only for desktop sessions (x11/wayland on Linux, interactive sessions on Windows); unknown when it cannot tell; and none when nothing is set (including a headless macOS host).
  • inspectEnvironment now reports the display kind and adds a real-display capability that virtual displays don't satisfy, alongside the existing looser display capability.
  • The display probe goes through the same safe wrapper as audio, so a throw — even a synchronous one — means "no display", never a crash. The scan of /proc runs in bounded batches so a host with many thousands of processes cannot exhaust file descriptors.
  • The late-ownership tests now wait for the hook to enter its phase, then let that phase's own 300 ms deadline cut it off, instead of racing a too-short deadline against a fixed sleep.

Written for commit 3c4aad4. Summary will update on new commits.

Review in cubic

…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>

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'.

Comment thread packages/qa/src/runner/display.ts Outdated
Comment thread packages/qa/src/runner/display.ts Outdated
| --- | --- | --- |
| 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 |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3c66a24. README now says 22.18 and mentions the no-build guarantee.

Comment thread packages/qa/package.json
"type": "module",
"engines": {
"node": ">=22.12.0"
"node": ">=22.18.0"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/qa/test/runner/environment.test.ts Outdated
Comment thread docs/decisions/tool-layout.md Outdated
…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>

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Comment thread packages/qa/test/runner/execute-hardening.test.ts Outdated
…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>

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Comment thread docs/decisions/tool-layout.md Outdated
Comment thread docs/decisions/tool-layout.md Outdated
Comment thread packages/qa/src/runner/display.ts Outdated
… 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>
@Andreas-Froyland
Andreas-Froyland merged commit f30d645 into main Sep 22, 2026
4 checks passed
@Andreas-Froyland
Andreas-Froyland deleted the task-2.2a-cli branch September 22, 2026 07:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant