Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions docs/medication-interaction-lexicon-review.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Medication interaction lexicon — clinical review sheet

**Status: reviewed 2026-08-22NOT 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
Expand Down Expand Up @@ -167,12 +167,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 unchanged and still applies, read as the 37 terms as they stand at the
mappings hash above rather than as they stood on 2026-08-22.

### What was corrected, and why

Expand Down
48 changes: 44 additions & 4 deletions scripts/guard-next-build.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
#!/usr/bin/env node
import { rmSync } from "node:fs";
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), "..");
Expand Down Expand Up @@ -80,19 +86,51 @@ 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;
}

return null;
}

/**
* Remove `.next/dev`, the dev server's own output, before a production build.
*
* A dev server stopped mid-write leaves a truncated `.next/dev/types/validator.ts`
* behind, and `next build` type-checks it: the build then fails with a syntax
* error in a generated file nobody wrote, on a tree where nothing is wrong. That
* cost a full `verify:pr-local` run on 2026-09-06. `guard-push.mjs` already works
* around the same artefact for Prettier; this closes it for the build.
*
* Only reached once the checks above have established no dev server is running,
* so nothing is reading or rewriting the directory as it is removed. Production
* output lives in `.next/server`, `.next/static` and `.next/types`, none of which
* are touched — the cost of being wrong is one slower dev start, not a rebuild.
*/
export function discardDevServerTypes(rootDir = projectRoot) {
rmSync(path.join(rootDir, ".next", "dev"), { recursive: true, force: true });
}

async function main() {
const ramDecision = evaluateNextBuildRamGuard();
if (ramDecision === "fail") {
Expand Down Expand Up @@ -124,6 +162,8 @@ async function main() {
);
process.exit(DEV_SERVER_BUILD_REFUSED_EXIT_CODE);
}

discardDevServerTypes();
Comment thread
BigSimmo marked this conversation as resolved.
}

const isDirectRun = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(modulePath);
Expand Down
8 changes: 7 additions & 1 deletion src/components/services/service-detail-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<article
className={cn(
"min-h-[7.25rem] rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-3 shadow-[var(--shadow-inset)] sm:min-h-[7.75rem]",
"flex min-h-[7.25rem] flex-col justify-center rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-3 shadow-[var(--shadow-inset)] sm:min-h-[7.75rem]",
isCost && "border-[color:var(--success-border)] bg-[color:var(--success-soft)]/25",
)}
>
Expand Down
32 changes: 27 additions & 5 deletions src/components/therapy-compass/record/key-facts.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -54,11 +54,20 @@ function FactCard({ card, onOpen }: { card: TherapyKeyFactCard; onOpen?: () => v
</h3>
);

// 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 = <div className="flex min-w-0 flex-1 flex-col justify-center">{face}</div>;

if (!isInteractive) {
return (
<article className={cardSurface}>
{header}
{face}
{body}
<CardFootnote />
</article>
);
}
Expand All @@ -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}
<p className={cn("mt-auto pt-1 text-2xs font-medium leading-4 sm:pt-1.5", textMuted)}>Tap for detail</p>
{body}
<CardFootnote>Tap for detail</CardFootnote>
</button>
</article>
);
}

/**
* 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 (
<p className={cn("mt-auto pt-1 text-2xs font-medium leading-4 sm:pt-1.5", textMuted)}>
{children ?? <span aria-hidden>&nbsp;</span>}
</p>
);
}

/**
* The four facts worth reading before anything else, at the top of the record.
*
Expand Down
103 changes: 101 additions & 2 deletions tests/guard-next-build.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
import { describe, expect, it } from "vitest";
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 { DEV_SERVER_BUILD_REFUSED_EXIT_CODE, evaluateNextBuildRamGuard } from "../scripts/guard-next-build.mjs";
import { afterEach, describe, expect, it } from "vitest";

import {
DEV_SERVER_BUILD_REFUSED_EXIT_CODE,
discardDevServerTypes,
evaluateNextBuildRamGuard,
findRunningProjectServer,
} 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;
Expand Down Expand Up @@ -31,3 +48,85 @@ describe("DEV_SERVER_BUILD_REFUSED_EXIT_CODE", () => {
expect(DEV_SERVER_BUILD_REFUSED_EXIT_CODE).toBe(76);
});
});

describe("discardDevServerTypes", () => {
const roots: string[] = [];

afterEach(() => {
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true, maxRetries: 5 });
});

function scratchRoot() {
const root = mkdtempSync(path.join(tmpdir(), "guard-next-build-"));
roots.push(root);
return root;
}

it("removes a dev server's leftover output so a truncated validator cannot fail the build", () => {
// A dev server stopped mid-write leaves `.next/dev/types/validator.ts` half
// finished, and `next build` type-checks it: the build then fails on a
// generated file nobody wrote. Observed on 2026-09-06, cost a full
// verify:pr-local run.
const root = scratchRoot();
mkdirSync(path.join(root, ".next", "dev", "types"), { recursive: true });
writeFileSync(path.join(root, ".next", "dev", "types", "validator.ts"), "export const truncated = {");

discardDevServerTypes(root);

expect(existsSync(path.join(root, ".next", "dev"))).toBe(false);
});

it("leaves production build output alone", () => {
const root = scratchRoot();
mkdirSync(path.join(root, ".next", "server"), { recursive: true });
writeFileSync(path.join(root, ".next", "BUILD_ID"), "abc123");

discardDevServerTypes(root);

expect(existsSync(path.join(root, ".next", "server"))).toBe(true);
expect(existsSync(path.join(root, ".next", "BUILD_ID"))).toBe(true);
});

it("is a no-op when there is nothing to discard", () => {
const root = scratchRoot();
expect(() => discardDevServerTypes(root)).not.toThrow();
});
});

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<void>((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<void>((resolve) => server.close(() => resolve()));
rmSync(rootDir, { recursive: true, force: true, maxRetries: 5 });
}
}, 60_000);
});
14 changes: 11 additions & 3 deletions tests/medication-lexicon-report-signoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
Expand All @@ -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 () => {
Expand Down
Loading