Tap for detail
+ {body} +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.
Tap for detail
+ {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