diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff893f608..ff8eb9ec5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,11 +61,14 @@ jobs: matrix: # setup-node rewrites devEngines.runtime.version to this, # so pnpm's managed runtime (onFail: download) actually runs scripts under it. - # 22.13.0 is the declared engines.node floor (the minimum the pinned pnpm@11.9.0 - # can run on; Node 22 is maintenance LTS until 2027-04); + # These are the release lines the DEV toolchain supports — NOT the published + # engines.node floor, which the `floor-smoke` job defends on 22.13.0 instead. + # Pinning the floor here would hold every dev dependency to it: jsdom 30 + # already requires ^22.22.2, and vite/oxlint sit 0.01 above 22.13.0. + # '22' is the latest 22.x (maintenance LTS until 2027-04); # 24.16.0 is the current release used for development (devEngines); # '26' is the latest of the Current line (active LTS from 2026-10) — early warning. - node-version: ['22.13.0', '24.16.0', '26'] + node-version: ['22', '24.16.0', '26'] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -85,6 +88,45 @@ jobs: run: pnpm build - name: Run tests run: pnpm test + # Every matrix entry supports unflagged type-stripping (>=22.18.0), so this + # is the only place that covers the `.ts` config contract's modern-Node + # branch (scripts/floor-smoke.mjs's fifth check); `floor-smoke` below pins + # to 22.13.0 and only ever takes the old-Node branch. Bare `node` is + # correct here — actions/setup-node puts the matrix version on PATH. + - name: Run the floor smoke on the matrix Node + run: node scripts/floor-smoke.mjs + + floor-smoke: + needs: check + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # 22.13.0 is the published packages' engines.node floor. This is the only job + # pinned to it: it runs the built dist under a bare `node`, so no dev + # dependency (jsdom, vitest, ...) is held to the end-user's Node version. + # pnpm itself is not exempt, though: the pinned pnpm@11.17.0 (root + # package.json) still runs `pnpm install`/`pnpm build` on this floor, so a + # future pnpm bump needs to stay runnable on 22.13.0 too. + - name: Setup Node.js and dependencies + uses: ./.github/workflows/setup-node + with: + node-version: '22.13.0' + - name: Restore package builds + id: dist-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: packages/*/dist + key: dist-${{ hashFiles('packages/*/src/**', 'pnpm-lock.yaml', 'packages/*/tsup.config.ts') }} + # Fallback only: `needs: check` populates this exact cache key, so a miss means + # eviction. Building here would run tsup on the floor Node. + - name: Build packages + if: steps.dist-cache.outputs.cache-hit != 'true' + run: pnpm build + - name: Run the end-user floor smoke + run: node scripts/floor-smoke.mjs docs: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 27ad34137..8f4746bf4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,16 +8,17 @@ svelte-vitals is a static code-health checker for SvelteKit — not a runtime We ## Verify commands -| Purpose | Command | Notes | -| -------------- | -------------------- | ------------------------------------- | -| Build | `pnpm build` | `pnpm -r build` | -| Typecheck | `pnpm typecheck` | `pnpm -r typecheck` | -| Test | `pnpm test` | `pnpm -r test` (vitest) | -| Lint | `pnpm lint` | `oxlint .` + `oxfmt --check .` | -| Format | `pnpm format` | `oxfmt --write .` | -| Publish checks | `pnpm check:publish` | publint + attw (`--profile esm-only`) | - -CI (`.github/workflows/ci.yml`) runs four jobs: `lint`, `check` (build + typecheck + check:publish), `test`, `docs`. Run the relevant verify commands yourself and confirm they pass **before** claiming a task is complete. +| Purpose | Command | Notes | +| -------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Build | `pnpm build` | `pnpm -r build` | +| Typecheck | `pnpm typecheck` | `pnpm -r typecheck` | +| Test | `pnpm test` | `pnpm -r test` (vitest) | +| Floor smoke | `pnpm smoke` | needs `pnpm build` first — it runs the built `dist` under a bare `node`; locally that is the devEngines Node, not the floor, so the floor claim is what CI's `floor-smoke` job (pinned to 22.13.0) adds | +| Lint | `pnpm lint` | `oxlint .` + `oxfmt --check .` | +| Format | `pnpm format` | `oxfmt --write .` | +| Publish checks | `pnpm check:publish` | publint + attw (`--profile esm-only`) | + +CI (`.github/workflows/ci.yml`) runs five jobs: `lint`, `check` (build + typecheck + check:publish), `test`, `floor-smoke`, `docs`. Run the relevant verify commands yourself and confirm they pass **before** claiming a task is complete. ## Package map @@ -39,6 +40,29 @@ install`/`ci upgrade` bundle into scaffolded workflows. ## Hard rules - **Core purity**: `packages/core/src/index.ts` states verbatim: "runtime-agnostic core (design §8). No `node:` imports, no I/O, no runtime-specific globals." All I/O is injected through the `Runtime` interface (`packages/core/src/runtime.ts`). Never add a `node:` import or direct I/O call inside `packages/core`. +- **Two Node floors, both jobs run the smoke**: the published packages promise + `engines.node: >=22.13.0` (end users); the dev toolchain is pinned by + `devEngines.runtime` and is free to require more. CI keeps these apart — + `test` runs the vitest suite on the release lines the toolchain supports + (`22` / `24.16.0` / `26`), then runs the built `dist` under a bare `node` on + that same matrix Node (`scripts/floor-smoke.mjs`); `floor-smoke` runs that same + script the same way, but pinned to 22.13.0. Running it on both floors is + deliberate: its `.ts`-config check branches on the host Node's type-stripping + support, so `floor-smoke` on 22.13.0 takes the old-Node branch (asserting the + CLI's guided error), while every `test` matrix entry supports unflagged + type-stripping and takes the modern-Node branch (asserting the `.ts` config + loads). That modern-Node assertion used to live in + `packages/cli/test/config-file.test.ts`; it was deleted once the smoke on the + `test` matrix covered it, since vitest's module runner transforms in-process + `import()` and could never reach the raw-Node behaviour either branch depends + on. So a dev dependency raising its Node floor is not a problem _for + dependencies the smoke actually executes_: jsdom 30 requires `^22.22.2` and + that is fine because `floor-smoke` never loads jsdom. pnpm itself, and the + build toolchain (tsup et al.), are not exempt — `floor-smoke` still runs + `pnpm install`/`pnpm build` on 22.13.0, so those stay floor-bound. Never pin + the `test` matrix back to 22.13.0, and never add a dev dependency to the + smoke — it must stay Node-builtins-only. Design doc: + `docs/superpowers/specs/2026-07-31-floor-smoke-design.md`. - **Dependencies via catalog**: root `package.json` devDependencies are all pinned as `catalog:`; actual versions live in `pnpm-workspace.yaml`. Add/bump shared devDependencies there, not as literal versions in a package's `package.json`. - **Changesets required**: any user-facing change needs `pnpm changeset`. Merging to `main` opens a release PR (Changesets bot). Internal-only / doc-only changes don't need one. - **en/ja docs stay in sync**: `docs/src/content/docs/` (English) and `docs/src/content/docs/ja/` (Japanese) are updated together by convention — don't ship an English-only doc change if the Japanese equivalent exists. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cfff0664b..89721849e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,7 @@ pnpm typecheck # tsc --noEmit pnpm lint # oxlint + oxfmt --check pnpm format # oxfmt --write pnpm check:publish # publint + attw (--profile esm-only) +pnpm smoke # built dist under a bare node (engines.node floor) — run pnpm build first pnpm bench # manual timing benchmark, never run in CI ``` diff --git a/docs/superpowers/plans/2026-07-31-node-floor-smoke.md b/docs/superpowers/plans/2026-07-31-node-floor-smoke.md new file mode 100644 index 000000000..3d775f403 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-node-floor-smoke.md @@ -0,0 +1,487 @@ +# End-user Node floor smoke — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop the development toolchain from being held to the published packages' `engines.node` floor, by moving the floor claim off the vitest suite and onto a bare-`node` smoke of the built `dist`. + +**Architecture:** The `test` matrix stops pinning the floor version and tracks the release lines the dev toolchain supports (`22`, `24.16.0`, `26`). A new `floor-smoke` CI job pins 22.13.0 and runs `scripts/floor-smoke.mjs` — plain ESM with `node:assert`, no test runner — against the built `dist`. One vitest test whose only reason to exist was the floor is deleted, because the smoke covers it better. + +**Tech Stack:** Node ESM (`.mjs`), `node:assert`, `node:child_process`; GitHub Actions; pnpm 11 workspaces; oxlint + oxfmt. + +**Design doc:** `docs/superpowers/specs/2026-07-31-floor-smoke-design.md` + +## Global Constraints + +- The published packages' floor is `engines.node: >=22.13.0` on all four packages. Do not change it. It is settled (`2026-07-05-config-file-design.md`: "This floor is final"). +- `scripts/floor-smoke.mjs` must not import vitest or any dev dependency. Node builtins only. Importing a test runner recreates the coupling this plan removes. +- The smoke asserts against the built `dist`, never `src`. +- Run `pnpm exec oxfmt --write ` on every file you touch before committing; `pnpm lint` gates CI. +- Conventional commits, scoped by package: `ci:`, `test(cli):`, `docs:`. +- This is CI + tooling only — no published-package code changes, so **no changeset**. +- Node facts used throughout: native TypeScript type-stripping is unflagged from **22.18** / **23.6**; on the floor (22.13–22.17) a `.ts` config needs `--experimental-strip-types`. jsdom 30 requires `^22.22.2 || ^24.15.0 || >=26.0.0`. + +--- + +### Task 1: The smoke script and its CLI-contract checks + +**Files:** + +- Create: `scripts/floor-smoke.mjs` +- Modify: `package.json` (root `scripts` block) + +**Interfaces:** + +- Consumes: the built `dist` of all four packages (`pnpm build` output) and `packages/cli/test/fixtures/basic-project`. +- Produces: `node scripts/floor-smoke.mjs` — exits 0 when every check passes, 1 otherwise, printing one `ok`/`FAIL` line per check. Task 2 appends a check to the same `check(name, fn)` registry. Task 3 invokes it from CI. + +- [ ] **Step 1: Build the packages so the smoke has something to run** + +```bash +pnpm build +``` + +Expected: exits 0; `packages/cli/dist/bin.js`, `packages/core/dist/index.js`, `packages/vite/dist/index.js`, `packages/vite/dist/hooks/index.js`, and `packages/mcp/dist/index.js` all exist. + +- [ ] **Step 2: Create `scripts/floor-smoke.mjs` with the four CLI-contract checks** + +The style to match is the existing `scripts/verify-svelte-import.js` — a plain script that verifies, under a bare runtime, the part whose behaviour is runtime-dependent. + +```js +// End-user Node floor smoke (design doc: +// docs/superpowers/specs/2026-07-31-floor-smoke-design.md). +// +// Runs the BUILT `dist` of the published packages under a bare `node` — never +// through vitest. CI pins this to the `engines.node` floor (22.13.0), which is +// the version no dev dependency is held to any more; `pnpm test` covers the +// release lines the dev toolchain supports instead. +// +// node scripts/floor-smoke.mjs +// +// Assertions are hand-rolled against `node:assert`: pulling in a test runner +// would put the dev toolchain back on the floor, which is the whole point. + +import { strict as assert } from 'node:assert'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const root = join(import.meta.dirname, '..'); +const cliBin = join(root, 'packages/cli/dist/bin.js'); +const basicProject = join(root, 'packages/cli/test/fixtures/basic-project'); + +/** Run the built CLI. Never throws: returns the exit code alongside the captured streams. */ +function runCli(args, opts = {}) { + try { + const stdout = execFileSync(process.execPath, [cliBin, ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + ...opts + }); + return { code: 0, stdout, stderr: '' }; + } catch (err) { + return { code: err.status ?? 1, stdout: String(err.stdout ?? ''), stderr: String(err.stderr ?? '') }; + } +} + +const checks = []; +function check(name, fn) { + checks.push([name, fn]); +} + +check('--version prints the CLI and core versions and exits 0', () => { + const { code, stdout } = runCli(['--version']); + assert.equal(code, 0); + assert.match(stdout.trim(), /^\d+\.\d+\.\d+ \(core \d+\.\d+\.\d+\)$/); +}); + +check('a directory that is not a SvelteKit project exits 2', () => { + const empty = mkdtempSync(join(tmpdir(), 'floor-smoke-empty-')); + const { code, stderr } = runCli([empty]); + assert.equal(code, 2); + assert.match(stderr, /No SvelteKit project found/); +}); + +check('analysing a real project emits a well-formed JSON report', () => { + const { code, stdout } = runCli([basicProject, '--reporter', 'json']); + // 0 (clean) and 1 (a finding reached the fail threshold) are both contractual; + // asserting the score would make this smoke a hostage of the rule set. + assert.ok(code === 0 || code === 1, `expected exit 0 or 1, got ${code}`); + const report = JSON.parse(stdout); + assert.equal(typeof report.version, 'string'); + assert.equal(typeof report.score, 'number'); + assert.ok(report.categories && typeof report.categories === 'object'); +}); + +check('every published entry point imports under bare node', async () => { + for (const entry of [ + 'packages/core/dist/index.js', + 'packages/cli/dist/index.js', + 'packages/vite/dist/index.js', + 'packages/vite/dist/hooks/index.js', + 'packages/mcp/dist/index.js' + ]) { + const mod = await import(join(root, entry)); + assert.ok(Object.keys(mod).length > 0, `${entry} exported nothing`); + } +}); + +console.log(`floor-smoke: node ${process.versions.node}`); + +let failed = 0; +for (const [name, fn] of checks) { + try { + await fn(); + console.log(` ok ${name}`); + } catch (err) { + failed++; + console.error(` FAIL ${name}\n ${err.message}`); + } +} + +if (failed > 0) { + console.error(`floor-smoke: ${failed} of ${checks.length} checks failed`); + process.exit(1); +} +console.log(`floor-smoke: ${checks.length} checks passed`); +``` + +- [ ] **Step 3: Run it** + +```bash +node scripts/floor-smoke.mjs +``` + +Expected: exit 0, and + +``` +floor-smoke: node 24.16.0 + ok --version prints the CLI and core versions and exits 0 + ok a directory that is not a SvelteKit project exits 2 + ok analysing a real project emits a well-formed JSON report + ok every published entry point imports under bare node +floor-smoke: 4 checks passed +``` + +- [ ] **Step 4: Prove the checks can fail** + +These checks describe behaviour that already works, so passing on the first run proves nothing. Temporarily change the first check's `assert.equal(code, 0);` to `assert.equal(code, 9);` and re-run: + +```bash +node scripts/floor-smoke.mjs; echo "EXIT=$?" +``` + +Expected: `EXIT=1` and a line beginning ` FAIL --version prints the CLI and core versions`. Revert the edit and re-run to confirm it is back to 4 passing checks. Do not commit the temporary edit. + +- [ ] **Step 5: Add the `smoke` script to the root `package.json`** + +Insert after the `"test"` line in the root `scripts` block, so it reads: + +```json + "test": "pnpm -r test", + "smoke": "node scripts/floor-smoke.mjs", + "bench": "pnpm --filter svelte-vitals... build && pnpm --filter @svelte-vitals/vite run bench", +``` + +- [ ] **Step 6: Verify the script entry and formatting** + +```bash +pnpm smoke && pnpm exec oxfmt --write scripts/floor-smoke.mjs package.json && pnpm lint +``` + +Expected: the smoke prints 4 passing checks, then `pnpm lint` exits 0. + +- [ ] **Step 7: Commit** + +```bash +git add scripts/floor-smoke.mjs package.json +git commit -m "ci: add a bare-node smoke of the built dist for the engines.node floor" +``` + +--- + +### Task 2: Assert the `.ts` config contract, which vitest cannot reach + +**Files:** + +- Modify: `scripts/floor-smoke.mjs` + +**Interfaces:** + +- Consumes: `check(name, fn)` and `runCli(args)` from Task 1; the fixture `packages/cli/test/fixtures/config-file-ts/svelte-vitals.config.ts`. +- Produces: a fifth check. Task 4 deletes the vitest test this supersedes. + +Background the implementer needs: `loadConfigFile` (`packages/cli/src/config-file.ts:263-271`) catches Node's `ERR_UNKNOWN_FILE_EXTENSION` for a `.ts` config and rethrows an actionable message. That branch is only reachable on Node 22.13–22.17, and **no vitest test can ever reach it** — vitest's module runner transforms in-process dynamic `import()`, so a `.ts` config always loads inside vitest regardless of the host Node. A bare-`node` smoke on the floor is the only way to cover it. + +- [ ] **Step 1: Widen the imports this check needs** + +The check copies a fixture, so `cpSync` joins the `node:fs` import and the fixtures directory gets its own constant: + +```js +import { cpSync, mkdtempSync } from 'node:fs'; +``` + +```js +const fixtures = join(root, 'packages/cli/test/fixtures'); +const basicProject = join(fixtures, 'basic-project'); +``` + +- [ ] **Step 2: Add the check** + +Insert after the `'every published entry point imports under bare node'` check, and add the `supportsUnflaggedTypeStripping` helper directly above the first `check(...)` call: + +```js +/** + * Whether this Node strips TypeScript types from `import()` without a flag: + * unflagged in 23.6.0, backported to 22.18.0. The floor (22.13.0) is inside the + * window that needs `--experimental-strip-types`, so this decides which side of + * the `.ts` config contract to assert. + */ +function supportsUnflaggedTypeStripping() { + const [major = 0, minor = 0] = process.versions.node.split('.').map(Number); + return (major === 22 && minor >= 18) || (major === 23 && minor >= 6) || major >= 24; +} +``` + +```js +check("a .ts config file matches this Node runtime's type-stripping support", () => { + // The CLI resolves the project before it loads the config, so the `.ts` config + // needs to sit in something that looks like a SvelteKit app. + const project = mkdtempSync(join(tmpdir(), 'floor-smoke-ts-')); + cpSync(basicProject, project, { recursive: true }); + cpSync(join(fixtures, 'config-file-ts/svelte-vitals.config.ts'), join(project, 'svelte-vitals.config.ts')); + + const { code, stderr } = runCli([project, '--reporter', 'json']); + if (supportsUnflaggedTypeStripping()) { + assert.ok(code === 0 || code === 1, `expected the .ts config to load, got exit ${code}: ${stderr}`); + } else { + // The floor's contract: loadConfigFile turns Node's raw + // ERR_UNKNOWN_FILE_EXTENSION into an actionable message. vitest can never + // reach this branch — its module runner transforms in-process `import()`. + assert.equal(code, 2); + assert.match(stderr, /does not support TypeScript config files without a flag/); + assert.match(stderr, /22\.18\+/); + } +}); +``` + +Also extend the banner so the log records which side ran: + +```js +console.log( + `floor-smoke: node ${process.versions.node} (unflagged type stripping: ${supportsUnflaggedTypeStripping()})` +); +``` + +- [ ] **Step 3: Run it** + +```bash +pnpm smoke +``` + +Expected: exit 0, `floor-smoke: 5 checks passed`, and the banner ends with `(unflagged type stripping: true)` on a modern dev Node. + +- [ ] **Step 4: Prove the new check can fail** + +Temporarily change `assert.ok(code === 0 || code === 1, ...)` in the new check to `assert.equal(code, 2);` and re-run: + +```bash +pnpm smoke; echo "EXIT=$?" +``` + +Expected: `EXIT=1` with `FAIL a .ts config file matches this Node runtime's type-stripping support`. Revert and re-run to confirm 5 passing checks. Do not commit the temporary edit. + +- [ ] **Step 5: Format, lint, commit** + +```bash +pnpm exec oxfmt --write scripts/floor-smoke.mjs && pnpm lint && pnpm smoke +git add scripts/floor-smoke.mjs +git commit -m "ci: assert the .ts config contract that vitest cannot reach" +``` + +--- + +### Task 3: Split the CI jobs + +**Files:** + +- Modify: `.github/workflows/ci.yml` (the `test` job's matrix comment and `node-version`; new `floor-smoke` job after `test`) + +**Interfaces:** + +- Consumes: `node scripts/floor-smoke.mjs` from Tasks 1–2; the existing `./.github/workflows/setup-node` composite action, whose `node-version` input rewrites `devEngines.runtime.version` so pnpm's managed runtime actually runs under that version. +- Produces: a `floor-smoke` CI job, and a `test` matrix that no longer pins the published floor. + +- [ ] **Step 1: Retarget the `test` matrix** + +Replace the matrix block (currently `node-version: ['22.13.0', '24.16.0', '26']` with the comment above it) with: + +```yaml +matrix: + # setup-node rewrites devEngines.runtime.version to this, + # so pnpm's managed runtime (onFail: download) actually runs scripts under it. + # These are the release lines the DEV toolchain supports — NOT the published + # engines.node floor, which the `floor-smoke` job defends on 22.13.0 instead. + # Pinning the floor here would hold every dev dependency to it: jsdom 30 + # already requires ^22.22.2, and vite/oxlint sit 0.01 above 22.13.0. + # '22' is the latest 22.x (maintenance LTS until 2027-04); + # 24.16.0 is the current release used for development (devEngines); + # '26' is the latest of the Current line (active LTS from 2026-10) — early warning. + node-version: ['22', '24.16.0', '26'] +``` + +- [ ] **Step 2: Add the `floor-smoke` job** + +Insert between the `test` job and the `docs` job: + +```yaml +floor-smoke: + needs: check + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # 22.13.0 is the published packages' engines.node floor. This is the only job + # pinned to it: it runs the built dist under a bare `node`, so no dev + # dependency (jsdom, vitest, ...) is held to the end-user's Node version. + - name: Setup Node.js and dependencies + uses: ./.github/workflows/setup-node + with: + node-version: '22.13.0' + - name: Restore package builds + id: dist-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: packages/*/dist + key: dist-${{ hashFiles('packages/*/src/**', 'pnpm-lock.yaml', 'packages/*/tsup.config.ts') }} + # Fallback only: `needs: check` populates this exact cache key, so a miss means + # eviction. Building here would run tsup on the floor Node. + - name: Build packages + if: steps.dist-cache.outputs.cache-hit != 'true' + run: pnpm build + - name: Run the end-user floor smoke + run: node scripts/floor-smoke.mjs +``` + +Note the last step calls `node` directly, not `pnpm smoke`: `actions/setup-node` puts 22.13.0 on `PATH`, and going through pnpm would route the script into pnpm's managed runtime instead. + +- [ ] **Step 3: Verify the workflow parses** + +```bash +pnpm exec oxfmt --check .github/workflows/ci.yml || true +node -e "import('node:fs').then(({readFileSync})=>{const t=readFileSync('.github/workflows/ci.yml','utf8');for (const j of [' lint:',' check:',' test:',' floor-smoke:',' docs:']) if(!t.includes(j)) throw new Error('missing job '+j); if(t.includes(\"'22.13.0', '24.16.0'\")) throw new Error('test matrix still pins the floor'); console.log('ci.yml jobs ok')})" +``` + +Expected: `ci.yml jobs ok`. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: defend the engines.node floor with a smoke job, not the test matrix" +``` + +--- + +### Task 4: Delete the superseded vitest test and record the two floors + +**Files:** + +- Modify: `packages/cli/test/config-file.test.ts` (delete the child-process `.ts` test, its helper, and the now-unused imports) +- Modify: `AGENTS.md` (verify-commands table + a new subsection under "Hard rules") + +**Interfaces:** + +- Consumes: the check added in Task 2, which supersedes the deleted test. +- Produces: nothing other tasks depend on. This is the last task. + +Why the test goes rather than gets edited: it asserts **Node's own** behaviour (a bare `import()` of a `.ts` file yields `ERR_UNKNOWN_FILE_EXTENSION` on old Node), branching so that it passes on either side without ever asserting which side it is on. The smoke's check asserts **the CLI's** guided error on the floor side, which is the actual end-user contract. Keeping both would leave a test whose old-Node branch no CI job executes. + +- [ ] **Step 1: Confirm nothing else uses the pieces you are deleting** + +```bash +grep -rn "execFileSync\|pathToFileURL\|nodeSupportsUnflaggedTypeStripping" packages/cli/test/config-file.test.ts +grep -rn "config-file-ts" --include="*.ts" --include="*.mjs" --include="*.md" . --exclude-dir=node_modules +``` + +Expected: the first prints only lines 1, 3, 21, 265, 279, 288 (all inside the test being deleted). The second prints only `scripts/floor-smoke.mjs` and this plan — the fixture stays, because the smoke now uses it. + +- [ ] **Step 2: Delete the test, the helper, and the unused imports** + +Delete these, and nothing else: + +1. Line 1 — `import { execFileSync } from 'node:child_process';` +2. Line 3 — `import { pathToFileURL } from 'node:url';` +3. The `nodeSupportsUnflaggedTypeStripping` function and its doc comment (the block starting `/**\n * Whether this Node runtime strips TypeScript types` through the closing `}`) +4. The final `it(...)` in the file — the one titled `native import() of a .ts config succeeds on Node 22.18+/23.6+, else fails with ERR_UNKNOWN_FILE_EXTENSION (child process)` — together with the two comment lines above it beginning `// Spike finding: this MUST run in a child process.` + +In their place, leave a pointer as the last line inside the `describe` block: + +```ts +// The `.ts`-config contract on old Node (22.13–22.17 needs +// --experimental-strip-types) is asserted in scripts/floor-smoke.mjs, under a +// bare `node`. It cannot live here: vitest's module runner transforms +// in-process dynamic `import()`, so a `.ts` config always loads inside vitest +// regardless of the host Node. +``` + +- [ ] **Step 3: Run the CLI test suite** + +```bash +pnpm --filter svelte-vitals test +``` + +Expected: all tests pass, with the `loadConfigFile` describe block one test lighter and no unused-import lint error. + +- [ ] **Step 4: Record the two floors in AGENTS.md** + +In the verify-commands table, add a row after `Test`: + +```markdown +| Floor smoke | `pnpm smoke` | built `dist` under a bare `node` | +``` + +Then add this subsection to "Hard rules", after the "Core purity" bullet: + +```markdown +- **Two Node floors, two jobs**: the published packages promise + `engines.node: >=22.13.0` (end users); the dev toolchain is pinned by + `devEngines.runtime` and is free to require more. CI keeps these apart — + `test` runs the vitest suite on the release lines the toolchain supports + (`22` / `24.16.0` / `26`), and `floor-smoke` runs the built `dist` under a bare + `node` on 22.13.0 (`scripts/floor-smoke.mjs`). So a dev dependency raising its + Node floor is not a problem: jsdom 30 requires `^22.22.2` and that is fine. + Never pin the `test` matrix back to 22.13.0, and never add a dev dependency to + the smoke — it must stay Node-builtins-only. Design doc: + `docs/superpowers/specs/2026-07-31-floor-smoke-design.md`. +``` + +- [ ] **Step 5: Full verification** + +```bash +pnpm lint && pnpm build && pnpm typecheck && pnpm test && pnpm smoke +``` + +Expected: every command exits 0. + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/test/config-file.test.ts AGENTS.md +git commit -m "test(cli): move the .ts-config floor contract to the bare-node smoke" +``` + +--- + +## Done when + +- `pnpm smoke` passes locally (5 checks). +- CI shows a `floor-smoke` job green on 22.13.0 and `test` green on `22` / `24.16.0` / `26`. +- `packages/cli/test/config-file.test.ts` no longer branches on `process.versions.node`. +- AGENTS.md states which job defends which floor. + +## Follow-up, explicitly out of scope + +Renovate PR #332 (jsdom 29 → 30) can merge before or after this work; it is not blocked by it. The floor mismatch predates jsdom 30 — jsdom 30 is only the first dev dependency to make it visible. diff --git a/docs/superpowers/specs/2026-07-31-floor-smoke-design.md b/docs/superpowers/specs/2026-07-31-floor-smoke-design.md new file mode 100644 index 000000000..0d0a3c4b1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-floor-smoke-design.md @@ -0,0 +1,176 @@ +# Separate the end-user Node floor from the development Node floor + +**Date:** 2026-07-31 +**Status:** Designed +**Packages:** none (CI + a root script). No published-package code changes, so no changeset. + +## Goal + +Stop the development toolchain from dictating what Node version the published +packages can claim to support. + +Today the repo declares the two floors separately — +`engines.node: >=22.13.0` on every published package (the end-user contract) and +`devEngines.runtime: 24.16.0` at the root (the development pin) — but CI ignores +the distinction: the `test (22.13.0)` matrix job runs `pnpm -r test`, which drags +vitest, jsdom, vite, and every other dev dependency onto the end-user floor. A dev +dependency that raises its own Node floor therefore breaks a job whose purpose has +nothing to do with that dependency. + +## Why now + +`jsdom@30` (PR #332) is the first dev dependency to cross the floor. Its only +breaking change is the Node requirement: + +| Package | `engines.node` | On 22.13.0 | +| ---------------------------------------------------------------------------------------------------- | -------------------------------------- | --------------- | +| Runtime deps of the published packages (tinyglobby, mri, smol-toml, node-html-parser, log-update, …) | `>=12` – `>=22` | fine | +| vite 8 / oxlint / oxfmt | `^20.19.0 \|\| >=22.12.0` | fine by 0.01 | +| vitest 4 | `^20.0.0 \|\| ^22.0.0 \|\| >=24.0.0` | fine | +| **jsdom 30** | `^22.22.2 \|\| ^24.15.0 \|\| >=26.0.0` | **unsatisfied** | + +CI is green only because pnpm's `engine-strict` is off by default (no `.npmrc` in +this repo), so the mismatch is not even warned about — verified by grepping the +`test (22.13.0)` job log. The failure mode this sets up is a future jsdom (or +vite, or oxlint — both sit 0.01 above the floor) using an API newer than 22.13.0, +at which point one matrix job fails with a runtime error that has no visible +connection to the floor. + +The three tests that would break first — +`packages/vite/test/app-shell-static.test.ts`, +`dashboard-script-staleness.test.ts`, `dashboard-script-ai-prompt.test.ts` — assert +the behaviour of the dev-overlay dashboard's **browser** client script. They say +nothing about whether the CLI runs on Node 22.13.0. + +## What the floor job must not lose + +`packages/cli/test/config-file.test.ts` branches on `process.versions.node` +(`nodeSupportsUnflaggedTypeStripping`) because native TypeScript type-stripping is +unflagged only from Node 22.18 / 23.6 — so on this repo's floor, 22.13–22.17 is the +one window where a `.ts` config file needs `--experimental-strip-types`. Moving the +test suite off 22.13.0 without replacement would silently drop the only execution of +that window. + +Two things are worth separating here: + +1. The existing child-process test asserts **Node's own** behaviour (a bare + `import()` of a `.ts` file yields `ERR_UNKNOWN_FILE_EXTENSION` on old Node). It + passes on every matrix entry via the branch, so it never _asserts_ which side it + is on. +2. `loadConfigFile` catches that error and rethrows an actionable message + ("upgrade Node to 22.18+, re-run with `--experimental-strip-types`, or rename the + file to .mjs/.js" — `packages/cli/src/config-file.ts:263-271`). This branch is the + actual end-user contract, and **no vitest test can ever reach it**: vitest's module + runner transforms in-process dynamic `import()`, so a `.ts` config always loads + inside vitest regardless of the host Node (recorded in the test's own comment). + +So the floor job does not merely preserve coverage — it is the only place this +branch can be covered at all. + +## Design + +Three jobs with three distinct claims: + +``` +check build / typecheck / publint / attw (dev Node) +test (22, 24.16.0, 26) pnpm -r test — vitest, jsdom, the lot — then (dev Node line, + scripts/floor-smoke.mjs on the matrix Node modern-Node branch) +floor-smoke (22.13.0) built dist under bare `node`, no vitest (end-user floor, + old-Node branch) +``` + +**`test` matrix becomes `['22', '24.16.0', '26']`.** Bare `'22'` resolves to the +latest 22.x, which satisfies jsdom and everything else, so the 22 line keeps full +unit-test coverage. The matrix now tracks _release lines_ the dev toolchain +supports, not the published floor. + +**`floor-smoke` runs on 22.13.0** and asserts the end-user contract only, by +executing the built `dist` with a bare `node` — never through vitest: + +1. `node packages/cli/dist/bin.js ` exits with the documented code + (`packages/cli/test/fixtures/basic-project`; the contract is 0 / 1 / 2 per + `packages/cli/src/bin.ts`). +2. Each published entry point (`@svelte-vitals/core`, `svelte-vitals`, + `@svelte-vitals/vite`, `@svelte-vitals/mcp`) imports cleanly under bare `node`. +3. A `.ts` config on the floor Node produces the CLI's guided error, not a raw + `ERR_UNKNOWN_FILE_EXTENSION` — the assertion promoted from the branch above. + +### Fidelity: workspace install, bare-node execution (A-1) + +`floor-smoke` reuses the existing `./.github/workflows/setup-node` composite with +`node-version: 22.13.0` and the `packages/*/dist` cache, then invokes `node` +directly rather than through a pnpm script. jsdom lands on disk as part of the +workspace install but is never loaded, which is the whole point: installing is not +executing. + +The rejected alternative (A-2) was packing the four packages with `pnpm pack` and +installing the tarballs into a temp project, so the dev tree leaves the floor job's +dependency graph entirely. It is rejected as scope creep: packaging correctness is +already covered by publint + attw in `check`, `workspace:*` would have to be patched +back to the local tarballs via `pnpm.overrides`, and the result is a release +verification job, not a Node floor job. + +### The script + +A single root-level `scripts/floor-smoke.mjs`, plain ESM with `node:assert`, in the +style of the existing `scripts/verify-svelte-import.js` — which is the same idea +already applied once: verify under a bare runtime the part whose behaviour is +runtime-dependent. Using vitest here would defeat the purpose, so assertions are +hand-rolled. + +Exposed as `pnpm smoke` for local use, with the caveat (documented in AGENTS.md +next to the other verify commands) that locally it runs under the devEngines Node — +the floor claim is what CI adds, not the script itself. + +## Non-goals + +- Raising `engines.node` above 22.13.0. That floor is settled + (`2026-07-05-config-file-design.md`: "This floor is final") and no runtime + dependency of a published package requires more. +- Enabling `engine-strict`. It would fail the install on the floor Node because of a + dev dependency, i.e. exactly the coupling this design removes. +- Pinning jsdom back to 29. jsdom 30's only breaking change is the Node floor. +- Making `floor-smoke` a full end-to-end or release-verification job. + +## Files touched + +- `.github/workflows/ci.yml` — `test` matrix `22.13.0` → `22`; new `floor-smoke` job. +- `scripts/floor-smoke.mjs` — new. +- `package.json` — `smoke` script. +- `packages/cli/test/config-file.test.ts` — the `.ts` child-process test loses its + reason to branch on the host Node once the floor assertion lives in the smoke; + deleted outright rather than kept as a new-Node-only assertion, since running + `scripts/floor-smoke.mjs` on every `test` matrix entry already covers the + modern-Node branch under a bare `node` — a check vitest's module runner could + never provide (see "What the floor job must not lose" above). +- `AGENTS.md` — record the two floors and which job defends which, so the next dev + dependency that crosses 22.13.0 does not restart this investigation. + +## Verification + +- `node scripts/floor-smoke.mjs` passes locally. +- CI: `floor-smoke` green on 22.13.0; `test` green on 22 / 24.16.0 / 26. +- Every `test` matrix entry also runs `scripts/floor-smoke.mjs` directly (a bare + `node`, not through vitest), which is what asserts the modern-Node branch of + the `.ts`-config check now that `config-file.test.ts` no longer covers it. +- Deliberately break it: temporarily point the smoke at a `.ts` config and confirm + `floor-smoke` fails on 22.13.0 while `test` stays green — proving the job actually + discriminates. + +## Risks + +- **Reduced unit-test coverage on the exact floor version.** Accepted: unit tests run + transpiled source, never the shipped `dist`, so they were always a proxy for the + floor claim. The smoke tests the artifact users actually get. +- **The smoke can rot into a no-op** if it stops asserting exit codes or output. + Mitigated by the deliberate-break step in Verification. +- **The `test` matrix's bare `'22'` entry floats, and floating has a cost.** (a) + It is not reproducible: two runs of the same commit can resolve to different + 22.x patch versions as new releases land, unlike the pinned `24.16.0` and + `floor-smoke`'s `22.13.0`. (b) With `engine-strict` off — an explicit non-goal + above — an unsatisfied dev-dependency `engines.node` on that floating line is + still silent, just far less likely to bite because `'22'` tracks the latest + patch instead of sitting still at 22.13.0. Both are accepted as the owned + tradeoff of floating rather than a defect: pinning `'22'` to a fixed patch + would recreate the exact staleness problem (a dev dependency quietly + outrunning a stale floor) that this design exists to fix. diff --git a/package.json b/package.json index 3c1063ec6..a9850ddfb 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "pnpm -r build", "typecheck": "pnpm -r typecheck", "test": "pnpm -r test", + "smoke": "node scripts/floor-smoke.mjs", "bench": "pnpm --filter svelte-vitals... build && pnpm --filter @svelte-vitals/vite run bench", "lint": "oxlint . && oxfmt --check .", "format": "oxfmt --write .", diff --git a/packages/cli/test/config-file.test.ts b/packages/cli/test/config-file.test.ts index 6230e3933..580935cbe 100644 --- a/packages/cli/test/config-file.test.ts +++ b/packages/cli/test/config-file.test.ts @@ -1,6 +1,4 @@ -import { execFileSync } from 'node:child_process'; import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; import { describe, it, expect } from 'vitest'; import { loadConfigFile } from '../src/config-file.js'; @@ -11,18 +9,6 @@ import { loadConfigFile } from '../src/config-file.js'; const fixture = (name: string) => join(import.meta.dirname, 'fixtures', name); -/** - * Whether this Node runtime strips TypeScript types from `import()` without a - * flag. Unflagged in 23.6.0, backported to 22.18.0 (design doc §2); on this - * repo's floor (>=22.13.0), only 22.13–22.17 requires - * `--experimental-strip-types`, so the `.ts` test below branches on this - * instead of assuming a single Node version. - */ -function nodeSupportsUnflaggedTypeStripping(): boolean { - const [major = 0, minor = 0] = process.versions.node.split('.').map(Number); - return (major === 22 && minor >= 18) || (major === 23 && minor >= 6) || major >= 24; -} - describe('loadConfigFile', () => { it('returns undefined when no config file exists', async () => { await expect(loadConfigFile(fixture('config-file-none'))).resolves.toBeUndefined(); @@ -256,40 +242,9 @@ describe('loadConfigFile', () => { expect(loaded?.config.overrides![0]!.rules['architecture/prop-count']).toEqual({ options: { max: 4 } }); }); - // Spike finding: this MUST run in a child process. vitest's module runner - // intercepts and transforms in-process dynamic `import()` calls, so a `.ts` - // config always loads inside vitest regardless of the host Node's native - // type-stripping support — native behavior is only observable by having a - // real `node` process (no test-runner loader hooks) perform the import. - it('native import() of a .ts config succeeds on Node 22.18+/23.6+, else fails with ERR_UNKNOWN_FILE_EXTENSION (child process)', () => { - const tsUrl = pathToFileURL(join(fixture('config-file-ts'), 'svelte-vitals.config.ts')).href; - const script = [ - 'try {', - ` const mod = await import(${JSON.stringify(tsUrl)});`, - " if (!mod.default) { console.error('NO_DEFAULT_EXPORT'); process.exit(1); }", - '} catch (e) {', - ' console.error(e && e.code ? e.code : String(e));', - ' process.exit(1);', - '}' - ].join('\n'); - - let exitCode = 0; - let stderr = ''; - try { - execFileSync(process.execPath, ['--input-type=module', '-e', script], { - stdio: ['ignore', 'ignore', 'pipe'] - }); - } catch (err) { - const e = err as { status?: number | null; stderr?: Buffer | string }; - exitCode = e.status ?? 1; - stderr = String(e.stderr ?? ''); - } - - if (nodeSupportsUnflaggedTypeStripping()) { - expect(exitCode).toBe(0); - } else { - expect(exitCode).not.toBe(0); - expect(stderr).toContain('ERR_UNKNOWN_FILE_EXTENSION'); - } - }); + // The `.ts`-config contract on old Node (22.13–22.17 needs + // --experimental-strip-types) is asserted in scripts/floor-smoke.mjs, under a + // bare `node`. It cannot live here: vitest's module runner transforms + // in-process dynamic `import()`, so a `.ts` config always loads inside vitest + // regardless of the host Node. }); diff --git a/scripts/floor-smoke.mjs b/scripts/floor-smoke.mjs new file mode 100644 index 000000000..b80c4a8ea --- /dev/null +++ b/scripts/floor-smoke.mjs @@ -0,0 +1,162 @@ +// End-user Node floor smoke (design doc: +// docs/superpowers/specs/2026-07-31-floor-smoke-design.md). +// +// Runs the BUILT `dist` of the published packages under a bare `node` — never +// through vitest. CI pins this to the `engines.node` floor (22.13.0), which is +// the version no dev dependency is held to any more; `pnpm test` covers the +// release lines the dev toolchain supports instead. +// +// node scripts/floor-smoke.mjs +// +// Assertions are hand-rolled against `node:assert`: pulling in a test runner +// would put the dev toolchain back on the floor, which is the whole point. + +import { strict as assert } from 'node:assert'; +import { execFileSync } from 'node:child_process'; +import { cpSync, existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const root = join(import.meta.dirname, '..'); +const cliBin = join(root, 'packages/cli/dist/bin.js'); +const fixtures = join(root, 'packages/cli/test/fixtures'); +const basicProject = join(fixtures, 'basic-project'); + +if (!existsSync(cliBin)) { + console.error('floor-smoke: packages/cli/dist/bin.js is missing — run `pnpm build` first.'); + process.exit(1); +} + +/** + * Run the built CLI. Never throws: returns the exit code alongside the captured streams. + * A signal kill (status === null) is surfaced as its own field rather than folded into + * `code`, so callers can't mistake it for a normal exit 1. + */ +function runCli(args, opts = {}) { + try { + const stdout = execFileSync(process.execPath, [cliBin, ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + ...opts + }); + return { code: 0, signal: null, stdout, stderr: '' }; + } catch (err) { + return { + code: err.status, + signal: err.signal ?? null, + stdout: String(err.stdout ?? ''), + stderr: String(err.stderr ?? '') + }; + } +} + +const checks = []; +function check(name, fn) { + checks.push([name, fn]); +} + +check('--version prints the CLI and core versions and exits 0', () => { + const { code, signal, stdout, stderr } = runCli(['--version']); + assert.equal(signal, null, `killed by signal ${signal}, not a normal exit (stderr: ${stderr})`); + assert.equal(code, 0, `expected exit 0, got ${code}: ${stderr}`); + assert.match(stdout.trim(), /^\d+\.\d+\.\d+(-[\w.]+)? \(core \d+\.\d+\.\d+(-[\w.]+)?\)$/); +}); + +check('a directory that is not a SvelteKit project exits 2', () => { + const empty = mkdtempSync(join(tmpdir(), 'floor-smoke-empty-')); + try { + const { code, signal, stderr } = runCli([empty]); + assert.equal(signal, null, `killed by signal ${signal}, not a normal exit (stderr: ${stderr})`); + assert.equal(code, 2, `expected exit 2, got ${code}: ${stderr}`); + assert.match(stderr, /No SvelteKit project found/); + } finally { + rmSync(empty, { recursive: true, force: true }); + } +}); + +check('analysing a real project emits a well-formed JSON report', () => { + const { code, signal, stdout, stderr } = runCli([basicProject, '--reporter', 'json']); + assert.equal(signal, null, `killed by signal ${signal}, not a normal exit (stderr: ${stderr})`); + // 0 (clean) and 1 (a finding reached the fail threshold) are both contractual; + // asserting the score would make this smoke a hostage of the rule set. + assert.ok(code === 0 || code === 1, `expected exit 0 or 1, got ${code}: ${stderr}`); + const report = JSON.parse(stdout); + assert.equal(typeof report.version, 'string'); + assert.equal(typeof report.score, 'number'); + assert.ok(report.categories && typeof report.categories === 'object'); +}); + +check('every published entry point except the mcp stdio server bin imports under bare node', async () => { + // @svelte-vitals/mcp's `bin` (svelte-vitals-mcp -> dist/bin.js) is a stdio + // server: invoking it would block waiting on stdin and hang the smoke. Its + // library entry point (dist/index.js, below) is covered instead. + for (const entry of [ + 'packages/core/dist/index.js', + 'packages/cli/dist/index.js', + 'packages/vite/dist/index.js', + 'packages/vite/dist/hooks/index.js', + 'packages/mcp/dist/index.js' + ]) { + const mod = await import(join(root, entry)); + assert.ok(Object.keys(mod).length > 0, `${entry} exported nothing`); + } +}); + +/** + * Whether this Node strips TypeScript types from `import()` without a flag: + * unflagged in 23.6.0, backported to 22.18.0. The floor (22.13.0) is inside the + * window that needs `--experimental-strip-types`, so this decides which side of + * the `.ts` config contract to assert. + */ +function supportsUnflaggedTypeStripping() { + const [major = 0, minor = 0] = process.versions.node.split('.').map(Number); + return (major === 22 && minor >= 18) || (major === 23 && minor >= 6) || major >= 24; +} + +check("a .ts config file matches this Node runtime's type-stripping support", () => { + // `loadConfigFile` runs before project detection, so on the floor Node the config + // throws whatever this directory holds. It has to look like a SvelteKit app for the + // other branch: there the config loads and execution continues into detection, which + // must succeed to reach a report. + const project = mkdtempSync(join(tmpdir(), 'floor-smoke-ts-')); + try { + cpSync(basicProject, project, { recursive: true }); + cpSync(join(fixtures, 'config-file-ts/svelte-vitals.config.ts'), join(project, 'svelte-vitals.config.ts')); + + const { code, signal, stderr } = runCli([project, '--reporter', 'json']); + assert.equal(signal, null, `killed by signal ${signal}, not a normal exit (stderr: ${stderr})`); + if (supportsUnflaggedTypeStripping()) { + assert.ok(code === 0 || code === 1, `expected the .ts config to load, got exit ${code}: ${stderr}`); + } else { + // The floor's contract: loadConfigFile turns Node's raw + // ERR_UNKNOWN_FILE_EXTENSION into an actionable message. vitest can never + // reach this branch — its module runner transforms in-process `import()`. + assert.equal(code, 2, `expected exit 2, got ${code}: ${stderr}`); + assert.match(stderr, /does not support TypeScript config files without a flag/); + assert.match(stderr, /22\.18\+/); + } + } finally { + rmSync(project, { recursive: true, force: true }); + } +}); + +console.log( + `floor-smoke: node ${process.versions.node} (unflagged type stripping: ${supportsUnflaggedTypeStripping()})` +); + +let failed = 0; +for (const [name, fn] of checks) { + try { + await fn(); + console.log(` ok ${name}`); + } catch (err) { + failed++; + console.error(` FAIL ${name}\n ${err.stack ?? err.message}`); + } +} + +if (failed > 0) { + console.error(`floor-smoke: ${failed} of ${checks.length} checks failed`); + process.exit(1); +} +console.log(`floor-smoke: ${checks.length} checks passed`);