From 7c8dadd409002572358570564be78cb80f9b3819 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:47:56 +0800 Subject: [PATCH] fix(runtime): enforce Node floor in setup paths --- .claude/hooks/session-start.sh | 44 ++++++++++++++++++---- AGENTS.md | 2 +- docs/branch-review-ledger.md | 2 + docs/outstanding-issues.md | 4 +- package.json | 2 +- scripts/check-codex-cloud-setup.mjs | 20 +++++++++- scripts/check-node-engine.cjs | 36 +++++++++++++++--- scripts/check-runtime.ts | 38 ++++++++++++++++++- scripts/setup-codex-cloud.sh | 30 +++++++++++++-- scripts/setup-codex-worktree.mjs | 32 ++++++++++++++-- tests/check-runtime.test.ts | 57 ++++++++++++++++++++++++++++- tests/codex-cloud-setup.test.ts | 4 ++ tests/setup-codex-worktree.test.ts | 12 ++++++ 13 files changed, 252 insertions(+), 31 deletions(-) diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index da4583ee9f..ba351dbd28 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -1,22 +1,46 @@ #!/bin/bash # SessionStart hook for Claude Code on the web. -# The app is engine-strict on Node 24.x / npm 11.x, but web containers ship an -# older Node on PATH, so nothing installs or runs until Node 24 is present. -# Installs Node 24 into $HOME/.node24 (cached with the container), exposes it -# via $CLAUDE_ENV_FILE, and installs npm dependencies. +# The app is engine-strict on Node >=24.15 <25 / npm 11.x, but web containers +# ship an older Node on PATH, so nothing installs or runs until a Node meeting +# that floor is present. Installs one into $HOME/.node24 (cached with the +# container), exposes it via $CLAUDE_ENV_FILE, and installs npm dependencies. set -euo pipefail if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then exit 0 fi -NODE_VERSION="24.13.0" +NODE_VERSION="24.19.0" +# Keep in step with the floor in package.json engines.node. A matching major is +# not enough: dev dependencies (jsdom) carry a minor-level floor, so a 24.13 on +# PATH satisfied the old major-only check and then failed `npm ci` with +# EBADENGINE. That blocked PRs #1611, #1697, #1705 and #1740. +NODE_MINIMUM="24.15.0" +# Exclusive major ceiling, matching the "<25" half of engines.node. Checking only +# the floor would let a container shipping Node 25+ skip provisioning and then +# fail `npm ci`, which is the same blind-spot as the major-only check above. +NODE_MAJOR_CEILING="25" NODE_HOME="$HOME/.node24" NODE_BIN="$NODE_HOME/node-v${NODE_VERSION}-linux-x64/bin" -current_major="$(node -v 2>/dev/null | sed -E 's/^v([0-9]+).*/\1/' || echo 0)" -if [ "$current_major" != "24" ] && [ ! -x "$NODE_BIN/node" ]; then - echo "[session-start] Installing Node ${NODE_VERSION} (found v${current_major:-none})" +supported_runtime() { + local version="$1" + local actual_major actual_minor actual_patch minimum_major minimum_minor minimum_patch + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1 + IFS=. read -r actual_major actual_minor actual_patch <<< "$version" + IFS=. read -r minimum_major minimum_minor minimum_patch <<< "$NODE_MINIMUM" + + (( actual_major < NODE_MAJOR_CEILING )) || return 1 + (( actual_major > minimum_major )) && return 0 + (( actual_major == minimum_major )) || return 1 + (( actual_minor > minimum_minor )) && return 0 + (( actual_minor == minimum_minor )) || return 1 + (( actual_patch >= minimum_patch )) +} + +current_version="$(node -v 2>/dev/null | sed -E 's/^v//' || true)" +if ! supported_runtime "$current_version" && [ ! -x "$NODE_BIN/node" ]; then + echo "[session-start] Installing Node ${NODE_VERSION} (found v${current_version:-none}, need >= ${NODE_MINIMUM} and < ${NODE_MAJOR_CEILING})" mkdir -p "$NODE_HOME" curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \ | tar -xJ -C "$NODE_HOME" @@ -27,6 +51,10 @@ if [ -x "$NODE_BIN/node" ]; then echo "export PATH=\"$NODE_BIN:\$PATH\"" >> "$CLAUDE_ENV_FILE" fi +if ! supported_runtime "$(node -v 2>/dev/null | sed -E 's/^v//' || true)"; then + echo "[session-start] WARNING: node $(node -v 2>/dev/null || echo 'not found') is outside the supported >=${NODE_MINIMUM} <${NODE_MAJOR_CEILING} range; npm ci will refuse to install." +fi + echo "[session-start] Using node $(node -v) / npm $(npm -v)" cd "$CLAUDE_PROJECT_DIR" diff --git a/AGENTS.md b/AGENTS.md index 2742f9dd7b..61c493d42f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1066,7 +1066,7 @@ Use `docs/codex-cloud.md` as the environment contract: Durable notes for Cloud Agents. Standard commands live in `README.md` and `package.json`; only non-obvious caveats are captured here. - Context7 peer-library docs habit (and the Next 16 local-docs carve-out) lives in `docs/agents-guide.md`. Project MCP is local `@upstash/context7-mcp@3.2.5` with `CONTEXT7_API_KEY` from env/Secrets. If the host-injected Context7 MCP returns quota exceeded, use `npx ctx7 library|docs …` with the same secret — do not invent peer APIs from training data. -- Runtime: the app hard-requires Node 24.x / npm 11.x (`engine-strict`, and `scripts/dev-free-port.mjs` exits on any other major). Node 24 is installed via nvm and symlinked into `/usr/local/cargo/bin` (first entry in `PATH`) so `node`/`npm` resolve to v24 in every shell. If a shell ever resolves `/exec-daemon/node` (v22) instead, prepend the installed nvm Node 24 bin to `PATH` (for example `"$HOME/.nvm/versions/node/v24.18.1/bin"`; run `ls "$HOME/.nvm/versions/node"` to confirm the exact patch version). +- Runtime: the app hard-requires Node >=24.15.0 <25 / npm 11.x (`engine-strict`; the preinstall and runtime gates enforce the minor floor, while `scripts/dev-free-port.mjs` rejects other majors). A compatible Node 24 is installed via nvm and symlinked into `/usr/local/cargo/bin` (first entry in `PATH`) so `node`/`npm` resolve to it in every shell. If a shell ever resolves `/exec-daemon/node` (v22) instead, prepend the installed nvm Node 24 bin to `PATH` (for example `"$HOME/.nvm/versions/node/v24.18.1/bin"`; run `ls "$HOME/.nvm/versions/node"` to confirm the exact patch version). - Live vs demo mode: the app auto-detects. When the Supabase + OpenAI env vars below are present (set them as Cloud Agent **Secrets** so they inject into `.env.local`/`process.env`), `isDemoMode()` (`src/lib/env.ts`) is false and the app runs against the live `Clinical KB Database` project (~2000 indexed docs) with OpenAI answer generation. When they are absent, dev auto-falls back to demo mode using the synthetic corpus in `src/lib/demo-data.ts` / `public/demo-documents/`. Required for live mode: `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_PROJECT_REF`, `SUPABASE_PROJECT_NAME`, `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` (`sb_publishable_…`), `SUPABASE_SERVICE_ROLE_KEY` (accepts the `sb_secret_…` secret key), `OPENAI_API_KEY`. Keep `RAG_PROVIDER_MODE=auto` so OpenAI is used with graceful source-only fallback. `E2E_USER_EMAIL`/`E2E_USER_PASSWORD` power CI env-check and Playwright. - Live-mode caveat: `RAG_PROVIDER_MODE=auto` attempts OpenAI (fast → strong route); if generation fails the built-in quality gates it silently degrades to a deterministic "Source-only" answer that still cites real documents — this is expected, not a failure. The header sign-in UI exposes magic-link + OAuth only (no password field), but the `/api/answer` + retrieval flow works server-side without a browser session. - What still won't run in this VM even with secrets: `npm run worker` also needs the Python OCR stack (`worker/python/requirements.txt`) and heavy parsing deps; Supabase edge functions need Deno v2.x + deployment. `verify:release` additionally runs governance/eval gates. Treat missing-secret failures of `check:supabase-project`/`verify:release` in demo mode as expected, not regressions. diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 10c157eb69..73740082f3 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -837,3 +837,5 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-09 | cursor/differentials-diagnosis-links-9f18 | 0e77e7bc0842bef4ffc045c6dbea4626152490d1 | differentials diagnosis term links | implemented exact+alias termLinks chips on diagnosis+presentation pages; vitest 58/58; verify:pr-local green | vitest differential-diagnosis-links+detail+section-nav+route; verify:pr-local; ensure spot-check | | 2026-08-09 | cursor/differentials-diagnosis-links-9f18 | 0daa9e2f9fc84e879fd661da94203568309234a6 | PR #1768 Autopilot+Bugbot review-and-fix | Merged origin/main (DIRTY was ledger+detail-page staleness; merge-tree clean). Fixed SEGMENT_SPLIT to spaced-slash only so Delirium / medical psychosis links while alcohol/benzo, DVT/PE, food/fluid stay intact. Dispositioned: Copilot termLinks ??{} + Fragment key already fixed; CodeRabbit clean-keys moot (visibleSectionItems already cleans); CodeRabbit bare-slash split rejected (clinical harm). No Bugbot findings. Threads cleared on push. Merge left to user. | vitest differential-diagnosis-links+detail+route 49/49; verify:cheap exit 0 (543 files, 5828 passed/4 skipped); verify:pr-local exit 0 (lint/typecheck/test/build/rag-fixtures); merge-tree clean vs origin/main; no provider gates | | 2026-08-09 | cursor/differentials-diagnosis-links-9f18 | f784e81bcc0b53ef76b3da07a8e81f96d9bf0c71 | pr-1768 unblock | merged origin/main onto ba590f9; merge-tree clean; DIRTY mergeability cleared; push tip follows amend with this ledger | merge-tree clean; threads resolved; auto-merge was armed | +| 2026-08-09 | claude/planning-build-intelligence-9ot0nm | 3df3cb3993f73cda4dbbc4ac7549f84b3c6ea7ed | Node 24.15 engine floor: engines.node, preinstall hook, check:runtime, session-start provisioning, codex-cloud assertion | Authored and handed off as PR #1771; closes #285; operationalRisk true, clinicalRisk/ragRanking false | test 5800 passed/1 pre-existing root-uid failure (pr-handoff-stop, confirmed on stashed clean tree); lint 0; typecheck 0; prettier --check . pass; check:runtime pass; check:codex-cloud pass; check:outstanding-issues pass; preinstall boundary proof 24.13/24.14.9 reject, 24.15/24.19 accept, 25.0.0 reject; contract test mutation-checked red | +| 2026-08-09 | pull/1771 | 466ec4216272c31c5f754db213dbdc529583b167 | PR 1771 runtime floor enforcement | P2: Cloud and Desktop setup paths remain major-only; do not merge until range-aware | static review; check:runtime PASS; check:codex-cloud PASS; ledger PASS; outstanding issues PASS; focused Vitest blocked by active Playwright lease | diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index e29af4210f..f155750c95 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -324,7 +324,6 @@ removed after current-main verification; it is not missing recommended work. | #282 | P3 | task | Probe the corpus for JBIG2/JPX before deciding whether pdf.js needs its decoder assets shipped | **Outcome:** a measured decision about pdf.js's cMap/standard-font/WASM assets rather than an assumption either way. **Detail:** getDocument is configured with url plus the on-demand fetch flags and nothing else, so 'wasmUrl', 'standardFontDataUrl', 'cMapUrl' and 'iccUrl' are all unset. pdfjs-dist ships those assets (wasm 1.5 MB, standard_fonts 804 KB, cmaps 1.7 MB) and nothing copies them into public/. With wasmUrl null, 'useWorkerFetch' resolves false and the WASM image decoders cannot load, so JBIG2 and JPEG2000 images fall back to the JS decoders or fail; those are exactly the encodings a scanned guideline uses, and this repo runs an OCR pipeline, which implies scanned sources exist. Non-embedded standard-14 fonts fall back to system fonts, which is a fidelity risk on a clinical document rather than a failure. **Next:** sample the real corpus for JBIG2/JPX-encoded images and for PDFs relying on the standard 14 before shipping ~2 MB of static assets; if the corpus does use them, copy into public/pdfjs, set the URLs, and add immutable cache headers in next.config.ts (public/ is not counted by check:bundle-budget, so there is no budget risk — the cost is bytes over the wire on first use). **Stop:** do not ship the assets on the assumption alone. | session 2026-08-08 document-viewer optimisation; node_modules/pdfjs-dist/types/src/display/api.d.ts | 2026-08-08 | | #283 | P3 | rec | The 100-id batch signed-URL route still has no caller | **Outcome:** either the batch minter is used or it is retired, rather than sitting as an untested, unreachable privileged surface. **Detail:** src/app/api/images/signed-urls/route.ts POSTs up to 100 image ids and returns their signed URLs, with its own rate limit, owner scoping and committed-generation filter. Nothing in src/ calls it — only tests/private-access-routes.test.ts imports it. **DEFERRED AGAIN, DELIBERATELY, 2026-08-09 (document viewer Phase 3, Task 3).** The user chose deferral over wiring when asked. Two reasons beyond cost: (a) wiring it puts a privileged owner-scoped API route into a diff that is otherwise confined to src/components/document-viewer/**, and it matches clinicalRiskPatterns (/^src\/app\/api\//) so pr-policy hard-blocks the merge without a complete Clinical Governance Preflight; (b) Phase 3 Task 2 windowed the rail to six rows and tightened its IntersectionObserver root margin from 640px to 240px, so the many-distinct-images case the batch route was meant to serve is now materially smaller — a page of N figures no longer mounts N rows at once. The batching win should be re-measured against the windowed rail before it is wired at all, rather than assumed from the pre-window numbers. **Next:** decide deliberately — measure concurrent distinct-image requests on a figure-heavy document with the windowed rail, then either wire the batch route in its own PR or delete it and its tests. **Stop:** if wiring it, keep the per-image endpoint for the lightbox's retry path; do not make the batch the only way to mint a URL. | session 2026-08-08 document-viewer optimisation; src/app/api/images/signed-urls/route.ts | 2026-08-08 | | #284 | P3 | issue | tests/pr-handoff-stop.test.ts fails whenever the suite runs as root | **Outcome:** 'npm run test' is green in a root container, so a real failure is not hidden behind a known one. **Detail:** 'pr-handoff-stop hook > emits handoff context only when the marker file exists' expects markerExists('sess-readonly') to be false — it makes the marker directory read-only and asserts the hook could not write there. Root ignores the permission bits, so the write succeeds and the assertion fails. Reproduced on an unmodified bc33d41 checkout as well as on the viewer-optimisation branch, so it is environment-dependent, not a regression. Cost is that every full-suite run in a root container reports '1 failed', which trains readers to skim past the failure count. **Next:** skip the case when 'process.getuid?.() === 0' with an explicit reason, or drop privileges for that assertion. **Stop:** do not delete the coverage — the read-only case is the point of the test on a normal user account. | session 2026-08-08 full-suite runs; reproduced on bc33d41 | 2026-08-08 | -| #285 | P2 | issue | Fresh remote/Cloud containers cannot run npm ci — shipped Node 24.13.0 is below the ^24.15.0 floor that main's jsdom@30.0.1 now requires | Observed 2026-08-08 in a Claude Code web container while syncing PR #1730. npm ci --include=dev aborts with EBADENGINE on jsdom@30.0.1 (needs Node ^22.22.2, ^24.15.0 or >=26); the container ships v24.13.0, so node_modules stays stale and the pre-push static guard then fails typecheck on the missing tailwind-merge added by #1678. Worked around by nvm install 24.19.0 plus a PATH prefix (nvm use alone does not stick — system node shadows it). Next action: raise the engines.node floor in package.json to >=24.15 so the mismatch fails loudly at the declared contract, and provision a compatible Node in the remote/Cloud setup path so a fresh container is not blocked at first install. | session 2026-08-08 (PR #1730) | 2026-08-08 | | #286 | P2 | task | PR 2 of the in-page nav series - convert the six pill-rail information pages onto InPageNavHeader | IMPLEMENTED in PR #1766 (branch claude/inpage-nav-pr-2-6d32f9); close this row when that PR merges. All six routes (seven components: services, forms, specifiers record + catalogue reference, formulation, /dsm/diagnoses/ and its /differentials child) mount InPageNavHeader, drop their breadcrumb row, keep their in-body h1, and move record actions into the ellipsis sheet. Three things the conversion needed first, none of which were in the original sketch: the actions render prop had to widen to ReactNode \| ((close) => ReactNode) because four of the seven are Server Components, and onSelectSection plus PageSection.icon have the same RSC-boundary problem, so those four mount the header through a 'use client' sibling module; both sheets derive open state from the pathname so navigation closes them; and information-page sections had no scroll-mt at all, so a shared inPageAnchor token consuming --inpage-anchor-offset was added, published by useInPageChromeMetrics from the live chrome height. The pill rail is deleted with it: hasLocalInformationPageNavigation collapses to isInformationPage and the section kind leaves secondary-navigation.tsx. Guard shipped: tests/in-page-nav-route-sections.dom.test.tsx asserts every declared section id against rendered DOM for all seven components. | session 2026-08-08; PR #1740 (2806d5e) | 2026-08-08 | | #287 | P2 | task | PR 3 of the in-page nav series - the three locally-owned routes each need a decision, not just a conversion | **Outcome:** every information page uses the documented in-page navigation template, or has a recorded reason not to. **Detail:** the last three routes each own a different bespoke pattern, and none is a mechanical port. (1) /medications/[slug] - SectionTabs at medication-record-page.tsx:183 SWAPS CONTENT rather than scrolling: sectionsByTab[activeTab] at :391 filters record.sections by type, so a different set mounts per tab. The InPageNavHeader track is scroll-spy over anchors that all exist at once, so adopting it means either driving tab state from the track (the track stops meaning where am I on the page) or flattening to one scrolling page - a real behaviour change to a clinical record, and a product call. (2) /differentials/presentations/[slug] - MobileTabs at differential-presentation-workflow-page.tsx plus the xl review sidebar; the old `differentialPresentationSections` shell set is gone and the route is locally owned (`page-secondary-navigation.tsx`). Remaining work is the product decision to adopt `InPageNavHeader` (or keep the tab/sidebar model with a recorded reason), not resurrecting deleted section targetIds. (3) /factsheets/[slug] - the On this page list at factsheet-detail-page.tsx:365-371 is li text with no link, button or handler, and the sections themselves carry no ids at all (:214, :267, :313, :447, :453, :471); the tail is data-driven via factsheet.sections.map keyed on section.heading (:538), so anchor ids must be generated deterministically from headings and that generator becomes the contract the section list depends on. Therapy Compass is deliberately excluded from the whole series - ModeNav is a different multi-route pattern. **Next:** decide the medications tab model first (owner decision, blocks planning); decide whether presentations keep MobileTabs/sidebar or adopt InPageNavHeader; choose the factsheets heading-to-id scheme. Then convert. **Stop:** do not port medications mechanically - swapping the tablist for a scroll track silently changes what a clinician sees on a medication record. | session 2026-08-08; follows #286 | 2026-08-08 | | #288 | P3 | rec | Decide whether DocumentViewer adopts the template it was extracted from, or the partial adoption is recorded as final | **Outcome:** the in-page navigation template has one deliberate owner story rather than an unexplained gap. **Detail:** PR 1 (2806d5e, #1740) extracted the header from DocumentViewer.tsx and differential-detail-page.tsx, which held it near-verbatim twice, into src/components/in-page-nav/InPageNavHeader. differential-detail-page was converted onto it; DocumentViewer was deliberately NOT, because its own useDocumentSectionSpy and useDocumentChromeMetrics wiring and its CSS custom-property names (--document-anchor-offset, --document-sticky-header-height, [data-document-sticky-header]) are pinned verbatim by tests/header-scroll-hide-contract.test.ts:110-113. Once #286 and #287 land, the template is adopted on every information page EXCEPT DocumentViewer. `docs/search-chrome-behaviour.md` (Default in-page navigation template) already records that DocumentViewer keeps its own header copy because it owns the page h1, uses edge-glass-header, and is pinned by visual baselines — so the gap is documented, not overlooked. #286 generalises chrome metrics for information pages only; it does not close DocumentViewer convergence. **Next:** owner decision only — convert DocumentViewer later (leaving pinned `--document-*` property names untouched per tests/header-scroll-hide-contract.test.ts:110-113), or explicitly mark the documented non-adoption as the final end state in this ledger when the series closes. **Stop:** do not rename or repoint the pinned document CSS custom properties to unify them with the information-page ones - the contract test pins those exact strings and the document route is the highest-traffic surface in the app. | session 2026-08-08; PR #1740 | 2026-08-08 | @@ -493,9 +492,8 @@ Move resolved rows here with the resolution date and a one-line outcome. Keep th | #247 | task | Widen the one-line results bar to the six modes that pass a full-width phone select | RESOLVED 2026-08-07. All seven surfaces that shipped a full-width phone select now pass one compact badged trigger and opt into `mobileControlsPlacement="inline"`, so the one-line results bar is universal rather than a documents/therapy-compass exception. Converted: differentials, services, factsheets, prescribing, and the tools launcher (one select each) plus formulation and specifiers (two selects each, in the two-column grid this row named as the hard case). `MobileResultFilterControl` is deleted — no caller remains. The shared idiom lives in `src/components/clinical-dashboard/result-filter-control.tsx`: `ResultFilterTrigger` (lifted verbatim from `DocumentFilterTrigger`, so the control is the same component everywhere) and `ResultFilterSheet`, which renders one `role="radiogroup"` per dimension because these are genuinely one-of-N. Documents keeps its own panel — multi-select facet groups with counts, a find-a-filter field and collapse-by-default are not radios. Desktop is untouched: the ribbon renders `filterControls` from `sm` up and `mobileControls` below it, never both, so every mode keeps its chip row or tab strip on a wide screen. Verified in a real browser at 390px: the differentials band is geometrically identical to the documents band (89px at 390, 60px at 414 and 430). The "Stop" in this row is honoured — `mobileControlsPlacement` still defaults to `row`; nothing relies on that fallback now, and it stays so a mode that forgets the prop degrades to a second row rather than to an unreadable line. Follow-up captured separately: the `max-[413px]:flex-wrap` threshold was measured when Sort still occupied the phone line and is now stale — one line fits with zero overflow at 320/360/375/390/402px. | 2026-08-07 | | #263 | task | DS Track A4: close the 13 open COMPONENTS section 0.4 defect rows | Closed on branch claude/ds-a4-component-defects (commit e674f6e20). Eleven defects fixed across ten registered components: Button ref forwarding (+ a testId prop, because @types/react@19 gives components no data-${string} index signature); Progress indeterminate sweep onto the animate-shimmer theme token; StageList step index clamped to >=1 and announcement moved off the
    onto an sr-only role=status SIBLING (a child
  1. would make a five-stage job announce as 'list, 6 items'); StatusMark DocumentStatus declared in the component with the app row type asserted to conform, not the reverse; PageHeader title column floored at minmax(20ch,1fr) — the pre-existing wrap decided WHERE actions sat, not how wide, so the title still starved; Disclosure collapsed panel print:block (and the docstring's Ctrl-F claim was false); AccessibleTable dense header keeps its full string as title and the expander is now the registered Button; Tabs invalid value no longer empties the tab order (reachability only, no onChange fired to repair caller state); Pagination props clamped, row wraps at 320px, boundary focus handed to the current page, page announced via LiveAnnouncer; Links download type-omitted AND written after the spread, and LinkAction's hover nudge is a composited translate-x because gap is not in Tailwind's transition list so hover:gap-2 never eased; Checkbox/RadioGroup raw size-[1.125rem]/h-[2px] onto size-5/h-0.5 (size-4.5 retired by check:icon-scale). THREE ROWS RE-MEASURED AS STALE and were not work: Checkbox/RadioGroup unsanitised ids (optionId already sanitises) and no group hint/error (fieldset already carries hint/error/describedBy). Button is now genuinely product-adopted by AccessibleTable with a real v2 mount, so it left the reference-only snapshot in tests/design-system-adoption.test.ts. Ratchets fell and none rose: edge conflicts 28->27, legacy shadow aliases 229->228; baseline deliberately NOT lowered — that is A3/A5 work and needs a full debt-baseline regeneration. Evidence executed: check:design-system-contract exit 0 (all three sub-checks); tsc --noEmit exit 0; lint exit 0 at --max-warnings 0; prettier --check . clean; verify:ui 407 passed (14.1m) exit 0; 260+191 unit tests across component suites. npm run verify:pr-local aggregate NOT run — Vitest fork workers unreliable under box load; components run individually. Deliberately OUT of scope and still open: TextField/SearchField/Select (PR 7) and the ui-primitives.tsx module split (PR 12) were never in this task; Disclosure title truncation, StatusMark inline styles/raw geometry, Citation route/source modes, Links implicit new-tab policy, AccessibleTable content-role widths and the Button client boundary remain on their own rows. New finding worth a row: LinkAction accepts tone via BaseProps but never destructures it, so tone is silently ignored (#276). | 2026-08-07 | | #276 | issue | LinkAction accepts a tone prop it never reads | Refused rather than honoured: LinkActionProps now carries tone?: never, shipped in PR #1720 (5c0504a40). Omit alone was not enough — excess-property checking only fires on object literals, so a spread still type-checked clean and rendered the accent; verified with a focused tsc probe (Omit accepted the spread with no diagnostic, tone?: never rejected it with TS2345). A type-level contract test in tests/ui-v2-components.dom.test.tsx stops compiling if the prop widens back, plus a render assertion that the accent is what ships. | 2026-08-08 | +| #285 | issue | Fresh remote/Cloud containers cannot run npm ci — shipped Node 24.13.0 is below the ^24.15.0 floor that main's jsdom@30.0.1 now requires | Resolved 2026-08-09: package.json engines.node raised from 24.x to >=24.15.0 <25, so the floor jsdom@30 already imposed is now declared. The preinstall hook (check-node-engine.cjs) and check:runtime enforce the minor floor instead of the major alone, and .claude/hooks/session-start.sh provisions Node 24.19.0 with a bounded guard (floor and exclusive major ceiling) so a cached container on 24.13 upgrades and a Node 25+ container does not skip provisioning. Proven live: the hook installed 24.19.0 and npm ci succeeded in-session. PR #1771. | 2026-08-09 | | #279 | issue | pdf.js 6 cannot raster in this container's Chromium, so no browser gate covers the viewer canvas | Resolved by tests/ui-document-canvas.spec.ts (Phase 3 Task 0, PR for claude/document-viewer-phase-3-bj5k5v): a Chromium viewer-canvas journey that reads the raster back — non-blank ink pixels on page 1, the real page count in the one toolbar readout, and a page flip whose FNV pixel signature differs from page 1's. Registered in all three hand-maintained lists (playwright.config.ts testMatch + productionSpecPattern, scripts/playwright-pr-shards.mjs productionSpecFilePattern + shard group 3) with a new fail-closed assertion in tests/playwright-project-isolation.test.ts so a future regex edit cannot silently drop it. The container skip is guarded asymmetrically: without CI it skips with a reason naming the browser version, with CI set it FAILS — verified both ways on 2026-08-09 (local run: 3 skipped; CI=1 run: 1 failed at the probe). Measurements re-derived after npm ci and they match the corrected row exactly: playwright-core/browsers.json chromium revision 1234 = 151.0.7922.34, container /opt/pw-browsers/chromium-1194 = 141.0.7390.37, pdfjs-dist 6.2.108 calling Map.prototype.getOrInsertComputed at pdf.mjs:2454/6889/6896. New datapoint: Node 24.13.0 also lacks getOrInsertComputed, so pdf.js 6 cannot be driven headlessly from this runtime either. Neither refuted remedy was actioned. | 2026-08-09 | | #264 | task | DS Track A5: gate the six ungated design-system prohibitions | CLOSED 2026-08-09. All six prohibitions named in this row now have a gate. Measured against origin/main 8db1e53937 before writing: FOUR of the six were ALREADY gated and GATES.md said otherwise - border+ring co-occurrence (edgeOwnershipConflicts), the 1px shadow spread check (onePixelShadowSpreads), layout-property animation (layoutTransitionExceptions) and the --shadow-tight alias lint (legacyShadowAliases) are all live ratchets in scripts/design-system-contract-baseline.json, and because findDebtPathRegressions compares per path a new violation in any file already failed. Their section 3 rows read planned; that understatement is what deferred this task twice and is corrected in GATES.md section 5. The dark: override lint closed 7 Aug. Genuinely new here: statusColouredNumerals (ratcheted 2) and colourOnlyStatusIndicators (ratcheted 4) for the colour-boundary rule, and imageInversions pinned at zero for the PDF/diagram/clinical-image invert rule. Also tightened two ratchets carrying stale slack to their measured values - edgeOwnershipConflicts 28 to 27 and legacyShadowAliases 231 to 224 - which closed headroom for up to seven new violations across seven files that had paid debt down without a baseline refresh. All new checks mutation-verified. Recorded debt that remains is NOT this row: the 224 shadow aliases are #262, the 27 edge conflicts and 2 spreads are gate 8 in #265. | 2026-08-09 | | #277 | issue | docs/design-system/HANDOVER-2026-08-07.md is cited as provenance by nine ledger rows but is measurably wrong | CLOSED 2026-08-09 as already satisfied — verified against origin/main 8db1e53937, not inferred. Both halves of this row's own 'cheapest fix' are present: docs/design-system/HANDOVER-2026-08-07.md carries a SUPERSEDED IMPORTANT callout naming docs/outstanding-issues.md as the current source of truth, listing the nine citing rows (#261, #262, #264-#270) and enumerating each disproved figure; and docs/design-system/README.md line 12 already reads 'superseded and must not be used to ...'. The file was correctly kept rather than deleted, preserving the nine Source citations as provenance. One note for whoever reads the banner next: its quoted figures have themselves drifted, which is precisely why this row insisted corrections live in the rows and not in the document. Measured today at 8db1e53937: legacyShadowAliases total 224, not the banner's 228, and the manifest reports 53 registered components with 31 product-imported, i.e. 22 unadopted rather than 23. Do NOT edit those numbers into the banner - re-stating live figures in a superseded document is the drift this row exists to stop. Also note the banner did still mislead one session despite existing: the M2 session of 2026-08-09 was scoped from a handover that repeated its claims, and four of #264's six prohibitions turned out to be already gated. The remaining risk is downstream documents copying the figures, not this file. | 2026-08-09 | - - diff --git a/package.json b/package.json index d4487d64b3..2d600894c8 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "UNLICENSED", "packageManager": "npm@11.17.0", "engines": { - "node": "24.x", + "node": ">=24.15.0 <25", "npm": "11.x" }, "scripts": { diff --git a/scripts/check-codex-cloud-setup.mjs b/scripts/check-codex-cloud-setup.mjs index 8fefe2bd22..2b05e564cc 100644 --- a/scripts/check-codex-cloud-setup.mjs +++ b/scripts/check-codex-cloud-setup.mjs @@ -761,8 +761,18 @@ export function validateCodexCloudSetup() { const mcp = read(".mcp.json"); const codexProjectConfig = read(".codex/config.toml"); - if (packageJson.engines?.node !== `${nodeVersion}.x`) { - errors.push(`package.json engines.node must match .node-version (${nodeVersion}.x).`); + // engines.node declares a minor-level floor (">=24.15.0 <25") rather than a + // bare major, because dev dependencies carry a floor a "24.x" range cannot + // express. Validate the shape and that its major still tracks .node-version. + const engineRange = String(packageJson.engines?.node ?? ""); + const engineFloor = engineRange.match(/>=\s*(\d+)\.(\d+)\.(\d+)/); + const engineCeiling = engineRange.match(/<\s*(\d+)/); + if (!engineFloor || !engineCeiling) { + errors.push( + `package.json engines.node must declare a floor and an exclusive major ceiling, e.g. ">=${nodeVersion}.15.0 <${Number(nodeVersion) + 1}". Found "${engineRange}".`, + ); + } else if (engineFloor[1] !== nodeVersion || Number(engineCeiling[1]) !== Number(nodeVersion) + 1) { + errors.push(`package.json engines.node major must match .node-version (${nodeVersion}).`); } if (packageJson.engines?.npm !== "11.x") errors.push("package.json must require npm 11.x."); if (!String(packageJson.packageManager ?? "").startsWith("npm@11.")) { @@ -772,6 +782,12 @@ export function validateCodexCloudSetup() { requireMatch(errors, gitignore, /^\/error\.log$/m, "Codex Cloud diagnostic error.log must stay ignored."); for (const [pattern, message] of [ + [/expected_node_range=/, "Cloud setup must read the complete Node engine range."], + [/node_version_supported/, "Cloud setup must validate the complete Node engine range."], + [ + /node_version_supported "\$actual_node_version" \|\| fail/, + "Cloud setup must fail closed if provisioning does not satisfy the Node engine range.", + ], [/npm ci --include=dev/, "Cloud setup must install the exact lockfile with dev dependencies."], [/deno@2/, "Cloud setup must install Deno 2.x."], [/worker\/python\/requirements-cloud\.txt/, "Cloud setup must install the Python 3.12 Cloud worker lock."], diff --git a/scripts/check-node-engine.cjs b/scripts/check-node-engine.cjs index fe8ea79538..3959617b68 100644 --- a/scripts/check-node-engine.cjs +++ b/scripts/check-node-engine.cjs @@ -1,13 +1,37 @@ -const major = Number(process.versions.node.split(".")[0]); -const npmUserAgent = process.env.npm_config_user_agent ?? ""; -const npmVersion = npmUserAgent.match(/\bnpm\/(\d+\.\d+\.\d+)/)?.[1] ?? ""; -const npmMajor = Number(npmVersion.split(".")[0]); +// npm preinstall hook. The Dockerfile COPYs this file on its own before +// `npm ci`, so it stays import-free and restates the range as a literal; +// tests/check-runtime.test.ts pins this string to package.json engines.node, +// which remains the single source of truth. +const nodeRange = ">=24.15.0 <25"; +const minimum = nodeRange.match(/>=\s*(\d+)\.(\d+)\.(\d+)/); +const exclusiveMajor = nodeRange.match(/<\s*(\d+)/); + +if (!minimum || !exclusiveMajor) { + console.error(`Could not read the supported Node range from package.json engines.node ("${nodeRange}").`); + process.exit(1); +} -if (major !== 24) { - console.error(`This project must be installed with Node 24.x. Current runtime: ${process.versions.node}.`); +const required = [Number(minimum[1]), Number(minimum[2]), Number(minimum[3])]; +const maxMajor = Number(exclusiveMajor[1]); +const actual = process.versions.node.split(".").map(Number); + +const belowFloor = + actual[0] < required[0] || + (actual[0] === required[0] && actual[1] < required[1]) || + (actual[0] === required[0] && actual[1] === required[1] && actual[2] < required[2]); + +// A too-old 24.x used to pass this hook and then fail deep in resolution with an +// opaque EBADENGINE for a transitive dev dependency (jsdom carries the same +// floor). Failing here names the actual requirement instead. +if (belowFloor || actual[0] >= maxMajor) { + console.error(`This project must be installed with Node ${nodeRange}. Current runtime: ${process.versions.node}.`); process.exit(1); } +const npmUserAgent = process.env.npm_config_user_agent ?? ""; +const npmVersion = npmUserAgent.match(/\bnpm\/(\d+\.\d+\.\d+)/)?.[1] ?? ""; +const npmMajor = Number(npmVersion.split(".")[0]); + if (npmVersion && npmMajor !== 11) { console.error(`This project must be installed with npm 11.x. Current npm runtime: ${npmVersion}.`); process.exit(1); diff --git a/scripts/check-runtime.ts b/scripts/check-runtime.ts index df38231bd4..8101c7fec4 100644 --- a/scripts/check-runtime.ts +++ b/scripts/check-runtime.ts @@ -43,8 +43,42 @@ function runtimeResult(runtimeName: string, version: string, expectedMajor: numb }; } -export function checkNodeRuntime(version: string, expectedMajor = 24): RuntimeCheckResult { - return runtimeResult("Node", version, expectedMajor); +// Must stay equal to the floor declared by package.json engines.node, which is +// the single source of truth. tests/check-runtime.test.ts pins the two together. +export const NODE_MINIMUM_VERSION = "24.15.0"; + +function isBelow(version: string, minimum: string): boolean { + const actual = version.split(".").map(Number); + const required = minimum.split(".").map(Number); + for (let index = 0; index < 3; index += 1) { + const left = actual[index] ?? 0; + const right = required[index] ?? 0; + if (left !== right) return left < right; + } + return false; +} + +export function checkNodeRuntime( + version: string, + expectedMajor = 24, + minimumVersion = NODE_MINIMUM_VERSION, +): RuntimeCheckResult { + const result = runtimeResult("Node", version, expectedMajor); + if (!result.ok) return result; + + // A matching major is not sufficient: dev dependencies (jsdom) carry a + // minor-level floor, and a too-old 24.x otherwise passes every gate and then + // fails at install with an opaque EBADENGINE for a transitive package. + if (isBelow(version, minimumVersion)) { + return { + ok: false, + expectedMajor, + actualVersion: version, + message: `Node ${version} is below the ${minimumVersion} floor this project requires (package.json engines.node). Install Node ${minimumVersion} or newer.`, + }; + } + + return result; } export function checkNpmRuntime( diff --git a/scripts/setup-codex-cloud.sh b/scripts/setup-codex-cloud.sh index 9ff9dd4ef4..ade3aa3581 100644 --- a/scripts/setup-codex-cloud.sh +++ b/scripts/setup-codex-cloud.sh @@ -34,12 +34,31 @@ repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || fail "Run this scrip cd "$repo_root" expected_node_major="$(tr -cd '0-9' < .node-version)" +expected_node_range="$(sed -n 's/.*"node"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' package.json | head -n 1)" +expected_node_floor="$(printf '%s\n' "$expected_node_range" | sed -n 's/^>=\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\) <[0-9][0-9]*$/\1/p')" +expected_node_ceiling="$(printf '%s\n' "$expected_node_range" | sed -n 's/^>=[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]* <\([0-9][0-9]*\)$/\1/p')" expected_npm_version="$(sed -n 's/.*"packageManager"[[:space:]]*:[[:space:]]*"npm@\([^"]*\)".*/\1/p' package.json | head -n 1)" codex_cli_version="0.146.0" expected_cloud_python="3.12" [[ -n "$expected_node_major" ]] || fail "Could not read the Node major from .node-version." +[[ -n "$expected_node_floor" && -n "$expected_node_ceiling" ]] || fail "Could not read the bounded Node range from package.json engines.node." [[ -n "$expected_npm_version" ]] || fail "Could not read the npm version from package.json." +node_version_supported() { + local version="$1" + local actual_major actual_minor actual_patch minimum_major minimum_minor minimum_patch + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1 + IFS=. read -r actual_major actual_minor actual_patch <<< "$version" + IFS=. read -r minimum_major minimum_minor minimum_patch <<< "$expected_node_floor" + + (( actual_major < expected_node_ceiling )) || return 1 + (( actual_major > minimum_major )) && return 0 + (( actual_major == minimum_major )) || return 1 + (( actual_minor > minimum_minor )) && return 0 + (( actual_minor == minimum_minor )) || return 1 + (( actual_patch >= minimum_patch )) +} + # Codex Cloud supplies standards-based proxy variables as well. Remove npm's # deprecated lowercase aliases before the first npm invocation. unset npm_config_http_proxy npm_config_https_proxy npm_config_proxy @@ -62,17 +81,20 @@ install_npm_cli() { setup_step="node-runtime" export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" setup_step="node-runtime" -actual_node_major="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || true)" -if [[ "$actual_node_major" != "$expected_node_major" ]]; then - [[ -s "$NVM_DIR/nvm.sh" ]] || fail "Node ${expected_node_major}.x is required. Select it in the Codex Cloud environment or provide nvm." +actual_node_version="$(node -p 'process.versions.node' 2>/dev/null || true)" +if ! node_version_supported "$actual_node_version"; then + [[ -s "$NVM_DIR/nvm.sh" ]] || fail "Node ${expected_node_range} is required; detected ${actual_node_version:-unavailable}. Select it in the Codex Cloud environment or provide nvm." # shellcheck source=/dev/null source "$NVM_DIR/nvm.sh" - log "Installing and selecting Node ${expected_node_major}.x." + log "Installing and selecting Node ${expected_node_major}.x to satisfy ${expected_node_range}." nvm install "$expected_node_major" nvm alias default "$expected_node_major" nvm use "$expected_node_major" fi +actual_node_version="$(node -p 'process.versions.node' 2>/dev/null || true)" +node_version_supported "$actual_node_version" || fail "Node ${expected_node_range} is required; detected ${actual_node_version:-unavailable}." + if [[ "$(npm --version)" != "$expected_npm_version" ]]; then log "Installing the repository npm version ${expected_npm_version}." npm install --global "npm@${expected_npm_version}" diff --git a/scripts/setup-codex-worktree.mjs b/scripts/setup-codex-worktree.mjs index 498c3ffda9..9fccf71459 100644 --- a/scripts/setup-codex-worktree.mjs +++ b/scripts/setup-codex-worktree.mjs @@ -121,16 +121,42 @@ function runNpm(args, options = {}) { return run("npm", args, options); } +export function nodeVersionSatisfiesRange(version, range) { + const actualMatch = String(version).match(/^(\d+)\.(\d+)\.(\d+)$/u); + const rangeMatch = String(range) + .trim() + .match(/^>=\s*(\d+)\.(\d+)\.(\d+)\s+<\s*(\d+)$/u); + if (!actualMatch || !rangeMatch) return false; + + const actual = actualMatch.slice(1).map(Number); + const minimum = rangeMatch.slice(1, 4).map(Number); + const exclusiveMajor = Number(rangeMatch[4]); + const belowMinimum = actual.some((part, index) => { + if (part === minimum[index]) return false; + return ( + actual.slice(0, index).every((value, prefixIndex) => value === minimum[prefixIndex]) && part < minimum[index] + ); + }); + + return !belowMinimum && actual[0] < exclusiveMajor; +} + function assertRuntime(projectRoot) { const expectedNodeMajor = readFileSync(path.join(projectRoot, ".node-version"), "utf8").trim(); const packageJson = JSON.parse(readFileSync(path.join(projectRoot, "package.json"), "utf8")); + const expectedNodeRange = String(packageJson.engines?.node ?? ""); const expectedNpm = String(packageJson.packageManager ?? "").replace(/^npm@/u, ""); - const actualNodeMajor = process.versions.node.split(".")[0]; const npmResult = runNpm(["--version"], { cwd: projectRoot, capture: true }); const actualNpm = npmResult.stdout?.trim(); - if (actualNodeMajor !== expectedNodeMajor) { - fail(`Node ${expectedNodeMajor}.x is required; detected ${process.versions.node}.`); + const declaredFloorMajor = expectedNodeRange.match(/^>=\s*(\d+)\./u)?.[1]; + if (declaredFloorMajor !== expectedNodeMajor) { + fail( + `package.json engines.node (${expectedNodeRange || "missing"}) must track .node-version (${expectedNodeMajor}).`, + ); + } + if (!nodeVersionSatisfiesRange(process.versions.node, expectedNodeRange)) { + fail(`Node ${expectedNodeRange} is required; detected ${process.versions.node}.`); } if (npmResult.status !== 0 || actualNpm !== expectedNpm) { fail(`npm ${expectedNpm} is required; detected ${actualNpm || "unavailable"}.`); diff --git a/tests/check-runtime.test.ts b/tests/check-runtime.test.ts index 846d9db580..844cf78439 100644 --- a/tests/check-runtime.test.ts +++ b/tests/check-runtime.test.ts @@ -1,6 +1,9 @@ +import { readFileSync } from "node:fs"; + import { describe, expect, it } from "vitest"; -import { checkNodeRuntime, checkNpmRuntime } from "../scripts/check-runtime"; +import packageJson from "../package.json"; +import { NODE_MINIMUM_VERSION, checkNodeRuntime, checkNpmRuntime } from "../scripts/check-runtime"; describe("runtime release gate", () => { it("accepts the Node 24 release target", () => { @@ -15,6 +18,58 @@ describe("runtime release gate", () => { expect(checkNodeRuntime("25.0.0")).toMatchObject({ ok: false }); }); + // A matching major used to be sufficient, so 24.13.0 passed every gate and + // then failed `npm ci` with an opaque EBADENGINE for jsdom. + it("rejects a matching major that is below the dependency floor", () => { + const result = checkNodeRuntime("24.13.0"); + expect(result.ok).toBe(false); + expect(result.message).toContain(NODE_MINIMUM_VERSION); + }); + + it("accepts runtimes at or above the floor", () => { + expect(checkNodeRuntime(NODE_MINIMUM_VERSION)).toMatchObject({ ok: true }); + expect(checkNodeRuntime("24.19.0")).toMatchObject({ ok: true }); + }); + + it("keeps the floor equal to the package.json engines.node declaration", () => { + const declared = packageJson.engines.node.match(/>=\s*(\d+\.\d+\.\d+)/)?.[1]; + expect(declared).toBe(NODE_MINIMUM_VERSION); + }); + + // The preinstall hook is COPYed into the Docker image on its own, so it + // cannot import package.json and restates the range as a literal instead. + it("keeps the preinstall hook's declared range equal to package.json engines.node", () => { + const source = readFileSync(new URL("../scripts/check-node-engine.cjs", import.meta.url), "utf8"); + const declared = source.match(/const nodeRange = "([^"]+)"/)?.[1]; + expect(declared).toBe(packageJson.engines.node); + }); + + // The SessionStart hook provisions Node before npm exists, so it restates the + // bounds in shell. Checking only the floor let a Node 25 container skip + // provisioning and then fail npm ci against the "<25" half of the range. + describe("SessionStart hook runtime bounds", () => { + const hook = readFileSync(new URL("../.claude/hooks/session-start.sh", import.meta.url), "utf8"); + const engineFloor = packageJson.engines.node.match(/>=\s*(\d+\.\d+\.\d+)/)?.[1]; + const engineCeiling = packageJson.engines.node.match(/<\s*(\d+)/)?.[1]; + + it("declares the same floor and exclusive ceiling as package.json engines.node", () => { + expect(hook.match(/^NODE_MINIMUM="([^"]+)"/m)?.[1]).toBe(engineFloor); + expect(hook.match(/^NODE_MAJOR_CEILING="([^"]+)"/m)?.[1]).toBe(engineCeiling); + }); + + it("pins an install version that is itself inside the supported range", () => { + const pinned = hook.match(/^NODE_VERSION="([^"]+)"/m)?.[1]; + expect(pinned).toBeDefined(); + expect(checkNodeRuntime(pinned!)).toMatchObject({ ok: true }); + expect(Number(pinned!.split(".")[0])).toBeLessThan(Number(engineCeiling)); + }); + + it("uses a Bash-native comparison without GNU sort", () => { + expect(hook).not.toContain("sort -V"); + expect(hook).toContain("(( actual_patch >= minimum_patch ))"); + }); + }); + it("reports unparsable runtime versions as failures", () => { expect(checkNodeRuntime("not-a-version")).toMatchObject({ ok: false }); }); diff --git a/tests/codex-cloud-setup.test.ts b/tests/codex-cloud-setup.test.ts index 8f4cf6e47d..a7217d1e20 100644 --- a/tests/codex-cloud-setup.test.ts +++ b/tests/codex-cloud-setup.test.ts @@ -575,6 +575,10 @@ describe("Codex Cloud environment contract", () => { ); expect(setup).toContain("if ! grep -Fq '.clinical-kb-codex-cloud.sh'"); expect(setup).toContain('if [[ "$actual_version" != "$expected_version" ]]'); + expect(setup).toContain('expected_node_range="$(sed'); + expect(setup).toContain('if ! node_version_supported "$actual_node_version"; then'); + expect(setup).toContain('node_version_supported "$actual_node_version" || fail'); + expect(setup).not.toContain("actual_node_major="); expect(setup).toContain('"$HOME/.bash_profile"'); expect(setup.match(/unset npm_config_http_proxy npm_config_https_proxy npm_config_proxy/g)).toHaveLength(2); expect(setup).toContain("worker/python/requirements-cloud.txt"); diff --git a/tests/setup-codex-worktree.test.ts b/tests/setup-codex-worktree.test.ts index cbd3ad2d24..a3f38e7177 100644 --- a/tests/setup-codex-worktree.test.ts +++ b/tests/setup-codex-worktree.test.ts @@ -8,6 +8,7 @@ import { installationIsComplete, installedMetadataMatches, lockDigest, + nodeVersionSatisfiesRange, parseWorktreeList, resolveNpmCli, } from "../scripts/setup-codex-worktree.mjs"; @@ -47,6 +48,17 @@ afterEach(() => { }); describe("Codex Desktop worktree setup", () => { + it("enforces the complete declared Node range before dependency handling", () => { + const range = ">=24.15.0 <25"; + + expect(nodeVersionSatisfiesRange("24.13.0", range)).toBe(false); + expect(nodeVersionSatisfiesRange("24.14.9", range)).toBe(false); + expect(nodeVersionSatisfiesRange("24.15.0", range)).toBe(true); + expect(nodeVersionSatisfiesRange("24.19.0", range)).toBe(true); + expect(nodeVersionSatisfiesRange("25.0.0", range)).toBe(false); + expect(nodeVersionSatisfiesRange("not-a-version", range)).toBe(false); + }); + it("parses worktree paths without treating metadata as paths", () => { expect(parseWorktreeList("worktree C:/repo/main\nHEAD abc\n\nworktree C:/repo/feature\ndetached\n")).toEqual([ path.resolve("C:/repo/main"),