From 735a9371345d140469b9975df97b541f81f804c2 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:24:51 +0800 Subject: [PATCH 01/12] fix(tooling,ui,cross-mode): complete safe tooling guards, landmark deduplication, and loading skeletons --- docs/branch-cleanup-guide.md | 13 +- docs/design-system/adoption-manifest.json | 2 +- docs/launch-operator-runbook.md | 8 +- docs/rag-improvement/HANDOVER.md | 3 +- docs/rag-improvement/README.md | 5 +- docs/site-map.md | 30 +- eval/docling/fixtures/generate_fixtures.py | 37 +- eval/docling/fixtures/manifest.v1.json | 28 +- eval/docling/report/lab-contract.mjs | 18 +- scripts/audit-source-governance.ts | 324 +++++++++++++++ scripts/ci-change-scope.mjs | 23 +- scripts/generate-site-map.ts | 26 ++ scripts/guard-next-build.mjs | 9 +- scripts/pr-policy.mjs | 20 +- scripts/verify-pr-local.mjs | 67 +++- src/app/(search-app)/calculators/loading.tsx | 5 + src/app/(search-app)/dictionary/loading.tsx | 5 + .../presentations/[slug]/loading.tsx | 4 +- .../medications/[slug]/loading.tsx | 4 +- src/app/(search-app)/medications/loading.tsx | 5 + src/app/api/documents/signed-urls/route.ts | 3 +- src/components/calculators/home-page.tsx | 10 +- src/components/calculators/search-detail.tsx | 9 +- .../clinical-dashboard/differentials-home.tsx | 8 +- .../document-search-results.tsx | 39 +- .../master-search-header.tsx | 2 + .../medication-prescribing-workspace.tsx | 8 +- .../dictionary-browse-header-mockups.tsx | 4 +- .../dictionary/dictionary-home-page.tsx | 10 +- src/components/dsm/dsm-home-page.tsx | 10 +- .../factsheets/factsheets-home-page.tsx | 10 +- src/components/forms/forms-home-page.tsx | 9 +- .../formulation/formulation-home-page.tsx | 8 +- .../in-page-nav/in-page-nav-header.tsx | 4 +- .../services/services-home-page.tsx | 10 +- .../specifiers/specifiers-home-page.tsx | 8 +- .../therapy-compass/screens/home-screen.tsx | 11 +- .../therapy-compass/screens/sheets-screen.tsx | 2 +- .../universal-search-command-mockups.tsx | 2 +- src/lib/app-modes.ts | 8 +- src/lib/search-command-surface.ts | 31 +- src/lib/ui-copy.ts | 16 + ...ent-search-scope-zero-results.dom.test.tsx | 68 +++- tests/mode-home-loading-contract.test.ts | 3 + tests/source-governance-attribution.test.ts | 369 ++++++++++++++++++ tests/therapy-compass-mode-wiring.test.ts | 4 +- tests/ui-accessibility.spec.ts | 2 +- tests/ui-tools.spec.ts | 6 +- tests/verify-pr-local.test.ts | 56 ++- 49 files changed, 1201 insertions(+), 165 deletions(-) create mode 100644 src/app/(search-app)/calculators/loading.tsx create mode 100644 src/app/(search-app)/dictionary/loading.tsx create mode 100644 src/app/(search-app)/medications/loading.tsx create mode 100644 tests/source-governance-attribution.test.ts diff --git a/docs/branch-cleanup-guide.md b/docs/branch-cleanup-guide.md index e5ea087594..5ab156ff97 100644 --- a/docs/branch-cleanup-guide.md +++ b/docs/branch-cleanup-guide.md @@ -121,10 +121,21 @@ credentials. 5. Record completed cleanup reviews with `npm run ledger:append -- --ref --head --scope branch-cleanup --outcome --checks `. The scope cell must be exactly `branch-cleanup` for a later sweep to treat it as complete; `branch-cleanup-deletion-pending` deliberately does not count. 6. Remove detached worktrees only when clean, unneeded, and absent from active `git worktree list` output. -## Dev Drive Stale Worktree Pruning (Inbox `ec356a7d`) +## Dev Drive Stale Worktree Pruning (Inbox `ec356a7d`, `#6SMMB4`) Worktrees accumulate across active multi-agent development fleets (e.g. on Dev Drive `D:` or secondary disks, where dozens of worktrees can hold ~19+ GB of duplicated `node_modules` at ~0.89 GB and ~50,000 files each). Stale worktrees compete for disk capacity, cause dependency drift, and contend on the cross-worktree test run coordinator lock (`scripts/run-heavy.mjs` / `scripts/test-run-lock.mjs`). +### Dev Drive Trusted Package Cache (#6SMMB4) + +When operating multi-worktree fleets on a Windows Dev Drive (`D:`, ReFS), verify that the npm package cache (`D:\.npm-cache`) is registered as a Dev Drive trusted cache. If unverified or untrusted, Microsoft Defender real-time scanning inspects every `npm ci` extraction across all worktrees. + +From an **elevated administrator prompt**: + +```cmd +fsutil devdrv query D: +fsutil devdrv trust D:\.npm-cache +``` + ### Safety Rules for Worktree Pruning - **Never pass `--force` to `git worktree remove`.** If git refuses removal due to untracked files, submodules, or uncommitted changes, that refusal is a critical safety signal, not an obstacle to override. diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 0977e7567a..079a234f2d 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -3849,6 +3849,6 @@ "productImportedComponentCount": 34, "rootCount": 82, "productionRouteCount": 69, - "nextUiEntryCount": 122 + "nextUiEntryCount": 125 } } diff --git a/docs/launch-operator-runbook.md b/docs/launch-operator-runbook.md index 55a7f35e0e..a44cc39689 100644 --- a/docs/launch-operator-runbook.md +++ b/docs/launch-operator-runbook.md @@ -173,12 +173,18 @@ Following a Supabase database restore or disaster recovery failover, verify all 4. **Cron Job Schedule Registration:** Verify pg_cron extensions and scheduled maintenance jobs (cache retention, query log sweeps) are active. 5. **Storage Bucket Policies:** Confirm private document storage buckets (`documents`, `document-images`) have active RLS policies preventing unauthenticated public reads. -## 8. Operational notes & diagnostics (#248, #305, #315, #102) +## 8. Operational notes & diagnostics (#248, #305, #315, #102, #6SMMB4) - **Search-Health Indexes (#248):** Ensure migration `20260705180000_reconcile_search_health_indexes.sql` is active on live and `search_schema_health()` reports `ok: true` with no `missing` entries (`npm run check:indexing`) — treat its `required_indexes` list as the recovery criterion, not a fixed count, since later migrations extend it. - **Concurrent Document Index Recipe (#102):** When applying additive document index optimizations on a busy database (`documents_title_bare_trgm_idx`, `documents_file_name_bare_trgm_idx`, and `documents_status_id_idx`), pre-create indexes concurrently (`CREATE INDEX CONCURRENTLY IF NOT EXISTS`) before applying the committed migration and registering in `search_schema_health()` to avoid write lock contention. Validate each index with `pg_index.indisvalid`. Note that bare-column trigrams and composite `(status, id)` indexes on the RAG path are canary-gated due to unordered `LIMIT 12` selection in candidate retrieval ([operator-apply-performance-latency-remediation.md](operator-apply-performance-latency-remediation.md)). - **Canary Latency & Cost Boundaries (#305):** Retrieval latency p90 SLO is ≤ 20s. Canary cost metrics provide lower-bound estimates without cache warmup. - **UI Smoke Reporter Stranding (#315):** When debugging rare UI smoke test timeouts, inspect reporter stranding in Playwright hooks rather than assuming layout regressions. +- **Dev Drive Trusted Package Cache (#6SMMB4):** On Windows workstations hosting worktrees on a Dev Drive (e.g. `D:`, ReFS), verify that the local npm package cache (`D:\.npm-cache`) is registered as a trusted Dev Drive cache. If unverified or not registered, Microsoft Defender real-time scanning runs over every `npm ci` across all worktrees. From an **elevated administrator prompt**, inspect and register the trusted cache: + ```cmd + fsutil devdrv query D: + fsutil devdrv trust D:\.npm-cache + ``` + Confirming or trusting the cache on the volume exempts package extractions from Defender real-time scan overhead during multi-worktree operations. ## 9. Ledger queue derivation & credential discipline (#327, #042) diff --git a/docs/rag-improvement/HANDOVER.md b/docs/rag-improvement/HANDOVER.md index 2521276624..013f91fe31 100644 --- a/docs/rag-improvement/HANDOVER.md +++ b/docs/rag-improvement/HANDOVER.md @@ -337,7 +337,8 @@ offline fixtures and validation only`. against an existing branch: it can discard unpushed packet commits. Never build on a stale head. 2. Read: your packet here → the matching README section → `docs/rag-behaviour/README.md` (+ `safeguards.md` before touching any protected surface). -3. Check the packet's status row AND the open PR list for a duplicate implementation +3. Check the canonical queue in [`docs/outstanding-issues.md`](../outstanding-issues.md), + this file's status table (§2), AND the open PR list for a duplicate implementation (`#292`: an open ledger row is not proof nobody is building it — PR #1899 already covers A1 phase 1). 4. State the RAG-impact flag to the owner in your first message if your packet touches a diff --git a/docs/rag-improvement/README.md b/docs/rag-improvement/README.md index 7f46eee116..26b1cdbef6 100644 --- a/docs/rag-improvement/README.md +++ b/docs/rag-improvement/README.md @@ -13,6 +13,8 @@ infrastructure, corrected. behaviour change needs a live eval-canary pair (provider-backed, ~$1–2, explicit approval per run). Nothing here authorises reindexing, migrations, or provider calls by itself. +**Canonical task queue:** per `AGENTS.md`, canonical cross-session task tracking and deduplication live in [`docs/outstanding-issues.md`](../outstanding-issues.md). Always consult the canonical ledger and check open PRs before beginning work on any packet or RAG surface. + --- ## 1. Review verdict on the original PDF @@ -358,7 +360,8 @@ default-off flags. ## 5. Sequencing summary Execution across cloud sessions is coordinated by [HANDOVER.md](HANDOVER.md): per-session -work packets, the live status table, checklists, and paste-ready prompts. +work packets, the live status table, checklists, and paste-ready prompts. Canonical task +ownership and cross-session tracking remain registered in [`docs/outstanding-issues.md`](../outstanding-issues.md). | Order | Item | Depends on | Behaviour change? | | ----- | ----------------------------------------------- | ------------------------------- | ----------------------------------------- | diff --git a/docs/site-map.md b/docs/site-map.md index d27de43d4e..aea0407dd7 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -84,19 +84,23 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir ## Mode page index -| Mode | Home page | Search/results page | Information/detail pages | -| --------------- | ---------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Answer | `/?mode=answer` | `/?mode=answer&q=example+question&focus=1&run=1` | Answer, citations, evidence, and source panels render inside the root dashboard shell. | -| Documents | `/documents` | `/documents/search?mode=documents&q=lithium+monitoring&focus=1&run=1` | `/documents/search` live results and `/documents/[id]` canonical viewer; `/documents/source*` are compatibility redirects. | -| Services | `/services` | `/services?q=13YARN&focus=1&run=1` | `/services/[slug]` service record pages. | -| Forms | `/forms` | `/forms?q=transport+forms&focus=1&run=1` | `/forms/[slug]` form record pages. | -| Favourites | `/favourites` | `/favourites?q=clozapine+set&focus=1&run=1` | Saved set and saved item detail render inside the favourites page surface. | -| Differentials | `/differentials` | `/differentials?q=acute+confusion&focus=1&run=1` | `/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, `/differentials/presentations`, `/differentials/presentations/[slug]`, and `/differentials/compare`. | -| DSM-5 Diagnosis | `/dsm` | `/dsm/search?q=major+depressive+disorder&focus=1&run=1` | `/dsm/diagnoses/[slug]`, `/dsm/compare`, and `/dsm/diagnoses/[slug]/differentials`. | -| Specifiers | `/specifiers` | `/specifiers?q=depressed+but+racing+thoughts&focus=1&run=1` | `/specifiers/[slug]`, `/specifiers/builder`, `/specifiers/compare`, and `/specifiers/map`. | -| Formulation | `/formulation` | `/formulation?q=I+keep+going+over+it&focus=1&run=1` | `/formulation/[slug]`, `/formulation/builder`, `/formulation/compare`, and `/formulation/map`. | -| Medication | `/medications` | `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1` | `/medications/[slug]`; submitted searches resolve to `/?mode=prescribing&q=…&run=1`. | -| Tools | `/tools` | `/tools?q=medications&focus=1&run=1` | Canonical all-tools results directory at `/tools`; the universal mode picker opens it directly. `/?mode=tools` remains a dashboard-mode alias. | +| Mode | Home page | Search/results page | Information/detail pages | +| --------------- | ------------------ | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Answer | `/?mode=answer` | `/?mode=answer&q=example+question&focus=1&run=1` | Answer, citations, evidence, and source panels render inside the root dashboard shell. | +| Documents | `/documents` | `/documents/search?mode=documents&q=lithium+monitoring&focus=1&run=1` | `/documents/search` live results and `/documents/[id]` canonical viewer; `/documents/source*` are compatibility redirects. | +| Services | `/services` | `/services?q=13YARN&focus=1&run=1` | `/services/[slug]` service record pages. | +| Forms | `/forms` | `/forms?q=transport+forms&focus=1&run=1` | `/forms/[slug]` form record pages. | +| Favourites | `/favourites` | `/favourites?q=clozapine+set&focus=1&run=1` | Saved set and saved item detail render inside the favourites page surface. | +| Differentials | `/differentials` | `/differentials?q=acute+confusion&focus=1&run=1` | `/differentials/diagnoses`, `/differentials/diagnoses/[slug]`, `/differentials/presentations`, `/differentials/presentations/[slug]`, and `/differentials/compare`. | +| DSM-5 Diagnosis | `/dsm` | `/dsm/search?q=major+depressive+disorder&focus=1&run=1` | `/dsm/diagnoses/[slug]`, `/dsm/compare`, and `/dsm/diagnoses/[slug]/differentials`. | +| Specifiers | `/specifiers` | `/specifiers?q=depressed+but+racing+thoughts&focus=1&run=1` | `/specifiers/[slug]`, `/specifiers/builder`, `/specifiers/compare`, and `/specifiers/map`. | +| Formulation | `/formulation` | `/formulation?q=I+keep+going+over+it&focus=1&run=1` | `/formulation/[slug]`, `/formulation/builder`, `/formulation/compare`, and `/formulation/map`. | +| Medication | `/medications` | `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1` | `/medications/[slug]`; submitted searches resolve to `/?mode=prescribing&q=…&run=1`. | +| Tools | `/tools` | `/tools?q=medications&focus=1&run=1` | Canonical all-tools results directory at `/tools`; the universal mode picker opens it directly. `/?mode=tools` remains a dashboard-mode alias. | +| Calculators | `/calculators` | `/calculators?q=PHQ-9&focus=1&run=1` | Validated psychiatry scores and clinical decision calculators at `/calculators`. | +| Therapy | `/therapy-compass` | `/therapy-compass/search?q=behavioural+activation&focus=1&run=1` | `/therapy-compass/[slug]`, `/therapy-compass/search`, `/therapy-compass/recommend`, `/therapy-compass/compare`, `/therapy-compass/pathways`, `/therapy-compass/review`, `/therapy-compass/[slug]/brief`, and `/therapy-compass/[slug]/sheet`. | +| Factsheets | `/factsheets` | `/factsheets/search?q=sertraline&focus=1&run=1` | `/factsheets/[slug]` and `/factsheets/search`. | +| Dictionary | `/dictionary` | `/dictionary/search?q=mental+state+examination&focus=1&run=1` | `/dictionary/[slug]`, `/dictionary/browse`, `/dictionary/compare`, `/dictionary/search`, `/dictionary/sources`, `/dictionary/topics`, and `/dictionary/topics/[slug]`. | ## Documents flow index diff --git a/eval/docling/fixtures/generate_fixtures.py b/eval/docling/fixtures/generate_fixtures.py index 0810b8bc6a..14e1e6fa08 100755 --- a/eval/docling/fixtures/generate_fixtures.py +++ b/eval/docling/fixtures/generate_fixtures.py @@ -67,19 +67,36 @@ def draw_table(page: fitz.Page, top: float, table: dict) -> float: rows, cols = table["rows"], table["cols"] rect = usable_rect() col_width = (rect.x1 - rect.x0) / cols - bottom = top + rows * ROW_HEIGHT + row_height = float(table.get("rowHeight", ROW_HEIGHT)) + bottom = top + rows * row_height if bottom > rect.y1: die(f"table {table['tableId']} does not fit on its page — reduce rows in the manifest") - for r in range(rows + 1): - y = top + r * ROW_HEIGHT - page.draw_line(fitz.Point(rect.x0, y), fitz.Point(rect.x1, y), width=0.5) - for c in range(cols + 1): - x = rect.x0 + c * col_width - page.draw_line(fitz.Point(x, top), fitz.Point(x, bottom), width=0.5) + + unruled = bool(table.get("unruled") or table.get("style") == "unruled") + rotated_headers = bool(table.get("rotatedHeaders") or table.get("rotated_headers")) + + if not unruled: + for r in range(rows + 1): + y = top + r * row_height + page.draw_line(fitz.Point(rect.x0, y), fitz.Point(rect.x1, y), width=0.5) + for c in range(cols + 1): + x = rect.x0 + c * col_width + page.draw_line(fitz.Point(x, top), fitz.Point(x, bottom), width=0.5) + else: + # Unruled / booktabs style: horizontal rules only (top, mid/header, bottom) + page.draw_line(fitz.Point(rect.x0, top), fitz.Point(rect.x1, top), width=1.0) + page.draw_line(fitz.Point(rect.x0, top + row_height), fitz.Point(rect.x1, top + row_height), width=0.5) + page.draw_line(fitz.Point(rect.x0, bottom), fitz.Point(rect.x1, bottom), width=1.0) + for cell in table["cells"]: - x = rect.x0 + cell["col"] * col_width + 3 - y = top + cell["row"] * ROW_HEIGHT + ROW_HEIGHT - 6 - page.insert_text(fitz.Point(x, y), cell["text"], fontname=BODY_FONT, fontsize=TABLE_SIZE) + r, c = cell["row"], cell["col"] + is_rotated = (r == 0 and rotated_headers) or bool(cell.get("rotate") == 90 or cell.get("rotated")) + x = rect.x0 + c * col_width + 3 + y = top + r * row_height + row_height - 6 + if is_rotated: + page.insert_text(fitz.Point(x + min(col_width, 16.0), y), cell["text"], fontname=BODY_FONT, fontsize=TABLE_SIZE, rotate=90) + else: + page.insert_text(fitz.Point(x, y), cell["text"], fontname=BODY_FONT, fontsize=TABLE_SIZE) return bottom diff --git a/eval/docling/fixtures/manifest.v1.json b/eval/docling/fixtures/manifest.v1.json index b01511abf5..867a631088 100644 --- a/eval/docling/fixtures/manifest.v1.json +++ b/eval/docling/fixtures/manifest.v1.json @@ -1747,6 +1747,7 @@ "page": 1, "rows": 8, "cols": 5, + "unruled": true, "cells": [ { "row": 0, @@ -1955,6 +1956,7 @@ "page": 2, "rows": 11, "cols": 4, + "unruled": true, "cells": [ { "row": 0, @@ -2227,6 +2229,7 @@ "page": 1, "rows": 8, "cols": 5, + "mergedCells": true, "cells": [ { "row": 0, @@ -2236,12 +2239,8 @@ { "row": 0, "col": 1, - "text": "Morning dose (mg)" - }, - { - "row": 0, - "col": 2, - "text": "Evening dose (mg)" + "colSpan": 2, + "text": "Daily dose schedule (mg)" }, { "row": 0, @@ -2707,6 +2706,7 @@ "page": 1, "rows": 8, "cols": 5, + "rotatedHeaders": true, "cells": [ { "row": 0, @@ -2915,6 +2915,7 @@ "page": 2, "rows": 11, "cols": 4, + "rotatedHeaders": true, "cells": [ { "row": 0, @@ -3187,6 +3188,8 @@ "page": 1, "rows": 8, "cols": 5, + "unruled": true, + "mergedCells": true, "cells": [ { "row": 0, @@ -3196,12 +3199,8 @@ { "row": 0, "col": 1, - "text": "Morning dose (mg)" - }, - { - "row": 0, - "col": 2, - "text": "Evening dose (mg)" + "colSpan": 2, + "text": "Daily dose schedule (mg)" }, { "row": 0, @@ -3395,6 +3394,7 @@ "page": 2, "rows": 11, "cols": 4, + "unruled": true, "cells": [ { "row": 0, @@ -3667,6 +3667,8 @@ "page": 1, "rows": 8, "cols": 5, + "unruled": true, + "rotatedHeaders": true, "cells": [ { "row": 0, @@ -3875,6 +3877,8 @@ "page": 2, "rows": 12, "cols": 4, + "unruled": true, + "rotatedHeaders": true, "cells": [ { "row": 0, diff --git a/eval/docling/report/lab-contract.mjs b/eval/docling/report/lab-contract.mjs index a85b2ac438..174595451e 100644 --- a/eval/docling/report/lab-contract.mjs +++ b/eval/docling/report/lab-contract.mjs @@ -144,15 +144,23 @@ function validateTable(fixture, table, index, seenTableIds, failures) { failures.push(`${label}: every cell needs integer row/col and non-empty text`); continue; } - if (cell.row < 0 || cell.row >= table.rows || cell.col < 0 || cell.col >= table.cols) { + const colSpan = Number.isInteger(cell.colSpan) && cell.colSpan > 1 ? cell.colSpan : 1; + const rowSpan = Number.isInteger(cell.rowSpan) && cell.rowSpan > 1 ? cell.rowSpan : 1; + if (cell.row < 0 || cell.row + rowSpan > table.rows || cell.col < 0 || cell.col + colSpan > table.cols) { failures.push( `${label}: cell (${cell.row},${cell.col}) is outside the declared ${table.rows}x${table.cols} grid`, ); } - const position = `${cell.row}:${cell.col}`; - if (positions.has(position)) failures.push(`${label}: duplicate cell at (${cell.row},${cell.col})`); - positions.add(position); - if (cell.row === 0) headerCols.add(cell.col); + for (let r = 0; r < rowSpan; r++) { + for (let c = 0; c < colSpan; c++) { + const position = `${cell.row + r}:${cell.col + c}`; + if (positions.has(position)) failures.push(`${label}: duplicate cell at (${cell.row + r},${cell.col + c})`); + positions.add(position); + } + } + if (cell.row === 0) { + for (let c = 0; c < colSpan; c++) headerCols.add(cell.col + c); + } } if (headerCols.size !== table.cols) { failures.push(`${label}: header row 0 must fill all ${table.cols} columns — table recall scoring anchors on it`); diff --git a/scripts/audit-source-governance.ts b/scripts/audit-source-governance.ts index 7cbb517315..8e59c8c1fc 100644 --- a/scripts/audit-source-governance.ts +++ b/scripts/audit-source-governance.ts @@ -1,4 +1,6 @@ +import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; +import { join } from "node:path"; import { pathToFileURL } from "node:url"; import * as nextEnv from "@next/env"; @@ -10,6 +12,223 @@ import { } from "@/lib/source-review"; import type { DocumentLabel } from "@/lib/types"; +export type AuditableRecord = { + record: Record; + recordType: string; + identifier: string; + title: string; + source: string; +}; + +export type ReviewAttributionViolation = { + record_type: string; + identifier: string; + title: string; + source: string; + review_status: string; + found_attribution: Record; + reason: string; +}; + +export type ReviewAttributionAuditReport = { + audited_record_count: number; + reviewed_record_count: number; + unattributed_reviewed_record_count: number; + passed: boolean; + violations: ReviewAttributionViolation[]; +}; + +const TRIVIAL_REVIEWER_PATTERNS = new Set([ + "", + "unknown", + "none", + "n/a", + "na", + "null", + "undefined", + "todo", + "tbd", + "test", + "testing", + "placeholder", + "anonymous", + "anon", + "unattributed", + "unassigned", + "missing", + "pending", + "default", + "system", + "bot", + "automated", + "auto", +]); + +const NIL_UUID = "00000000-0000-0000-0000-000000000000"; + +export function isNonTrivialReviewerString(value: unknown): value is string { + if (typeof value !== "string") return false; + const trimmed = value.trim(); + if (!trimmed) return false; + if (TRIVIAL_REVIEWER_PATTERNS.has(trimmed.toLowerCase())) return false; + if (trimmed === NIL_UUID) return false; + if (/^[^a-zA-Z0-9]+$/.test(trimmed)) return false; + return true; +} + +export function isRecordMarkedReviewed(record: Record): { + isReviewed: boolean; + rawStatus: string | null; +} { + const metadata = metadataRecord(record.metadata); + const candidates = [record.reviewStatus, record.review_status, metadata.reviewStatus, metadata.review_status]; + + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) { + const normalized = candidate.trim().toLowerCase(); + if (normalized === "reviewed") { + return { isReviewed: true, rawStatus: candidate.trim() }; + } + } + } + + return { isReviewed: false, rawStatus: null }; +} + +export function extractReviewerAttribution(record: Record): { + hasAttribution: boolean; + attribution: unknown; + foundFields: Record; +} { + const metadata = metadataRecord(record.metadata); + const reviewChecklist = metadataRecord(record.reviewChecklist); + const clinicalValidationEvidence = metadataRecord(metadata.clinical_validation_evidence); + const attestation = metadataRecord(record.attestation ?? metadata.attestation); + + const candidateEntries: Array<[string, unknown]> = [ + ["reviewedBy", record.reviewedBy], + ["reviewed_by", record.reviewed_by], + ["reviewedByClinician", record.reviewedByClinician], + ["reviewer", record.reviewer], + ["reviewer_id", record.reviewer_id], + ["reviewer_name", record.reviewer_name], + ["reviewerQualification", record.reviewerQualification], + ["reviewer_qualification", record.reviewer_qualification], + ["metadata.reviewedBy", metadata.reviewedBy], + ["metadata.reviewed_by", metadata.reviewed_by], + ["metadata.reviewer", metadata.reviewer], + ["metadata.reviewer_id", metadata.reviewer_id], + ["metadata.reviewer_name", metadata.reviewer_name], + ["metadata.reviewer_qualification", metadata.reviewer_qualification], + ["metadata.clinical_validation_evidence.attested_by", clinicalValidationEvidence.attested_by], + ["metadata.clinical_validation_evidence.reviewer_id", clinicalValidationEvidence.reviewer_id], + ["reviewChecklist.reviewedBy", reviewChecklist.reviewedBy], + ["reviewChecklist.reviewer", reviewChecklist.reviewer], + ["attestation.reviewedBy", attestation.reviewedBy], + ["attestation.reviewer", attestation.reviewer], + ["attestation.attested_by", attestation.attested_by], + ]; + + const foundFields: Record = {}; + for (const [key, value] of candidateEntries) { + if (value !== undefined && value !== null) { + foundFields[key] = value; + } + } + + for (const [, value] of candidateEntries) { + if (isNonTrivialReviewerString(value)) { + return { hasAttribution: true, attribution: value.trim(), foundFields }; + } + if (value && typeof value === "object" && !Array.isArray(value)) { + const obj = value as Record; + const innerCandidateKeys = [ + "name", + "reviewer_name", + "full_name", + "id", + "reviewer_id", + "user_id", + "email", + "qualification", + "reviewer_qualification", + "title", + ]; + for (const innerKey of innerCandidateKeys) { + const innerVal = obj[innerKey]; + if (isNonTrivialReviewerString(innerVal)) { + return { hasAttribution: true, attribution: innerVal.trim(), foundFields }; + } + } + } + if (Array.isArray(value) && value.length > 0) { + for (const item of value) { + if (isNonTrivialReviewerString(item)) { + return { hasAttribution: true, attribution: item.trim(), foundFields }; + } + if (item && typeof item === "object" && !Array.isArray(item)) { + const obj = item as Record; + for (const innerKey of ["name", "id", "reviewer_id", "email", "qualification"]) { + const innerVal = obj[innerKey]; + if (isNonTrivialReviewerString(innerVal)) { + return { hasAttribution: true, attribution: innerVal.trim(), foundFields }; + } + } + } + } + } + } + + return { hasAttribution: false, attribution: null, foundFields }; +} + +export function auditReviewAttribution(records: AuditableRecord[]): ReviewAttributionAuditReport { + const violations: ReviewAttributionViolation[] = []; + let reviewedCount = 0; + + for (const item of records) { + const { isReviewed, rawStatus } = isRecordMarkedReviewed(item.record); + if (!isReviewed) continue; + + reviewedCount += 1; + const { hasAttribution, foundFields } = extractReviewerAttribution(item.record); + + if (!hasAttribution) { + violations.push({ + record_type: item.recordType, + identifier: item.identifier, + title: item.title, + source: item.source, + review_status: rawStatus ?? "reviewed", + found_attribution: foundFields, + reason: + Object.keys(foundFields).length === 0 + ? "Record is marked as reviewed but has no reviewer attribution (e.g. reviewedBy or reviewer field is missing)." + : `Record is marked as reviewed but reviewer attribution contains empty or trivial placeholder value(s): ${JSON.stringify(foundFields)}.`, + }); + } + } + + return { + audited_record_count: records.length, + reviewed_record_count: reviewedCount, + unattributed_reviewed_record_count: violations.length, + passed: violations.length === 0, + violations, + }; +} + +async function loadStaticJson(relativePath: string): Promise { + try { + const fullPath = join(process.cwd(), relativePath); + if (!existsSync(fullPath)) return null; + const raw = await readFile(fullPath, "utf8"); + return JSON.parse(raw) as T; + } catch { + return null; + } +} + const loadEnvConfig = nextEnv.loadEnvConfig ?? (nextEnv as unknown as { default?: { loadEnvConfig?: typeof nextEnv.loadEnvConfig } }).default?.loadEnvConfig; @@ -338,6 +557,94 @@ export async function main(argv = process.argv.slice(2)) { })), bmjAttestationEvents, ); + + const auditableRecords: AuditableRecord[] = documents.map((doc) => ({ + record: doc as unknown as Record, + recordType: isRegistryRecordSource(doc) ? "registry_record" : "document", + identifier: doc.id, + title: doc.title || doc.file_name, + source: doc.source_path || doc.file_name, + })); + + const therapiesSource = await loadStaticJson>>("src/data/therapies-source.json"); + if (therapiesSource && Array.isArray(therapiesSource)) { + for (const therapy of therapiesSource) { + auditableRecords.push({ + record: therapy, + recordType: "therapy", + identifier: stringValue(therapy.slug) !== "missing" ? (therapy.slug as string) : (therapy.name as string), + title: stringValue(therapy.name) !== "missing" ? (therapy.name as string) : "Unnamed therapy", + source: "src/data/therapies-source.json", + }); + } + } + + const pathwaysSource = await loadStaticJson>>( + "public/therapy-compass-data/pathways.json", + ); + if (pathwaysSource && Array.isArray(pathwaysSource)) { + for (const pathway of pathwaysSource) { + auditableRecords.push({ + record: pathway, + recordType: "pathway", + identifier: stringValue(pathway.slug) !== "missing" ? (pathway.slug as string) : (pathway.name as string), + title: stringValue(pathway.name) !== "missing" ? (pathway.name as string) : "Unnamed pathway", + source: "public/therapy-compass-data/pathways.json", + }); + } + } + + const referenceSource = await loadStaticJson<{ measures?: Array> }>( + "public/therapy-compass-data/reference.json", + ); + if (referenceSource && Array.isArray(referenceSource.measures)) { + for (const measure of referenceSource.measures) { + auditableRecords.push({ + record: measure, + recordType: "measure", + identifier: stringValue(measure.name) !== "missing" ? (measure.name as string) : "unnamed_measure", + title: stringValue(measure.name) !== "missing" ? (measure.name as string) : "Unnamed measure", + source: "public/therapy-compass-data/reference.json", + }); + } + } + + const differentialsSource = await loadStaticJson< + { records?: Array> } | Array> + >("data/differentials-snapshot.json"); + const diffItems = Array.isArray(differentialsSource) ? differentialsSource : (differentialsSource?.records ?? []); + for (const diff of diffItems) { + auditableRecords.push({ + record: diff, + recordType: "differential", + identifier: + stringValue(diff.id ?? diff.slug) !== "missing" + ? String(diff.id ?? diff.slug) + : String(diff.title ?? "unknown"), + title: + stringValue(diff.title ?? diff.name) !== "missing" ? String(diff.title ?? diff.name) : "Unnamed differential", + source: "data/differentials-snapshot.json", + }); + } + + const specifiersSource = await loadStaticJson< + { specifiers?: Array> } | Array> + >("data/specifiers-content.json"); + const specItems = Array.isArray(specifiersSource) ? specifiersSource : (specifiersSource?.specifiers ?? []); + for (const spec of specItems) { + auditableRecords.push({ + record: spec, + recordType: "specifier", + identifier: + stringValue(spec.id ?? spec.slug) !== "missing" + ? String(spec.id ?? spec.slug) + : String(spec.title ?? "unknown"), + title: stringValue(spec.title ?? spec.name) !== "missing" ? String(spec.title ?? spec.name) : "Unnamed specifier", + source: "data/specifiers-content.json", + }); + } + + const reviewerAttributionAudit = auditReviewAttribution(auditableRecords); const debtPolicyFailures: string[] = []; if (debtPolicy) { @@ -419,6 +726,7 @@ export async function main(argv = process.argv.slice(2)) { }, debt_counts: debtCounts, operational_review_debt_counts: operationalDebtCounts, + reviewer_attribution: reviewerAttributionAudit, sample_review_due_documents: clinicalGovernanceDocuments .filter((document) => metadataRecord(document.metadata).document_status === "review_due") .slice(0, 10) @@ -441,6 +749,7 @@ export async function main(argv = process.argv.slice(2)) { passed_required_metadata_gate: requiredMetadataMissingTotal === 0, source_authority: sourceAuthorityAudit, passed_source_authority_gate: sourceAuthorityAudit.passed, + passed_reviewer_attribution_gate: reviewerAttributionAudit.passed, debt_policy: debtPolicy ? { path: debtPolicy.path, @@ -521,6 +830,15 @@ export async function main(argv = process.argv.slice(2)) { console.log(`- ${document.title} (${document.file_name})`); } } + console.log( + `Reviewer attribution: audited=${report.reviewer_attribution.audited_record_count}, reviewed=${report.reviewer_attribution.reviewed_record_count}, violations=${report.reviewer_attribution.unattributed_reviewed_record_count}`, + ); + if (report.reviewer_attribution.violations.length) { + console.log("Reviewer attribution governance violations:"); + for (const violation of report.reviewer_attribution.violations) { + console.log(`- [${violation.record_type}] ${violation.identifier} (${violation.title}): ${violation.reason}`); + } + } console.log( report.passed_required_metadata_gate ? "PASS: required source governance metadata is complete." @@ -531,6 +849,11 @@ export async function main(argv = process.argv.slice(2)) { ? "PASS: recognised source authority metadata is complete and compatible." : "FAIL: recognised source authority metadata has missing or conflicting locality fields.", ); + console.log( + report.passed_reviewer_attribution_gate + ? "PASS: all reviewed records have non-empty, non-trivial reviewer attribution." + : "FAIL: un-attributed reviewed records found (governance violation).", + ); if (report.debt_policy) { console.log( report.debt_policy.passed @@ -543,6 +866,7 @@ export async function main(argv = process.argv.slice(2)) { if (!report.passed_required_metadata_gate) process.exitCode = 1; if (!report.passed_source_authority_gate) process.exitCode = 1; + if (!report.passed_reviewer_attribution_gate) process.exitCode = 1; if (report.debt_policy && !report.debt_policy.passed) process.exitCode = 1; } diff --git a/scripts/ci-change-scope.mjs b/scripts/ci-change-scope.mjs index 4867687247..1c5e4069e1 100644 --- a/scripts/ci-change-scope.mjs +++ b/scripts/ci-change-scope.mjs @@ -40,7 +40,7 @@ const outputs = [ ]; function normalizePath(filePath) { - return filePath + return String(filePath ?? "") .replaceAll("\\", "/") .replace(/^\.\/+/, "") .replace(/^github\//, ".github/"); @@ -181,6 +181,10 @@ const uiPatterns = [ // PNG reports ui_changed=false, the visual job is skipped, and an incorrect or // corrupted baseline is never compared against the app it claims to describe. /^tests\/__screenshots__\//, + // Library modules that configure modes, shell routing, UI copy, navigation, + // or rendering lists. Editing these directly alters what the browser shell + // and mode homes render without touching a component or route file (#0HFDWD). + /^src\/lib\/(?:app-modes|app-mode-icons|search-route-ownership|ui-copy|mode-home-composer|mode-secondary-navigation|category-identity(?:-icons)?|brand-mark|brand-image|search-command-surface|search-navigation-context|search-scope-filter-chips|search-shell-props|document-flow-routes|document-viewer-navigation|differentials-navigation|therapy-compass-navigation|therapies)\.tsx?$/, // The pre-merge Lighthouse budget and its inputs. Without these, enabling // enforcement, refreshing the baseline, or breaking the runner is not exercised // until some unrelated UI or build change happens to trigger the job. @@ -1042,6 +1046,23 @@ function selfTest() { build_changed: true, db_changed: false, }); + // Mode configuration, routing, UI copy and therapies feed shell/home rendering (#0HFDWD) + assertScope("mode-config-triggers-ui", ["src/lib/app-modes.ts"], { + ui_changed: true, + source_changed: true, + }); + assertScope("search-route-ownership-triggers-ui", ["src/lib/search-route-ownership.ts"], { + ui_changed: true, + source_changed: true, + }); + assertScope("ui-copy-triggers-ui", ["src/lib/ui-copy.ts"], { + ui_changed: true, + source_changed: true, + }); + assertScope("therapies-lib-triggers-ui", ["src/lib/therapies.ts"], { + ui_changed: true, + source_changed: true, + }); assertScope("rag-fixture", ["src/lib/retrieval-selection.ts", "scripts/fixtures/rag-retrieval-golden.json"], { rag_eval_changed: true, source_changed: true, diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 7d46982b30..eff531983c 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -379,6 +379,32 @@ function renderModePageIndex() { detail: "Canonical all-tools results directory at `/tools`; the universal mode picker opens it directly. `/?mode=tools` remains a dashboard-mode alias.", }, + { + mode: "Calculators", + home: appModeHomeHref("calculators"), + search: appModeHomeHref("calculators", { query: "PHQ-9", focus: true, run: true }), + detail: "Validated psychiatry scores and clinical decision calculators at `/calculators`.", + }, + { + mode: "Therapy", + home: appModeHomeHref("therapy-compass"), + search: appModeHomeHref("therapy-compass", { query: "behavioural activation", focus: true, run: true }), + detail: + "`/therapy-compass/[slug]`, `/therapy-compass/search`, `/therapy-compass/recommend`, `/therapy-compass/compare`, `/therapy-compass/pathways`, `/therapy-compass/review`, `/therapy-compass/[slug]/brief`, and `/therapy-compass/[slug]/sheet`.", + }, + { + mode: "Factsheets", + home: appModeHomeHref("factsheets"), + search: appModeHomeHref("factsheets", { query: "sertraline", focus: true, run: true }), + detail: "`/factsheets/[slug]` and `/factsheets/search`.", + }, + { + mode: "Dictionary", + home: appModeHomeHref("dictionary"), + search: appModeHomeHref("dictionary", { query: "mental state examination", focus: true, run: true }), + detail: + "`/dictionary/[slug]`, `/dictionary/browse`, `/dictionary/compare`, `/dictionary/search`, `/dictionary/sources`, `/dictionary/topics`, and `/dictionary/topics/[slug]`.", + }, ]); } diff --git a/scripts/guard-next-build.mjs b/scripts/guard-next-build.mjs index fe82fea90a..301372a72e 100644 --- a/scripts/guard-next-build.mjs +++ b/scripts/guard-next-build.mjs @@ -80,11 +80,12 @@ function requestJson(port) { }); } -async function findRunningProjectServer() { - const expectedProjectId = localProjectId(projectRoot); - const stablePort = stableProjectPort(projectRoot); +export async function findRunningProjectServer(rootDir = projectRoot) { + const expectedProjectId = localProjectId(rootDir); + const stablePort = stableProjectPort(rootDir); + const maxPort = Math.min(stablePort + 50, projectPortEnd); - for (let port = stablePort; port <= projectPortEnd; port += 1) { + for (let port = stablePort; port <= maxPort; port += 1) { const payload = await requestJson(port); if (payload?.appName === appName && payload?.projectId === expectedProjectId) return port; } diff --git a/scripts/pr-policy.mjs b/scripts/pr-policy.mjs index d5e382b324..ff2e1458fe 100644 --- a/scripts/pr-policy.mjs +++ b/scripts/pr-policy.mjs @@ -46,9 +46,10 @@ const clinicalRiskPatterns = [ /^supabase\//, /^src\/app\/api\//, // Library-layer behavior carries the clinical logic (retrieval, ranking, - // answer generation, ingestion, source governance, privacy) so it keeps the + // answer generation, ingestion, source governance, privacy, mode gating, + // unreviewed content reachability, clinical content policy) so it keeps the // full token set. - /^src\/lib\/.*(?:auth|permission|privacy|security|rag|retriev|rank|search|answer|clinical|citation|source|document|upload|download)/i, + /^src\/lib\/.*(?:auth|permission|privacy|security|rag|retriev|rank|search|answer|clinical|citation|source|document|upload|download|therap|mode|review|policy|content|unreviewed|medication)/i, // Presentation surfaces (pages + components) are clinical-risk only when they // touch access control, privacy, patient data, or document upload/download — // NOT merely because a UI file lives under a clinically-named directory (the @@ -91,6 +92,7 @@ const ragRankingPatterns = [ const uiPatterns = [ /^src\/app\/(?!api\/)/, /^src\/(?:components|styles)\//, + /^src\/lib\/(?:app-modes|app-mode-icons|search-route-ownership|ui-copy|mode-home-composer|mode-secondary-navigation|category-identity(?:-icons)?|brand-mark|brand-image|search-command-surface|search-navigation-context|search-scope-filter-chips|search-shell-props|document-flow-routes|document-viewer-navigation|differentials-navigation|therapy-compass-navigation|therapies)\.tsx?$/, /^public\//, /^tests\/ui-.*\.spec\.ts$/, /^playwright(?:\..*)?\.config\.ts$/, @@ -171,7 +173,7 @@ function branchLikeTitle(title, headRef) { } function checkedChecklistEntries(value) { - return [...String(value ?? "").matchAll(/^\s*-\s*\[[xX]\]\s*(.+?)\s*$/gm)].map((match) => match[1].trim()); + return [...String(value ?? "").matchAll(/^\s*[-*+]\s*\[[xX]\]\s*(.+?)\s*$/gm)].map((match) => match[1].trim()); } function governanceMatcherGroupSatisfied(group, checkedEntries) { @@ -199,8 +201,8 @@ export function collectSatisfiedGovernanceItems(value) { function governanceBoxStats(value) { const source = String(value ?? ""); return { - checked: (source.match(/-\s*\[[xX]\]/g) ?? []).length, - unchecked: (source.match(/-\s*\[ \]/g) ?? []).length, + checked: (source.match(/[-*+]\s*\[[xX]\]/g) ?? []).length, + unchecked: (source.match(/[-*+]\s*\[ \]/g) ?? []).length, }; } @@ -579,6 +581,14 @@ function selfTest() { assert.equal(classifyPullRequestFiles(["src/data/therapies-index.json"]).clinicalRisk, true); assert.equal(classifyPullRequestFiles(["public/therapy-compass-data/therapies-home.json"]).clinicalRisk, true); assert.equal(classifyPullRequestFiles(["data/clinical-snapshot.json"]).clinicalRisk, true); + // Unreviewed clinical content switches and mode reachability in src/lib (#P5542X). + assert.equal(classifyPullRequestFiles(["src/lib/clinical-content-policy.ts"]).clinicalRisk, true); + assert.equal(classifyPullRequestFiles(["src/lib/app-modes.ts"]).clinicalRisk, true); + assert.equal(classifyPullRequestFiles(["src/lib/therapies.ts"]).clinicalRisk, true); + // Mode configuration, search routing, and UI copy modules are recognized as UI (#0HFDWD). + assert.equal(classifyPullRequestFiles(["src/lib/app-modes.ts"]).ui, true); + assert.equal(classifyPullRequestFiles(["src/lib/search-route-ownership.ts"]).ui, true); + assert.equal(classifyPullRequestFiles(["src/lib/ui-copy.ts"]).ui, true); // ...but an unrelated public asset is not clinical output. assert.equal(classifyPullRequestFiles(["public/favicon.ico"]).clinicalRisk, false); // End-to-end: a dashboard UI PR with Summary + UI verification + risk but no diff --git a/scripts/verify-pr-local.mjs b/scripts/verify-pr-local.mjs index 30cbfcccb1..9074d310e2 100644 --- a/scripts/verify-pr-local.mjs +++ b/scripts/verify-pr-local.mjs @@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { childProcessExitCode } from "./child-process-result.mjs"; -import { DEV_SERVER_BUILD_REFUSED_EXIT_CODE } from "./guard-next-build.mjs"; +import { DEV_SERVER_BUILD_REFUSED_EXIT_CODE, findRunningProjectServer } from "./guard-next-build.mjs"; const isWindows = process.platform === "win32"; // Live Supabase audits (check:locality-metadata) stay out of this unconditional gate. @@ -154,7 +154,7 @@ export function summarizePrLocalRun(scripts, progress) { lines.push(`- failed: ${failedScript} (exit ${failedExitCode})`); if (failedScript === "build" && failedExitCode === DEV_SERVER_BUILD_REFUSED_EXIT_CODE) { lines.push( - `- note: production build was refused while the Clinical KB dev server is running (BUILD_REFUSED_DEV_SERVER). This is a failed gate, not a skip.`, + `- note: production build was refused while the Clinical KB dev server is running (BUILD_REFUSED_DEV_SERVER). This is a failed gate, not a skip. Advice: Stop the dev server process, clear .next, and re-run verify:pr-local (or set ALLOW_BUILD_WITH_DEV_SERVER=1).`, ); } } else { @@ -164,6 +164,33 @@ export function summarizePrLocalRun(scripts, progress) { return lines.join("\n"); } +/** + * Pre-check for running dev server before executing PR-local build step (#G4M3DV). + * Warns early with actionable advice so an active `npm run ensure` / dev server + * process doesn't cause a surprising BUILD_REFUSED_DEV_SERVER failure. + */ +export async function checkDevServerPreflight( + scripts, + { findServer = findRunningProjectServer, warn = console.warn, env = process.env } = {}, +) { + if (!scripts.includes("build") || env.ALLOW_BUILD_WITH_DEV_SERVER === "1") { + return null; + } + + const runningPort = await findServer(); + if (runningPort) { + warn( + [ + `\n[verify:pr-local] WARNING: Clinical KB dev server is running on http://localhost:${runningPort}.`, + "The upcoming 'build' step will fail with BUILD_REFUSED_DEV_SERVER (exit 76).", + "Advice: Stop the running dev server process before the build step, or set ALLOW_BUILD_WITH_DEV_SERVER=1.", + ].join("\n"), + ); + return runningPort; + } + return null; +} + export function runPrLocalScripts( scripts, { runScript = runNpmScript, log = console.log, error = console.error } = {}, @@ -196,7 +223,7 @@ function assertPlan(name, scope, expected, extended = false) { } } -function selfTest() { +async function selfTest() { assertPlan("docs-only", { docs_changed: true }, [...commonScripts, ...docsScripts]); assertPlan("workflow-only", { workflow_changed: true }, [ ...commonScripts, @@ -272,11 +299,41 @@ function selfTest() { "check:medication-lexicon-report", ], ); + + const warnings = []; + const warn = (msg) => warnings.push(msg); + + const noBuildPort = await checkDevServerPreflight(["lint", "test"], { + findServer: async () => 3000, + warn, + }); + if (noBuildPort !== null || warnings.length !== 0) { + throw new Error("checkDevServerPreflight: expected null and no warning when build is absent"); + } + + const allowedPort = await checkDevServerPreflight(["build"], { + findServer: async () => 3000, + warn, + env: { ALLOW_BUILD_WITH_DEV_SERVER: "1" }, + }); + if (allowedPort !== null || warnings.length !== 0) { + throw new Error("checkDevServerPreflight: expected null and no warning when ALLOW_BUILD_WITH_DEV_SERVER=1"); + } + + const runningPort = await checkDevServerPreflight(["build"], { + findServer: async () => 3000, + warn, + env: {}, + }); + if (runningPort !== 3000 || warnings.length === 0 || !warnings[0].includes("BUILD_REFUSED_DEV_SERVER")) { + throw new Error("checkDevServerPreflight: expected port 3000 and warning when dev server is running"); + } + console.log("PR-local verification plan self-test passed."); } if (process.argv.includes("--self-test")) { - selfTest(); + await selfTest(); process.exit(0); } @@ -304,6 +361,8 @@ if (isDirectRun()) { process.exit(0); } + await checkDevServerPreflight(scripts); + const exitCode = runPrLocalScripts(scripts); if (exitCode !== 0) process.exit(exitCode); diff --git a/src/app/(search-app)/calculators/loading.tsx b/src/app/(search-app)/calculators/loading.tsx new file mode 100644 index 0000000000..59334e70b5 --- /dev/null +++ b/src/app/(search-app)/calculators/loading.tsx @@ -0,0 +1,5 @@ +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; + +export default function Loading() { + return ; +} diff --git a/src/app/(search-app)/dictionary/loading.tsx b/src/app/(search-app)/dictionary/loading.tsx new file mode 100644 index 0000000000..59334e70b5 --- /dev/null +++ b/src/app/(search-app)/dictionary/loading.tsx @@ -0,0 +1,5 @@ +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; + +export default function Loading() { + return ; +} diff --git a/src/app/(search-app)/differentials/presentations/[slug]/loading.tsx b/src/app/(search-app)/differentials/presentations/[slug]/loading.tsx index 1810a0a70f..8a27f2828b 100644 --- a/src/app/(search-app)/differentials/presentations/[slug]/loading.tsx +++ b/src/app/(search-app)/differentials/presentations/[slug]/loading.tsx @@ -2,7 +2,7 @@ import { Skeleton, searchPageShell, searchPageContainer } from "@/components/ui- export default function Loading() { return ( -
+
@@ -14,7 +14,7 @@ export default function Loading() {
- Loading forms + Loading presentation
); } diff --git a/src/app/(search-app)/medications/[slug]/loading.tsx b/src/app/(search-app)/medications/[slug]/loading.tsx index 00dfde647d..ace095c9b2 100644 --- a/src/app/(search-app)/medications/[slug]/loading.tsx +++ b/src/app/(search-app)/medications/[slug]/loading.tsx @@ -2,7 +2,7 @@ import { Skeleton, searchPageShell, searchPageContainer } from "@/components/ui- export default function Loading() { return ( -
+
@@ -12,7 +12,7 @@ export default function Loading() {
- Loading tools + Loading medication
); } diff --git a/src/app/(search-app)/medications/loading.tsx b/src/app/(search-app)/medications/loading.tsx new file mode 100644 index 0000000000..59334e70b5 --- /dev/null +++ b/src/app/(search-app)/medications/loading.tsx @@ -0,0 +1,5 @@ +import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; + +export default function Loading() { + return ; +} diff --git a/src/app/api/documents/signed-urls/route.ts b/src/app/api/documents/signed-urls/route.ts index 53dcf29258..cc1d372ad7 100644 --- a/src/app/api/documents/signed-urls/route.ts +++ b/src/app/api/documents/signed-urls/route.ts @@ -1 +1,2 @@ -export { runtime, POST } from "@/app/api/images/signed-urls/route"; +export const runtime = "nodejs"; +export { POST } from "@/app/api/images/signed-urls/route"; diff --git a/src/components/calculators/home-page.tsx b/src/components/calculators/home-page.tsx index ad0ebf56fc..cd3e475626 100644 --- a/src/components/calculators/home-page.tsx +++ b/src/components/calculators/home-page.tsx @@ -1,8 +1,10 @@ -import { Calculator, CloudRain, GlassWater, Search, Zap } from "lucide-react"; +import { CloudRain, GlassWater, Search, Zap } from "lucide-react"; import { ModeHomeMain, ModeHomeTemplate } from "@/components/mode-home-template"; +import { appModeIcons } from "@/lib/app-mode-icons"; import { appModeHomeHref } from "@/lib/app-modes"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; +import { sharedHomePresentation } from "@/lib/ui-copy"; const calculatorQuickLinks = ["PHQ-9", "GAD-7", "K10", "MDQ", "AUDIT-C"] as const; @@ -15,9 +17,9 @@ export function CalculatorsHomePage() {
) + ) : sortedMatches.length === 0 ? ( + // Facet toggles empty this list without a navigation. The shared + // empty state leads with Remove / Clear all against the chips + // that caused it (F11); the band's `role="status"` already + // re-announced the zero count, so the empty state suppresses its + // own live region on the filtered path to avoid a double polite + // announcement for one interaction. +
+ 0 ? `Browse all ${documentCount.toLocaleString()} sources` : "Browse all sources" + } + /> +
) : ( <> {showResultsControls && !showFilterControl ? browseLibraryControl : null} @@ -1553,26 +1572,6 @@ function DocumentSearchResultsPanelImpl({ ) : null}
- {sortedMatches.length === 0 ? ( - // Facet toggles empty this list without a navigation. The shared - // empty state leads with Remove / Clear all against the chips - // that caused it (F11); the band's `role="status"` already - // re-announced the zero count, so the empty state suppresses its - // own live region on the filtered path to avoid a double polite - // announcement for one interaction. -
- 0 ? `Browse all ${documentCount.toLocaleString()} sources` : "Browse all sources" - } - /> -
- ) : null}
{renderedMatches.map((document, index) => { const relevanceDisplay = relevanceTone(document); diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index aabfb56a13..2fef49a1a5 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1830,6 +1830,8 @@ export function MasterSearchHeader({ return ( { diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index 0d802de435..e0d8f977d6 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -20,6 +20,8 @@ import { useSearchParams } from "next/navigation"; import { useId, useMemo, useState } from "react"; import { ModeHomeTemplate } from "@/components/mode-home-template"; +import { appModeIcons } from "@/lib/app-mode-icons"; +import { sharedHomePresentation } from "@/lib/ui-copy"; import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; import { ResultFilterSheet, @@ -282,9 +284,9 @@ function MedicationHome({ return (
diff --git a/src/components/dictionary/dictionary-home-page.tsx b/src/components/dictionary/dictionary-home-page.tsx index 99ca5df016..96f1c81117 100644 --- a/src/components/dictionary/dictionary-home-page.tsx +++ b/src/components/dictionary/dictionary-home-page.tsx @@ -1,17 +1,19 @@ -import { BookOpen, GitCompareArrows, LibraryBig, List, Quote, Tags } from "lucide-react"; +import { GitCompareArrows, LibraryBig, List, Quote, Tags } from "lucide-react"; import { ModeHomeMain, ModeHomeTemplate } from "@/components/mode-home-template"; +import { appModeIcons } from "@/lib/app-mode-icons"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { dictionaryEntries, dictionaryTopics } from "@/lib/dictionary-data"; +import { sharedHomePresentation } from "@/lib/ui-copy"; export function DictionaryHomePage() { return ( -
0 ? ( ) : null} -
+
{/* Both sheets are siblings of the portal, never children of it: a sheet inside the collapse row would be carried away with the header when the diff --git a/src/components/services/services-home-page.tsx b/src/components/services/services-home-page.tsx index 838af7fe60..968756ae0b 100644 --- a/src/components/services/services-home-page.tsx +++ b/src/components/services/services-home-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { FileQuestion, FileSearch, Loader2, MapPinned, Route, ShieldAlert, Users } from "lucide-react"; +import { FileQuestion, FileSearch, Loader2, MapPinned, Route, ShieldAlert } from "lucide-react"; import { ModeHomeMain, @@ -9,8 +9,10 @@ import { type ModeHomeAction, type ModeHomePill, } from "@/components/mode-home-template"; +import { appModeIcons } from "@/lib/app-mode-icons"; import { appModeHomeHref } from "@/lib/app-modes"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; +import { sharedHomePresentation } from "@/lib/ui-copy"; import { useRegistryRecords } from "@/lib/use-registry-records"; // The default service slug is computed server-side (app/services/page.tsx) and @@ -132,9 +134,9 @@ export function ServicesHomePage({ defaultServiceSlug = null }: { defaultService > setQ(e.target.value)} - placeholder="Search therapies…" + placeholder="Search therapies..." aria-label="Search therapies for the patient sheet" autoFocus className="w-full h-tap pt-0 pr-3 pb-0 pl-[34px] border border-[color:var(--border)] rounded-md bg-[color:var(--surface)] text-[color:var(--text)] text-sm-minus" diff --git a/src/components/universal-search-command-mockups.tsx b/src/components/universal-search-command-mockups.tsx index 8e569912e8..e9c313bb4b 100644 --- a/src/components/universal-search-command-mockups.tsx +++ b/src/components/universal-search-command-mockups.tsx @@ -268,7 +268,7 @@ const modeConfigs: Record = { id: "differentials", label: "Differentials", icon: BrainCircuit, - placeholder: "Ask or search a presentation", + placeholder: "Ask or search a presentation...", defaultQuery: "confusion", examples: ["acute confusion", "first episode psychosis", "catatonia vs NMS"], recents: ["confusion", "serotonin syndrome", "alcohol withdrawal"], diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts index 9162919a62..1fafd534ac 100644 --- a/src/lib/app-modes.ts +++ b/src/lib/app-modes.ts @@ -199,7 +199,7 @@ export const appModeDefinitions = [ href: "/differentials", search: { kind: "differentials", - placeholder: "Ask or search a presentation", + placeholder: "Ask or search a presentation...", inputAriaLabel: "Search differential presentations, symptoms, and scenarios", submitIdleLabel: "Diffs", submitBusyLabel: "Diffs", @@ -380,7 +380,7 @@ export const appModeDefinitions = [ // The longer phrase became the late portal's LCP element on Therapy Home. // Keep the full search scope in the accessible name below; the concise // visible prompt lets the already-painted hero remain the LCP owner. - placeholder: "Search therapies…", + placeholder: "Search therapies...", inputAriaLabel: "Search therapies by problem, symptom, skill, or population", submitIdleLabel: "Therapy", submitBusyLabel: "Therapy", @@ -406,7 +406,7 @@ export const appModeDefinitions = [ // library (not the document corpus), so it borrows the benign "tools" search // kind — like Therapy Compass — while keeping the shared composer visible. kind: "tools", - placeholder: "Search a medicine, condition, therapy or test…", + placeholder: "Search a medicine, condition, therapy or test...", inputAriaLabel: "Search patient information factsheets", submitIdleLabel: "Sheets", submitBusyLabel: "Sheets", @@ -431,7 +431,7 @@ export const appModeDefinitions = [ // Dictionary owns a local static catalogue. The shared composer uses the // benign tools command kind, then appModeHomeHref routes into its results. kind: "tools", - placeholder: "Search a term or abbreviation…", + placeholder: "Search a term or abbreviation...", inputAriaLabel: "Search clinical terms, abbreviations, and topics", submitIdleLabel: "Terms", submitBusyLabel: "Terms", diff --git a/src/lib/search-command-surface.ts b/src/lib/search-command-surface.ts index 8b17f3ba60..d13801cd36 100644 --- a/src/lib/search-command-surface.ts +++ b/src/lib/search-command-surface.ts @@ -1,4 +1,5 @@ import type { AppModeId } from "@/lib/app-modes"; +import { sharedHomePresentation } from "@/lib/ui-copy"; export type CommandSuggestion = { text: string; @@ -43,7 +44,7 @@ export function commandDropdownCanDisplay({ const searchCommandSurfaceByMode: Partial> = { documents: { - examples: ["clozapine ANC thresholds", "lithium monitoring table", "QT prolongation quote"], + examples: [...sharedHomePresentation.documents.suggestions], suggestions: [ { text: "clozapine monitoring table", meta: "Tables" }, { text: "clozapine ANC thresholds", meta: "Guidelines" }, @@ -52,7 +53,7 @@ const searchCommandSurfaceByMode: Partial; diff --git a/tests/document-search-scope-zero-results.dom.test.tsx b/tests/document-search-scope-zero-results.dom.test.tsx index 91541987af..329c1828b7 100644 --- a/tests/document-search-scope-zero-results.dom.test.tsx +++ b/tests/document-search-scope-zero-results.dom.test.tsx @@ -6,6 +6,7 @@ import { DocumentSearchResultsPanel } from "@/components/clinical-dashboard/docu import { removeScopeFilterValue, scopeFilterChips } from "@/lib/search-scope-filter-chips"; import type { DocumentMatch, SearchScopeSummary } from "@/lib/types"; +let mockSearchParams = new URLSearchParams(); vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), @@ -15,7 +16,7 @@ vi.mock("next/navigation", () => ({ forward: vi.fn(), prefetch: vi.fn(), }), - useSearchParams: () => new URLSearchParams(), + useSearchParams: () => mockSearchParams, usePathname: () => "/", })); @@ -262,3 +263,68 @@ describe("scope filter chips", () => { }); }); }); + +describe("documents hoisted filtered-to-zero empty state", () => { + it("hoists the empty state full width directly beneath the header band when facet-filtered to zero", () => { + const docA: DocumentMatch = { + ...agitationMatch, + document_id: "22222222-2222-4222-8222-222222222222", + title: "Adult Acute Agitation Clinical Guideline", + labels: [ + { + id: "l1", + document_id: "22222222-2222-4222-8222-222222222222", + label_type: "population", + label: "Adult", + confidence: 0.95, + source: "generated", + metadata: { review_status: "approved" }, + }, + { + id: "l2", + document_id: "22222222-2222-4222-8222-222222222222", + label_type: "setting", + label: "Inpatient", + confidence: 0.95, + source: "generated", + metadata: { review_status: "approved" }, + }, + ], + }; + const docB: DocumentMatch = { + ...agitationMatch, + document_id: "33333333-3333-4333-8333-333333333333", + title: "Paediatric Acute Agitation Clinical Guideline", + labels: [ + { + id: "l3", + document_id: "33333333-3333-4333-8333-333333333333", + label_type: "population", + label: "Paediatric", + confidence: 0.95, + source: "generated", + metadata: { review_status: "approved" }, + }, + { + id: "l4", + document_id: "33333333-3333-4333-8333-333333333333", + label_type: "setting", + label: "Outpatient", + confidence: 0.95, + source: "generated", + metadata: { review_status: "approved" }, + }, + ], + }; + + // Stacking constraints across different facet groups (AND across groups) filters the combined set to zero + mockSearchParams = new URLSearchParams("facet=population:adult,setting:outpatient"); + render(); + + const emptyStateContainer = screen.getByTestId("document-filter-empty-results"); + expect(emptyStateContainer).toBeInTheDocument(); + expect(emptyStateContainer).toHaveClass("w-full"); + expect(screen.queryByTestId("document-result-card")).not.toBeInTheDocument(); + mockSearchParams = new URLSearchParams(); + }); +}); diff --git a/tests/mode-home-loading-contract.test.ts b/tests/mode-home-loading-contract.test.ts index ba95eb94e5..1f12d9150f 100644 --- a/tests/mode-home-loading-contract.test.ts +++ b/tests/mode-home-loading-contract.test.ts @@ -15,6 +15,9 @@ const MODE_HOME_LOADING_ROUTES = [ "therapy-compass", "factsheets", "tools", + "medications", + "calculators", + "dictionary", ] as const; describe("mode-home loading contract", () => { diff --git a/tests/source-governance-attribution.test.ts b/tests/source-governance-attribution.test.ts new file mode 100644 index 0000000000..010c7f0313 --- /dev/null +++ b/tests/source-governance-attribution.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, it } from "vitest"; +import { + auditReviewAttribution, + extractReviewerAttribution, + isNonTrivialReviewerString, + isRecordMarkedReviewed, + type AuditableRecord, +} from "../scripts/audit-source-governance"; + +describe("source governance reviewer attribution", () => { + describe("isNonTrivialReviewerString", () => { + it("accepts valid non-trivial reviewer names, identifiers, and emails", () => { + expect(isNonTrivialReviewerString("Dr. Jane Doe")).toBe(true); + expect(isNonTrivialReviewerString("Psychiatrist Lead")).toBe(true); + expect(isNonTrivialReviewerString("reviewer@health.wa.gov.au")).toBe(true); + expect(isNonTrivialReviewerString("01M0B4YZFPSBKXZ7BGFC4YJ52G")).toBe(true); + expect(isNonTrivialReviewerString("12345")).toBe(true); + }); + + it("rejects non-strings, empty strings, and whitespace", () => { + expect(isNonTrivialReviewerString(null)).toBe(false); + expect(isNonTrivialReviewerString(undefined)).toBe(false); + expect(isNonTrivialReviewerString(12345)).toBe(false); + expect(isNonTrivialReviewerString({})).toBe(false); + expect(isNonTrivialReviewerString([])).toBe(false); + expect(isNonTrivialReviewerString("")).toBe(false); + expect(isNonTrivialReviewerString(" ")).toBe(false); + expect(isNonTrivialReviewerString("\t\n")).toBe(false); + }); + + it("rejects known trivial placeholders and empty sentinel values", () => { + expect(isNonTrivialReviewerString("unknown")).toBe(false); + expect(isNonTrivialReviewerString("UNKNOWN")).toBe(false); + expect(isNonTrivialReviewerString("none")).toBe(false); + expect(isNonTrivialReviewerString("n/a")).toBe(false); + expect(isNonTrivialReviewerString("N/A")).toBe(false); + expect(isNonTrivialReviewerString("na")).toBe(false); + expect(isNonTrivialReviewerString("null")).toBe(false); + expect(isNonTrivialReviewerString("undefined")).toBe(false); + expect(isNonTrivialReviewerString("todo")).toBe(false); + expect(isNonTrivialReviewerString("TODO")).toBe(false); + expect(isNonTrivialReviewerString("tbd")).toBe(false); + expect(isNonTrivialReviewerString("test")).toBe(false); + expect(isNonTrivialReviewerString("placeholder")).toBe(false); + expect(isNonTrivialReviewerString("anonymous")).toBe(false); + expect(isNonTrivialReviewerString("unattributed")).toBe(false); + expect(isNonTrivialReviewerString("unassigned")).toBe(false); + expect(isNonTrivialReviewerString("missing")).toBe(false); + expect(isNonTrivialReviewerString("pending")).toBe(false); + expect(isNonTrivialReviewerString("default")).toBe(false); + expect(isNonTrivialReviewerString("system")).toBe(false); + expect(isNonTrivialReviewerString("bot")).toBe(false); + expect(isNonTrivialReviewerString("automated")).toBe(false); + }); + + it("rejects nil UUID and punctuation-only strings", () => { + expect(isNonTrivialReviewerString("00000000-0000-0000-0000-000000000000")).toBe(false); + expect(isNonTrivialReviewerString("---")).toBe(false); + expect(isNonTrivialReviewerString("???")).toBe(false); + expect(isNonTrivialReviewerString("...")).toBe(false); + expect(isNonTrivialReviewerString("___")).toBe(false); + expect(isNonTrivialReviewerString(" - ")).toBe(false); + }); + }); + + describe("isRecordMarkedReviewed", () => { + it("detects top-level and metadata reviewStatus: reviewed", () => { + expect(isRecordMarkedReviewed({ reviewStatus: "reviewed" })).toEqual({ + isReviewed: true, + rawStatus: "reviewed", + }); + expect(isRecordMarkedReviewed({ review_status: "Reviewed" })).toEqual({ + isReviewed: true, + rawStatus: "Reviewed", + }); + expect(isRecordMarkedReviewed({ metadata: { reviewStatus: "reviewed" } })).toEqual({ + isReviewed: true, + rawStatus: "reviewed", + }); + expect(isRecordMarkedReviewed({ metadata: { review_status: "REVIEWED" } })).toEqual({ + isReviewed: true, + rawStatus: "REVIEWED", + }); + }); + + it("returns false for unreviewed statuses or missing review status", () => { + expect(isRecordMarkedReviewed({ reviewStatus: "needs_review" })).toEqual({ + isReviewed: false, + rawStatus: null, + }); + expect(isRecordMarkedReviewed({ reviewStatus: "Pending review" })).toEqual({ + isReviewed: false, + rawStatus: null, + }); + expect(isRecordMarkedReviewed({ reviewStatus: "Pending qualified clinician review" })).toEqual({ + isReviewed: false, + rawStatus: null, + }); + expect(isRecordMarkedReviewed({})).toEqual({ + isReviewed: false, + rawStatus: null, + }); + }); + }); + + describe("extractReviewerAttribution", () => { + it("extracts valid reviewedBy and reviewer fields", () => { + expect(extractReviewerAttribution({ reviewedBy: "Dr. Jane Doe" })).toMatchObject({ + hasAttribution: true, + attribution: "Dr. Jane Doe", + }); + expect(extractReviewerAttribution({ reviewer: "Dr. John Smith" })).toMatchObject({ + hasAttribution: true, + attribution: "Dr. John Smith", + }); + expect(extractReviewerAttribution({ reviewed_by: "Consultant Psychiatrist" })).toMatchObject({ + hasAttribution: true, + attribution: "Consultant Psychiatrist", + }); + }); + + it("extracts valid reviewer attribution from nested metadata and attestations", () => { + expect( + extractReviewerAttribution({ + metadata: { + reviewedBy: "Dr. Jane Doe", + }, + }), + ).toMatchObject({ + hasAttribution: true, + attribution: "Dr. Jane Doe", + }); + + expect( + extractReviewerAttribution({ + metadata: { + clinical_validation_evidence: { + attested_by: "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", + }, + }, + }), + ).toMatchObject({ + hasAttribution: true, + attribution: "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", + }); + + expect( + extractReviewerAttribution({ + reviewChecklist: { + reviewedBy: "Dr. Alice", + }, + }), + ).toMatchObject({ + hasAttribution: true, + attribution: "Dr. Alice", + }); + }); + + it("extracts structured reviewer object attribution", () => { + expect( + extractReviewerAttribution({ + reviewer: { + name: "Dr. Jane Doe", + qualification: "FRANZCP", + }, + }), + ).toMatchObject({ + hasAttribution: true, + attribution: "Dr. Jane Doe", + }); + + expect( + extractReviewerAttribution({ + reviewer: { + id: "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", + }, + }), + ).toMatchObject({ + hasAttribution: true, + attribution: "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", + }); + }); + + it("extracts array of reviewers", () => { + expect( + extractReviewerAttribution({ + reviewer: ["Dr. Jane Doe", "Dr. John Smith"], + }), + ).toMatchObject({ + hasAttribution: true, + attribution: "Dr. Jane Doe", + }); + }); + + it("returns hasAttribution: false when reviewer is missing, empty, or trivial", () => { + expect(extractReviewerAttribution({})).toMatchObject({ + hasAttribution: false, + attribution: null, + }); + expect(extractReviewerAttribution({ reviewedBy: "" })).toMatchObject({ + hasAttribution: false, + attribution: null, + }); + expect(extractReviewerAttribution({ reviewedBy: " " })).toMatchObject({ + hasAttribution: false, + attribution: null, + }); + expect(extractReviewerAttribution({ reviewedBy: "none" })).toMatchObject({ + hasAttribution: false, + attribution: null, + }); + expect(extractReviewerAttribution({ reviewer: "N/A" })).toMatchObject({ + hasAttribution: false, + attribution: null, + }); + expect(extractReviewerAttribution({ reviewed_by: "TODO" })).toMatchObject({ + hasAttribution: false, + attribution: null, + }); + expect(extractReviewerAttribution({ reviewedBy: "00000000-0000-0000-0000-000000000000" })).toMatchObject({ + hasAttribution: false, + attribution: null, + }); + expect( + extractReviewerAttribution({ + reviewer: { + name: "none", + id: "00000000-0000-0000-0000-000000000000", + }, + }), + ).toMatchObject({ + hasAttribution: false, + attribution: null, + }); + }); + }); + + describe("auditReviewAttribution", () => { + it("passes when there are no reviewed records or all reviewed records have valid attribution", () => { + const records: AuditableRecord[] = [ + { + record: { + slug: "therapy-needs-review", + name: "Therapy 1", + reviewStatus: "needs_review", + }, + recordType: "therapy", + identifier: "therapy-needs-review", + title: "Therapy 1", + source: "src/data/therapies-source.json", + }, + { + record: { + slug: "therapy-reviewed", + name: "Therapy 2", + reviewStatus: "reviewed", + reviewedBy: "Dr. Jane Doe (FRANZCP)", + }, + recordType: "therapy", + identifier: "therapy-reviewed", + title: "Therapy 2", + source: "src/data/therapies-source.json", + }, + ]; + + const report = auditReviewAttribution(records); + expect(report).toMatchObject({ + audited_record_count: 2, + reviewed_record_count: 1, + unattributed_reviewed_record_count: 0, + passed: true, + violations: [], + }); + }); + + it("detects governance violations when reviewed records lack attribution", () => { + const records: AuditableRecord[] = [ + { + record: { + slug: "unattributed-reviewed-therapy", + name: "Unattributed Therapy", + reviewStatus: "reviewed", + }, + recordType: "therapy", + identifier: "unattributed-reviewed-therapy", + title: "Unattributed Therapy", + source: "src/data/therapies-source.json", + }, + { + record: { + id: "doc-reviewed-empty-attribution", + title: "Guideline with Empty Attribution", + metadata: { + reviewStatus: "reviewed", + reviewedBy: " ", + }, + }, + recordType: "document", + identifier: "doc-reviewed-empty-attribution", + title: "Guideline with Empty Attribution", + source: "WA Health/guideline.pdf", + }, + { + record: { + id: "diff-trivial-attribution", + title: "Differential with Trivial Placeholder", + reviewStatus: "reviewed", + reviewer: "N/A", + }, + recordType: "differential", + identifier: "diff-trivial-attribution", + title: "Differential with Trivial Placeholder", + source: "data/differentials-snapshot.json", + }, + { + record: { + slug: "valid-reviewed-therapy", + name: "Valid Therapy", + reviewStatus: "reviewed", + reviewedBy: "Dr. Jane Doe", + }, + recordType: "therapy", + identifier: "valid-reviewed-therapy", + title: "Valid Therapy", + source: "src/data/therapies-source.json", + }, + { + record: { + slug: "unreviewed-therapy", + name: "Unreviewed Therapy", + reviewStatus: "needs_review", + }, + recordType: "therapy", + identifier: "unreviewed-therapy", + title: "Unreviewed Therapy", + source: "src/data/therapies-source.json", + }, + ]; + + const report = auditReviewAttribution(records); + expect(report.audited_record_count).toBe(5); + expect(report.reviewed_record_count).toBe(4); + expect(report.unattributed_reviewed_record_count).toBe(3); + expect(report.passed).toBe(false); + expect(report.violations).toHaveLength(3); + + expect(report.violations[0]).toMatchObject({ + record_type: "therapy", + identifier: "unattributed-reviewed-therapy", + review_status: "reviewed", + reason: expect.stringContaining("has no reviewer attribution"), + }); + + expect(report.violations[1]).toMatchObject({ + record_type: "document", + identifier: "doc-reviewed-empty-attribution", + review_status: "reviewed", + reason: expect.stringContaining("trivial placeholder"), + }); + + expect(report.violations[2]).toMatchObject({ + record_type: "differential", + identifier: "diff-trivial-attribution", + review_status: "reviewed", + reason: expect.stringContaining("trivial placeholder"), + }); + }); + }); +}); diff --git a/tests/therapy-compass-mode-wiring.test.ts b/tests/therapy-compass-mode-wiring.test.ts index f123d0c0d3..ba10ed9537 100644 --- a/tests/therapy-compass-mode-wiring.test.ts +++ b/tests/therapy-compass-mode-wiring.test.ts @@ -56,10 +56,10 @@ describe("Therapy Compass production-mode wiring", () => { ); expect(appModesSrc).toContain('label: "Therapy"'); - expect(appModesSrc).toContain('placeholder: "Search therapies…"'); + expect(appModesSrc).toContain('placeholder: "Search therapies..."'); expect(appModesSrc).toContain('inputAriaLabel: "Search therapies by problem, symptom, skill, or population"'); expect(appModesSrc).toContain('submitAriaLabel: "Open Therapy"'); - expect(homeSrc).toContain('title="Therapy"'); + expect(homeSrc).toContain('title={sharedHomePresentation["therapy-compass"].title}'); // Search route owns filters/results only; the results ribbon is the page h1. expect(searchSrc).toContain("SearchResultsHeaderBand"); expect(searchSrc).toContain("headingLevel={1}"); diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index 6cf8391cc1..4069a1a385 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -462,7 +462,7 @@ test.describe("Clinical KB accessibility coverage", () => { // Retry fill-then-enabled together: the server-rendered composer is visible // before React controls it, and a fill landing in that gap is discarded by // hydration, leaving the search button disabled and the click a no-op. - const presentationInput = page.locator('input[placeholder="Ask or search a presentation"]:visible').first(); + const presentationInput = page.locator('input[placeholder="Ask or search a presentation..."]:visible').first(); const differentialSubmit = page.locator('button[aria-label="Search differential presentations"]:visible'); await expect(async () => { await presentationInput.fill("acute confusion"); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 824e90be7c..b14a26be8c 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -80,7 +80,7 @@ function waitForDifferentialCatalogQuery(page: Page, query: string) { } async function submitDifferentialSearch(page: Page, query: string) { - const input = page.locator('input[placeholder="Ask or search a presentation"]:visible').first(); + const input = page.locator('input[placeholder="Ask or search a presentation..."]:visible').first(); const submit = page.locator('button[aria-label="Search differential presentations"]:visible'); // Own the fill here rather than leaving it to callers. The server-rendered @@ -1853,7 +1853,7 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expect(page.getByRole("button", { name: "Mode Differentials" })).toBeVisible(); await expect(page.getByTestId("differentials-home")).toBeVisible(); await expect(page.getByRole("heading", { level: 1, name: "Differentials" })).toBeVisible(); - await expect(page.locator('input[placeholder="Ask or search a presentation"]:visible').first()).toBeVisible(); + await expect(page.locator('input[placeholder="Ask or search a presentation..."]:visible').first()).toBeVisible(); await expect(page.getByRole("button", { name: "Search presentations" })).toBeVisible(); await expect(page.getByRole("button", { name: "Compare differentials" })).toBeVisible(); await expect(page.getByRole("region", { name: "Differential actions" }).getByRole("button")).toHaveCount(2); @@ -2303,7 +2303,7 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await page.setViewportSize({ width: 390, height: 844 }); await gotoLauncher(page, "/differentials"); - const input = page.locator('input[placeholder="Ask or search a presentation"]:visible'); + const input = page.locator('input[placeholder="Ask or search a presentation..."]:visible'); const submit = page.locator('button[aria-label="Search differential presentations"]:visible'); await expect(input).toHaveCount(1, { timeout: 15_000 }); await expect(submit).toHaveCount(1, { timeout: 15_000 }); diff --git a/tests/verify-pr-local.test.ts b/tests/verify-pr-local.test.ts index 5efd476c2f..3ebd8b44b6 100644 --- a/tests/verify-pr-local.test.ts +++ b/tests/verify-pr-local.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { DEV_SERVER_BUILD_REFUSED_EXIT_CODE } from "../scripts/guard-next-build.mjs"; -import { runPrLocalScripts, summarizePrLocalRun } from "../scripts/verify-pr-local.mjs"; +import { checkDevServerPreflight, runPrLocalScripts, summarizePrLocalRun } from "../scripts/verify-pr-local.mjs"; const script = path.resolve("scripts/verify-pr-local.mjs"); @@ -109,4 +109,58 @@ describe("verify-pr-local CLI", () => { expect(summary).toContain("failed: (none)"); expect(summary).toContain("not reached: (none)"); }); + + it("selects extended UI checks for mode configuration, routing, and UI copy changes (#0HFDWD)", () => { + for (const file of [ + "src/lib/app-modes.ts", + "src/lib/search-route-ownership.ts", + "src/lib/ui-copy.ts", + "src/lib/therapies.ts", + ]) { + const output = dryRun(file, "--extended"); + expect(output).toContain("- npm run verify:ui"); + } + }); + + it("preflights running dev server before build step and emits clear advice (#G4M3DV)", async () => { + const warn = vi.fn(); + const findServer = vi.fn().mockResolvedValue(3000); + + const port = await checkDevServerPreflight(["check:runtime", "build"], { + findServer, + warn, + env: {} as unknown as NodeJS.ProcessEnv, + }); + + expect(port).toBe(3000); + expect(findServer).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledOnce(); + const warningMessage = String(warn.mock.calls[0]?.[0] ?? ""); + expect(warningMessage).toContain("http://localhost:3000"); + expect(warningMessage).toContain("BUILD_REFUSED_DEV_SERVER"); + expect(warningMessage).toContain("ALLOW_BUILD_WITH_DEV_SERVER=1"); + }); + + it("skips dev server preflight when build is absent or ALLOW_BUILD_WITH_DEV_SERVER is set", async () => { + const warn = vi.fn(); + const findServer = vi.fn().mockResolvedValue(3000); + + const noBuildPort = await checkDevServerPreflight(["check:runtime", "lint"], { + findServer, + warn, + env: {} as unknown as NodeJS.ProcessEnv, + }); + expect(noBuildPort).toBeNull(); + expect(findServer).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + + const allowedPort = await checkDevServerPreflight(["build"], { + findServer, + warn, + env: { ALLOW_BUILD_WITH_DEV_SERVER: "1" } as unknown as NodeJS.ProcessEnv, + }); + expect(allowedPort).toBeNull(); + expect(findServer).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + }); }); From cc9c567a9ce1e2ae4883f624260edca2cbe274ed Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:48:54 +0800 Subject: [PATCH 02/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- scripts/audit-source-governance.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/audit-source-governance.ts b/scripts/audit-source-governance.ts index 8e59c8c1fc..79e31109eb 100644 --- a/scripts/audit-source-governance.ts +++ b/scripts/audit-source-governance.ts @@ -218,14 +218,16 @@ export function auditReviewAttribution(records: AuditableRecord[]): ReviewAttrib }; } -async function loadStaticJson(relativePath: string): Promise { +async function loadStaticJson(relativePath: string): Promise { + const fullPath = join(process.cwd(), relativePath); + if (!existsSync(fullPath)) { + throw new Error(`Required source governance input is missing: ${relativePath}`); + } try { - const fullPath = join(process.cwd(), relativePath); - if (!existsSync(fullPath)) return null; const raw = await readFile(fullPath, "utf8"); return JSON.parse(raw) as T; - } catch { - return null; + } catch (error) { + throw new Error(`Failed to load source governance input: ${relativePath}`, { cause: error }); } } From 5246925cebee7ab1eab7e569470e48ce5dcf3c79 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:53:04 +0800 Subject: [PATCH 03/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- scripts/guard-next-build.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/guard-next-build.mjs b/scripts/guard-next-build.mjs index 301372a72e..38cb36356e 100644 --- a/scripts/guard-next-build.mjs +++ b/scripts/guard-next-build.mjs @@ -83,7 +83,7 @@ function requestJson(port) { export async function findRunningProjectServer(rootDir = projectRoot) { const expectedProjectId = localProjectId(rootDir); const stablePort = stableProjectPort(rootDir); - const maxPort = Math.min(stablePort + 50, projectPortEnd); + const maxPort = projectPortEnd; for (let port = stablePort; port <= maxPort; port += 1) { const payload = await requestJson(port); From a7b24b95c554747df5798ab17652549dfb4c4d21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 06:54:51 +0000 Subject: [PATCH 04/12] fix: revert therapy-compass home title from "Therapy Compass" back to "Therapy" Co-authored-by: BigSimmo <87357024+BigSimmo@users.noreply.github.com> --- src/lib/ui-copy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/ui-copy.ts b/src/lib/ui-copy.ts index 852dddda88..34566dd4f8 100644 --- a/src/lib/ui-copy.ts +++ b/src/lib/ui-copy.ts @@ -91,7 +91,7 @@ export const sharedHomePresentation = { suggestions: ["depression severity", "anxiety screening", "alcohol use"], }, "therapy-compass": { - title: "Therapy Compass", + title: "Therapy", subtitle: "Source-grounded therapy records.", suggestions: ["trauma-focused CBT", "behavioural activation", "insomnia"], }, From edb92edd627a5b07310c3b318f11614e8532a3b7 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:53:53 +0800 Subject: [PATCH 05/12] fix(ui,modes): restore canonical placeholder ellipsis and presentation selectors --- src/components/universal-search-command-mockups.tsx | 2 +- src/lib/app-modes.ts | 8 ++++---- tests/therapy-compass-mode-wiring.test.ts | 2 +- tests/ui-accessibility.spec.ts | 2 +- tests/ui-tools.spec.ts | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/universal-search-command-mockups.tsx b/src/components/universal-search-command-mockups.tsx index e9c313bb4b..8e569912e8 100644 --- a/src/components/universal-search-command-mockups.tsx +++ b/src/components/universal-search-command-mockups.tsx @@ -268,7 +268,7 @@ const modeConfigs: Record = { id: "differentials", label: "Differentials", icon: BrainCircuit, - placeholder: "Ask or search a presentation...", + placeholder: "Ask or search a presentation", defaultQuery: "confusion", examples: ["acute confusion", "first episode psychosis", "catatonia vs NMS"], recents: ["confusion", "serotonin syndrome", "alcohol withdrawal"], diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts index 1fafd534ac..9162919a62 100644 --- a/src/lib/app-modes.ts +++ b/src/lib/app-modes.ts @@ -199,7 +199,7 @@ export const appModeDefinitions = [ href: "/differentials", search: { kind: "differentials", - placeholder: "Ask or search a presentation...", + placeholder: "Ask or search a presentation", inputAriaLabel: "Search differential presentations, symptoms, and scenarios", submitIdleLabel: "Diffs", submitBusyLabel: "Diffs", @@ -380,7 +380,7 @@ export const appModeDefinitions = [ // The longer phrase became the late portal's LCP element on Therapy Home. // Keep the full search scope in the accessible name below; the concise // visible prompt lets the already-painted hero remain the LCP owner. - placeholder: "Search therapies...", + placeholder: "Search therapies…", inputAriaLabel: "Search therapies by problem, symptom, skill, or population", submitIdleLabel: "Therapy", submitBusyLabel: "Therapy", @@ -406,7 +406,7 @@ export const appModeDefinitions = [ // library (not the document corpus), so it borrows the benign "tools" search // kind — like Therapy Compass — while keeping the shared composer visible. kind: "tools", - placeholder: "Search a medicine, condition, therapy or test...", + placeholder: "Search a medicine, condition, therapy or test…", inputAriaLabel: "Search patient information factsheets", submitIdleLabel: "Sheets", submitBusyLabel: "Sheets", @@ -431,7 +431,7 @@ export const appModeDefinitions = [ // Dictionary owns a local static catalogue. The shared composer uses the // benign tools command kind, then appModeHomeHref routes into its results. kind: "tools", - placeholder: "Search a term or abbreviation...", + placeholder: "Search a term or abbreviation…", inputAriaLabel: "Search clinical terms, abbreviations, and topics", submitIdleLabel: "Terms", submitBusyLabel: "Terms", diff --git a/tests/therapy-compass-mode-wiring.test.ts b/tests/therapy-compass-mode-wiring.test.ts index ba10ed9537..1d5175a690 100644 --- a/tests/therapy-compass-mode-wiring.test.ts +++ b/tests/therapy-compass-mode-wiring.test.ts @@ -56,7 +56,7 @@ describe("Therapy Compass production-mode wiring", () => { ); expect(appModesSrc).toContain('label: "Therapy"'); - expect(appModesSrc).toContain('placeholder: "Search therapies..."'); + expect(appModesSrc).toContain('placeholder: "Search therapies…"'); expect(appModesSrc).toContain('inputAriaLabel: "Search therapies by problem, symptom, skill, or population"'); expect(appModesSrc).toContain('submitAriaLabel: "Open Therapy"'); expect(homeSrc).toContain('title={sharedHomePresentation["therapy-compass"].title}'); diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index 4069a1a385..6cf8391cc1 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -462,7 +462,7 @@ test.describe("Clinical KB accessibility coverage", () => { // Retry fill-then-enabled together: the server-rendered composer is visible // before React controls it, and a fill landing in that gap is discarded by // hydration, leaving the search button disabled and the click a no-op. - const presentationInput = page.locator('input[placeholder="Ask or search a presentation..."]:visible').first(); + const presentationInput = page.locator('input[placeholder="Ask or search a presentation"]:visible').first(); const differentialSubmit = page.locator('button[aria-label="Search differential presentations"]:visible'); await expect(async () => { await presentationInput.fill("acute confusion"); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index b14a26be8c..824e90be7c 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -80,7 +80,7 @@ function waitForDifferentialCatalogQuery(page: Page, query: string) { } async function submitDifferentialSearch(page: Page, query: string) { - const input = page.locator('input[placeholder="Ask or search a presentation..."]:visible').first(); + const input = page.locator('input[placeholder="Ask or search a presentation"]:visible').first(); const submit = page.locator('button[aria-label="Search differential presentations"]:visible'); // Own the fill here rather than leaving it to callers. The server-rendered @@ -1853,7 +1853,7 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expect(page.getByRole("button", { name: "Mode Differentials" })).toBeVisible(); await expect(page.getByTestId("differentials-home")).toBeVisible(); await expect(page.getByRole("heading", { level: 1, name: "Differentials" })).toBeVisible(); - await expect(page.locator('input[placeholder="Ask or search a presentation..."]:visible').first()).toBeVisible(); + await expect(page.locator('input[placeholder="Ask or search a presentation"]:visible').first()).toBeVisible(); await expect(page.getByRole("button", { name: "Search presentations" })).toBeVisible(); await expect(page.getByRole("button", { name: "Compare differentials" })).toBeVisible(); await expect(page.getByRole("region", { name: "Differential actions" }).getByRole("button")).toHaveCount(2); @@ -2303,7 +2303,7 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await page.setViewportSize({ width: 390, height: 844 }); await gotoLauncher(page, "/differentials"); - const input = page.locator('input[placeholder="Ask or search a presentation..."]:visible'); + const input = page.locator('input[placeholder="Ask or search a presentation"]:visible'); const submit = page.locator('button[aria-label="Search differential presentations"]:visible'); await expect(input).toHaveCount(1, { timeout: 15_000 }); await expect(submit).toHaveCount(1, { timeout: 15_000 }); From a3e081d0adbe89bf674b81df72f0c0986b86ee7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:36:38 +0000 Subject: [PATCH 06/12] fix(ci): disable PWA install prompts in Lighthouse budget runs Co-authored-by: BigSimmo <87357024+BigSimmo@users.noreply.github.com> --- scripts/run-lighthouse-budget.mjs | 10 +++++++++- tests/check-lighthouse-budget.test.ts | 7 +++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/run-lighthouse-budget.mjs b/scripts/run-lighthouse-budget.mjs index 2b0d4ebbb6..ca673bcca4 100644 --- a/scripts/run-lighthouse-budget.mjs +++ b/scripts/run-lighthouse-budget.mjs @@ -255,6 +255,14 @@ process.once("exit", cleanup); const budget = loadBudget(); const routes = budget.routes ?? []; const strategies = budget.strategies ?? ["mobile", "desktop"]; + +function routeWithLighthouseParams(route) { + // Lighthouse is a synthetic unattended run. Keep local-dev PWA prompts off so + // browser-timed install eligibility cannot inject the fixed install sheet and + // register a non-product CLS spike in budget grading. + const separator = route.includes("?") ? "&" : "?"; + return `${route}${separator}pwa-dev=0`; +} /** * Pinned in `lighthouse-budget.json` so this runner and the live-domain workflow * share one version; `tests/check-lighthouse-budget.test.ts` fails if they drift. @@ -385,7 +393,7 @@ try { ...npxInvocation.prefixArgs, "--yes", `lighthouse@${LIGHTHOUSE_VERSION}`, - `${baseUrl}${route}`, + `${baseUrl}${routeWithLighthouseParams(route)}`, "--output=json", `--output-path=${output}`, `--preset=${strategy === "desktop" ? "desktop" : "perf"}`, diff --git a/tests/check-lighthouse-budget.test.ts b/tests/check-lighthouse-budget.test.ts index f5fddb6da3..395838db6a 100644 --- a/tests/check-lighthouse-budget.test.ts +++ b/tests/check-lighthouse-budget.test.ts @@ -504,6 +504,13 @@ describe("committed lighthouse-budget.json", () => { expect(runner).toContain("--max-wait-for-load=60000"); expect(runner).not.toMatch(/stdio:\s*"inherit",\s*\n\s*timeout,/); }); + + it("disables local-dev install prompts during Lighthouse measurements", () => { + const runner = readFileSync(path.join(process.cwd(), "scripts", "run-lighthouse-budget.mjs"), "utf8"); + + expect(runner).toContain("routeWithLighthouseParams(route)"); + expect(runner).toContain("pwa-dev=0"); + }); }); describe("Lighthouse time budget", () => { From d6b49b5cb8ef6c1de8e3c54d2429780648adeec8 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:53:52 +0800 Subject: [PATCH 07/12] Fix stale shared-home CI expectations (#2179) --- tests/mode-home-no-caveat-footer.test.ts | 5 +++-- tests/shared-home-empty-state.dom.test.tsx | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/mode-home-no-caveat-footer.test.ts b/tests/mode-home-no-caveat-footer.test.ts index 7b000b8195..3d70746ad0 100644 --- a/tests/mode-home-no-caveat-footer.test.ts +++ b/tests/mode-home-no-caveat-footer.test.ts @@ -85,9 +85,10 @@ describe("mode homes carry no caveat footer", () => { it("leaves no caveat copy in the shared-home presentation table", () => { // The copy route into the same defect: a mode could carry the line as data - // rather than as a component. Every entry is exactly a title and a subtitle. + // rather than as a component. The table now also carries starter + // suggestions, but it must not grow any caveat/footer field. for (const modeId of appModeIds) { - expect(Object.keys(sharedHomePresentation[modeId]).sort(), modeId).toEqual(["subtitle", "title"]); + expect(Object.keys(sharedHomePresentation[modeId]).sort(), modeId).toEqual(["subtitle", "suggestions", "title"]); } }); }); diff --git a/tests/shared-home-empty-state.dom.test.tsx b/tests/shared-home-empty-state.dom.test.tsx index ad68c54ec2..210aa01e6a 100644 --- a/tests/shared-home-empty-state.dom.test.tsx +++ b/tests/shared-home-empty-state.dom.test.tsx @@ -85,7 +85,7 @@ const expectedPresentations = [ }, { modeId: "therapy-compass", - title: "Therapy Compass", + title: "Therapy", subtitle: "Source-grounded therapy records.", iconClass: "lucide-compass", }, @@ -129,11 +129,11 @@ describe("SharedHomeEmptyState", () => { expect(icon).toHaveClass(iconClass); expect(icon).toHaveAttribute("aria-hidden", "true"); - // No mode home carries a caveat line under the composer any more, and no - // mode may reintroduce one as filler: the presentation table is exactly a - // title and a subtitle. + // No mode home carries a caveat line under the composer any more. The + // shared-home table now also feeds mode-specific starter suggestions, so + // the contract is title + subtitle + suggestions — and nothing else. const presentation: SharedHomePresentation = sharedHomePresentation[modeId]; - expect(Object.keys(presentation).sort()).toEqual(["subtitle", "title"]); + expect(Object.keys(presentation).sort()).toEqual(["subtitle", "suggestions", "title"]); }, ); From ff1c0afc76ee6cd0967da3e4a2a2d090453ffa5f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:54:55 +0800 Subject: [PATCH 08/12] Initial plan (#2178) From ccb4c44c106ed7166a6220ab5e0b5bce1f6cb2e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:08:25 +0000 Subject: [PATCH 09/12] fix: resolve remaining PR review feedback Co-authored-by: BigSimmo <87357024+BigSimmo@users.noreply.github.com> --- docs/rag-improvement/HANDOVER.md | 9 ++- docs/rag-improvement/README.md | 2 +- eval/docling/fixtures/generate_fixtures.py | 75 +++++++++++++++---- scripts/audit-source-governance.ts | 20 +++-- scripts/lib/lighthouse-route-params.mjs | 7 ++ scripts/pr-policy.mjs | 27 +++---- scripts/run-lighthouse-budget.mjs | 8 +- tests/check-lighthouse-budget.test.ts | 7 +- ...ent-search-scope-zero-results.dom.test.tsx | 7 +- tests/source-governance-attribution.test.ts | 5 ++ 10 files changed, 115 insertions(+), 52 deletions(-) create mode 100644 scripts/lib/lighthouse-route-params.mjs diff --git a/docs/rag-improvement/HANDOVER.md b/docs/rag-improvement/HANDOVER.md index a0c7e912bf..3ac6860afa 100644 --- a/docs/rag-improvement/HANDOVER.md +++ b/docs/rag-improvement/HANDOVER.md @@ -337,10 +337,11 @@ offline fixtures and validation only`. against an existing branch: it can discard unpushed packet commits. Never build on a stale head. 2. Read: your packet here → the matching README section → `docs/rag-behaviour/README.md` (+ `safeguards.md` before touching any protected surface). -3. Check the canonical queue in [`docs/outstanding-issues.md`](../outstanding-issues.md), - this file's status table (§2), AND the open PR list for a duplicate implementation - (`#292`: an open ledger row is not proof nobody is building it — PR #1899 already covers - A1 phase 1). +3. Check the canonical queue in [`docs/outstanding-issues.md`](../outstanding-issues.md) + and this file's status table (§2). Check the open PR list for duplicate implementation + only when explicit owner approval for provider access exists; otherwise proceed with + local/offline evidence and note the duplicate-risk caveat (`#292`: an open ledger row is + not proof nobody is building it — PR #1899 already covers A1 phase 1). 4. State the RAG-impact flag to the owner in your first message if your packet touches a protected surface (all of Track A does). 5. Confirm what is NOT authorised: live canary dispatches, provider-backed evals diff --git a/docs/rag-improvement/README.md b/docs/rag-improvement/README.md index 26b1cdbef6..a11c39f918 100644 --- a/docs/rag-improvement/README.md +++ b/docs/rag-improvement/README.md @@ -13,7 +13,7 @@ infrastructure, corrected. behaviour change needs a live eval-canary pair (provider-backed, ~$1–2, explicit approval per run). Nothing here authorises reindexing, migrations, or provider calls by itself. -**Canonical task queue:** per `AGENTS.md`, canonical cross-session task tracking and deduplication live in [`docs/outstanding-issues.md`](../outstanding-issues.md). Always consult the canonical ledger and check open PRs before beginning work on any packet or RAG surface. +**Canonical task queue:** per `AGENTS.md`, canonical cross-session task tracking and deduplication live in [`docs/outstanding-issues.md`](../outstanding-issues.md). Always consult the canonical ledger first. Check open PRs only when explicit owner approval for provider access exists; otherwise continue with local/offline evidence and note the duplicate-risk caveat. --- diff --git a/eval/docling/fixtures/generate_fixtures.py b/eval/docling/fixtures/generate_fixtures.py index 14e1e6fa08..d06df8b5d9 100755 --- a/eval/docling/fixtures/generate_fixtures.py +++ b/eval/docling/fixtures/generate_fixtures.py @@ -68,33 +68,80 @@ def draw_table(page: fitz.Page, top: float, table: dict) -> float: rect = usable_rect() col_width = (rect.x1 - rect.x0) / cols row_height = float(table.get("rowHeight", ROW_HEIGHT)) - bottom = top + rows * row_height - if bottom > rect.y1: - die(f"table {table['tableId']} does not fit on its page — reduce rows in the manifest") - unruled = bool(table.get("unruled") or table.get("style") == "unruled") rotated_headers = bool(table.get("rotatedHeaders") or table.get("rotated_headers")) + header_row_height = float( + table.get("headerRowHeight", row_height if not rotated_headers else max(row_height, TABLE_SIZE * 4.5)) + ) + row_heights = [header_row_height] + [row_height] * max(0, rows - 1) + row_edges = [top] + for height in row_heights: + row_edges.append(row_edges[-1] + height) + col_edges = [rect.x0 + idx * col_width for idx in range(cols + 1)] + bottom = row_edges[-1] + if bottom > rect.y1: + die(f"table {table['tableId']} does not fit on its page — reduce rows in the manifest") + + hidden_horizontal_segments: set[tuple[int, int]] = set() + hidden_vertical_segments: set[tuple[int, int]] = set() + for cell in table["cells"]: + r, c = cell["row"], cell["col"] + col_span = max(1, int(cell.get("colSpan", 1))) + row_span = max(1, int(cell.get("rowSpan", 1))) + for interior_row in range(r + 1, min(rows, r + row_span)): + for col_segment in range(c, min(cols, c + col_span)): + hidden_horizontal_segments.add((interior_row, col_segment)) + for interior_col in range(c + 1, min(cols, c + col_span)): + for row_segment in range(r, min(rows, r + row_span)): + hidden_vertical_segments.add((interior_col, row_segment)) if not unruled: - for r in range(rows + 1): - y = top + r * row_height - page.draw_line(fitz.Point(rect.x0, y), fitz.Point(rect.x1, y), width=0.5) - for c in range(cols + 1): - x = rect.x0 + c * col_width - page.draw_line(fitz.Point(x, top), fitz.Point(x, bottom), width=0.5) + for row_boundary in range(rows + 1): + y = row_edges[row_boundary] + for col_segment in range(cols): + if (row_boundary, col_segment) in hidden_horizontal_segments: + continue + page.draw_line( + fitz.Point(col_edges[col_segment], y), + fitz.Point(col_edges[col_segment + 1], y), + width=0.5, + ) + for col_boundary in range(cols + 1): + x = col_edges[col_boundary] + for row_segment in range(rows): + if (col_boundary, row_segment) in hidden_vertical_segments: + continue + page.draw_line( + fitz.Point(x, row_edges[row_segment]), + fitz.Point(x, row_edges[row_segment + 1]), + width=0.5, + ) else: # Unruled / booktabs style: horizontal rules only (top, mid/header, bottom) page.draw_line(fitz.Point(rect.x0, top), fitz.Point(rect.x1, top), width=1.0) - page.draw_line(fitz.Point(rect.x0, top + row_height), fitz.Point(rect.x1, top + row_height), width=0.5) + page.draw_line(fitz.Point(rect.x0, row_edges[1]), fitz.Point(rect.x1, row_edges[1]), width=0.5) page.draw_line(fitz.Point(rect.x0, bottom), fitz.Point(rect.x1, bottom), width=1.0) for cell in table["cells"]: r, c = cell["row"], cell["col"] + col_span = max(1, int(cell.get("colSpan", 1))) + row_span = max(1, int(cell.get("rowSpan", 1))) is_rotated = (r == 0 and rotated_headers) or bool(cell.get("rotate") == 90 or cell.get("rotated")) - x = rect.x0 + c * col_width + 3 - y = top + r * row_height + row_height - 6 + x0 = col_edges[c] + x1 = col_edges[min(cols, c + col_span)] + y0 = row_edges[r] + y1 = row_edges[min(rows, r + row_span)] + cell_rect = fitz.Rect(x0, y0, x1, y1) + x = cell_rect.x0 + 3 + y = cell_rect.y1 - 6 if is_rotated: - page.insert_text(fitz.Point(x + min(col_width, 16.0), y), cell["text"], fontname=BODY_FONT, fontsize=TABLE_SIZE, rotate=90) + page.insert_text( + fitz.Point(cell_rect.x0 + min(cell_rect.width, 16.0), cell_rect.y1 - 4), + cell["text"], + fontname=BODY_FONT, + fontsize=TABLE_SIZE, + rotate=90, + ) else: page.insert_text(fitz.Point(x, y), cell["text"], fontname=BODY_FONT, fontsize=TABLE_SIZE) return bottom diff --git a/scripts/audit-source-governance.ts b/scripts/audit-source-governance.ts index 79e31109eb..cafc7c52dd 100644 --- a/scripts/audit-source-governance.ts +++ b/scripts/audit-source-governance.ts @@ -26,7 +26,7 @@ export type ReviewAttributionViolation = { title: string; source: string; review_status: string; - found_attribution: Record; + found_attribution: Record; reason: string; }; @@ -194,17 +194,21 @@ export function auditReviewAttribution(records: AuditableRecord[]): ReviewAttrib const { hasAttribution, foundFields } = extractReviewerAttribution(item.record); if (!hasAttribution) { + const attributionFieldNames = Object.keys(foundFields).sort(); + const redactedAttributionFields = Object.fromEntries( + attributionFieldNames.map((fieldName) => [fieldName, "[redacted]"]), + ); violations.push({ record_type: item.recordType, identifier: item.identifier, title: item.title, source: item.source, review_status: rawStatus ?? "reviewed", - found_attribution: foundFields, + found_attribution: redactedAttributionFields, reason: - Object.keys(foundFields).length === 0 + attributionFieldNames.length === 0 ? "Record is marked as reviewed but has no reviewer attribution (e.g. reviewedBy or reviewer field is missing)." - : `Record is marked as reviewed but reviewer attribution contains empty or trivial placeholder value(s): ${JSON.stringify(foundFields)}.`, + : `Record is marked as reviewed but reviewer attribution contains empty or trivial placeholder value(s) in: ${attributionFieldNames.join(", ")}.`, }); } } @@ -227,7 +231,13 @@ async function loadStaticJson(relativePath: string): Promise { const raw = await readFile(fullPath, "utf8"); return JSON.parse(raw) as T; } catch (error) { - throw new Error(`Failed to load source governance input: ${relativePath}`, { cause: error }); + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(`Required source governance input is missing: ${relativePath}`, { cause: error }); + } + throw new Error( + `Failed to load source governance input: ${relativePath}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); } } diff --git a/scripts/lib/lighthouse-route-params.mjs b/scripts/lib/lighthouse-route-params.mjs new file mode 100644 index 0000000000..be4ce98464 --- /dev/null +++ b/scripts/lib/lighthouse-route-params.mjs @@ -0,0 +1,7 @@ +export function routeWithLighthouseParams(route) { + // Lighthouse is a synthetic unattended run. Keep local-dev PWA prompts off so + // browser-timed install eligibility cannot inject the fixed install sheet and + // register a non-product CLS spike in budget grading. + const separator = route.includes("?") ? "&" : "?"; + return `${route}${separator}pwa-dev=0`; +} diff --git a/scripts/pr-policy.mjs b/scripts/pr-policy.mjs index ff2e1458fe..62cbe2ed15 100644 --- a/scripts/pr-policy.mjs +++ b/scripts/pr-policy.mjs @@ -26,6 +26,7 @@ const legacyClinicalGovernanceEvidence = new Map([ /\bprovenance\b/i, /\bclinical-content governance\b/i, ], + [/\blinked-source verification\b/i, /\bclinical use\b/i], ], ], [ @@ -150,7 +151,7 @@ function meaningfulText(value) { function checkedCommand(value, command) { const escaped = command.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return new RegExp(`-\\s*\\[[xX]\\]\\s*[^\\n]*${escaped}`, "i").test(value); + return checkedChecklistEntries(value).some((entry) => new RegExp(escaped, "i").test(entry)); } function explicitNotRun(value, scope = "verification") { @@ -176,6 +177,10 @@ function checkedChecklistEntries(value) { return [...String(value ?? "").matchAll(/^\s*[-*+]\s*\[[xX]\]\s*(.+?)\s*$/gm)].map((match) => match[1].trim()); } +function uncheckedChecklistEntries(value) { + return [...String(value ?? "").matchAll(/^\s*[-*+]\s*\[\s\]\s*(.+?)\s*$/gm)].map((match) => match[1].trim()); +} + function governanceMatcherGroupSatisfied(group, checkedEntries) { return group.every((matcher) => checkedEntries.some((entry) => matcher.test(entry))); } @@ -194,18 +199,6 @@ export function collectSatisfiedGovernanceItems(value) { return requiredClinicalGovernanceItems.filter((item) => governanceItemSatisfied(checkedEntries, item)); } -// Tolerant matching for the policy gate itself: affirm every item is checked -// without demanding the exact required wording. Authors may lightly reword or -// reformat the checklist, but a clinical-risk PR must leave no box unchecked -// and must cover at least the required number of governance items. -function governanceBoxStats(value) { - const source = String(value ?? ""); - return { - checked: (source.match(/[-*+]\s*\[[xX]\]/g) ?? []).length, - unchecked: (source.match(/[-*+]\s*\[ \]/g) ?? []).length, - }; -} - function fieldValue(value, field) { const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return ( @@ -312,8 +305,12 @@ export function evaluatePullRequestPolicy({ title, body, headRef, files }) { if (!meaningfulText(governance)) { errors.push("Clinical-risk paths require the `## Clinical Governance Preflight` section."); } else { - const { checked, unchecked } = governanceBoxStats(governance); - if (unchecked > 0 || checked < requiredClinicalGovernanceItems.length) { + const satisfiedItems = collectSatisfiedGovernanceItems(governance); + const uncheckedItems = uncheckedChecklistEntries(governance); + const hasUncheckedRequiredItem = requiredClinicalGovernanceItems.some((item) => + uncheckedItems.some((uncheckedItem) => uncheckedItem === item), + ); + if (satisfiedItems.length < requiredClinicalGovernanceItems.length || hasUncheckedRequiredItem) { errors.push( `Check every Clinical Governance Preflight item before marking the PR ready (all ${requiredClinicalGovernanceItems.length} boxes checked, none left unchecked).`, ); diff --git a/scripts/run-lighthouse-budget.mjs b/scripts/run-lighthouse-budget.mjs index ca673bcca4..ccace8b29f 100644 --- a/scripts/run-lighthouse-budget.mjs +++ b/scripts/run-lighthouse-budget.mjs @@ -64,6 +64,7 @@ import { processTimeoutMs, remainingMs, } from "./lighthouse-time-budget.mjs"; +import { routeWithLighthouseParams } from "./lib/lighthouse-route-params.mjs"; import { appName, circularProjectPortRange, @@ -256,13 +257,6 @@ const budget = loadBudget(); const routes = budget.routes ?? []; const strategies = budget.strategies ?? ["mobile", "desktop"]; -function routeWithLighthouseParams(route) { - // Lighthouse is a synthetic unattended run. Keep local-dev PWA prompts off so - // browser-timed install eligibility cannot inject the fixed install sheet and - // register a non-product CLS spike in budget grading. - const separator = route.includes("?") ? "&" : "?"; - return `${route}${separator}pwa-dev=0`; -} /** * Pinned in `lighthouse-budget.json` so this runner and the live-domain workflow * share one version; `tests/check-lighthouse-budget.test.ts` fails if they drift. diff --git a/tests/check-lighthouse-budget.test.ts b/tests/check-lighthouse-budget.test.ts index 395838db6a..8b6d8e96be 100644 --- a/tests/check-lighthouse-budget.test.ts +++ b/tests/check-lighthouse-budget.test.ts @@ -17,6 +17,7 @@ import { renderBudgetTable, validateBaselineBrowserVersions, } from "../scripts/check-lighthouse-budget.mjs"; +import { routeWithLighthouseParams } from "../scripts/lib/lighthouse-route-params.mjs"; import { measurementFailureReason } from "../scripts/lighthouse-measurement-outcome.mjs"; import { deadlineAfter, processTimeoutMs, remainingMs } from "../scripts/lighthouse-time-budget.mjs"; @@ -506,10 +507,8 @@ describe("committed lighthouse-budget.json", () => { }); it("disables local-dev install prompts during Lighthouse measurements", () => { - const runner = readFileSync(path.join(process.cwd(), "scripts", "run-lighthouse-budget.mjs"), "utf8"); - - expect(runner).toContain("routeWithLighthouseParams(route)"); - expect(runner).toContain("pwa-dev=0"); + expect(routeWithLighthouseParams("/forms")).toBe("/forms?pwa-dev=0"); + expect(routeWithLighthouseParams("/forms?feature=abc")).toBe("/forms?feature=abc&pwa-dev=0"); }); }); diff --git a/tests/document-search-scope-zero-results.dom.test.tsx b/tests/document-search-scope-zero-results.dom.test.tsx index 329c1828b7..c150ff3715 100644 --- a/tests/document-search-scope-zero-results.dom.test.tsx +++ b/tests/document-search-scope-zero-results.dom.test.tsx @@ -1,12 +1,16 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { DocumentSearchResultsPanel } from "@/components/clinical-dashboard/document-search-results"; import { removeScopeFilterValue, scopeFilterChips } from "@/lib/search-scope-filter-chips"; import type { DocumentMatch, SearchScopeSummary } from "@/lib/types"; let mockSearchParams = new URLSearchParams(); +afterEach(() => { + mockSearchParams = new URLSearchParams(); +}); + vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), @@ -325,6 +329,5 @@ describe("documents hoisted filtered-to-zero empty state", () => { expect(emptyStateContainer).toBeInTheDocument(); expect(emptyStateContainer).toHaveClass("w-full"); expect(screen.queryByTestId("document-result-card")).not.toBeInTheDocument(); - mockSearchParams = new URLSearchParams(); }); }); diff --git a/tests/source-governance-attribution.test.ts b/tests/source-governance-attribution.test.ts index 010c7f0313..04045847f6 100644 --- a/tests/source-governance-attribution.test.ts +++ b/tests/source-governance-attribution.test.ts @@ -356,14 +356,19 @@ describe("source governance reviewer attribution", () => { identifier: "doc-reviewed-empty-attribution", review_status: "reviewed", reason: expect.stringContaining("trivial placeholder"), + found_attribution: { reviewedBy: "[redacted]" }, }); + expect(report.violations[1].reason).not.toContain(" "); expect(report.violations[2]).toMatchObject({ record_type: "differential", identifier: "diff-trivial-attribution", review_status: "reviewed", reason: expect.stringContaining("trivial placeholder"), + found_attribution: { reviewer: "[redacted]" }, }); + expect(report.violations[2].reason).toContain("reviewer"); + expect(report.violations[2].reason).not.toContain("unknown"); }); }); }); From 65148377338cbda15e93c5c8dc458f0e208f3bb2 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:55:23 +0800 Subject: [PATCH 10/12] fix: tighten pr-policy verification parsing and legacy governance matchers; regenerate scripts inventory --- docs/scripts-index.md | 2 +- scripts/pr-policy.mjs | 46 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/docs/scripts-index.md b/docs/scripts-index.md index de8c6f9e09..d8b753edbc 100644 --- a/docs/scripts-index.md +++ b/docs/scripts-index.md @@ -1,6 +1,6 @@ # Scripts index -Curated map of `scripts/` (247 files) and the `package.json` script surface (252 entries), +Curated map of `scripts/` (248 files) and the `package.json` script surface (252 entries), grouped by purpose. This is orientation, not an exhaustive per-file listing — the authoritative command list is `package.json`, and `npm run docs:check-scripts` verifies every `npm run ` referenced in docs resolves to a real script. `npm run docs:update` refreshes the exact counts above. diff --git a/scripts/pr-policy.mjs b/scripts/pr-policy.mjs index 62cbe2ed15..1478e271ad 100644 --- a/scripts/pr-policy.mjs +++ b/scripts/pr-policy.mjs @@ -35,10 +35,13 @@ const legacyClinicalGovernanceEvidence = new Map([ ], [requiredClinicalGovernanceItems[2], [[/\bconfigured supabase project\/target\b/i]]], [requiredClinicalGovernanceItems[3], [[/\bservice-role credentials?\b/i, /\bserver-side only\b/i]]], - [requiredClinicalGovernanceItems[4], [[/\bclinical-content governance\b/i, /\bsource verification\b/i]]], + [ + requiredClinicalGovernanceItems[4], + [[/\b(?:demo|synthetic)\b/i, /\b(?:separat(?:ed|ion)|real|production|clinical sources?)\b/i]], + ], [ requiredClinicalGovernanceItems[5], - [[/\bprovenance\b/i, /\bclinical-content governance\b/i, /\bsource verification\b/i]], + [[/\b(?:source metadata|review status|reviewed|outdated|unknown[- ]source|conservative)\b/i]], ], [requiredClinicalGovernanceItems[6], [[/\bsamd\/tga assessment\b/i]]], ]); @@ -151,7 +154,9 @@ function meaningfulText(value) { function checkedCommand(value, command) { const escaped = command.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return checkedChecklistEntries(value).some((entry) => new RegExp(escaped, "i").test(entry)); + // The command must end at a token boundary so a checked entry such as + // `npm run verify:ui-disabled` does not satisfy a check for `npm run verify:ui`. + return checkedChecklistEntries(value).some((entry) => new RegExp(`${escaped}(?![\\w:-])`, "i").test(entry)); } function explicitNotRun(value, scope = "verification") { @@ -266,7 +271,7 @@ export function evaluatePullRequestPolicy({ title, body, headRef, files }) { warnings.push("Complete the `## Summary` section with the outcome and affected area."); if (!meaningfulText(verification)) { warnings.push("Complete the `## Verification` section with exact results or a reason checks were not run."); - } else if (!/-\s*\[[xX]\]/.test(verification) && !explicitNotRun(verification)) { + } else if (checkedChecklistEntries(verification).length === 0 && !explicitNotRun(verification)) { warnings.push( "Verification should contain a checked result or an explicit `Verification not run: ` entry.", ); @@ -373,6 +378,28 @@ function selfTest() { }).warnings.join(" "), /verify:ui/, ); + // Command-token boundary: a checked `npm run verify:ui-disabled` entry must not + // satisfy the `npm run verify:ui` requirement for UI-classified PRs. + assert.match( + evaluatePullRequestPolicy({ + title: "fix: update search behavior", + body: completeBody.replace("- [x] `npm run verify:ui`\n", "- [x] `npm run verify:ui-disabled`\n"), + headRef: "codex/search-fix", + files: ["src/components/search.tsx"], + }).warnings.join(" "), + /verify:ui/, + ); + // Marker tolerance: `*` and `+` checklist markers satisfy the generic + // verification check, not just `-`. + assert.doesNotMatch( + evaluatePullRequestPolicy({ + title: "docs: explain the review process", + body: "## Summary\n\n- Useful documentation.\n\n## Verification\n\n* [x] `npm run verify:pr-local`\n+ [x] `npm run typecheck`\n", + headRef: "codex/review-docs", + files: ["docs/process-hardening.md"], + }).warnings.join(" "), + /checked result/, + ); // Outline semantics: a ### sub-heading inside a required section must not // truncate it — checklist evidence after the sub-heading still counts. assert.equal( @@ -643,6 +670,8 @@ function selfTest() { - [x] This change does not alter privacy controls, patient-data handling, authentication, authorization, or document-access behavior. - [x] Any Supabase service-role credentials used by the migration-history diagnostic remain CI/server-side only and are not exposed to clients. - [x] This change does not alter the configured Supabase project/target or perform a production database migration. +- [x] This change does not alter demo/synthetic content separation from real clinical sources. +- [x] This change does not alter source metadata, review status, or how outdated/unknown sources are handled. - [x] No SaMD/TGA assessment is required because this change has no clinical decision-support impact.`), requiredClinicalGovernanceItems, "legacy governance attestations should map onto required checklist coverage", @@ -659,10 +688,19 @@ function selfTest() { - [x] This change does not alter citation requirements, source verification, provenance, or clinical-content governance. - [x] This change does not alter privacy controls, patient-data handling, authentication, authorization, or document-access behavior. - [x] Any Supabase service-role credentials used by the migration-history diagnostic remain CI/server-side only and are not exposed to clients. +- [x] This change does not alter demo/synthetic content separation from real clinical sources. +- [x] This change does not alter source metadata, review status, or how outdated/unknown sources are handled. - [x] No SaMD/TGA assessment is required because this change has no clinical decision-support impact.`), requiredClinicalGovernanceItems, "mixed canonical + legacy governance attestations should satisfy all required items", ); + assert.deepEqual( + collectSatisfiedGovernanceItems( + "- [x] This change does not alter citation requirements, source verification, provenance, or clinical-content governance.", + ), + [requiredClinicalGovernanceItems[0]], + "generic legacy phrasing must satisfy only the source-backed-claims item, not demo/synthetic or source-metadata items", + ); assert.equal( collectSatisfiedGovernanceItems("- [x] Legacy governance text without the expected policy keywords.").length, 0, From 377831dadc2c7df2ce52effcf06679d78f58f7ae Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 10:38:48 +0000 Subject: [PATCH 11/12] fix: sync stale test assertions with this branch's copy/attribution changes - ui-formulation.spec.ts asserted the old unwired "Formulation" heading; the branch now wires the page to the established sharedHomePresentation title "Clinical Formulation" (already expected by ui-tools.spec.ts and shared-home-empty-state.dom.test.tsx). - ui-route-coverage.spec.ts clicked the old hardcoded "Anxiety in outpatient care" suggestion; therapy-compass now sources suggestions from sharedHomePresentation, which lists "trauma-focused CBT" first. - source-governance-attribution.test.ts expected the flat "reviewedBy" key; the item-specific legacy-matcher fix in this branch reports the actual dotted path ("metadata.reviewedBy") where the value was found. --- tests/source-governance-attribution.test.ts | 2 +- tests/ui-formulation.spec.ts | 2 +- tests/ui-route-coverage.spec.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/source-governance-attribution.test.ts b/tests/source-governance-attribution.test.ts index 04045847f6..44a337b7fe 100644 --- a/tests/source-governance-attribution.test.ts +++ b/tests/source-governance-attribution.test.ts @@ -356,7 +356,7 @@ describe("source governance reviewer attribution", () => { identifier: "doc-reviewed-empty-attribution", review_status: "reviewed", reason: expect.stringContaining("trivial placeholder"), - found_attribution: { reviewedBy: "[redacted]" }, + found_attribution: { "metadata.reviewedBy": "[redacted]" }, }); expect(report.violations[1].reason).not.toContain(" "); diff --git a/tests/ui-formulation.spec.ts b/tests/ui-formulation.spec.ts index 952532d0c1..805fc8f148 100644 --- a/tests/ui-formulation.spec.ts +++ b/tests/ui-formulation.spec.ts @@ -60,7 +60,7 @@ test("searches patient language, opens a mechanism guide, and carries it into th }, testInfo) => { await gotoApp(page, "/formulation"); - await expect(page.getByRole("heading", { name: "Formulation", exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Clinical Formulation", exact: true })).toBeVisible(); await expect(page.getByTestId("formulation-home")).toBeVisible(); const search = page.getByTestId("global-search-input").filter({ visible: true }).first(); diff --git a/tests/ui-route-coverage.spec.ts b/tests/ui-route-coverage.spec.ts index 4f9cfccb95..291cfe87e5 100644 --- a/tests/ui-route-coverage.spec.ts +++ b/tests/ui-route-coverage.spec.ts @@ -257,11 +257,11 @@ test.describe("previously uncovered production routes", () => { async (currentPage) => { const search = currentPage .getByRole("region", { name: "Common therapy searches" }) - .getByRole("button", { name: "Anxiety in outpatient care", exact: true }); + .getByRole("button", { name: "trauma-focused CBT", exact: true }); await expect(search).toBeEnabled(); await search.click(); await expect( - currentPage.getByRole("heading", { name: "Anxiety in outpatient care", level: 1, exact: true }), + currentPage.getByRole("heading", { name: "trauma-focused CBT", level: 1, exact: true }), ).toBeVisible(); await expect(visibleByTestId(currentPage, "search-query-ribbon")).toBeVisible(); // The common-search pill lands on `/therapy-compass/search`, which is the From bdacc17e66f58035f4e54162852369119972f11e Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:43:41 +0800 Subject: [PATCH 12/12] test: align mode-home and attribution assertions with current component copy - Update standalone mode home headings to the sharedHomePresentation titles (Clinical Formulation, Diagnostic Specifiers, Medication Guidance, Clinical Services, Clinical Forms, Differential Diagnosis) introduced by the title unification. - Update the therapy common-search suggestion to the consolidated list. - Fix the source-governance attribution test to expect the full-path field name (metadata.reviewedBy) the audit now reports. --- tests/source-governance-attribution.test.ts | 2 +- tests/ui-formulation.spec.ts | 2 +- tests/ui-route-coverage.spec.ts | 4 ++-- tests/ui-specifiers.spec.ts | 2 +- tests/ui-tools.spec.ts | 12 ++++++------ 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/source-governance-attribution.test.ts b/tests/source-governance-attribution.test.ts index 04045847f6..44a337b7fe 100644 --- a/tests/source-governance-attribution.test.ts +++ b/tests/source-governance-attribution.test.ts @@ -356,7 +356,7 @@ describe("source governance reviewer attribution", () => { identifier: "doc-reviewed-empty-attribution", review_status: "reviewed", reason: expect.stringContaining("trivial placeholder"), - found_attribution: { reviewedBy: "[redacted]" }, + found_attribution: { "metadata.reviewedBy": "[redacted]" }, }); expect(report.violations[1].reason).not.toContain(" "); diff --git a/tests/ui-formulation.spec.ts b/tests/ui-formulation.spec.ts index 952532d0c1..805fc8f148 100644 --- a/tests/ui-formulation.spec.ts +++ b/tests/ui-formulation.spec.ts @@ -60,7 +60,7 @@ test("searches patient language, opens a mechanism guide, and carries it into th }, testInfo) => { await gotoApp(page, "/formulation"); - await expect(page.getByRole("heading", { name: "Formulation", exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Clinical Formulation", exact: true })).toBeVisible(); await expect(page.getByTestId("formulation-home")).toBeVisible(); const search = page.getByTestId("global-search-input").filter({ visible: true }).first(); diff --git a/tests/ui-route-coverage.spec.ts b/tests/ui-route-coverage.spec.ts index 4f9cfccb95..291cfe87e5 100644 --- a/tests/ui-route-coverage.spec.ts +++ b/tests/ui-route-coverage.spec.ts @@ -257,11 +257,11 @@ test.describe("previously uncovered production routes", () => { async (currentPage) => { const search = currentPage .getByRole("region", { name: "Common therapy searches" }) - .getByRole("button", { name: "Anxiety in outpatient care", exact: true }); + .getByRole("button", { name: "trauma-focused CBT", exact: true }); await expect(search).toBeEnabled(); await search.click(); await expect( - currentPage.getByRole("heading", { name: "Anxiety in outpatient care", level: 1, exact: true }), + currentPage.getByRole("heading", { name: "trauma-focused CBT", level: 1, exact: true }), ).toBeVisible(); await expect(visibleByTestId(currentPage, "search-query-ribbon")).toBeVisible(); // The common-search pill lands on `/therapy-compass/search`, which is the diff --git a/tests/ui-specifiers.spec.ts b/tests/ui-specifiers.spec.ts index aa4f75e72b..f53a50251a 100644 --- a/tests/ui-specifiers.spec.ts +++ b/tests/ui-specifiers.spec.ts @@ -63,7 +63,7 @@ test("searches clinical language without provenance fields and carries a result }, testInfo) => { await gotoApp(page, "/specifiers"); - await expect(page.getByRole("heading", { name: "Specifiers", exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Diagnostic Specifiers", exact: true })).toBeVisible(); await expect(page.getByTestId("specifiers-home")).toBeVisible(); const search = page.getByTestId("global-search-input").filter({ visible: true }).first(); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 824e90be7c..42884450cf 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1248,12 +1248,12 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { { path: "/medications", testId: "medication-home", - heading: "Medication", + heading: "Medication Guidance", headingLevel: 2, }, - { path: "/services", testId: "services-home", heading: "Services", headingLevel: 1 }, - { path: "/forms", testId: "forms-home", heading: "Forms", headingLevel: 1 }, - { path: "/differentials", testId: "differentials-home", heading: "Differentials", headingLevel: 1 }, + { path: "/services", testId: "services-home", heading: "Clinical Services", headingLevel: 1 }, + { path: "/forms", testId: "forms-home", heading: "Clinical Forms", headingLevel: 1 }, + { path: "/differentials", testId: "differentials-home", heading: "Differential Diagnosis", headingLevel: 1 }, ] as const) { test(`mode home search is centered at ${viewport.name} width on ${home.path}`, async ({ page }) => { await mockAnswerDashboardApi(page); @@ -1852,7 +1852,7 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expect(page.getByRole("button", { name: "Mode Differentials" })).toBeVisible(); await expect(page.getByTestId("differentials-home")).toBeVisible(); - await expect(page.getByRole("heading", { level: 1, name: "Differentials" })).toBeVisible(); + await expect(page.getByRole("heading", { level: 1, name: "Differential Diagnosis" })).toBeVisible(); await expect(page.locator('input[placeholder="Ask or search a presentation"]:visible').first()).toBeVisible(); await expect(page.getByRole("button", { name: "Search presentations" })).toBeVisible(); await expect(page.getByRole("button", { name: "Compare differentials" })).toBeVisible(); @@ -1868,7 +1868,7 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { await expect(async () => { const differentialsSearchBox = await differentialsHomeSearch.boundingBox(); const differentialsHeadingBox = await page - .getByRole("heading", { level: 1, name: "Differentials" }) + .getByRole("heading", { level: 1, name: "Differential Diagnosis" }) .boundingBox(); expect(differentialsSearchBox).not.toBeNull(); expect(differentialsHeadingBox).not.toBeNull();