diff --git a/.npmrc b/.npmrc index b6f27f135..4ade96e63 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ engine-strict=true +allow-scripts=true diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 4a82c5bc3..50d498402 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -411,6 +411,7 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-14 | codex/specifiers-design | c3e7024e15d2157bbc0b989276324cc6ad3eea5c | Specifiers UI, clinical decision-support, accessibility, and release-readiness review | One P2 WCAG contrast defect was reproduced and fixed across the builder, comparison, and map accent eyebrows. No remaining high-confidence defect was found in the changed scope; residual risk is clinical/manual governance of the original specifier summaries. | Focused Vitest 18/18; focused Chromium desktop/mobile 2/2 with serious/critical axe WCAG A/AA scanning and overflow checks; `npm run check:production-readiness:ci` READY; `npm run verify:cheap` 2,210 passed/1 skipped; `npm run verify:pr-local` 2,213 passed/1 skipped plus production build and client-bundle secret scan; `git diff --check`. Full advisory `verify:ui` exceeded the local 10-minute execution window; provider-backed checks were not run. | | 2026-07-14 | PR #633 / codex/specifiers-design | 03daca3bfdf86517b9956f3c7d91be27d9f1a751 | Review follow-up and failed UI regression | Eight distinct P2 behaviors across nine review threads were confirmed and fixed: initial selection normalization, duplicate query removal, clinical applicability mapping, canonical Specifiers routing, severity-neutral base wording, diagnostic-section ordering, base/specifier compatibility, and severe psychotic-features wording. The failed app-menu keyboard test was a stale order assertion and now covers the inserted Specifiers item. | Focused Vitest 6/6; focused Chromium 4/4; scoped ESLint and TypeScript; CI-mode production readiness READY; production build and client-bundle secret scan passed. Hosted required checks passed on the refreshed `main` merge before the final compatibility fixes; provider-backed Supabase/OpenAI checks were not run. | | 2026-07-14 | PR #634 / codex/global-answer-reliability | b411329ec5f181661e5d49276c398440aa928fa2 | review-followup | One late P2 fast-context defect was confirmed: Australian tier ordering could push a higher-ranked supplementary passage outside the four-chunk routine fast budget. Fixed by preserving the retrieval-ranked, crowding-capped candidate budget before applying the order-only Australian preference within that set. | GitHub connector review-thread inspection; focused RAG context-budget suite 22/22; ESLint; TypeScript; Prettier; `git diff --check`. | + | 2026-07-14 | codex/release-blocker-remediation | 550e0588866c38583bd9445fc109ea7832a98211 | working-tree release remediation review | Reconciled the remediation onto current main after three retained-stash fast-forward syncs, preserving the extracted RAG and document-viewer architecture and main's Sentry removal. Review confirmed and fixed the Windows offline-release launcher failure, the offline Railway health-check blocker, and shared staging-test passwords. No remaining high-confidence issue was found in the changed local scope. Provider-backed production and staging evidence remains a post-PR gate. | `npm ci` and `npm ls --depth=0` passed; focused registry/offline/RAG/viewer/tenancy Vitest 207/207 plus health/config follow-up 15/15; `npm run format:check`; `npm run check:github-actions`; `npm run check:ci-scope`; `npm run verify:cheap` passed runtime, generated guards, lint, TypeScript, and full Vitest (254 files passed, 1 skipped; 2,325 tests passed, 1 skipped); `git diff --check`. | | 2026-07-14 | claude/audit-ci-browser-gate-2026-07-13 | 09be064a09e8199e9359c50ea0dfceb25847cacf | branch-cleanup | Deleted local ref after confirming no patch-unique content remained; remote ref was left untouched. | Local cherry-pick-aware comparison to current `origin/main`; worktree/activity scan. | | 2026-07-14 | claude/beautiful-hamilton-5df54c | 5e2e90f0a3af4039c7e15515151569228476a60c | branch-cleanup | Deleted local redundant ref; exact head was already represented on `origin/main`. | Local cherry-pick-aware comparison and prior exact-head ledger evidence. | @@ -583,3 +584,5 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-17 | codex/performance-latency-remediation-20260717 | f277d13512e85dcaae4f9b94d028a8087e41375a | end-to-end database, network, middleware, client rendering, migration, privacy, and merge-readiness review | Fixed all confirmed performance findings plus three final review blockers: title-vocabulary backfill now installs its privacy trigger first, canonical schema replay preserves hardened trigger-function ACLs, and medication auto-seeding no longer aborts its own owner-cache flight. The isolated worktree also uses Next's supported Webpack fallback only when shared dependencies resolve outside the project. No remaining high-confidence P0-P2 defect was found in the reviewed diff. | Docker scratch replay and drift-manifest regeneration passed; live read-only plans selected lexical, table-fact trigram, and chunk HNSW indexes; exact TypeScript passed; focused Vitest owner-cache 9/9, runner safety 12/12, bundle budget 10/10, offline RAG 291/291, and earlier aggregate unit run 2,657 passed with its sole corrected schema assertion subsequently validated by direct ordering/ACL checks; production build and bundle budget passed at 1,363,382 gzip bytes; targeted Chromium dashboard deferral and NDJSON search passed, while the viewer trace reached all hydration/preview assertions and its corrected download-control selector remains for exact-head hosted UI. No OpenAI calls, live Supabase writes/migrations, deployment, or production mutation ran. | | 2026-07-17 | codex/mobile-search-phone-refresh-20260717 (supersedes PR #700) | 42a3e3ce65dc5a0e1dce386e0b91fccd23d13d6c + reviewed follow-up diff | phone universal-search command-panel recovery and merge-readiness review | Recovered the still-useful behavior from PR #700 onto current `main`, including its hydration fix and wide-touch regression coverage. Hosted Production UI then exposed one desktop focus race: capability state intentionally initializes false for hydration safety, but an input could receive focus before the post-hydration effect synchronized the real browser state. The follow-up recomputes the same guarded predicate synchronously on focus; it requires the placement breakpoint plus either a fine pointer or a zero-touch desktop fallback, so wide touch devices remain suppressed while desktop keeps the first command-panel interaction. No remaining high-confidence P0-P2 defect was found in the scoped diff. | Focused Vitest 7/7; `npm run ensure` verified the project at `http://localhost:3751`; hosted static, safety, coverage, build, advisory UI, Semgrep, Gitleaks, and GitGuardian passed; the first hosted Production UI run isolated the nine desktop regressions. The focused browser proof reproduced the desktop race while the wide-touch regression passed, and exact-head hosted Production UI remains required after the focus fix. `format:changed -- --check` and `git diff --check` passed before the final follow-up. No Supabase/OpenAI/product-provider command ran. | | 2026-07-17 | final historical branch/worktree cleanup against `origin/main` | 5d195d7ca8752b2ae4006725c6b145c5662bb687 | branch-cleanup | Merged PRs #716 and #717, closed superseded PR #700, and removed the three clean task worktrees. Deleted exact remote refs for `codex/mobile-search-phone-fix-20260717` (`590f32b73`), `claude/audit-findings-review-phgz92` (`ea3b8f95b8`), `codex/chat-forms-import-6914` (`b05da82f82`), `codex/chat-supabase-migration-preflight-b463` (`1ef0faee95`), and `codex/dsm-diagnosis-mode` (`f6cda83ca6`); the merged #716/#717 branches were deleted automatically. Deleted 14 unregistered local refs only after direct-main ancestry, exact ledger deletion-pending proof, or exact merged-PR commit provenance. Final inventory found zero remote branches without an open PR or registered worktree, zero locally merged or exact deletion-pending orphan refs, and no retired target refs or paths. Thirteen non-ancestor local refs and 25 registered worktrees remain preserved because they are backups, patch-unique/unresolved, open-PR-owned, or ownership could not be safely disproved. | Fresh fetch/prune; exact GitHub PR/head/merge associations; cherry-pick-aware logs; DSM PR #661 exact commit/file provenance; exact leased remote deletes; exact-old-value local `update-ref` deletes; clean-worktree and path/process checks; worktree prune; final zero-orphan inventory. The Codex task registry lookup timed out, so ambiguous registered worktrees were conservatively retained. No Supabase, OpenAI, production-data, or live clinical workflow ran. | + +| 2026-07-15 | HEAD detached 570e6ba56 + WIP tree | 570e6ba56ae60bea56a32801b9cc96c5a8dfde4f | thorough multi-lens review: WIP RAG/schema + clinical design/UI + architecture/bug-hunt | Changes requested: no P0. Confirmed P1s in WIP � registryCorpusDetailHref typecheck break; ChunkLoadCache error/null poisoning across parallel hydrations; registry cleanup `::uuid` cast abort; corrector GIN unused by query path; new table-facts trgm index expression mismatch vs trgm_matches. Design: production clinical shell stays token/a11y-aligned; favourites nav multi-gradient bars and mockup hex drift fight clinical density. Residual: concurrent cache race, SECURITY DEFINER revoke gaps, schema/migration lifecycle drift, accidental pnpm-lock.yaml. | `npm run typecheck` (red: registry link callers + stale .next apps types); static SQL/expr/diff review; architecture + bug-hunt agents; design-system grep (tokens, reduced-motion, forced-colors). Not run: vitest, verify:*, ensure/browser screenshots, live Supabase/OpenAI. frontend-ui-reviewer subagent blocked by usage limit � design pass done inline. | diff --git a/docs/rag-scalability-wip-review-handover-2026-07-15.md b/docs/rag-scalability-wip-review-handover-2026-07-15.md new file mode 100644 index 000000000..ec2d3e6bb --- /dev/null +++ b/docs/rag-scalability-wip-review-handover-2026-07-15.md @@ -0,0 +1,296 @@ +# Handover — RAG scalability WIP review findings (2026-07-15) + +**Status:** findings + remediation plan recorded; **fixes not yet applied**. +**Review date:** 2026-07-15 +**Git state at review:** detached HEAD `570e6ba56ae60bea56a32801b9cc96c5a8dfde4f` (`feat(rag): ship D4/D5 governance levers…` / aligned with then-current main tip) plus uncommitted WIP listed below. +**Ledger row:** `docs/branch-review-ledger.md` — scope `thorough multi-lens review: WIP RAG/schema + clinical design/UI + architecture/bug-hunt`. +**Product context:** Clinical KB Next.js + Supabase; target project `Clinical KB Database` / `sjrfecxgysukkwxsowpy`. Provider-backed apply/eval requires explicit confirmation. + +This file is the single handoff artifact for the next agent or engineer. It consolidates the multi-lens review (design-review, architecture, bug-hunter, code-review, clinical UI) and the remediation plan. Do **not** ship the WIP as-is. + +--- + +## 1. Snapshot of the working tree (at review) + +### Modified + +| Path | Role in WIP | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `src/lib/rag-candidate-sources.ts` | New `ChunkLoadCache` + shared hydration caching | +| `src/lib/rag.ts` | Wires one `chunkLoadCache` into table-facts / embedding-field / index-unit paths | +| `src/lib/registry-corpus-links.ts` | Tightened `registryCorpusDetailHref` arg types | +| `supabase/schema.sql` | Appended registry cleanup, `document_title_words`, corrector vocab source change, wide table-facts trgm index | +| `tests/supabase-schema.test.ts` | Weak “identical” substring checks for new objects | +| `docs/branch-review-ledger.md` | Review record appended (allowed during pure review) | + +### Untracked (WIP) + +| Path | Notes | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `supabase/migrations/20260714180000_patch_rag_and_corrector_scalability.sql` | Registry cascade delete + title-words table + corrector rewrite (incomplete scalability) | +| `supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql` | Wide GIN trgm index — **mismatched** to live `trgm_matches` predicate | +| `pnpm-lock.yaml` | **Accidental** — repo is npm (`packageManager: npm@…`, committed `package-lock.json`). Delete; do not commit. | + +### Environment caveats + +- Many concurrent worktrees existed at review time; park this WIP on a **named feature branch** before committing. +- Stale `.next/types` `/applications` layout errors also appeared during typecheck; treat as separate from WIP registry typing failures (clear/regenerate `.next` if they persist after A1). + +--- + +## 2. Verdict + +| Severity | Count | Bottom line | +| -------- | ------- | ------------------------------------------------------------------------------------------------------------------------ | +| P0 | 0 | No immediate data-loss / clinical-harm P0 confirmed | +| P1 | 5 | Typecheck break; cache error poisoning; registry `::uuid` abort; unused corrector GIN; mismatched table-facts trgm index | +| P2 | several | Scope/race cache footguns; schema/migration/test drift; SECURITY DEFINER revoke gaps; design gradient density | +| P3 | several | Accidental pnpm lock; mockup hex drift; optional pill/glow pass | + +**Highest residual risk if shipped unchanged:** red typecheck + false-scalability migrations + registry delete brittleness + hydration cache coupling under transient DB errors. + +--- + +## 3. Findings (complete catalog) + +### P1 — must fix before merge + +#### F1. `registryCorpusDetailHref` typing breaks callers + +- **Where:** `src/lib/registry-corpus-links.ts` (params narrowed to `string` / `RegistryCorpusKind | string`). +- **Callers failing typecheck:** + - `src/app/api/documents/[id]/signed-url/route.ts` + - `src/components/clinical-dashboard/source-actions.tsx` + - `src/lib/citations.ts` + - `src/lib/universal-search.ts` +- **Trigger:** Pass `unknown` / nullable metadata fields into the helper. +- **Expected:** Runtime `typeof` guards accept loose metadata; types stay compatible. +- **Actual:** `npm run typecheck` fails (`Type 'unknown' is not assignable to type 'string'`). +- **Proof:** `npm run typecheck` (already red at review). +- **Fix:** Restore args to `unknown` (or `string | null | undefined`) and keep runtime guards. Prefer not changing every call site. + +#### F2. `ChunkLoadCache` negative-caches failures across parallel hydrations + +- **Where:** `src/lib/rag-candidate-sources.ts` (`loadChunksForSignalMatches`); wired in `src/lib/rag.ts` via shared `createChunkLoadCache()`. +- **Trigger:** `{ error }` or missing `data` on a batch fetch → empty `Map` → per-id promises resolve `null` and stick for the request. +- **Expected:** Transient failure degrades that path only; siblings can still hydrate overlapping ids. +- **Actual:** Shared cache poisons table-facts + embedding-field + index-unit for overlapping chunk/doc ids. +- **Proof:** Unit test with shared cache: first load mock `{ data: null, error }`; second healthy mock for same ids must still return chunks (today: `[]` from cache). +- **Fix:** Do not cache hard failures (leave keys unset or reject without caching). Optionally fail the batch loudly via existing RPC/hydration telemetry. + +#### F3. Registry cleanup trigger: non-UUID `registry_record_id` aborts deletes + +- **Where:** + - Migration `20260714180000_…` lines ~8–12 + - `supabase/schema.sql` appended `cleanup_registry_corpus_document` +- **SQL hazard:** `(metadata->>'registry_record_id')::uuid = OLD.id` +- **Trigger:** Delete from `clinical_registry_records` / `medication_records` / `differential_records` while any `documents` row has `source_kind = 'registry_record'` and a non-UUID `registry_record_id`. +- **Expected:** Ignore malformed metadata (elsewhere the repo regex-guards UUID casts). +- **Actual:** Cast can throw → **entire registry DELETE transaction fails**. SECURITY DEFINER amplifies blast radius. +- **Proof:** SQL insert dummy doc with `registry_record_id: 'not-a-uuid'`, delete a registry row → expect cast exception today. +- **Fix:** Compare as text: `metadata->>'registry_record_id' = OLD.id::text`. Prefer also match `registry_record_kind` (or map via `TG_TABLE_NAME`). Revoke `EXECUTE` on helpers from `public`/`anon`/`authenticated`. + +#### F4. Corrector “scalability” table/index unused by query path + +- **Where:** Migration `20260714180000_…` — `document_title_words`, GIN `document_title_words_word_trgm_idx`, rewritten `correct_clinical_query_terms`. +- **Trigger / pattern:** Function still `array_agg(distinct term)` over aliases ∪ all title words, then per-token `similarity()` over `unnest(vocab)`. +- **Expected:** Hot path probes the GIN (`%` / similarity lookup) so cost scales with candidates, not full vocab. +- **Actual:** GIN never used; write amplification on every title sync with no planner win. Large corpora → corrector latency/timeouts → weaker lexical/retrieval paths. +- **Proof:** Read function body; no `word % tok` / indexed probe. Schema tests today pass with mere `toContain`. +- **Fix (preferred):** Per-token indexed probe against aliases + `document_title_words` (`LIMIT 1`, keep `min_sim` and length 4–40). Cap candidates. Verify with `EXPLAIN` only under confirmed live access later. +- **Alt:** Drop unused GIN (or table) until rewrite lands — do not ship dead “scalability” scaffolding. + +#### F5. New table-facts trgm index does not match `trgm_matches` + +- **Where:** + - New: `20260714190000_document_table_facts_trgm_idx.sql` / `schema.sql` `document_table_facts_text_trgm_idx` (title + row + param + **threshold_value** + **action**) + - Live predicate: `schema.sql` `trgm_matches` (~6236–6244) uses only title + row + clinical_parameter + - Existing matching index: `document_table_facts_title_row_param_trgm_idx` +- **Expected:** GIN expression equals `%` / `similarity()` expression used by the RPC. +- **Actual:** Wide index is dead weight for current trgm path; dual overlapping GIN indexes raise ingest write cost. FTS already covers wider text via `search_tsv`. +- **Proof:** Diff index expression string vs `trgm_matches` expression (architecture agent correct; one conflicting note claimed alignment — **disproven** by schema lines above). +- **Fix (default):** Remove migration `…190000…` and schema index line; keep narrow index. +- **Alt:** Widen `trgm_matches` to the wide expression **and** drop the narrow index so only one remains. Add schema test for expression equality. + +--- + +### P2 — should fix in same remediation wave + +#### F6. Cache not keyed by access scope (latent authz) + +- **Where:** `ChunkLoadCache` maps keyed only by id; contrast `rag-cache.ts` which uses `retrievalAccessScopeKey`. +- **Current call site:** One scope per `searchChunksWithTelemetry` — production path OK today. +- **Risk:** Reuse across public-only then owner+public → cached `null` deny, or inverse over-share within process. +- **Fix:** Bind scope at cache creation or include scope in keys; refuse mismatched scope. Test in `tests/retrieval-hydration-scope.test.ts`. + +#### F7. Concurrent overlapping miss race + +- **Where:** Parallel embedding-field + index-unit hydration both compute `missing*` before either finishes `cache.*.set`. +- **Risk:** Later writer overwrites in-flight promise; erroring batch can poison a successful first waiters’ result. +- **Fix:** Single in-flight promise per id (check-then-set before creating fetch); never overwrite a healthy promise with a failing one. + +#### F8. Schema / migration / test drift + +| Concern | Migration `…180000…` | `schema.sql` | +| ------------------------------------------- | -------------------- | -------------------------------------------------- | +| `DROP TRIGGER IF EXISTS` before create | yes | **no** | +| Backfill `INSERT INTO document_title_words` | yes | **no** | +| Corrector body rewrite | yes | vocab source already updated; lifecycle incomplete | +| `…190000…` index | separate file | present in schema | +| New test “identically” | weak `toContain` | misses drops, backfill, expression contracts | + +- **Risk:** Schema-only bootstrap → empty title vocab until document writes; trigger-already-exists on reapply; CI green on broken contracts. +- **Fix:** Mirror lifecycle in schema; strengthen `tests/supabase-schema.test.ts` (load both migrations; assert text UUID compare, revoke lines, corrector probe shape, index↔RPC equality for remaining trgm index). + +#### F9. New SECURITY DEFINER helpers miss privilege hardening + +- **Where:** `cleanup_registry_corpus_document`, `sync_document_title_words` created without `revoke execute … from public, anon, authenticated` (unlike `correct_clinical_query_terms`). +- **Fix:** Revoke execute; grant only if something other than triggers must call them (usually none). + +#### F10. Title vocabulary is global under SECURITY DEFINER (tenancy product decision) + +- **Where:** Corrector reads all `document_title_words` with no owner filter; sync indexes every indexed title. +- **Risk:** Private title tokens can bias corrections / existence side-channel for other users (pre-existing shape had similar full-title scan). +- **Fix options:** Public-only (`owner_id is null`) ± caller-owner filter passed like retrieval RPCs. **Needs product decision** (see §7). + +#### F11. Favourites set accent gradient bars (design) + +- **Where:** `src/components/clinical-dashboard/favourites-library-nav.tsx` — `setAccentBars` with purple→rose / multi-hue gradients. +- **Why it matters:** Conflicts with clinical “dense, calm, scan-fast” (design-review). Decorative vs semantic. +- **Fix:** Single clinical accent or discrete set-color tokens without marketing multi-hue bars. Preserve selected/hover/`focus-visible`. + +--- + +### P3 — hygiene / optional + +#### F12. Accidental `pnpm-lock.yaml` + +- Delete untracked file; do not commit. Repo lockfile is `package-lock.json`. + +#### F13. Mockup hardcoded hex / focus colors + +- **Where:** `src/components/favourites-page-mockups/favourites-library-redesign-page.tsx` etc. (`#0e7490`, `#64748b`, …). +- Mockup routes under `/mockups/…`. Retokenize if long-lived; do not promote into production shell without tokens. + +#### F14. Pill / glow density (optional design pass) + +- Widespread `rounded-full` + soft glow is existing product chrome, not a defect. Separate intentional de-pill pass only if product wants it. +- **Non-goal:** Do **not** replace the clinical design system with the marketing `/10-experience-and-design-system` dark-cyan DTCG aesthetic. + +#### F15. Broader architecture residual (out of this WIP’s minimal fix) + +- `rag.ts` remains a large facade (~4.8k lines). Keep hydration/cache testable in `rag-candidate-sources`; avoid new re-exports that force everything through `rag.ts`. +- Process-global answer/search caches in `rag-cache.ts` are pre-existing scale/ops residual. + +#### F16. Cross-table shared cleanup key (low probability) + +- Same cleanup fn matches only `registry_record_id` across registry tables → theoretical UUID collision deletes wrong corpus doc. Mitigate with `registry_record_kind` filter (see F3). + +--- + +## 4. What looked solid (do not regress) + +- Intent of request-scoped dedupe for parallel signal hydrations is good (happy path). +- `document_title_words` RLS + service_role grants + sync-on-indexed status logic is coherent **once** the corrector actually queries the table. +- Existing narrow `document_table_facts_title_row_param_trgm_idx` correctly matches current `trgm_matches`. +- Ingestion ↛ importing `rag.ts` dependency direction is healthy — preserve it. +- Production clinical shell mostly uses `@theme` tokens, `focus-visible`, `prefers-reduced-motion`, and `forced-colors` in `src/app/globals.css`. + +--- + +## 5. Remediation plan (ordered) + +### Track A — correctness (same PR / branch) + +1. **Branch hygiene:** From detached HEAD + WIP, create/checkout named branch e.g. `codex/rag-scalability-review-remediation`. Delete `pnpm-lock.yaml`. +2. **F1:** Restore loose types on `registryCorpusDetailHref`. Run `npm run typecheck`. +3. **F2/F6/F7:** Add failing hydration-cache tests (error poisoning, scope reuse, concurrent overlap). Fix cache: no negative-cache on error; scope-bound; single in-flight per id. Green tests. +4. **F3/F9/F16:** Rewrite cleanup SQL (text + kind), revoke execute; sync migration + `schema.sql`. Prefer **edit migration before apply**; if any env already applied WIP SQL, ship a **follow-up** migration instead of rewriting history. +5. **F4:** Rewrite `correct_clinical_query_terms` to indexed per-token probes **or** drop unused GIN/table until rewrite ready. Sync schema. +6. **F5:** Default — remove `…190000…` and wide index from schema; keep narrow index. (Alt — widen RPC and drop narrow.) Assert expression parity in tests. +7. **F8:** Align schema lifecycle (`DROP TRIGGER IF EXISTS`; document or include backfill policy). Strengthen schema tests beyond substrings. + +### Track B — design (same PR optional, or follow-up) + +8. **F11:** Quiet favourites gradient bars to clinical tokens. +9. **F13:** Optionally retokenize mockups. +10. **F14:** Explicitly defer pill/glow unless requested. + +### Verification (local, no providers unless confirmed) + +| Gate | When | +| ----------------------------------------------------------------- | ---------------- | +| Focused Vitest: hydration cache + `tests/supabase-schema.test.ts` | After A2–A7 | +| `npm run typecheck` | After A1 | +| `npm run verify:cheap` (or lint + unit subset) | Before handoff | +| `git diff --check` | Before commit | +| `npm run ensure` + screenshots | If Track B ships | +| Live Supabase apply / `check:drift` / OpenAI eval | **Ask first** | + +### Rollout notes + +- Treat `…180000…` / `…190000…` as **unapplied** unless proven otherwise. Confirm with operator before any live mutation. +- After code + offline gates are green: commit on feature branch, open PR, run PR-local gate; live apply is a separate confirmation. + +--- + +## 6. Suggested implementation ownership (files) + +| Concern | Primary files | +| --------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Cache | `src/lib/rag-candidate-sources.ts`, `src/lib/rag.ts`, `tests/retrieval-hydration-scope.test.ts` (or new focused test) | +| Registry links typing | `src/lib/registry-corpus-links.ts` | +| SQL patch | `supabase/migrations/20260714180000_…`, possibly delete `…190000…`, `supabase/schema.sql` | +| Schema contracts | `tests/supabase-schema.test.ts` | +| Favourites design | `src/components/clinical-dashboard/favourites-library-nav.tsx` | +| Hygiene | delete `pnpm-lock.yaml` | + +--- + +## 7. Open questions (block product confidence) + +1. **Title vocab tenancy:** Should `correct_clinical_query_terms` use **public titles only**, or **public + caller-owner**? +2. **Table-facts trgm:** **Drop** the wide index (recommended) or **widen** `trgm_matches` and drop the narrow one? +3. **PR packaging:** Ship Track B (favourites design) in the **same** PR as RAG/SQL, or a follow-up? +4. **Live apply status:** Has any environment already applied `20260714180000` / `20260714190000`? (Assumed **no** at review time.) +5. **Corpus scale:** Approx. indexed doc / title-word cardinality — needed to prioritize F4 rewrite urgency vs drop-index interim. + +--- + +## 8. Checks already run (this review) + +| Check | Result | +| ------------------------------------------------------ | -------------------------------------------------------------------- | +| Static diff / SQL / RPC expression tracing | Done — findings above | +| Architecture explore + bug-hunt agents | Done — synthesized here | +| Clinical design / token / a11y greps | Done inline (`frontend-ui-reviewer` subagent blocked by usage limit) | +| `npm run typecheck` | **Fail** — F1 (+ possible stale `.next` noise) | +| Vitest / `verify:*` / browser / live Supabase / OpenAI | **Not run** | + +--- + +## 9. Next agent prompt (copy-paste) + +```text +Read docs/rag-scalability-wip-review-handover-2026-07-15.md and remediate Track A +findings F1–F9 (and F5 by dropping the mismatched trgm index unless told otherwise). +Park WIP on a named feature branch. Delete pnpm-lock.yaml. Do not apply live Supabase +or call OpenAI without confirmation. Use TDD for ChunkLoadCache. Strengthen +tests/supabase-schema.test.ts. End with typecheck + focused Vitest + verify:cheap +(or state what was skipped). Track B (favourites gradients) only if time / same PR +requested. Ask before commit/push/PR. +``` + +--- + +## 10. Related docs + +- `docs/codex-review-protocol.md` — severity / mutation / ledger rules +- `docs/branch-review-ledger.md` — this review’s ledger row +- `docs/design-system.md` / `docs/redesign/permanent-colour-direction.md` — clinical visual direction +- `docs/search-rag-master-context.md` — RAG orientation +- `AGENTS.md` — provider confirmation boundary, verify gates + +--- + +_Authored as a pure handover artifact from the 2026-07-15 multi-lens review. No production mutations performed._ diff --git a/docs/site-map.md b/docs/site-map.md index 8ad544b16..3b7516445 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -5,10 +5,8 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` ## Main product pages - `/` - Main Clinical KB shell. Source: `src/app/page.tsx`. -- `/applications` - Route discovered from app directory Source: `src/app/applications/page.tsx`. - `/differentials` - Differentials home and search surface. Source: `src/app/differentials/page.tsx`. - `/differentials/diagnoses` - Diagnosis stream. Source: `src/app/differentials/diagnoses/page.tsx`. -- `/differentials/presentations` - Presentation workflow stream. Source: `src/app/differentials/presentations/page.tsx`. - `/differentials/presentations/[slug]` - Route discovered from app directory Source: `src/app/differentials/presentations/[slug]/page.tsx`. - `/documents/search` - Documents search command centre. Source: `src/app/documents/search/page.tsx`. - `/documents/source` - Compatibility redirect to the canonical live document viewer when a valid id is supplied. Source: `src/app/documents/source/page.tsx`. @@ -22,7 +20,6 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/formulation/builder` - Structured clinical formulation builder. Source: `src/app/formulation/builder/page.tsx`. - `/formulation/compare` - Side-by-side mechanism comparison. Source: `src/app/formulation/compare/page.tsx`. - `/formulation/map` - Formulation mechanism domain map. Source: `src/app/formulation/map/page.tsx`. -- `/medications` - Medication index redirect. Source: `src/app/medications/page.tsx`. - `/privacy` - Privacy and data-processing governance draft. Source: `src/app/privacy/page.tsx`. - `/reference/colour-coding` - Route discovered from app directory Source: `src/app/reference/colour-coding/page.tsx`. - `/services` - Services home and search surface. Source: `src/app/services/page.tsx`. @@ -840,12 +837,17 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/api/search/universal` - Route discovered from app directory Source: `src/app/api/search/universal/route.ts`. - `/api/setup-status` - Setup status. Source: `src/app/api/setup-status/route.ts`. - `/api/upload` - Upload endpoint. Source: `src/app/api/upload/route.ts`. + +## App route handlers + - `/auth/callback` - Route discovered from app directory Source: `src/app/auth/callback/route.ts`. ## Redirects +- `/applications` - Redirects to `/tools`. Source: `src/app/applications/route.ts`. +- `/differentials/presentations` - Redirects to `/differentials/presentations/[slug]`. Source: `src/app/differentials/presentations/route.ts`. - `/documents/source` - Redirects to `/documents/search`. Source: `src/app/documents/source/page.tsx`. -- `/medications` - Redirects to `/?mode=prescribing`. Source: `src/app/medications/page.tsx`. +- `/medications` - Redirects to `/?mode=prescribing`. Source: `src/app/medications/route.ts`. - `/mockups/favourites-hub` - Redirects to `/favourites`. Source: `src/app/mockups/favourites-hub/page.tsx`. - `/mockups/medication-prescribing` - Redirects to `/medications/acamprosate`. Source: `src/app/mockups/medication-prescribing/page.tsx`. diff --git a/package-lock.json b/package-lock.json index 5e4db79c4..cb24168a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,14 @@ "hasInstallScript": true, "dependencies": { "@next/env": "16.2.10", + "@supabase/realtime-js": "^2.110.7", "@supabase/ssr": "^0.12.0", "@supabase/supabase-js": "^2.108.2", "exceljs": "^4.4.0", "jszip": "^3.10.1", "lucide-react": "^1.22.0", "mammoth": "^1.12.0", - "next": "16.2.10", + "next": "^16.2.10", "openai": "^6.45.0", "pdf-parse": "^2.4.5", "pdfjs-dist": "^6.1.200", @@ -24,6 +25,7 @@ "postcss": "^8.5.15", "react": "19.2.7", "react-dom": "19.2.7", + "server-only": "^0.0.1", "zod": "^4.4.3" }, "devDependencies": { @@ -49,7 +51,7 @@ "playwright": "^1.61.1", "prettier": "^3.9.4", "tailwindcss": "^4.3.1", - "tsx": "^4.22.4", + "tsx": "^4.23.1", "typescript": "^6.0.0", "vitest": "^4.1.10" }, @@ -3309,9 +3311,9 @@ } }, "node_modules/@supabase/phoenix": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz", - "integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==", + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", "license": "MIT" }, "node_modules/@supabase/postgrest-js": { @@ -3327,12 +3329,12 @@ } }, "node_modules/@supabase/realtime-js": { - "version": "2.110.2", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.2.tgz", - "integrity": "sha512-z3jTOTPgyn6E3r6dVOOQ10He4yAMB2czjFw7xVdX3s16MHElna5rY1gVaePs0NIo6xvtMYbtmOXlFaFt/ePLpg==", + "version": "2.110.7", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.7.tgz", + "integrity": "sha512-AMtZjyFA2gsmjuxopPNS/sRznLQHG0Ht5x+ytTPTOh3vAcOTUlVRLx7gW4/CONNnbb3PKOkE+HmM35HOSbmomQ==", "license": "MIT", "dependencies": { - "@supabase/phoenix": "0.4.4", + "@supabase/phoenix": "0.4.5", "tslib": "2.8.1" }, "engines": { @@ -3380,6 +3382,25 @@ "node": ">=22.0.0" } }, + "node_modules/@supabase/supabase-js/node_modules/@supabase/phoenix": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz", + "integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==", + "license": "MIT" + }, + "node_modules/@supabase/supabase-js/node_modules/@supabase/realtime-js": { + "version": "2.110.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.2.tgz", + "integrity": "sha512-z3jTOTPgyn6E3r6dVOOQ10He4yAMB2czjFw7xVdX3s16MHElna5rY1gVaePs0NIo6xvtMYbtmOXlFaFt/ePLpg==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.4", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -10307,6 +10328,12 @@ "semver": "bin/semver.js" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -11083,9 +11110,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", - "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 684c054b5..a2f67ddb8 100644 --- a/package.json +++ b/package.json @@ -169,13 +169,14 @@ }, "dependencies": { "@next/env": "16.2.10", + "@supabase/realtime-js": "^2.110.7", "@supabase/ssr": "^0.12.0", "@supabase/supabase-js": "^2.108.2", "exceljs": "^4.4.0", "jszip": "^3.10.1", "lucide-react": "^1.22.0", "mammoth": "^1.12.0", - "next": "16.2.10", + "next": "^16.2.10", "openai": "^6.45.0", "pdf-parse": "^2.4.5", "pdfjs-dist": "^6.1.200", @@ -183,6 +184,7 @@ "postcss": "^8.5.15", "react": "19.2.7", "react-dom": "19.2.7", + "server-only": "^0.0.1", "zod": "^4.4.3" }, "overrides": { @@ -214,8 +216,16 @@ "playwright": "^1.61.1", "prettier": "^3.9.4", "tailwindcss": "^4.3.1", - "tsx": "^4.22.4", + "tsx": "^4.23.1", "typescript": "^6.0.0", "vitest": "^4.1.10" + }, + "allowScripts": { + "esbuild@0.28.1": true, + "sharp@0.34.5": true, + "unrs-resolver@1.12.2": true, + "@eslint/eslintrc@3.3.6": true, + "@humanwhocodes/module-importer@1.0.1": true, + "@humanfs/node@0.16.8": true } } diff --git a/scripts/check-codebase-index-coverage.mjs b/scripts/check-codebase-index-coverage.mjs index 15a4d4686..b912467a0 100644 --- a/scripts/check-codebase-index-coverage.mjs +++ b/scripts/check-codebase-index-coverage.mjs @@ -22,6 +22,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const INDEX_PATH = "docs/codebase-index.md"; +const SCHEMA_PATH = "supabase/schema.sql"; // Directories intentionally not indexed at the top level. const ALLOWLIST = new Set([ @@ -29,38 +30,79 @@ const ALLOWLIST = new Set([ ]); function dirsIn(relativeDir) { - try { - return readdirSync(path.join(repoRoot, relativeDir), { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .map((e) => e.name); - } catch { - return []; - } + return readdirSync(path.join(repoRoot, relativeDir), { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); } -/** - * A directory counts as "indexed" when its base name appears anywhere in the index - * (case-insensitive). The index deliberately uses abbreviated forms — `validation/`, - * `extractors/document.ts`, grouped route tables — so matching the bare name (rather - * than a full `src/lib/x` path) is what actually catches a WHOLLY missing module - * without drowning in format false-positives. - */ export function coverageCandidates(kind, name) { - return [name]; + if (kind === "api") return [`/api/${name}`]; + if (kind === "route") return [`/${name}`]; + return [`${name}/`, `src/lib/${name}/`]; +} + +const SECTION_BOUNDS = { + route: ["### Product pages (`src/app/`)", "### API routes (`src/app/api/`)"], + api: ["### API routes (`src/app/api/`)", "## `src/lib/` module map"], + lib: ["## `src/lib/` module map", "## Supabase"], + schema: ["### Schema tables", "### Migration themes"], +}; + +function sectionText(indexText, kind) { + const [startMarker, endMarker] = SECTION_BOUNDS[kind] ?? []; + if (!startMarker) return ""; + const headingOffset = (marker, from = 0) => { + const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = new RegExp(`^${escaped}\\r?$`, "m").exec(indexText.slice(from)); + return match ? from + match.index : -1; + }; + const start = headingOffset(startMarker); + if (start < 0) return ""; + const end = headingOffset(endMarker, start + startMarker.length); + return indexText.slice(start, end < 0 ? indexText.length : end); +} + +function codeSpans(text) { + return [...text.matchAll(/`([^`\r\n]+)`/g)].map((match) => match[1].trim().toLowerCase()); +} + +function candidateMatches(span, candidate) { + const normalized = candidate.toLowerCase(); + if (normalized.endsWith("/")) return span.startsWith(normalized); + return span === normalized || span.startsWith(`${normalized}/`); } /** Pure: given the index text and the discovered groups, return the uncovered entries. */ export function coverageGaps(indexText, groups, allowlist = ALLOWLIST) { - const haystack = indexText.toLowerCase(); + const spansByKind = new Map(["lib", "route", "api"].map((kind) => [kind, codeSpans(sectionText(indexText, kind))])); const gaps = []; for (const { kind, dir, name } of groups) { const full = `${dir}/${name}`; if (allowlist.has(full)) continue; - if (!haystack.includes(name.toLowerCase())) gaps.push({ full, kind, tried: [name] }); + const tried = coverageCandidates(kind, name); + const spans = spansByKind.get(kind) ?? []; + if (!tried.some((candidate) => spans.some((span) => candidateMatches(span, candidate)))) { + gaps.push({ full, kind, tried }); + } } return gaps; } +/** Pure: compare the exhaustive schema-table list in the index with the schema mirror. */ +export function schemaTableGaps(indexText, schemaText) { + const schemaTables = new Set( + [...schemaText.matchAll(/create\s+table(?:\s+if\s+not\s+exists)?\s+public\.([a-z0-9_]+)/gi)].map((match) => + match[1].toLowerCase(), + ), + ); + const tableSection = sectionText(indexText, "schema"); + const documentedTables = new Set(codeSpans(tableSection).filter((span) => /^[a-z][a-z0-9_]*$/.test(span))); + return { + missing: [...schemaTables].filter((table) => !documentedTables.has(table)).sort(), + stale: [...documentedTables].filter((table) => !schemaTables.has(table)).sort(), + }; +} + function discoverGroups() { const groups = []; for (const name of dirsIn("src/lib")) groups.push({ kind: "lib", dir: "src/lib", name }); @@ -74,16 +116,22 @@ function discoverGroups() { function main() { const indexText = readFileSync(path.join(repoRoot, INDEX_PATH), "utf8"); + const schemaText = readFileSync(path.join(repoRoot, SCHEMA_PATH), "utf8"); const groups = discoverGroups(); const gaps = coverageGaps(indexText, groups); + const tables = schemaTableGaps(indexText, schemaText); - if (gaps.length > 0) { + if (gaps.length > 0 || tables.missing.length > 0 || tables.stale.length > 0) { console.error(`\n${INDEX_PATH} is missing ${gaps.length} top-level module(s)/route(s):`); for (const g of gaps) console.error(` UNINDEXED ${g.full} (${g.kind}) — add it or allowlist it`); + for (const table of tables.missing) console.error(` UNINDEXED public.${table} (schema table)`); + for (const table of tables.stale) console.error(` STALE ${table} (not present in ${SCHEMA_PATH})`); console.error(`\nUpdate ${INDEX_PATH} so the agent-orientation map stays current.`); process.exit(1); } - console.log(`${INDEX_PATH} coverage OK: all ${groups.length} top-level modules/routes are indexed.`); + console.log( + `${INDEX_PATH} coverage OK: all ${groups.length} top-level modules/routes and ${tables.missing.length + tables.stale.length === 0 ? "all" : "checked"} schema tables are indexed.`, + ); } const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; diff --git a/scripts/check-m13-migration.ts b/scripts/check-m13-migration.ts index 3ec4b6e20..7e09011ac 100644 --- a/scripts/check-m13-migration.ts +++ b/scripts/check-m13-migration.ts @@ -17,6 +17,20 @@ function missingMarkers(data: unknown) { } async function main() { + try { + const { createRequire, Module } = await import("module"); + const req = createRequire(import.meta.url); + const resolved = req.resolve("server-only"); + if (resolved) { + const serverOnlyStub = new Module(resolved); + serverOnlyStub.exports = {}; + serverOnlyStub.loaded = true; + req.cache[resolved] = serverOnlyStub; + } + } catch { + // ignore + } + const { createAdminClient } = await import("@/lib/supabase/admin"); const supabase = createAdminClient(); const { data, error } = await supabase.rpc("search_schema_health"); diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 921cf2b90..16b019bd8 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -32,6 +32,7 @@ type RedirectRoute = { type SiteMapData = { pageRoutes: DiscoveredRoute[]; apiRoutes: DiscoveredRoute[]; + appRouteHandlers: DiscoveredRoute[]; redirects: RedirectRoute[]; nonRoutedMockupArtifacts: string[]; }; @@ -158,12 +159,35 @@ function discoverRoutes(kind: RouteKind): DiscoveredRoute[] { .sort((left, right) => left.route.localeCompare(right.route) || left.file.localeCompare(right.file)); } -function discoverRedirects(pageRoutes: DiscoveredRoute[]): RedirectRoute[] { - return pageRoutes - .map((page) => { - const source = readFileSync(path.join(process.cwd(), page.file), "utf8"); - const target = source.match(/\bredirect\(\s*["']([^"']+)["']\s*\)/)?.[1]; - return target ? { ...page, target } : null; +function isApiRoute(route: string) { + return route === "/api" || route.startsWith("/api/"); +} + +function extractRedirectTarget(source: string): string | null { + const pageRedirect = source.match(/\bredirect\(\s*["']([^"']+)["']\s*\)/)?.[1]; + if (pageRedirect) return pageRedirect; + + const urlRedirect = source.match(/NextResponse\.redirect\(\s*new URL\(\s*["']([^"']+)["']/)?.[1]; + if (urlRedirect) return urlRedirect; + + const pathnameRedirect = source.match(/\.pathname\s*=\s*["']([^"']+)["']/)?.[1]; + if (pathnameRedirect) return pathnameRedirect; + + // Template-literal destination builders with a stable route prefix. + const presentationsRedirect = source.match(/`(\/differentials\/presentations\/)\$\{/)?.[1]; + if (presentationsRedirect && source.includes("NextResponse.redirect")) { + return `${presentationsRedirect}[slug]`; + } + + return null; +} + +function discoverRedirects(routes: DiscoveredRoute[]): RedirectRoute[] { + return routes + .map((route) => { + const source = readFileSync(path.join(process.cwd(), route.file), "utf8"); + const target = extractRedirectTarget(source); + return target ? { ...route, target } : null; }) .filter((value): value is RedirectRoute => Boolean(value)) .sort((left, right) => left.route.localeCompare(right.route)); @@ -179,10 +203,20 @@ function discoverNonRoutedMockupArtifacts() { export function collectSiteMapData(): SiteMapData { const pageRoutes = discoverRoutes("page"); + const routeHandlers = discoverRoutes("api"); + const apiRoutes = routeHandlers.filter((route) => isApiRoute(route.route)); + const nonApiHandlers = routeHandlers.filter((route) => !isApiRoute(route.route)); + const redirects = [...discoverRedirects(pageRoutes), ...discoverRedirects(nonApiHandlers)].sort( + (left, right) => left.route.localeCompare(right.route) || left.file.localeCompare(right.file), + ); + const redirectRoutes = new Set(redirects.map((redirect) => redirect.route)); + const appRouteHandlers = nonApiHandlers.filter((route) => !redirectRoutes.has(route.route)); + return { pageRoutes, - apiRoutes: discoverRoutes("api"), - redirects: discoverRedirects(pageRoutes), + apiRoutes, + appRouteHandlers, + redirects, nonRoutedMockupArtifacts: discoverNonRoutedMockupArtifacts(), }; } @@ -451,13 +485,19 @@ function renderSiteMapRaw(data = collectSiteMapData()) { "API routes", data.apiRoutes.map((route) => routeLine(route, apiDescriptions)), ), + ...(data.appRouteHandlers.length + ? section( + "App route handlers", + data.appRouteHandlers.map((route) => routeLine(route, routeDescriptions)), + ) + : []), ...section( "Redirects", data.redirects.length ? data.redirects.map((redirect) => bullet(redirect.route, `Redirects to \`${redirect.target}\`. Source: \`${redirect.file}\`.`), ) - : ["- No page-level redirects discovered."], + : ["- No redirects discovered."], ), ...section("Known caveats and stale-path flags", [ "- `/mockups/*` prototype routes are development-only; production returns 404 and `robots.txt` disallows indexing.", diff --git a/scripts/run-playwright.mjs b/scripts/run-playwright.mjs index 83565373a..a8efd0ac8 100644 --- a/scripts/run-playwright.mjs +++ b/scripts/run-playwright.mjs @@ -150,8 +150,10 @@ async function waitForServer(baseUrl, server) { if (isVerifiedProjectPayload(payload)) { let healthy = true; for (const smokePath of routeSmokePaths) { + // request() returns null on transport/status failure, or a string body on + // 2xx/3xx (including empty redirect bodies from legacy route handlers). const body = await request(`${baseUrl}${smokePath}`); - if (!body || body.includes(missingErrorComponentsNeedle)) { + if (body === null || body.includes(missingErrorComponentsNeedle)) { healthy = false; break; } diff --git a/src/app/applications/page.tsx b/src/app/applications/page.tsx deleted file mode 100644 index 15b66e16d..000000000 --- a/src/app/applications/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { redirect } from "next/navigation"; - -type ApplicationsPageProps = { - searchParams?: Promise>; -}; - -function forwardedSearchParams(params: Record) { - const forwarded = new URLSearchParams(); - for (const [key, value] of Object.entries(params)) { - for (const item of Array.isArray(value) ? value : [value]) { - if (item !== undefined) forwarded.append(key, item); - } - } - return forwarded.toString(); -} - -// "Tools" is the canonical name and /tools the canonical route (PT-11); this -// legacy route only forwards old links and browser history. -export default async function ApplicationsRedirect({ searchParams }: ApplicationsPageProps) { - const params = searchParams ? await searchParams : {}; - const query = forwardedSearchParams(params); - redirect(query ? `/tools?${query}` : "/tools"); -} diff --git a/src/app/applications/route.ts b/src/app/applications/route.ts new file mode 100644 index 000000000..0f19d2c8c --- /dev/null +++ b/src/app/applications/route.ts @@ -0,0 +1,11 @@ +import { type NextRequest, NextResponse } from "next/server"; + +// "Tools" is the canonical name and /tools the canonical route (PT-11). A +// route handler redirects the incoming legacy request before React renders it. +export function GET(request: NextRequest) { + const destination = request.nextUrl.clone(); + destination.pathname = "/tools"; + return NextResponse.redirect(destination); +} + +export const HEAD = GET; diff --git a/src/app/differentials/presentations/page.tsx b/src/app/differentials/presentations/page.tsx deleted file mode 100644 index f69101827..000000000 --- a/src/app/differentials/presentations/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { redirect } from "next/navigation"; - -import { getPresentationWorkflowSelectionForDiagnosisIds } from "@/lib/differentials"; - -type DifferentialPresentationsRouteProps = { - searchParams?: Promise<{ query?: string | string[]; q?: string | string[]; ids?: string | string[] }>; -}; - -function firstSearchParam(value?: string | string[]) { - return Array.isArray(value) ? value[0] : value; -} - -export default async function DifferentialPresentationsRoute({ searchParams }: DifferentialPresentationsRouteProps) { - const params = searchParams ? await searchParams : {}; - const query = firstSearchParam(params.query ?? params.q)?.trim(); - const ids = firstSearchParam(params.ids)?.trim(); - const selectedIds = (ids ?? "") - .split(",") - .map((id) => id.trim()) - .filter(Boolean); - const selection = getPresentationWorkflowSelectionForDiagnosisIds(selectedIds); - const destinationParams = new URLSearchParams(); - if (query) destinationParams.set("q", query); - if (selection?.diagnosisIds.length) destinationParams.set("ids", selection.diagnosisIds.join(",")); - const suffix = destinationParams.size ? `?${destinationParams.toString()}` : ""; - redirect(`/differentials/presentations/${selection?.workflow.id ?? "acute-confusion-encephalopathy"}${suffix}`); -} diff --git a/src/app/differentials/presentations/route.ts b/src/app/differentials/presentations/route.ts new file mode 100644 index 000000000..001912f0f --- /dev/null +++ b/src/app/differentials/presentations/route.ts @@ -0,0 +1,21 @@ +import { type NextRequest, NextResponse } from "next/server"; + +import { getPresentationWorkflowSelectionForDiagnosisIds } from "@/lib/differentials"; + +export function GET(request: NextRequest) { + const query = (request.nextUrl.searchParams.get("query") ?? request.nextUrl.searchParams.get("q"))?.trim(); + const selectedIds = (request.nextUrl.searchParams.get("ids") ?? "") + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + const selection = getPresentationWorkflowSelectionForDiagnosisIds(selectedIds); + const destination = new URL( + `/differentials/presentations/${selection?.workflow.id ?? "acute-confusion-encephalopathy"}`, + request.url, + ); + if (query) destination.searchParams.set("q", query); + if (selection?.diagnosisIds.length) destination.searchParams.set("ids", selection.diagnosisIds.join(",")); + return NextResponse.redirect(destination); +} + +export const HEAD = GET; diff --git a/src/app/forms/[slug]/page.tsx b/src/app/forms/[slug]/page.tsx index 1f30542e4..3e8ddd973 100644 --- a/src/app/forms/[slug]/page.tsx +++ b/src/app/forms/[slug]/page.tsx @@ -1,15 +1,21 @@ import type { Metadata } from "next"; import { FormDetailClient } from "@/components/forms/form-detail-client"; +import { getFormRecord } from "@/lib/forms"; type FormRouteProps = { params: Promise<{ slug: string }>; }; -export const metadata: Metadata = { - title: "Form record - Forms - Clinical KB", - description: "Psychiatry form and workflow details.", -}; +export async function generateMetadata({ params }: FormRouteProps): Promise { + const { slug } = await params; + const form = getFormRecord(slug); + + return { + title: form ? `${form.title} - Forms - Clinical KB` : "Form record - Forms - Clinical KB", + description: form?.subtitle ?? "Psychiatry form and workflow details.", + }; +} export default async function FormRoute({ params }: FormRouteProps) { const { slug } = await params; diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx deleted file mode 100644 index 64432c1b9..000000000 --- a/src/app/medications/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function MedicationsIndexPage() { - redirect("/?mode=prescribing"); -} diff --git a/src/app/medications/route.ts b/src/app/medications/route.ts new file mode 100644 index 000000000..c7ad52d16 --- /dev/null +++ b/src/app/medications/route.ts @@ -0,0 +1,7 @@ +import { type NextRequest, NextResponse } from "next/server"; + +export function GET(request: NextRequest) { + return NextResponse.redirect(new URL("/?mode=prescribing", request.url)); +} + +export const HEAD = GET; diff --git a/src/app/services/[slug]/page.tsx b/src/app/services/[slug]/page.tsx index 9f934c0b9..4c1986d41 100644 --- a/src/app/services/[slug]/page.tsx +++ b/src/app/services/[slug]/page.tsx @@ -1,15 +1,21 @@ import type { Metadata } from "next"; import { ServiceDetailClient } from "@/components/services/service-detail-client"; +import { getServiceRecord } from "@/lib/services"; type ServiceRouteProps = { params: Promise<{ slug: string }>; }; -export const metadata: Metadata = { - title: "Service record - Services - Clinical KB", - description: "Clinical service record details and referral information.", -}; +export async function generateMetadata({ params }: ServiceRouteProps): Promise { + const { slug } = await params; + const service = getServiceRecord(slug); + + return { + title: service ? `${service.title} - Services - Clinical KB` : "Service record - Services - Clinical KB", + description: service?.subtitle ?? "Clinical service record details and referral information.", + }; +} export default async function ServiceRoute({ params }: ServiceRouteProps) { const { slug } = await params; diff --git a/src/components/clinical-dashboard/answer-status.tsx b/src/components/clinical-dashboard/answer-status.tsx index efba7abc5..3712812e5 100644 --- a/src/components/clinical-dashboard/answer-status.tsx +++ b/src/components/clinical-dashboard/answer-status.tsx @@ -101,12 +101,12 @@ export function AnswerEmptyState({ light text links (not pills) with a hairline divider — this also breaks the visual repetition of stacked equal-weight chip rows. */}
-