From e4083e4b4771b2ddcd3850ea4e0baf9478e9dbff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 05:06:09 +0000 Subject: [PATCH] fix(engines): require Node 20.13.0, the floor clack's code actually needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @clack/prompts 1.8.1 declares ">= 20.12.0" but passes an ARRAY of formats to util.styleText, which Node accepts only from 20.13.0. On 20.12.x, cancelling a confirm prompt (or a picker after selecting something) crashed with ERR_INVALID_ARG_VALUE and exit 1 instead of "Cancelled" and exit 0 — measured in a pty against the packed CLI on the official 20.12.0 / 20.13.0 binaries. - engines.node -> ">=20.13.0" (package.json + the lockfile root entry, regenerated with npm 11.20.0 so nothing else moves). - tests/engines.test.ts also scans the runtime dependencies' shipped code against a measured known-API table, instead of trusting their declared floors; planted-text cases prove the scanner fires and stays quiet; the smoke workflow's FLOOR is pinned to package.json. - node-floor.yml: FLOOR 20.13.0, job renamed to the version-free `Smoke (node floor)` (not a required check). - README, SECURITY.md, CLAUDE.md, contributing, getting-started, troubleshooting and CHANGELOG updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0199owRmYfskqYQVQrVP679o --- .../features/engines-styletext-floor/GRILL.md | 59 +++++++ .dev/features/engines-styletext-floor/PLAN.md | 103 ++++++++++++ .../engines-styletext-floor/REGRESSION.md | 40 +++++ .../engines-styletext-floor/REVIEW.md | 61 ++++++++ .dev/features/engines-styletext-floor/SHIP.md | 19 +++ .../engines-styletext-floor/VERIFY.md | 46 ++++++ .../regression-report.json | 30 ++++ .../verify-report.json | 18 +++ .github/workflows/ci.yml | 4 +- .github/workflows/node-floor.yml | 21 ++- .pharn/writes-scope.json | 6 +- CHANGELOG.md | 4 + CLAUDE.md | 2 +- README.md | 6 +- SECURITY.md | 2 +- docs/contributing.md | 8 +- docs/getting-started.md | 2 +- docs/troubleshooting.md | 8 +- package-lock.json | 2 +- package.json | 2 +- tests/engines.test.ts | 146 +++++++++++++++++- 21 files changed, 555 insertions(+), 34 deletions(-) create mode 100644 .dev/features/engines-styletext-floor/GRILL.md create mode 100644 .dev/features/engines-styletext-floor/PLAN.md create mode 100644 .dev/features/engines-styletext-floor/REGRESSION.md create mode 100644 .dev/features/engines-styletext-floor/REVIEW.md create mode 100644 .dev/features/engines-styletext-floor/SHIP.md create mode 100644 .dev/features/engines-styletext-floor/VERIFY.md create mode 100644 .dev/features/engines-styletext-floor/regression-report.json create mode 100644 .dev/features/engines-styletext-floor/verify-report.json diff --git a/.dev/features/engines-styletext-floor/GRILL.md b/.dev/features/engines-styletext-floor/GRILL.md new file mode 100644 index 0000000..4b02f5d --- /dev/null +++ b/.dev/features/engines-styletext-floor/GRILL.md @@ -0,0 +1,59 @@ +# GRILL — engines-styletext-floor + +Plan: `.dev/features/engines-styletext-floor/PLAN.md`. Spec hash recomputed: +`bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e` — **matches** the plan's +`spec_content_hash`. Registered grillers: `{"registered":0,"grillers":[]}` → inline axes only. +The plan is `trust: untrusted`; nothing in it read as an instruction. + +## Findings + +### Guarantee audit (P0) + +```yaml +- type: FINDING + rule_id: 'P0' + severity: minor + file: '.dev/features/engines-styletext-floor/PLAN.md:42' + problem: 'The scan matches the literal call name. Both clack packages import `styleText` by that name today (`import { styleText } from ''node:util''`, checked), but an aliased import (`import { styleText as s }`) would not match, and neither would whitespace between the name and `(`. Write the regex as `\bstyleText\s*\(\s*\[` and have the test comment state that aliases are outside its reach.' + evidence: 'A `KNOWN_API_FLOORS` table (`/styleText\(\s*\[/` → 20.13.0, with the reason).' +``` + +### Eval coverage (P1) + +```yaml +- type: FINDING + rule_id: 'P1' + severity: minor + file: '.dev/features/engines-styletext-floor/PLAN.md:44' + problem: 'A hermetic case is missing for the scanner itself. Planting a dependency file that uses the array form, and one that does not, proves the scan can both fire and stay quiet. Otherwise a regex that never matches would pass once the live dependency stops using arrays.' + evidence: 'Ours must be ≥ every floor whose pattern matches.' +``` + +### Honest scope (P7) + +```yaml +- type: FINDING + rule_id: 'P7' + severity: minor + file: '.dev/features/engines-styletext-floor/PLAN.md:59' + problem: 'Raising `engines` excludes Node 20.12.x, a range that was already broken on every prompt cancel. The CHANGELOG entry should say that, so it does not read as dropping a working Node. With default npm settings it produces an EBADENGINE warning, not an install failure.' + evidence: '`CHANGELOG.md` — `[Unreleased]` → `### Changed` (Node ≥ 20.13.0, and why)' +``` + +### Checked, no finding + +- `package-lock.json` regeneration: the registry is reachable from this environment (`npm view` + works), so `npm install --package-lock-only` is feasible. Only the root `engines` mirror should + change. +- Renaming the smoke job: no test or floor pin reads `node-floor.yml`'s job name (only comments + in `check-run-pins.test.mjs` mention the file), and the job is not a required check. +- Trust (P2), axis (P3), determinism (P5): no concerns. The test reads dependency files as text. + +## Summary + +The plan is small and grounded in a measurement. The three concerns are sharpening only: make the +scan regex slightly wider and state its reach, prove the scanner with a planted fixture, and word +the CHANGELOG entry honestly. + +**ADVISORY VERDICT: 3 concerns raised (0 blocking-severity, 3 minor) — for the human to weigh +before /pharn-dev-build.** diff --git a/.dev/features/engines-styletext-floor/PLAN.md b/.dev/features/engines-styletext-floor/PLAN.md new file mode 100644 index 0000000..33a6a1f --- /dev/null +++ b/.dev/features/engines-styletext-floor/PLAN.md @@ -0,0 +1,103 @@ +# PLAN — engines-styletext-floor (the Node floor is what the prompt library's code needs, not what it declares) + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 +- increment: raise `engines.node` from `>=20.12.0` to `>=20.13.0`. `@clack/prompts` 1.8.1 passes + an ARRAY of formats to `util.styleText`, and Node accepts that only from 20.13.0. Make + `tests/engines.test.ts` check the dependencies' CODE (a known-API table scanned over their shipped + files), not only their declared `engines`. Pin the smoke workflow's `FLOOR` to `package.json`. + Correct every doc that repeats 20.12.0. +- layer(s): package metadata, CI (`node-floor.yml`), tests, docs +- constitution_refs: [P0, P1, P6, P7] + +## Discovery — verified this run (P6) + +- Measured here with official binaries: `styleText(['strikethrough','dim'], 'x')` throws + `ERR_INVALID_ARG_VALUE` on Node **20.12.0** and returns the styled string on **20.13.0**. +- `node_modules/@clack/prompts/dist/index.mjs` (1.8.1) has 21 array-form `styleText([...])` calls (and `@clack/core` 1.5.1 has 2 more), + most of them in the `cancelled` render state (`['strikethrough','dim']`). So every prompt the CLI + shows crashes on 20.12.x when cancelled: init's select/confirm, update's confirm, and the + add/remove `groupMultiselect` pickers. The review reproduced this in a pty: exit 1 with a stack + trace instead of "Cancelled" and exit 0. The pickers use `required: false`, so the one array call + in the empty-submit validation message is not reached. +- `@clack/prompts` and `@clack/core` both declare `"node": ">= 20.12.0"`. That declaration is wrong, + and `tests/engines.test.ts` trusts it: it only compares declared ranges. +- The CLI's own `src/**` uses no Node API newer than 20.13. A grep for `Promise.withResolvers`, + `Object.groupBy`, Set methods, `fromAsync`, `getBuiltinModule`, `globSync`, `styleText`, + `import.meta.dirname` and similar found nothing. esbuild targets `node20` (`scripts/build.mjs:14`). +- `node-floor.yml` has `FLOOR: 20.12.0` and job name `Smoke (node 20.12.0)`. It is NOT a required + check (CLAUDE.md; ci.yml:17-21). Only a comment asks to keep `FLOOR` equal to `package.json`'s + bound, and no test pins that. No test pins the job name. +- 20.12.0 is also stated in: `package.json:30`, `README.md:20` (badge, pinned by + engines.test.ts) and `README.md:315-317`, `SECURITY.md:7`, `CLAUDE.md:28`, `ci.yml:19-21` + (comment), `node-floor.yml:3-14,30,33`, `docs/contributing.md:56`, `docs/getting-started.md:11`, + `docs/troubleshooting.md:107`, and `tests/engines.test.ts:7,13` (comments). The mentions under + `.dev/features/*` are history and stay as they are. + +## Files + +- `package.json` — `engines.node: ">=20.13.0"` — layer metadata +- `package-lock.json` — the root package's mirrored `engines` field, regenerated with + `npm install --package-lock-only` (never hand-edited) — layer metadata +- `tests/engines.test.ts` — layer tests. Three additions: + - A known-API table (the array form of `styleText` → 20.13.0, with the reason), scanned over + every runtime dependency's shipped `.js`/`.mjs`/`.cjs` files, found by the same transitive + walk. Ours must be ≥ every floor whose pattern matches. The scanner is also proven on planted + fixture files (grill finding 2). + - A non-vacuity check: the scan read at least one file from `@clack/prompts`. + - The smoke workflow's `FLOOR` equals `package.json`'s lower bound. +- `.github/workflows/node-floor.yml` — `FLOOR: 20.13.0`; job name per open question 1; comments + updated with the styleText-array reason — layer CI +- `.github/workflows/ci.yml` — header comment only (the floor version and the smoke job's name) — + layer CI +- `README.md` — badge and "Current scope" bullet — layer docs +- `SECURITY.md` — `engines.node >= 20.13.0` — layer docs +- `CLAUDE.md` — the CI paragraph (floor, smoke name, and that engines.test.ts also scans dependency + code) — layer docs +- `docs/contributing.md` — the engines row — layer docs +- `docs/getting-started.md` — the Node row — layer docs +- `docs/troubleshooting.md` — the prerequisites paragraph, plus the new symptom: on 20.12.x, + cancelling a prompt fails with `ERR_INVALID_ARG_VALUE` — layer docs +- `CHANGELOG.md` — `[Unreleased]` → `### Changed` (Node ≥ 20.13.0, and why) — layer docs + +## Contracts satisfied + +- PHARN-06's own contract ("the declared floor is never below what the runtime dependencies need"). + It now holds for what they NEED, not only what they declare (cited, P4). + +## Evals to write (P1) + +- `engines.test.ts` "not below what runtime dependency code needs" → FAILS on the base (@clack/prompts + uses the array form, and `>=20.12.0` < 20.13.0). Passes after. +- `engines.test.ts` "smoke FLOOR equals package.json" → passes on the base (both are 20.12.0). It + guards the pair against drifting apart later. +- The existing README-badge test → FAILS if the badge is not updated. + +## Guarantee audit (P0) + +- "`engines` is ≥ every KNOWN code-level floor of the installed runtime dependencies" → floor: a + regex scan plus a version compare (a blocking vitest). Its reach is exactly the table, and the + plan says so. A newer API the table does not name is not caught. That residual stays with the + (advisory, non-required) smoke job. +- "the packed CLI starts on exactly the floor" → advisory. It is a non-required CI job. +- "the smoke job runs the floor `package.json` declares" → floor: the new equality test. + +## Trust audit (P2) + +- No untrusted input. The test reads installed dependency files as text and never executes them. + +## Determinism audit (P5) + +- Regex membership and integer compares only. + +## Open questions (HALT) + +None open. Resolved at GATE 1 (human, 2026-09-25): every question below → **(a)**, the +recommended answer. Kept for the record: + +1. The smoke job's name. (a) `Smoke (node floor)`: stable across future bumps, with the version + only in `FLOOR` — recommended. (b) `Smoke (node 20.13.0)`: renamed again on every bump. The job + is not required today, so either rename is safe. Only (a) stays safe if a maintainer later + makes it required. +2. Should the smoke job also drive a real cancelled prompt? (a) No. The unit scan covers the failure + that actually happened, and driving a prompt in CI needs a pty, which is flaky — recommended. + (b) Yes: add a `script(1)`-driven Ctrl-C on a prompt. diff --git a/.dev/features/engines-styletext-floor/REGRESSION.md b/.dev/features/engines-styletext-floor/REGRESSION.md new file mode 100644 index 0000000..dde8aff --- /dev/null +++ b/.dev/features/engines-styletext-floor/REGRESSION.md @@ -0,0 +1,40 @@ +# REGRESSION — engines-styletext-floor + +The verdict below is computed by `.dev/floor/check-regress.mjs`, not by this stage's judgment. + +## Base and partition + +- **base:** `abb274acbef59967d26068baa38676f1033ff182` (`HEAD` — `origin/main` after #218; the build is + an uncommitted working tree on top of it). +- **inside** (each declared in `PLAN.md` `## Files`): `package.json`, `package-lock.json`, + `tests/engines.test.ts`, `.github/workflows/node-floor.yml`, `.github/workflows/ci.yml`, + `README.md`, `SECURITY.md`, `CLAUDE.md`, `docs/contributing.md`, `docs/getting-started.md`, + `docs/troubleshooting.md`, `CHANGELOG.md`. +- **scope partition:** `check-regress.mjs scope` exited **0**, `escaped: []`. `.pharn/` (hook + scratch) and this feature's own stage artifacts are not build output. +- **outside gates:** the 46 stdlib `*.test.mjs` / `*.test.cjs` files `scope` returned (754 tests) + + whole-repo `validate`; 0 committed eval pairs. +- **style-gate skip:** `inside` touches no shared style config, so `lint` / `format:check` / + `lint:md` are absent from both maps. +- **environment:** both sides ran with no proxy variables and as root **without** + `CAP_DAC_OVERRIDE` / `CAP_DAC_READ_SEARCH` / `CAP_FOWNER` (`setpriv`), the CI-equivalent of this + root sandbox. + +## Per-gate exit codes + +| gate | base | head | flipped? | +| ---------- | ---- | ---- | -------- | +| `tests` | 0 | 0 | no | +| `validate` | 0 | 0 | no | + +- `regressions[]`: **empty** +- `pre_existing[]`: **empty** + +## Verdict + +**REGRESSIONS: none — no deterministically-detectable breakage outside the feature.** +(`regression-report.json` `.verdict` = `no-regressions`.) + +Residual (P0/P7): this catches exactly what its suite catches. The vitest suite exercising `src/**` +and `tests/**` is owned by `/pharn-dev-build`'s floor and `/pharn-dev-verify`. This certifies the +comparison, never the increment. diff --git a/.dev/features/engines-styletext-floor/REVIEW.md b/.dev/features/engines-styletext-floor/REVIEW.md new file mode 100644 index 0000000..4e114b9 --- /dev/null +++ b/.dev/features/engines-styletext-floor/REVIEW.md @@ -0,0 +1,61 @@ +# REVIEW — engines-styletext-floor + +Increment: + +- `package.json` / `package-lock.json`: `engines.node` `>=20.12.0` → `>=20.13.0`. The lockfile was + regenerated with npm 11.20.0, and the only change is the root `engines` line. +- `tests/engines.test.ts`: a code-level floor scan (`KNOWN_API_FLOORS`) over the runtime + dependencies' shipped JS, a non-vacuity case, a smoke-`FLOOR` equality case, and three + planted-text scanner cases. +- `node-floor.yml`: `FLOOR: 20.13.0`, job `Smoke (node floor)`. +- The version is corrected across the docs, plus a `### Changed` CHANGELOG entry. + +Treated as `trust: untrusted`; nothing in it read as an instruction. + +## Floor first (P0) + +`node .dev/floor/validate.mjs .` → `FLOOR: GREEN` (exit 0). `/pharn-dev-build`'s `npm run check` +passed with exit 0 (1513 tests), `/pharn-dev-regress` returned `no-regressions`, and +`/pharn-dev-verify` returned `PASS`. + +## Floor-gate findings (blocking) + +None. + +- **L-floor (P0)** + - "`engines` is ≥ every known code-level floor of the installed runtime dependencies" → a regex + scan plus a version compare in a blocking vitest. Its reach is stated in the test comment: + literal name only, and only the rows the table names. + - "the smoke job runs the declared floor" → the equality case. + - "the packed CLI starts on the floor" → still advisory (a non-required job), labeled as such in + the workflow and the docs. +- **L-eval (P1)** + - The code-floor case FAILED on the base `package.json`, run before the fix, with the message + "…needs node >= 20.13.0), package.json says `>=20.12.0`". + - The planted-text cases prove the scanner both fires and stays quiet (grill finding 2). + - The non-vacuity case pins that `@clack/prompts` was actually read. +- **L-trust (P2)** — dependency files are read as text and never executed. No untrusted input is + involved. +- **L-axis (P3)** — the test file keeps its one axis, the declared Node floor. The workflow and doc + edits are version text only. + +## Advisory findings (warn — severity is this reviewer's judgment, fix #3) + +```yaml +- type: FINDING + rule_id: 'P0' + severity: minor + file: 'tests/engines.test.ts:112' + problem: 'shippedJs scans every .js/.mjs/.cjs file a dependency ships, including files that are not runtime code (minimist ships test/ and example/). A match there would raise the required floor over code pharn never runs. That errs toward a higher floor and would be visible in the failure message, so it is kept, not filtered.' + evidence: 'function shippedJs(dir: string): string[] {' +- type: FINDING + rule_id: 'P7' + severity: minor + file: 'tests/engines.test.ts:94' + problem: 'The table has one row, the failure actually measured. Future rows need the same measurement, on the boundary Node versions, before they are added, or the table turns into a guess list. Stated in the comment ("whose minimum version was measured").' + evidence: 'const KNOWN_API_FLOORS: {' +``` + +## Verdict + +**GREEN — 0 floor-gate findings, 2 advisory (minor).** The standing decision is the human's (GATE 2). diff --git a/.dev/features/engines-styletext-floor/SHIP.md b/.dev/features/engines-styletext-floor/SHIP.md new file mode 100644 index 0000000..21b42d8 --- /dev/null +++ b/.dev/features/engines-styletext-floor/SHIP.md @@ -0,0 +1,19 @@ +# SHIP — engines-styletext-floor + +Stages run, in order: `/pharn-dev-plan` → GATE 1 (human: plans A–F accepted with every recommended +answer — "Approve all") → `/pharn-dev-grill` → plan `## Files` reworded so the writes-scope parser +reads every path (formatting only; intent unchanged) → `/pharn-dev-build` → `/pharn-dev-regress` → +`/pharn-dev-verify` → `/pharn-dev-review` → GATE 2. + +| stage | structural verdict (verbatim) | +| -------------------- | ------------------------------------------------------ | +| `/pharn-dev-build` | `node .dev/floor/validate.mjs .` exit `0` | +| `/pharn-dev-regress` | `regression-report.json` `.verdict` = `no-regressions` | +| `/pharn-dev-verify` | `verify-report.json` `.verdict` = `PASS` | + +- Review: [`REVIEW.md`](REVIEW.md) · Grill (advisory): [`GRILL.md`](GRILL.md) +- The run ended at **GATE 2**. The human's standing instruction for this batch: after each + increment, open a pull request and merge it once its checks are green, then start the next plan. + +chain ran; the named floor verdicts are as shown — this is NOT a judgment that the increment is good or +wise; that is the human's call at the post-review gate. diff --git a/.dev/features/engines-styletext-floor/VERIFY.md b/.dev/features/engines-styletext-floor/VERIFY.md new file mode 100644 index 0000000..e4e3495 --- /dev/null +++ b/.dev/features/engines-styletext-floor/VERIFY.md @@ -0,0 +1,46 @@ +# VERIFY — engines-styletext-floor + +## FLOOR layer (owns the verdict) + +The gates ran over the whole repo with the feature present, on node 22, with the session proxy +variables unset. They ran as root **without** `CAP_DAC_OVERRIDE` / `CAP_DAC_READ_SEARCH` / +`CAP_FOWNER` (`setpriv`), the CI-equivalent of this root sandbox. + +| gate | exit | +| -------------- | ---- | +| `format:check` | 0 | +| `lint` | 0 | +| `lint:md` | 0 | +| `test` | 0 | +| `test:floor` | 0 | +| `typecheck` | 0 | +| `validate` | 0 | + +- `test` is vitest (1513 tests). It collects this increment's own tests: `tests/engines.test.ts`, + whose code-floor case fails on the base `>=20.12.0`. +- `test:floor` is floor.yml's `node --test` run (754 tests). +- There is no `structural:*` gate: the increment ships no eval-actual pair. + +**VERDICT: PASS** (`.dev/floor/check-verify.mjs`, `failing_gates: []`). + +Outside the verdict, the packed CLI was driven in a 120×40 pty through `pharn remove`'s picker on +the official binaries. Each cell shows the exit code and what printed: + +| keys | Node 20.12.0 | Node 20.13.0 | +| ------------------------------ | ------------------------------ | -------------- | +| Ctrl-C at the picker | 0, "Cancelled" | 0, "Cancelled" | +| Space, Ctrl-C (a selection) | **1, `ERR_INVALID_ARG_VALUE`** | 0, "Cancelled" | +| Space, Enter, Ctrl-C (confirm) | **1, `ERR_INVALID_ARG_VALUE`** | 0, "Cancelled" | + +`--version` runs on both, which is why the smoke job alone could never catch this. + +## ADVISORY layer + +`node .dev/floor/count-verifiers.mjs .` → `{"registered":0,"verifiers":[]}`. No verifiers are +registered, so the verdict rests on the floor gates only. + +Residual (P0/P7): verified = the named gates passed; this is NOT a guarantee of correctness beyond +what those gates check — verifier concerns are advisory help, not assurance. + +The code scan sees only the API usages its table names, called by their literal name. The +non-required smoke job is the only runtime check at the floor, and it does not drive a prompt. diff --git a/.dev/features/engines-styletext-floor/regression-report.json b/.dev/features/engines-styletext-floor/regression-report.json new file mode 100644 index 0000000..124c321 --- /dev/null +++ b/.dev/features/engines-styletext-floor/regression-report.json @@ -0,0 +1,30 @@ +{ + "base": "abb274acbef59967d26068baa38676f1033ff182", + "inside": [ + ".github/workflows/ci.yml", + ".github/workflows/node-floor.yml", + "CHANGELOG.md", + "CLAUDE.md", + "README.md", + "SECURITY.md", + "docs/contributing.md", + "docs/getting-started.md", + "docs/troubleshooting.md", + "package-lock.json", + "package.json", + "tests/engines.test.ts" + ], + "outside_gates": { + "tests": { + "base": 0, + "head": 0 + }, + "validate": { + "base": 0, + "head": 0 + } + }, + "regressions": [], + "pre_existing": [], + "verdict": "no-regressions" +} diff --git a/.dev/features/engines-styletext-floor/verify-report.json b/.dev/features/engines-styletext-floor/verify-report.json new file mode 100644 index 0000000..e124d24 --- /dev/null +++ b/.dev/features/engines-styletext-floor/verify-report.json @@ -0,0 +1,18 @@ +{ + "feature": "engines-styletext-floor", + "gates": { + "format:check": 0, + "lint": 0, + "lint:md": 0, + "test": 0, + "test:floor": 0, + "typecheck": 0, + "validate": 0 + }, + "verdict": "PASS", + "failing_gates": [], + "verifiers": { + "registered": 0, + "findings": [] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e0514a..f48f9af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,9 +16,9 @@ name: ci # # Coverage is deliberately one platform, one Node version (see # `.dev/features/ci-matrix-required-checks/PLAN.md`). Note that `package.json` -# declares `engines.node: ">=20.12.0"` while these gates run node 24 — the lower +# declares `engines.node: ">=20.13.0"` while these gates run node 24 — the lower # end of that published range is exercised only by the separate, non-required -# `Smoke (node 20.12.0)` job in `node-floor.yml`, never by these gates. +# `Smoke (node floor)` job in `node-floor.yml`, never by these gates. on: pull_request: diff --git a/.github/workflows/node-floor.yml b/.github/workflows/node-floor.yml index 63be5a2..a3c7fe1 100644 --- a/.github/workflows/node-floor.yml +++ b/.github/workflows/node-floor.yml @@ -2,16 +2,21 @@ name: node-floor # Starts the PACKED CLI on exactly the Node version `engines.node` declares as # its floor. `package.json` once said ">=20" while @clack/prompts — a runtime -# dependency — needs 20.12.0 (it imports `styleText` from node:util), so on -# 20.0–20.11 even `pharn --version` died at load time and nothing caught it: -# the six gates in ci.yml run node 24 only. +# dependency — imports `styleText` from node:util (20.12.0+), so on 20.0–20.11 +# even `pharn --version` died at load time and nothing caught it: the six gates +# in ci.yml run node 24 only. The floor is 20.13.0, not 20.12.0, because clack +# also passes `styleText` an ARRAY of formats, which Node accepts only from +# 20.13.0 — on 20.12.x cancelling a confirm prompt crashed. `--version` does not +# reach that code, so tests/engines.test.ts is what catches it: it scans the +# dependencies' shipped code for known version-gated API uses. # # A SEPARATE workflow on purpose. ci.yml's six job names are a contract with the # `main` branch ruleset (tests/ci-workflow.test.ts); matrixing one of them would # rename its reported context and block every PR. This job's context -# (`Smoke (node 20.12.0)`) is additional and NOT required unless a maintainer -# adds it to the ruleset. Keep FLOOR below equal to package.json's lower bound -# (tests/engines.test.ts pins that bound against the runtime dependencies). +# (`Smoke (node floor)`) is additional and NOT required unless a maintainer adds +# it to the ruleset. The name carries no version so a floor bump never renames +# it. FLOOR below must equal package.json's lower bound — tests/engines.test.ts +# pins that equality. # # Build on node 24 (the toolchain's own floor is higher), then install the # tarball — runtime dependencies are external to the bundle (scripts/build.mjs), @@ -27,10 +32,10 @@ permissions: jobs: smoke-node-floor: - name: Smoke (node 20.12.0) + name: Smoke (node floor) runs-on: ubuntu-latest env: - FLOOR: 20.12.0 + FLOOR: 20.13.0 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index 9c0d566..fb5e879 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,7 +1,7 @@ { "scope": [ - ".dev/features/tar-entry-names/SHIP.md" + ".dev/features/engines-styletext-floor/REVIEW.md" ], - "set_by": ".claude/commands/pharn-dev-ship.md", - "set_at": "2026-09-24T19:32:56.865Z" + "set_by": ".claude/commands/pharn-dev-review.md", + "set_at": "2026-09-25T05:06:19.849Z" } diff --git a/CHANGELOG.md b/CHANGELOG.md index d6c0c89..b421ea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Node 20.13.0 or newer is now required (`engines.node: ">=20.13.0"`).** On Node 20.12.x, cancelling a `pharn` confirmation (Esc or Ctrl-C), or a picker after selecting something, crashed with `ERR_INVALID_ARG_VALUE`, exit 1 and a stack trace, instead of "Cancelled" and exit 0. The prompt library passes `util.styleText` an array of formats, which Node accepts only from 20.13.0, although the library itself declares `>= 20.12.0`. No project was ever changed at that point, but 20.12.x never worked properly, so this drops no working setup. `tests/engines.test.ts` now also scans the runtime dependencies' shipped code for known version-gated Node APIs, rather than trusting their declared floor. The smoke job that starts the packed CLI on the floor Node is now named `Smoke (node floor)`. + ### Fixed - **Releases could not publish: the `Pack` step wrote into a directory nothing had created.** Since the release workflow was split into an unprivileged `build` job and a `publish` job, `build` ran `npm pack --pack-destination "$RUNNER_TEMP/pkg"` without creating `pkg/`, and npm does not create it (`ENOENT` on npm 10 and 11). Every Release run would have failed at `Pack` and never reached `publish`. The step now runs `mkdir -p` first, and a live test pins that every workflow's pack destination is created earlier in the job that packs. diff --git a/CLAUDE.md b/CLAUDE.md index eef77ed..ebd747f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ npm run check # format:check + lint + lint:md + typecheck + test npx vitest run tests/install-capabilities.test.ts # single test file ``` -CI (`.github/workflows/ci.yml`) runs six gates independently — format:check, lint, lint:md, typecheck, test (via `test:coverage`), and build — all must pass. Each gate is its **own job**, so each reports its **own status check** and one red gate can neither mask nor be masked by another (this replaced a single `check` job whose six steps shared one context and leaned on `if: always()`). The six job `name:` values — `Format check`, `Lint`, `Markdown lint`, `Typecheck`, `Test`, `Build` — are a **contract with the `main` branch ruleset**, which lists exactly those strings in `required_status_checks` (alongside `floor`, `gitleaks`, and `Analyze (javascript-typescript)` from the other workflows): GitHub reports a job under its `name:`, so renaming one makes a required check unreportable and blocks every PR on a context nothing produces. `tests/ci-workflow.test.ts` pins the names, their npm scripts, and the runner/node pair; it cannot see the ruleset, so the other half of that invariant stays advisory. Gates run on **ubuntu-latest / node 24 only**, so two support claims are wider than what is tested: `engines.node: ">=20.12.0"` (the test suite never runs on Node 20/22; a separate, non-required `Smoke (node 20.12.0)` workflow, `node-floor.yml`, starts the packed CLI on exactly the floor, and `tests/engines.test.ts` fails if a runtime dependency — today `@clack/*`, which imports `styleText` — declares a higher one) and the absence of an `os` field (Windows implied; the `win32` branches of `toPosix`/`symlink-guard` never run in CI). Both are documented in `docs/contributing.md`. Closing either by **matrixing one of the six jobs would rename its reported context and block every PR** — a separate, additionally-named job is the only safe shape. A second workflow, `.github/workflows/publish.yml`, publishes to npm on a published GitHub Release — see **Releasing** below. `PHARN_DEBUG=1` enables full error output for fetch/install failures. +CI (`.github/workflows/ci.yml`) runs six gates independently — format:check, lint, lint:md, typecheck, test (via `test:coverage`), and build — all must pass. Each gate is its **own job**, so each reports its **own status check** and one red gate can neither mask nor be masked by another (this replaced a single `check` job whose six steps shared one context and leaned on `if: always()`). The six job `name:` values — `Format check`, `Lint`, `Markdown lint`, `Typecheck`, `Test`, `Build` — are a **contract with the `main` branch ruleset**, which lists exactly those strings in `required_status_checks` (alongside `floor`, `gitleaks`, and `Analyze (javascript-typescript)` from the other workflows): GitHub reports a job under its `name:`, so renaming one makes a required check unreportable and blocks every PR on a context nothing produces. `tests/ci-workflow.test.ts` pins the names, their npm scripts, and the runner/node pair; it cannot see the ruleset, so the other half of that invariant stays advisory. Gates run on **ubuntu-latest / node 24 only**, so two support claims are wider than what is tested: `engines.node: ">=20.13.0"` (the test suite never runs on Node 20/22; a separate, non-required `Smoke (node floor)` workflow, `node-floor.yml`, starts the packed CLI on exactly the floor — its `FLOOR` is pinned equal to `package.json`'s bound — and `tests/engines.test.ts` fails if a runtime dependency declares a higher floor OR its shipped code calls a Node API newer than ours, via a measured known-API table: today `@clack/*` passes `styleText` an ARRAY of formats, which Node accepts only from 20.13.0, and on 20.12.x cancelling a confirm prompt crashed although clack declares `>= 20.12.0`) and the absence of an `os` field (Windows implied; the `win32` branches of `toPosix`/`symlink-guard` never run in CI). Both are documented in `docs/contributing.md`. Closing either by **matrixing one of the six jobs would rename its reported context and block every PR** — a separate, additionally-named job is the only safe shape. A second workflow, `.github/workflows/publish.yml`, publishes to npm on a published GitHub Release — see **Releasing** below. `PHARN_DEBUG=1` enables full error output for fetch/install failures. ## Releasing diff --git a/README.md b/README.md index 68443ed..c46438e 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ chat window. [![CI](https://github.com/pharn-dev/pharn-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/pharn-dev/pharn-cli/actions/workflows/ci.yml) [![CodeQL](https://github.com/pharn-dev/pharn-cli/actions/workflows/codeql.yml/badge.svg)](https://github.com/pharn-dev/pharn-cli/actions/workflows/codeql.yml) [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-green)](./LICENSE) -[![Node](https://img.shields.io/badge/node-%3E%3D20.12.0-brightgreen)](./package.json) +[![Node](https://img.shields.io/badge/node-%3E%3D20.13.0-brightgreen)](./package.json) ```bash npx @pharn-dev/pharn@latest init @@ -312,9 +312,9 @@ PHARN is intentionally scoped: - It targets **Claude Code today**. Codex and Cursor support are planned, not shipped. -- It requires a git-initialized project and Node >= 20.12.0 (the floor its +- It requires a git-initialized project and Node >= 20.13.0 (the floor its prompt library needs). CI runs on Node 24, and a smoke job starts the packed - CLI on exactly Node 20.12.0. + CLI on exactly Node 20.13.0. - **Archetype detection is JS/TS-shaped.** The signals are `package.json` dependency names plus `next.config.*`, `app/` route handlers, `.tsx`/`.jsx`, `migrations/` and `.sql`. A Python, Go or Rust repo produces no signal, diff --git a/SECURITY.md b/SECURITY.md index 4c12ee7..f4b79e5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ PHARN is an audit-grade methodology — taking security seriously is part of the ## What `pharn` is, and its security surface -This repository **is `pharn`** — an ESM-only Node CLI (`"type": "module"`, NodeNext, `engines.node >= 20.12.0`) that detects a project's **archetype(s)**, downloads `pharn-dev/pharn-oss` as a tarball from `codeload.github.com` and extracts it into a temp directory with its own reader (`src/lib/tar-extract.ts`), derives the capability index from that tree's frontmatter, copies the resolved capabilities plus fixed product surfaces into the user's project — the `.claude/` command/hook surfaces, the mirrored capability dirs (`pharn-pipeline/grillers/`, `pharn-review/`), and the trusted docs / `pharn-contracts/` / `.dev/floor/` product surfaces at the project root — and writes `pharn.config.json` and `pharn.records.json`. Under the current `pharn` layout the capability dirs, contracts, `pharn-core`, the floor (`pharn/floor/`, **not** `pharn/.dev/floor/`), `CONSTITUTION.md` and `ARCHITECTURE.md` move under `pharn/`, while `THREAT-MODEL.md`, `LIMITS.md`, `features/README.md` and `.claude/` stay at the project root. There is **no fetch, unpack, or cache dependency**: `pharn` spawns no `git` binary and keeps no tarball cache, so every fetch is a fresh download into a fresh temp directory, removed on success, cancel and error alike. There is no module catalog, no `manifest.json` fetch, and no wizard questionnaire. It has a small, thin dependency set — **three** runtime dependencies (`@clack/prompts`, `minimist`, `picocolors`) — no bundled runtime services, and no telemetry. Its security-relevant surface is exactly the two things that cross a trust boundary: **remote input** (the pharn-oss tarball and its extraction, the lightweight `SKILLS_VERSION` / commit-SHA fetches, and every name read from the untrusted tree) and **file-system writes** (everything it copies into the project — the `.claude/` surfaces, the mirrored capability dirs, the root/`pharn/` product surfaces, and upstream's Apache-2.0 `LICENSE` at the one deliberate source-differs-from-destination mapping, `pharn/LICENSE` or `PHARN-LICENSE` — plus the `pharn.config.json`, `pharn.records.json`, `.pharn.lock` and `.pharn-backup//` files it writes). See `THREAT-MODEL.md` for the trust-boundary map — its §2 describes this fetch boundary in full — and `CLAUDE.md` for the command architecture. +This repository **is `pharn`** — an ESM-only Node CLI (`"type": "module"`, NodeNext, `engines.node >= 20.13.0`) that detects a project's **archetype(s)**, downloads `pharn-dev/pharn-oss` as a tarball from `codeload.github.com` and extracts it into a temp directory with its own reader (`src/lib/tar-extract.ts`), derives the capability index from that tree's frontmatter, copies the resolved capabilities plus fixed product surfaces into the user's project — the `.claude/` command/hook surfaces, the mirrored capability dirs (`pharn-pipeline/grillers/`, `pharn-review/`), and the trusted docs / `pharn-contracts/` / `.dev/floor/` product surfaces at the project root — and writes `pharn.config.json` and `pharn.records.json`. Under the current `pharn` layout the capability dirs, contracts, `pharn-core`, the floor (`pharn/floor/`, **not** `pharn/.dev/floor/`), `CONSTITUTION.md` and `ARCHITECTURE.md` move under `pharn/`, while `THREAT-MODEL.md`, `LIMITS.md`, `features/README.md` and `.claude/` stay at the project root. There is **no fetch, unpack, or cache dependency**: `pharn` spawns no `git` binary and keeps no tarball cache, so every fetch is a fresh download into a fresh temp directory, removed on success, cancel and error alike. There is no module catalog, no `manifest.json` fetch, and no wizard questionnaire. It has a small, thin dependency set — **three** runtime dependencies (`@clack/prompts`, `minimist`, `picocolors`) — no bundled runtime services, and no telemetry. Its security-relevant surface is exactly the two things that cross a trust boundary: **remote input** (the pharn-oss tarball and its extraction, the lightweight `SKILLS_VERSION` / commit-SHA fetches, and every name read from the untrusted tree) and **file-system writes** (everything it copies into the project — the `.claude/` surfaces, the mirrored capability dirs, the root/`pharn/` product surfaces, and upstream's Apache-2.0 `LICENSE` at the one deliberate source-differs-from-destination mapping, `pharn/LICENSE` or `PHARN-LICENSE` — plus the `pharn.config.json`, `pharn.records.json`, `.pharn.lock` and `.pharn-backup//` files it writes). See `THREAT-MODEL.md` for the trust-boundary map — its §2 describes this fetch boundary in full — and `CLAUDE.md` for the command architecture. The CLI's security model is **deterministic, not model-driven**: it never asks an AI to decide what is safe. Every value that arrives from the network or the fetched tree is validated against strict regex/enum allowlists (`src/lib/validate.ts`), rejected for `..` and control characters, and every copy is confined with a `safeJoin` guard plus symlink rejection at the write sites (`src/lib/install-capabilities.ts`) so nothing can escape its intended target — checks that hold regardless of what the fetched content says. Preserve that shape: a security fix that relies on "the content will be well-behaved" is not a fix. diff --git a/docs/contributing.md b/docs/contributing.md index 17563be..9be105a 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -51,10 +51,10 @@ Each gate is a **separate job**, so it reports its own status check and a failur Gates run on **ubuntu-latest with Node 24 only**. Two support claims are therefore wider than what CI tests, and both are deliberate — stated here rather than quietly implied: -| Claim | Tested | Notes | -| ---------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `engines.node: ">=20.12.0"` | Node 24 + a smoke job on 20.12.0 | The separate `Smoke (node 20.12.0)` workflow (`.github/workflows/node-floor.yml`, not a required check) installs the packed CLI on exactly the floor and runs `--version`/`--help`; the test suite itself runs on Node 24 only. `tests/engines.test.ts` fails if a runtime dependency declares a higher floor | -| No `os` field, so Windows is implied supported | ubuntu only | The `win32` branches of `toPosix` (`src/lib/validate.ts`) and the separator handling in `symlink-guard` never execute in CI — `tests/symlink-guard.test.ts` calls this out as PLATFORM-LATENT | +| Claim | Tested | Notes | +| ---------------------------------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `engines.node: ">=20.13.0"` | Node 24 + a smoke job on 20.13.0 | The separate `Smoke (node floor)` workflow (`.github/workflows/node-floor.yml`, not a required check) installs the packed CLI on exactly the floor and runs `--version`/`--help`; the test suite itself runs on Node 24 only. `tests/engines.test.ts` fails if a runtime dependency declares a higher floor, or if its shipped code uses a Node API newer than ours from a measured list (today: `util.styleText` with an array of formats, 20.13.0+), and pins the smoke job's `FLOOR` to `package.json` | +| No `os` field, so Windows is implied supported | ubuntu only | The `win32` branches of `toPosix` (`src/lib/validate.ts`) and the separator handling in `symlink-guard` never execute in CI — `tests/symlink-guard.test.ts` calls this out as PLATFORM-LATENT | **Do not close either gap by adding a `strategy.matrix` to one of the six existing jobs.** GitHub renders a matrixed job's context as ` ()`, so `Test` would stop being reported and every PR would hang blocked on a required context nothing produces — the exact incident [`tests/ci-workflow.test.ts`](../tests/ci-workflow.test.ts) exists to prevent. A separate, additionally-named job (`Test (node 20)`, `Test (windows)`) leaves the six required contexts byte-identical and is the safe shape. diff --git a/docs/getting-started.md b/docs/getting-started.md index 87d5ee9..e16beba 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -8,7 +8,7 @@ PHARN does not scaffold your app. You create your project (e.g. with `create-nex | -------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------- | | Git | A `.git` directory exists in the project root | Always — checked up front, before detection | | Interactive terminal | `process.stdin`/`stdout` are TTYs | Right after the git check, before any fetch | -| Node | `engines.node` declares `>=20.12.0` (CI exercises Node 24; a smoke job starts the CLI on 20.12.0) | By npm/npx when the package is resolved | +| Node | `engines.node` declares `>=20.13.0` (CI exercises Node 24; a smoke job starts the CLI on 20.13.0) | By npm/npx when the package is resolved | `.git` is required for every install. So is a real terminal: `pharn init` **exits 1** rather than rendering a prompt into a dead stream, and there is deliberately no `--yes` for it — its second prompt diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f00df7b..d0443c6 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -104,8 +104,12 @@ directory (or move it aside) and re-run. ## Prerequisites failed -`pharn init` has three prerequisites — a git repository, an interactive terminal, and Node >= 20.12.0. On an older Node 20 even -`pharn --version` fails at load time with `does not provide an export named 'styleText'` — upgrade Node. +`pharn init` has three prerequisites — a git repository, an interactive terminal, and Node >= 20.13.0. On an older Node 20 even +`pharn --version` fails at load time with `does not provide an export named 'styleText'` — upgrade Node. On +Node 20.12.x the CLI starts, but cancelling a confirmation (Esc or Ctrl-C), or a picker after selecting something, crashes with +`ERR_INVALID_ARG_VALUE … Received [ 'strikethrough', 'dim' ]` and a stack trace instead of exiting cleanly: the +prompt library passes `styleText` an array of formats, which Node accepts only from 20.13.0. Nothing was +written at that point — upgrade Node. There is no stack-pack or package prerequisite: archetype detection reads `package.json` names and the file tree, and installs whatever capabilities apply. diff --git a/package-lock.json b/package-lock.json index fdd2776..ceedf97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "vitest": "^5.0.0" }, "engines": { - "node": ">=20.12.0" + "node": ">=20.13.0" } }, "node_modules/@babel/helper-string-parser": { diff --git a/package.json b/package.json index 0792569..1a60856 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "dist" ], "engines": { - "node": ">=20.12.0" + "node": ">=20.13.0" }, "publishConfig": { "access": "public", diff --git a/tests/engines.test.ts b/tests/engines.test.ts index b21e93c..a5a8cb7 100644 --- a/tests/engines.test.ts +++ b/tests/engines.test.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; @@ -8,9 +8,12 @@ import { describe, expect, it } from 'vitest'; // node:util, so on Node 20.0–20.11 even `pharn --version` died at load time. // The CLI's declared floor can never be lower than any runtime dependency's. // +// A DECLARED floor can itself be wrong, which is why the second half of this +// file reads the dependencies' shipped code as well (see KNOWN_API_FLOORS). +// // Reads the INSTALLED manifests (what the lockfile resolves). A consumer // resolving the ^ ranges fresh may get a newer dependency with a higher floor — -// the separate `Smoke (node 20.12.0)` workflow is the runtime check for that. +// the separate `Smoke (node floor)` workflow is the runtime check for that. const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const readJson = (p: string) => @@ -41,9 +44,13 @@ function findInstalled(from: string, dep: string): string { } } -/** Every runtime dependency (transitively) with its declared engines.node. */ -function runtimeFloors(): { name: string; range: string }[] { - const out: { name: string; range: string }[] = []; +/** Every installed runtime dependency, transitively: name, dir, engines.node. */ +function runtimeDependencies(): { + name: string; + dir: string; + range?: string; +}[] { + const out: { name: string; dir: string; range?: string }[] = []; const seen = new Set(); const visit = (manifestPath: string): void => { const pkg = readJson(manifestPath); @@ -52,8 +59,11 @@ function runtimeFloors(): { name: string; range: string }[] { if (seen.has(depManifest)) continue; seen.add(depManifest); const depPkg = readJson(depManifest); - if (depPkg.engines?.node) - out.push({ name: depPkg.name, range: depPkg.engines.node }); + out.push({ + name: depPkg.name, + dir: dirname(depManifest), + range: depPkg.engines?.node, + }); visit(depManifest); } }; @@ -61,6 +71,78 @@ function runtimeFloors(): { name: string; range: string }[] { return out; } +/** Every runtime dependency (transitively) with its declared engines.node. */ +function runtimeFloors(): { name: string; range: string }[] { + return runtimeDependencies().flatMap(({ name, range }) => + range ? [{ name, range }] : [], + ); +} + +// A dependency's DECLARED floor can be wrong. @clack/prompts 1.8.1 declares +// ">= 20.12.0" but passes an ARRAY of formats to util.styleText, which Node +// accepts only from 20.13.0 (measured: 20.12.0 throws ERR_INVALID_ARG_VALUE, +// 20.13.0 styles). Most of those calls render a CANCELLED prompt, so on 20.12.x +// an Esc / Ctrl-C at a pharn confirmation (or at a picker with something +// selected) crashed with a stack trace instead of exiting 0. So the floor is +// also held to what the dependencies' shipped CODE calls: each row is a Node API +// usage whose minimum version was measured. +// +// Reach, stated exactly: a call by its literal name. An aliased import +// (`import { styleText as s }`) is not seen — both @clack packages import it by +// name today. A usage no row names is not seen at all; that residual stays with +// the smoke workflow. +const KNOWN_API_FLOORS: { + usage: string; + pattern: RegExp; + floor: [number, number, number]; +}[] = [ + { + usage: 'util.styleText with an array of formats', + pattern: /\bstyleText\s*\(\s*\[/, + floor: [20, 13, 0], + }, +]; + +/** The KNOWN_API_FLOORS rows whose usage appears in `code`. */ +function usagesIn(code: string): (typeof KNOWN_API_FLOORS)[number][] { + return KNOWN_API_FLOORS.filter((row) => row.pattern.test(code)); +} + +/** A package's shipped JS files, never descending into a nested node_modules. */ +function shippedJs(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules') continue; + const path = join(dir, entry.name); + if (entry.isDirectory()) out.push(...shippedJs(path)); + else if (entry.isFile() && /\.(?:m|c)?js$/.test(entry.name)) out.push(path); + } + return out; +} + +/** Every known API usage found in the runtime dependencies' shipped code. */ +function codeFloors(): { + scanned: Map; + found: { name: string; usage: string; floor: [number, number, number] }[]; +} { + const scanned = new Map(); + const found: { + name: string; + usage: string; + floor: [number, number, number]; + }[] = []; + for (const { name, dir } of runtimeDependencies()) { + const files = shippedJs(dir); + scanned.set(name, files.length); + for (const file of files) { + for (const row of usagesIn(readFileSync(file, 'utf8'))) { + found.push({ name, usage: row.usage, floor: row.floor }); + } + } + } + return { scanned, found }; +} + describe('engines.node (PHARN-06)', () => { const ours = readJson(join(root, 'package.json')).engines?.node ?? ''; @@ -83,9 +165,59 @@ describe('engines.node (PHARN-06)', () => { } }); + it('is never below what a runtime dependency’s shipped code needs', () => { + for (const { name, usage, floor } of codeFloors().found) { + expect( + cmp(lowerBound(ours)!, floor), + `${name} calls ${usage} (needs node >= ${floor.join('.')}), package.json says ${ours}`, + ).toBeGreaterThanOrEqual(0); + } + }); + + // Without this, a scan that silently read nothing (a moved dist dir, a + // changed walk) would pass the test above vacuously. + it('actually reads @clack/prompts’ shipped code', () => { + expect(codeFloors().scanned.get('@clack/prompts') ?? 0).toBeGreaterThan(0); + }); + it('matches the README badge', () => { const readme = readFileSync(join(root, 'README.md'), 'utf8'); const [maj, min, pat] = lowerBound(ours)!; expect(readme).toContain(`node-%3E%3D${maj}.${min}.${pat}-`); }); + + // node-floor.yml starts the packed CLI on `FLOOR`. A FLOOR above the declared + // bound would smoke-test a Node the published range does not bottom out at. + it('is the exact Node the smoke workflow runs', () => { + const workflow = readFileSync( + join(root, '.github', 'workflows', 'node-floor.yml'), + 'utf8', + ); + const floor = /^\s*FLOOR:\s*['"]?(\d+\.\d+\.\d+)['"]?\s*$/m.exec( + workflow, + )?.[1]; + expect(floor).toBe(lowerBound(ours)!.join('.')); + }); +}); + +// The scanner itself, on planted text: it must fire on the array form and stay +// quiet on the single-format form, or the live check above could pass because +// the pattern never matches anything. +describe('KNOWN_API_FLOORS scanner', () => { + it('fires on the array form, however it is spaced', () => { + expect(usagesIn('styleText(["strikethrough", "dim"], label)')).toHaveLength( + 1, + ); + expect(usagesIn('styleText (\n [ "gray" ], x)')).toHaveLength(1); + }); + + it('stays quiet on the single-format form', () => { + expect(usagesIn('styleText("dim", label)')).toEqual([]); + }); + + // Documented reach, pinned so a reader does not assume more: an aliased + // import is outside what a name scan can see. + it('does not see an aliased import', () => { + expect(usagesIn('s(["strikethrough", "dim"], label)')).toEqual([]); + }); });