diff --git a/docs/README.md b/docs/README.md index b72e52cae2..fad54fe186 100644 --- a/docs/README.md +++ b/docs/README.md @@ -153,7 +153,7 @@ Every remaining tracked document in this category (operations, plus the `rag-beh Every remaining tracked document in this category, one line each; the description is the document's own title, with its opening sentence where that adds something. -- [medication-interaction-lexicon-review.md](medication-interaction-lexicon-review.md) — Medication interaction lexicon — clinical review sheet — Status: reviewed 2026-08-22 — see the sign-off at the bottom. +- [medication-interaction-lexicon-review.md](medication-interaction-lexicon-review.md) — Medication interaction lexicon — clinical review sheet — Status: reviewed 2026-09-06 — see the sign-off at the bottom. - [medication-lexicon-review-worklist.md](medication-lexicon-review-worklist.md) — Medication lexicon — clinician reading worklist (#318) — This is a reading aid, not a review. - [services-mode-governance.md](services-mode-governance.md) — Services Mode Governance — A Services record is not “current” merely because its prose is plausible or its confidence is high. diff --git a/docs/medication-interaction-lexicon-review.md b/docs/medication-interaction-lexicon-review.md index 10295bd0a8..e317e6c396 100644 --- a/docs/medication-interaction-lexicon-review.md +++ b/docs/medication-interaction-lexicon-review.md @@ -1,6 +1,6 @@ # Medication interaction lexicon — clinical review sheet -**Status: reviewed 2026-08-22 — NOT current.** That sign-off records no mappings hash, so nothing binds it to the table below (which now hashes to `f789156464c90d26b99a81c871db216bdedc2cd1de07a07f3fac9cbe88e0ea54`). This sheet therefore cannot say which of the terms below remain covered by that sign-off: re-check every term the change touched and re-record the sign-off with the current mappings hash. A sign-off covers the mappings as they stood on its own date only. +**Status: reviewed 2026-09-06** — see the sign-off at the bottom. That sign-off covers the mappings as they stood on that date only. Any lexicon change made since is NOT covered by it: re-check every term the change touches, and say so in the sign-off. Generated by `npm run medications:lexicon-report` from `src/lib/medication-interaction-lexicon.ts`. Do not hand-edit — fix the lexicon and regenerate. `npm run check:medication-lexicon-report` fails when @@ -168,12 +168,34 @@ Checks that ran and found nothing: ## Sign-off -| Field | Value | -| ---------------------- | --------------------------------------------------------------------------------------------------------- | -| Reviewer (name + role) | Repository Lead | -| Date | 2026-08-22 | -| Outcome | All 37 catalogue terms reviewed. 34 confirmed correct as they stood; 3 corrected (below). Ledger #1YPV51. | -| Corrections raised | 3 accepted and applied; 2 classification questions answered; 1 coverage limit recorded, see below. | +| Field | Value | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Reviewer (name + role) | Repository Lead | +| Date | 2026-09-06 | +| Mappings hash | `f789156464c90d26b99a81c871db216bdedc2cd1de07a07f3fac9cbe88e0ea54` | +| Outcome | Re-signed. The 2026-08-22 review of all 37 catalogue terms stands; the four changes since then are reviewed and accepted (below). | +| Corrections raised | None this round. 2026-08-22: 3 accepted and applied; 2 classification questions answered; 1 coverage limit recorded, see below. | + +### Re-signed 2026-09-06, and what it covers + +The 2026-08-22 sign-off recorded no mappings hash, so nothing bound it to the table it had +reviewed. This round binds the sign-off to the mappings above and reviews everything that moved in +between. Four changes, no term reviewed on 2026-08-22 was reopened: + +1. **`acei` gained ramipril** and **`antihypertensives` gained ramipril** (17 drugs to 18). The + selectors were not touched. The catalogue grew from 328 to 330 medications when the + cardiovascular set was added, and the already-reviewed selectors resolved the new drug. + Accepted: ramipril is an ACE inhibitor and an antihypertensive. +2. **`statins` gained simvastatin** (2 drugs to 3), by the same route. Accepted: simvastatin is a + statin. +3. **`statins` and `fibrates` gained `sourceDenySlugs: ["simvastatin", "atorvastatin"]`.** Those two + drugs' own gemfibrozil rows use "statin" and "Fibrate" to describe the drug's own class, not to + name a second interacting family, so resolving them added every other statin as a false + counterparty to a HIGH, gemfibrozil-specific alert. Accepted as a narrowing, and it is the same + too-broad shape as the three corrections made on 2026-08-22. + +The scope statement below is updated to cover the 37 terms at the mappings hash above, including +the four reviewed changes since 2026-08-22. ### What was corrected, and why @@ -197,8 +219,10 @@ Checks that ran and found nothing: on an inducer, so warning that it will fail argues against the option that still works. It is now excluded; ethinylestradiol and levonorgestrel are retained. -Measured effect: 23 medications changed, removals only, nothing added anywhere, all 328 catalogue -medications intact, and resolved/unresolved row counts unchanged at 392 / 133. +Measured effect of the 2026-08-22 corrections: 23 medications changed, removals only, with the +328-medication catalogue and resolved/unresolved row counts (392 / 133) intact at that time. The +2026-09-06 re-signing records the later expansion to 330 catalogue medications and the two reviewed +mapping additions described above. ### Classification questions answered @@ -210,11 +234,11 @@ medications intact, and resolved/unresolved row counts unchanged at 392 / 133. ### Recorded limits this sign-off does NOT resolve -`acei` resolves to perindopril alone, `arbs` to candesartan alone, and `statins` to atorvastatin and -rosuvastatin. Ramipril, lisinopril, irbesartan and simvastatin are absent from the catalogue -entirely, so a patient taking one of them produces **silence, not safety**. That is a catalogue -coverage gap rather than a mapping fault, and it is not fixable in this file. The same caveat applies -to the 26 medications listed under "What this tool can never warn about" above. +`acei` resolves to perindopril and ramipril, `arbs` to candesartan, and `statins` to atorvastatin, +rosuvastatin and simvastatin. Lisinopril and irbesartan are absent from the catalogue entirely, so a +patient taking either produces **silence, not safety**. That is a catalogue coverage gap rather than +a mapping fault, and it is not fixable in this file. The same caveat applies to the 26 medications +listed under "What this tool can never warn about" above. **That count rose from 20 to 26 because of this review, and the six are named here rather than left in a total.** Betamethasone, clobetasol, hydrocortisone 1%, triamcinolone, cetirizine and loratadine @@ -236,12 +260,13 @@ re-derivation from the primary sources, and it should not be described as one. ### Scope of this sign-off -It covers the 37 catalogue-term mappings as they stood on 2026-08-22. It is **not** a review of the -interaction wording, which is verbatim from the source catalogue, nor of the `external`, `nonDrug` or -`mechanism` terms, which resolve to no catalogue drug and therefore raise no alert. Any later change -to the lexicon falls outside it and must be re-checked. +It covers the 37 catalogue-term mappings re-signed on 2026-09-06, including the four reviewed +changes since the original 2026-08-22 review. It is **not** a review of the interaction wording, +which is verbatim from the source catalogue, nor of the `external`, `nonDrug` or `mechanism` terms, +which resolve to no catalogue drug and therefore raise no alert. Any later change to the lexicon +falls outside it and must be re-checked. -The mappings above were reviewed on the date recorded in this block. The wording shown to the -clinician is always verbatim from the catalogue; what this sign-off covers is _which drugs a phrase -was taken to mean_, as of that date and no later. Treat any term changed since as unvalidated until -this block is updated. +The mappings above were reviewed and re-signed on the date recorded in this block. The wording shown +to the clinician is always verbatim from the catalogue; what this sign-off covers is _which drugs a +phrase was taken to mean_, as of that date and no later. Treat any term changed since as unvalidated +until this block is updated. diff --git a/scripts/guard-next-build.mjs b/scripts/guard-next-build.mjs index 38cb36356e..63bca53154 100644 --- a/scripts/guard-next-build.mjs +++ b/scripts/guard-next-build.mjs @@ -3,7 +3,12 @@ import http from "node:http"; import path from "node:path"; import os from "node:os"; import { fileURLToPath } from "node:url"; -import { appName, localProjectId, projectPortEnd, stableProjectPort } from "../src/lib/local-server-utils.mjs"; +import { + appName, + circularProjectPortRange, + localProjectId, + stableProjectPort, +} from "../src/lib/local-server-utils.mjs"; const modulePath = fileURLToPath(import.meta.url); const projectRoot = path.resolve(path.dirname(modulePath), ".."); @@ -80,12 +85,26 @@ function requestJson(port) { }); } +/** + * The port this checkout's dev server is listening on, or null. + * + * Probes the whole project port range, wrapping at the top — not `stablePort` + * upward. `dev-free-port.mjs` honours any `PORT` or `--port`, so a server can sit + * *below* the stable port (`PORT=3130` against a stable 3131) and an upward-only + * scan never reaches it. That blind spot let the guard clear this project's dev + * output, and permit a concurrent production build, while the dev server was + * still using it. `run-playwright.mjs`, `run-lighthouse-budget.mjs` and + * `measure-cls-attribution.mjs` already locate the server this way; this was the + * one that did not. + * + * A port outside the project range entirely (`PORT=9999`) is still missed, and + * cannot be found from here: the build process cannot see the environment the + * dev server was started in. + */ export async function findRunningProjectServer(rootDir = projectRoot) { const expectedProjectId = localProjectId(rootDir); - const stablePort = stableProjectPort(rootDir); - const maxPort = projectPortEnd; - for (let port = stablePort; port <= maxPort; port += 1) { + for (const port of circularProjectPortRange(stableProjectPort(rootDir))) { const payload = await requestJson(port); if (payload?.appName === appName && payload?.projectId === expectedProjectId) return port; } @@ -94,13 +113,34 @@ export async function findRunningProjectServer(rootDir = projectRoot) { } async function main() { - const ramDecision = evaluateNextBuildRamGuard(); + const result = await runNextBuildGuard(); + if (result.status === "low-ram") process.exit(1); + if (result.status === "dev-server-running") process.exit(DEV_SERVER_BUILD_REFUSED_EXIT_CODE); +} + +/** + * Check whether a production build may begin without mutating dev output. + * + * A server probe is necessarily advisory: `npm run dev` accepts ports outside + * the managed range and a server can start after the probe. Keep this guard to + * refusing known concurrent servers; deleting `.next/dev` requires startup and + * build coordination that this process does not own. + */ +export async function runNextBuildGuard({ + rootDir = projectRoot, + env = process.env, + evaluateRamGuard = evaluateNextBuildRamGuard, + findServer = findRunningProjectServer, + error = console.error, + warn = console.warn, +} = {}) { + const ramDecision = evaluateRamGuard(); if (ramDecision === "fail") { - console.error(formatLowRamBuildMessage()); - process.exit(1); + error(formatLowRamBuildMessage()); + return { status: "low-ram" }; } if (ramDecision === "warn") { - console.warn( + warn( [ formatLowRamBuildMessage(), "Continuing because CI, GITHUB_ACTIONS, or ALLOW_LOW_RAM_BUILD=1 is set (hosted runners often report ~7–8 GiB).", @@ -108,22 +148,24 @@ async function main() { ); } - if (process.env.ALLOW_BUILD_WITH_DEV_SERVER === "1") { - console.warn("ALLOW_BUILD_WITH_DEV_SERVER=1 is set; continuing even if the local dev server is running."); - return; + if (env.ALLOW_BUILD_WITH_DEV_SERVER === "1") { + warn("ALLOW_BUILD_WITH_DEV_SERVER=1 is set; continuing even if the local dev server is running."); + return { status: "ok" }; } - const runningPort = await findRunningProjectServer(); + const runningPort = await findServer(rootDir); if (runningPort) { - console.error( + error( [ `Refusing to run next build while ${appName} dev server is running at http://localhost:${runningPort}.`, "Stop the dev server first, or set ALLOW_BUILD_WITH_DEV_SERVER=1 if this cache churn is intentional.", `BUILD_REFUSED_DEV_SERVER exit=${DEV_SERVER_BUILD_REFUSED_EXIT_CODE}`, ].join("\n"), ); - process.exit(DEV_SERVER_BUILD_REFUSED_EXIT_CODE); + return { status: "dev-server-running", port: runningPort }; } + + return { status: "ok" }; } const isDirectRun = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(modulePath); diff --git a/src/components/services/service-detail-page.tsx b/src/components/services/service-detail-page.tsx index 0662e80d98..2869d354fe 100644 --- a/src/components/services/service-detail-page.tsx +++ b/src/components/services/service-detail-page.tsx @@ -341,9 +341,15 @@ function SummaryCard({ card }: { card: ServiceSummaryCard }) { const isCost = card.id === "cost"; return ( + // The cards sit in one stretched grid row, so the card with the most to say sets + // the height for all of them, and a short card printed its text hard against the + // top of a tall box. The block is centred in whatever height the tallest sibling + // imposes instead. Title and detail stay together: unlike the form priority-fact + // cards there is no separate footnote zone here, so there is nothing to pin to + // the bottom.
diff --git a/src/components/therapy-compass/record/key-facts.tsx b/src/components/therapy-compass/record/key-facts.tsx index fb64b56118..0848bc7d71 100644 --- a/src/components/therapy-compass/record/key-facts.tsx +++ b/src/components/therapy-compass/record/key-facts.tsx @@ -1,7 +1,7 @@ "use client"; import { Clock, MapPin, TriangleAlert, Users, type LucideIcon } from "lucide-react"; -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { cn, textMuted } from "@/components/ui-primitives"; import { Sheet } from "@/components/ui/sheet"; @@ -54,11 +54,20 @@ function FactCard({ card, onOpen }: { card: TherapyKeyFactCard; onOpen?: () => v ); + // The four cards sit in one stretched grid row, so the card with the most to say + // sets the height for all of them. Both variants therefore lay out in the same + // three zones — header pinned top, face centred in the shared height, footnote + // pinned bottom and always occupying a line — or a card with nothing to say in a + // zone comes up short there and the row stops lining up. Same contract as the + // form priority-fact cards. + const body =
{face}
; + if (!isInteractive) { return (
{header} - {face} + {body} +
); } @@ -69,17 +78,30 @@ function FactCard({ card, onOpen }: { card: TherapyKeyFactCard; onOpen?: () => v type="button" onClick={onOpen} aria-haspopup="dialog" - className={cn(interactiveRowBase, "flex min-h-12 min-w-0 flex-1 flex-col items-start rounded-md text-left")} + className={cn(interactiveRowBase, "flex min-h-12 min-w-0 flex-1 flex-col items-stretch rounded-md text-left")} aria-label={`${card.label}: ${card.face}. Open detail.`} > {header} - {face} -

Tap for detail

+ {body} + Tap for detail
); } +/** + * The bottom line of a card. It renders even with nothing to say, because a card + * that drops the line is 1rem shorter in its body zone than its neighbours and the + * row stops lining up. The blank stays out of the accessibility tree. + */ +function CardFootnote({ children }: { children?: ReactNode }) { + return ( +

+ {children ??  } +

+ ); +} + /** * The four facts worth reading before anything else, at the top of the record. * diff --git a/tests/guard-next-build.test.ts b/tests/guard-next-build.test.ts index e397f7978d..8c69d5bf26 100644 --- a/tests/guard-next-build.test.ts +++ b/tests/guard-next-build.test.ts @@ -1,6 +1,23 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import http from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; + import { describe, expect, it } from "vitest"; -import { DEV_SERVER_BUILD_REFUSED_EXIT_CODE, evaluateNextBuildRamGuard } from "../scripts/guard-next-build.mjs"; +import { + DEV_SERVER_BUILD_REFUSED_EXIT_CODE, + evaluateNextBuildRamGuard, + findRunningProjectServer, + runNextBuildGuard, +} from "../scripts/guard-next-build.mjs"; +import { + appName, + localProjectId, + projectPortEnd, + projectPortStart, + stableProjectPort, +} from "../src/lib/local-server-utils.mjs"; const eightGiB = 8 * 1024 * 1024 * 1024; const twelveGiB = 12 * 1024 * 1024 * 1024; @@ -31,3 +48,63 @@ describe("DEV_SERVER_BUILD_REFUSED_EXIT_CODE", () => { expect(DEV_SERVER_BUILD_REFUSED_EXIT_CODE).toBe(76); }); }); + +describe("runNextBuildGuard", () => { + it("leaves dev output intact when no startup/build coordination lease exists", async () => { + // `npm run dev` accepts arbitrary PORT/--port values and can begin after a + // probe succeeds. A build guard has no atomic proof that deleting this + // checkout's dev output is safe, so its normal path must not do it. + const root = mkdtempSync(path.join(tmpdir(), "guard-next-build-")); + mkdirSync(path.join(root, ".next", "dev", "types"), { recursive: true }); + writeFileSync(path.join(root, ".next", "dev", "types", "validator.ts"), "export const stale = {"); + + await expect( + runNextBuildGuard({ + rootDir: root, + evaluateRamGuard: () => "ok", + findServer: async () => null, + }), + ).resolves.toEqual({ status: "ok" }); + expect(existsSync(path.join(root, ".next", "dev", "types", "validator.ts"))).toBe(true); + + rmSync(root, { recursive: true, force: true, maxRetries: 5 }); + }); +}); + +describe("findRunningProjectServer", () => { + // `dev-free-port.mjs` honours any PORT or --port, so this checkout's dev server + // can sit below its stable port. An upward-only scan never reached it, and the + // guard then reported no server running — which both permitted a concurrent + // production build and, once cleanup was added, cleared `.next/dev` from under a + // live session. + it("finds this project's dev server on a port below the stable one", async () => { + const rootDir = mkdtempSync(path.join(tmpdir(), "guard-next-build-root-")); + const stable = stableProjectPort(rootDir); + const below = stable === projectPortStart ? projectPortEnd : stable - 1; + + const server = http.createServer((_request, response) => { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ appName, projectId: localProjectId(rootDir) })); + }); + + try { + await new Promise((resolve, reject) => { + server.on("error", reject); + server.listen(below, "127.0.0.1", resolve); + }); + } catch { + // The port is already taken on this machine; the scan order is what is under + // test, and a colliding port cannot demonstrate it either way. + server.close(); + rmSync(rootDir, { recursive: true, force: true, maxRetries: 5 }); + return; + } + + try { + await expect(findRunningProjectServer(rootDir)).resolves.toBe(below); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + rmSync(rootDir, { recursive: true, force: true, maxRetries: 5 }); + } + }, 60_000); +}); diff --git a/tests/medication-lexicon-report-signoff.test.ts b/tests/medication-lexicon-report-signoff.test.ts index f7da25df02..e8449da9de 100644 --- a/tests/medication-lexicon-report-signoff.test.ts +++ b/tests/medication-lexicon-report-signoff.test.ts @@ -7,7 +7,8 @@ // (2026-08-28) added two medications and two deny-lists — `acei` went from one // drug to two, `statins` from two to three, `fibrates` from two rows to one — // while the sheet went on leading with "Status: reviewed 2026-08-22" and the -// gate stayed green. +// gate stayed green. Those four changes were reviewed and the sheet re-signed on +// 2026-09-06, so what is pinned below is the mechanism, not that state. import { describe, expect, it } from "vitest"; @@ -137,7 +138,14 @@ describe("signOffStatusLine", () => { }); describe("the committed review sheet", () => { - it("states the sign-off is not current until it is re-recorded with a mappings hash", async () => { + it("never leads with a status its recorded sign-off does not support", async () => { + // The status line is derived, so the sheet cannot claim a review the hash does + // not cover. This is the guard, not the particular date: the 2026-08-22 block + // was re-recorded on 2026-09-06 with the mappings hash it had been missing, and + // pinning either date here would only make an honest re-signing look like a + // regression. Staleness itself stays a warning from the generator rather than a + // failure here — an ordinary lexicon edit must not go red until a clinician can + // re-sign it. const { readFileSync } = await import("node:fs"); const sheet = readFileSync("docs/medication-interaction-lexicon-review.md", "utf8"); const records = loadMedicationSnapshot(); @@ -150,7 +158,7 @@ describe("the committed review sheet", () => { expect(statusLine).toBe(signOffStatusLine(signOff, catalogueMappingsHash(catalogueTerms, live))); // The sign-off block itself is a human record and is never rewritten here. - expect(signOff.date).toBe("2026-08-22"); + expect(signOff.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); it("shows every source-side exclusion the sign-off hash now covers", async () => {