From c29413ee506a240bfde5d6a0645b249442d5bb7a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 08:31:06 +0000 Subject: [PATCH] fix(engines): declare the Node floor the CLI actually starts on, >=20.12.0 (PHARN-06) `engines.node` said ">=20", but @clack/prompts and @clack/core (runtime deps) declare ">= 20.12.0" and import `styleText` from node:util, so on Node 20.0-20.11 even `pharn --version` died at load time with a SyntaxError. - engines.node -> ">=20.12.0" (package.json + the lockfile root entry). - tests/engines.test.ts fails if any runtime dependency declares a higher floor than ours, and pins the README badge to it. - New, separately-named workflow node-floor.yml (`Smoke (node 20.12.0)`): build on 24, pack, install the tarball on exactly 20.12.0 and run --version/--help. ci.yml's six required contexts are untouched. - check-run-pins.test.mjs: live count of lockfile/path installs 7 -> 9. - README badge/scope, getting-started, troubleshooting, contributing, SECURITY.md, CLAUDE.md, ci.yml comment corrected. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TvcuVhk8hTeDskp5pAJhnc --- .dev/features/engines-node-floor/GRILL.md | 29 ++++++ .dev/features/engines-node-floor/PLAN.md | 67 ++++++++++++++ .../features/engines-node-floor/REGRESSION.md | 30 ++++++ .dev/features/engines-node-floor/REVIEW.md | 55 +++++++++++ .dev/features/engines-node-floor/SHIP.md | 17 ++++ .dev/features/engines-node-floor/VERIFY.md | 27 ++++++ .../engines-node-floor/regression-report.json | 30 ++++++ .../engines-node-floor/verify-report.json | 17 ++++ .dev/floor/check-run-pins.test.mjs | 10 +- .github/workflows/ci.yml | 5 +- .github/workflows/node-floor.yml | 59 ++++++++++++ .pharn/writes-scope.json | 4 +- CLAUDE.md | 2 +- README.md | 7 +- SECURITY.md | 2 +- docs/contributing.md | 12 +-- docs/getting-started.md | 10 +- docs/troubleshooting.md | 3 +- package-lock.json | 2 +- package.json | 2 +- tests/engines.test.ts | 91 +++++++++++++++++++ 21 files changed, 454 insertions(+), 27 deletions(-) create mode 100644 .dev/features/engines-node-floor/GRILL.md create mode 100644 .dev/features/engines-node-floor/PLAN.md create mode 100644 .dev/features/engines-node-floor/REGRESSION.md create mode 100644 .dev/features/engines-node-floor/REVIEW.md create mode 100644 .dev/features/engines-node-floor/SHIP.md create mode 100644 .dev/features/engines-node-floor/VERIFY.md create mode 100644 .dev/features/engines-node-floor/regression-report.json create mode 100644 .dev/features/engines-node-floor/verify-report.json create mode 100644 .github/workflows/node-floor.yml create mode 100644 tests/engines.test.ts diff --git a/.dev/features/engines-node-floor/GRILL.md b/.dev/features/engines-node-floor/GRILL.md new file mode 100644 index 00000000..2ee45ba1 --- /dev/null +++ b/.dev/features/engines-node-floor/GRILL.md @@ -0,0 +1,29 @@ +# GRILL — engines-node-floor + +Plan: `.dev/features/engines-node-floor/PLAN.md` · spec-hash `bca940a5…d729d3c4e` matches live +`ARCHITECTURE.md`. Registered grillers: `{"registered":0,"grillers":[]}` → inline axes only. + +## Findings + +```yaml +- type: FINDING + rule_id: 'P0' + severity: important + file: '.dev/features/engines-node-floor/PLAN.md:47' + problem: 'The smoke job is not a required status check, so a later dependency bump that raises the real floor again would turn it red without blocking a merge; the unit test is the only floor-grade guard, and it only sees engines fields that dependencies declare.' + evidence: 'CI smoke job on exactly 20.12.0 (not a required check — advisory signal' +- type: FINDING + rule_id: 'P1' + severity: minor + file: '.dev/features/engines-node-floor/PLAN.md:30' + problem: 'The unit test should read the INSTALLED dependency manifests (node_modules), which is what npm resolves for a consumer at the pinned lockfile — say so, since a consumer resolving ^ ranges fresh may get a newer dependency with a higher floor.' + evidence: "every runtime dependency's installed `engines.node` lower bound" +- type: FINDING + rule_id: 'P7' + severity: minor + file: '.dev/features/engines-node-floor/PLAN.md:31' + problem: 'Pinning doc text in a unit test couples tests to prose; keep it to the badge and the engines string only.' + evidence: "and README's badge / docs state the same floor" +``` + +ADVISORY VERDICT: 3 concerns raised (0 blocking-severity, 3 advisory) — for the human to weigh before /pharn-dev-build. diff --git a/.dev/features/engines-node-floor/PLAN.md b/.dev/features/engines-node-floor/PLAN.md new file mode 100644 index 00000000..e432c4b0 --- /dev/null +++ b/.dev/features/engines-node-floor/PLAN.md @@ -0,0 +1,67 @@ +# PLAN — engines-node-floor (PHARN-06: the declared Node floor must be one the CLI actually starts on) + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 +- increment: raise `engines.node` from `>=20` to `>=20.12.0` (the floor `@clack/prompts` / `@clack/core` + declare — they import `styleText` from `node:util`), pin that the CLI's floor is never below any + runtime dependency's declared floor with a unit test, add a separately-named CI job that runs the + PACKED CLI on exactly Node 20.12.0, and correct every doc that repeats `>=20`. +- layer(s): package metadata, tests, CI tooling, docs +- constitution_refs: [P0, P1, P4, P7] + +## Discovery — verified this run (P6) + +- Review evidence: on Node 20.0.0 and 20.11.1, `pharn --version` dies with `SyntaxError: The requested +module 'node:util' does not provide an export named 'styleText'`; 20.12.0 works. +- `node_modules/@clack/prompts/package.json` and `@clack/core` declare `"engines"`; `package.json:29-31` + and the root `package-lock.json` entry say `>=20`; `scripts/build.mjs` keeps `@clack/prompts`, + `minimist`, `picocolors` EXTERNAL, so a smoke test must install the packed tarball, not run the bundle alone. +- Claims repeating `>=20`: README badge + "Current scope", `docs/getting-started.md:11`, + `docs/troubleshooting.md` (prerequisites), `docs/contributing.md:56`, `SECURITY.md:7`, `CLAUDE.md`. +- `ci.yml` has a six-job contract pinned by `tests/ci-workflow.test.ts`; a new, separately-named workflow + leaves those contexts untouched (the shape `docs/contributing.md` prescribes). + +## Files + +- `package.json` — `engines.node: ">=20.12.0"` — layer metadata +- `package-lock.json` — root `engines` regenerated with `npm install --package-lock-only` — layer metadata +- `tests/engines.test.ts` — the declared lower bound is ≥ every runtime dependency's installed + `engines.node` lower bound (parsed `>=x.y.z` comparator), and README's badge / docs state the same floor +- `.github/workflows/node-floor.yml` — job `Smoke (node 20.12.0)`: build on Node 24, `npm pack`, install + the tarball into a temp dir on Node 20.12.0, run `pharn --version` and `pharn --help` — layer CI +- `.dev/floor/check-run-pins.test.mjs` — the live-repo count of skipped (lockfile / local-path) installs + goes 7 → 9 for the new workflow's `npm ci` + local-tarball install, with the reason recorded +- `.github/workflows/ci.yml` — header comment only (it quotes `>=20`); jobs untouched (P4) +- `README.md` — badge + "Current scope" (P4) +- `docs/getting-started.md` — Node row (P4) +- `docs/troubleshooting.md` — prerequisites (P4) +- `docs/contributing.md` — the untested-claims table: floor now exercised by the smoke job (P4) +- `SECURITY.md` — `engines.node >= 20.12.0` (P4) +- `CLAUDE.md` — CI paragraph (P4) + +## Contracts satisfied + +- `docs/contributing.md` "a separate, additionally-named job is the only safe shape" — followed; the six + required contexts are unchanged. + +## Evals to write (P1) + +- `tests/engines.test.ts` fails on the base (`>=20` < `>=20.12.0` declared by @clack) and passes after. + +## Guarantee audit (P0) + +- "the declared floor is not below any runtime dependency's declared floor" → floor: version comparison + over installed package metadata (unit test). +- "the CLI starts on the declared floor" → CI smoke job on exactly 20.12.0 (not a required check — + advisory signal unless a maintainer adds it to the ruleset; named in `docs/contributing.md`). + +## Trust audit (P2) + +- No runtime input change. + +## Determinism audit (P5) + +- Numeric semver compare of `major.minor.patch`. + +## Open questions (HALT) + +- none diff --git a/.dev/features/engines-node-floor/REGRESSION.md b/.dev/features/engines-node-floor/REGRESSION.md new file mode 100644 index 00000000..1b2a6095 --- /dev/null +++ b/.dev/features/engines-node-floor/REGRESSION.md @@ -0,0 +1,30 @@ +# REGRESSION — engines-node-floor + +The verdict below is computed by `.dev/floor/check-regress.mjs`, not by this stage's judgment. + +## Base and partition + +- **base:** `28397171eba488bf6a31961808f9dbecba18da48` (`origin/main` at build time; the build is an uncommitted working tree on top of it). +- **inside** (each declared in `PLAN.md` `## Files`): `.dev/floor/check-run-pins.test.mjs`, `.github/workflows/ci.yml`, `.github/workflows/node-floor.yml`, `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`. +- **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 stdlib `*.test.mjs` / `*.test.cjs` files + 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. + +## 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/**` 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-node-floor/REVIEW.md b/.dev/features/engines-node-floor/REVIEW.md new file mode 100644 index 00000000..c1cabdea --- /dev/null +++ b/.dev/features/engines-node-floor/REVIEW.md @@ -0,0 +1,55 @@ +# REVIEW — engines-node-floor + +Floor first: `node .dev/floor/validate.mjs .` → exit 0 (GREEN). Everything below is **advisory**. + +## Floor-gate findings (blocking) + +None at the final state. The FIRST `/pharn-dev-regress` run returned `regressions` (`tests` 0 → 1) and +`/pharn-dev-verify` returned `FAIL` (`lint:md`), and both were acted on rather than waved through: + +- `check-run-pins.mjs` flagged `npx --no-install pharn` as an unpinned package exec → the workflow now + runs `./node_modules/.bin/pharn` directly (no package resolution at all). +- `check-run-pins.test.mjs` pins the exact count of lockfile/path installs in workflows (7); the new + workflow's `npm ci` + local-tarball install are deliberate, so the count is now 9 with the reason + recorded, and the file was added to the plan's `## Files`. +- Two doc tables were realigned for MD060. + +## Lenses + +- **L-floor (P0):** "declared floor ≥ every runtime dependency's declared floor" reduces to a numeric + version compare in `tests/engines.test.ts` (fails on the base: `@clack/prompts needs node >= 20.12.0, +package.json says >=20`). "The CLI starts on 20.12.0" rests on a non-required CI job — advisory, + stated in `docs/contributing.md`. +- **L-eval (P1):** 3 cases in `tests/engines.test.ts`; manual smoke of the packed CLI on local Node + 20.20.2 printed `0.5.0` (no 20.12.0 binary offline — the CI job covers the exact floor). +- **L-trust (P2):** no runtime input changes; the new workflow has `contents: read` only and no secrets. +- **L-axis (P3):** one new workflow with one job; `ci.yml`'s six contexts untouched (comment only). + +## Advisory findings + +```yaml +- type: FINDING + rule_id: 'P0' + severity: important + file: '.github/workflows/node-floor.yml' + problem: "`Smoke (node 20.12.0)` is not in the ruleset's required checks, so a red smoke does not block a merge unless a maintainer adds it; the unit test is the only blocking guard." + evidence: 'name: Smoke (node 20.12.0)' +- type: FINDING + rule_id: 'P7' + severity: minor + file: 'package-lock.json:35' + problem: "The lockfile root `engines` line was edited to match package.json because the only local npm (10) rewrites unrelated `libc` fields; CI's npm 11 would produce the same single-line change." + evidence: '"node": ">=20.12.0"' +- type: FINDING + rule_id: 'P4' + severity: minor + file: 'CHANGELOG.md:8' + problem: 'No CHANGELOG `[Unreleased]` entry for the raised engines floor.' + evidence: '## [Unreleased]' +``` + +## Verdict + +**GREEN** — 0 floor-gate findings at the final state, 3 advisory findings. Proposed lesson (not +promoted — `/pharn-dev-memory-promote` is human-gated): a new workflow must be run through +`check-run-pins.mjs` and its live-count test, which are outside the vitest suite. diff --git a/.dev/features/engines-node-floor/SHIP.md b/.dev/features/engines-node-floor/SHIP.md new file mode 100644 index 00000000..082a229d --- /dev/null +++ b/.dev/features/engines-node-floor/SHIP.md @@ -0,0 +1,17 @@ +# SHIP — engines-node-floor + +Stages run, in order: `/pharn-dev-plan` → GATE 1 (human: **Approve as written**) → `/pharn-dev-grill` → +`/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) +- Run ended at **GATE 2**. The human's standing instruction for this batch: open a PR and merge it only if + its CI checks are green. + +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-node-floor/VERIFY.md b/.dev/features/engines-node-floor/VERIFY.md new file mode 100644 index 00000000..97438af2 --- /dev/null +++ b/.dev/features/engines-node-floor/VERIFY.md @@ -0,0 +1,27 @@ +# VERIFY — engines-node-floor + +## FLOOR layer (owns the verdict) + +Gates run over the whole repo with the feature present, as a non-root user on node 22 with the session +proxy variables unset (as root with the proxy set, 5 pre-existing tests in `init.test.ts` / +`update.test.ts` fail for environmental reasons, identically at the baseline). + +| gate | exit | +| -------------- | ---- | +| `format:check` | 0 | +| `lint` | 0 | +| `lint:md` | 0 | +| `test` | 0 | +| `typecheck` | 0 | +| `validate` | 0 | + +No `structural:*` gate — the increment ships no eval-actual pair. + +**VERDICT: PASS** (`.dev/floor/check-verify.mjs`, `failing_gates: []`). + +## ADVISORY layer + +`node .dev/floor/count-verifiers.mjs .` → `{"registered":0,"verifiers":[]}` — floor gates only. + +Residual (P0/P7): "verified" means the named gates passed — not that the feature is correct in any sense +the suite does not encode. diff --git a/.dev/features/engines-node-floor/regression-report.json b/.dev/features/engines-node-floor/regression-report.json new file mode 100644 index 00000000..78cd8feb --- /dev/null +++ b/.dev/features/engines-node-floor/regression-report.json @@ -0,0 +1,30 @@ +{ + "base": "28397171eba488bf6a31961808f9dbecba18da48", + "inside": [ + ".dev/floor/check-run-pins.test.mjs", + ".github/workflows/ci.yml", + ".github/workflows/node-floor.yml", + "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-node-floor/verify-report.json b/.dev/features/engines-node-floor/verify-report.json new file mode 100644 index 00000000..d4d5615f --- /dev/null +++ b/.dev/features/engines-node-floor/verify-report.json @@ -0,0 +1,17 @@ +{ + "feature": "engines-node-floor", + "gates": { + "format:check": 0, + "lint": 0, + "lint:md": 0, + "test": 0, + "typecheck": 0, + "validate": 0 + }, + "verdict": "PASS", + "failing_gates": [], + "verifiers": { + "registered": 0, + "findings": [] + } +} diff --git a/.dev/floor/check-run-pins.test.mjs b/.dev/floor/check-run-pins.test.mjs index 4ff8bed3..5935baa7 100644 --- a/.dev/floor/check-run-pins.test.mjs +++ b/.dev/floor/check-run-pins.test.mjs @@ -371,10 +371,12 @@ test("★ the live repo has NO floating install in any workflow run: line", () = // `npm ci` in ci.yml and publish.yml — asserted EXACTLY, so an exemption can never become a // silent hole. If this number changes, a lockfile install was added or removed on purpose. // - // 7 = six in ci.yml (one per gate job — each required status check installs for itself) plus one - // in publish.yml. It was 2 while ci.yml ran a single `check` job; splitting that job into six is - // the deliberate change this count now records. - assert.equal(d.skipped, 7); + // 9 = six in ci.yml (one per gate job — each required status check installs for itself), one + // in publish.yml, and two in node-floor.yml (its `npm ci` build step, and the install of the + // locally packed tarball by PATH — `../pharn-dev-pharn-*.tgz` — onto the floor Node). It was 2 + // while ci.yml ran a single `check` job, 7 until the node-floor smoke job was added; each step is + // a deliberate change this count records. + assert.equal(d.skipped, 9); // Independent recount of the enumerated workflow files, case-insensitively — exit 0 is also what // a checker returns when it opened nothing. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90842fe2..9e0514ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +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"` while these gates run node 24 — the lower end -# of that published range is NOT exercised here. +# declares `engines.node: ">=20.12.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. on: pull_request: diff --git a/.github/workflows/node-floor.yml b/.github/workflows/node-floor.yml new file mode 100644 index 00000000..63be5a23 --- /dev/null +++ b/.github/workflows/node-floor.yml @@ -0,0 +1,59 @@ +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. +# +# 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). +# +# 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), +# so only an install exercises what a user actually runs. + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + smoke-node-floor: + name: Smoke (node 20.12.0) + runs-on: ubuntu-latest + env: + FLOOR: 20.12.0 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - run: npm ci + - run: npm run build + - name: Pack + run: npm pack --pack-destination "$RUNNER_TEMP" + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.FLOOR }} + - name: Install the packed CLI on the floor Node + working-directory: ${{ runner.temp }} + run: | + node --version + mkdir smoke && cd smoke + npm init -y > /dev/null + npm install --no-audit --no-fund ../pharn-dev-pharn-*.tgz + - name: Start it + working-directory: ${{ runner.temp }}/smoke + run: | + ./node_modules/.bin/pharn --version + ./node_modules/.bin/pharn --help diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index c8c1e596..f43abaed 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,7 +1,7 @@ { "scope": [ - ".dev/features/add-after-withheld-update/SHIP.md" + ".dev/features/engines-node-floor/SHIP.md" ], "set_by": ".claude/commands/pharn-dev-ship.md", - "set_at": "2026-09-24T08:22:49.900Z" + "set_at": "2026-09-24T08:31:05.706Z" } diff --git a/CLAUDE.md b/CLAUDE.md index 36deef99..759aba39 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"` (Node 20/22 unexercised) 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.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. ## Releasing diff --git a/README.md b/README.md index 344046d8..002b42a5 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-brightgreen)](./package.json) +[![Node](https://img.shields.io/badge/node-%3E%3D20.12.0-brightgreen)](./package.json) ```bash npx @pharn-dev/pharn@latest init @@ -281,8 +281,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 declares Node >= 20 support. CI - currently runs on Node 24. +- It requires a git-initialized project and Node >= 20.12.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. - **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 10f7feb3..4c12ee76 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`) 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.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. 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 c23d1193..17563be0 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"` | Node 24 only | Node 20 and 22 are unexercised; verify locally if your change touches runtime-version-sensitive APIs | -| 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.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 | **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. @@ -99,8 +99,8 @@ See [`CLAUDE.md`](../CLAUDE.md) for the architecture in depth (the archetype ins `pharn` has **no dependency that fetches or unpacks remote content**. `src/lib/repo.ts` resolves the branch head over the GitHub REST API and downloads that exact commit's tarball from `codeload.github.com`; `src/lib/tar-extract.ts` unpacks it. Both are pharn's own code, and that is the -point: when the download and extraction were delegated, `THREAT-MODEL.md` had to state *measured -properties of a dependency*, which a version bump could move without any pharn test noticing. +point: when the download and extraction were delegated, `THREAT-MODEL.md` had to state _measured +properties of a dependency_, which a version bump could move without any pharn test noticing. If you change either file, the guarantees they carry are the ones `THREAT-MODEL.md` §2/§4b and `LIMITS.md` §3a state — timeout, streamed-byte cap, decompressed-size cap, `redirect: 'error'`, diff --git a/docs/getting-started.md b/docs/getting-started.md index 485355af..f7db8c99 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -4,11 +4,11 @@ PHARN does not scaffold your app. You create your project (e.g. with `create-nex ## Prerequisites -| Requirement | How PHARN checks | When | -| -------------------- | ----------------------------------------------------- | ------------------------------------------- | -| 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` (CI exercises Node 24) | By npm/npx when the package is resolved | +| Requirement | How PHARN checks | When | +| -------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| 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 | `.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 5e55e2c9..b1819716 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -104,7 +104,8 @@ directory (or move it aside) and re-run. ## Prerequisites failed -`pharn init` has three prerequisites — a git repository, an interactive terminal, and Node >= 20. +`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. 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 613e5d5a..fdd27762 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "vitest": "^5.0.0" }, "engines": { - "node": ">=20" + "node": ">=20.12.0" } }, "node_modules/@babel/helper-string-parser": { diff --git a/package.json b/package.json index 0dc60b15..0792569e 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "dist" ], "engines": { - "node": ">=20" + "node": ">=20.12.0" }, "publishConfig": { "access": "public", diff --git a/tests/engines.test.ts b/tests/engines.test.ts new file mode 100644 index 00000000..b21e93cd --- /dev/null +++ b/tests/engines.test.ts @@ -0,0 +1,91 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +// PHARN-06: `engines.node` said ">=20" while @clack/prompts and @clack/core — +// runtime dependencies — declare ">= 20.12.0" and import `styleText` from +// 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. +// +// 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. + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const readJson = (p: string) => + JSON.parse(readFileSync(p, 'utf8')) as { + name: string; + engines?: { node?: string }; + dependencies?: Record; + }; + +/** The lower bound of a `>=x[.y[.z]]` range, as [major, minor, patch]. */ +function lowerBound(range: string): [number, number, number] | null { + const m = /^\s*>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?\s*$/.exec(range); + if (!m) return null; + return [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)]; +} + +function cmp(a: number[], b: number[]): number { + for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i]! - b[i]!; + return 0; +} + +/** Node's lookup: the nearest `node_modules/` walking up from `from`. */ +function findInstalled(from: string, dep: string): string { + for (let dir = from; ; dir = dirname(dir)) { + const candidate = join(dir, 'node_modules', dep, 'package.json'); + if (existsSync(candidate)) return candidate; + if (dirname(dir) === dir) throw new Error(`${dep} is not installed`); + } +} + +/** Every runtime dependency (transitively) with its declared engines.node. */ +function runtimeFloors(): { name: string; range: string }[] { + const out: { name: string; range: string }[] = []; + const seen = new Set(); + const visit = (manifestPath: string): void => { + const pkg = readJson(manifestPath); + for (const dep of Object.keys(pkg.dependencies ?? {})) { + const depManifest = findInstalled(dirname(manifestPath), dep); + 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 }); + visit(depManifest); + } + }; + visit(join(root, 'package.json')); + return out; +} + +describe('engines.node (PHARN-06)', () => { + const ours = readJson(join(root, 'package.json')).engines?.node ?? ''; + + it('is a plain >=x.y.z lower bound', () => { + expect(lowerBound(ours)).not.toBeNull(); + }); + + it('is never below a runtime dependency’s declared Node floor', () => { + const floors = runtimeFloors(); + // @clack/* is what raised the real floor; if it stopped being found, this + // test would pass vacuously. + expect(floors.map((f) => f.name)).toContain('@clack/core'); + for (const { name, range } of floors) { + const theirs = lowerBound(range); + if (theirs === null) continue; + expect( + cmp(lowerBound(ours)!, theirs), + `${name} needs node ${range}, package.json says ${ours}`, + ).toBeGreaterThanOrEqual(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}-`); + }); +});