diff --git a/.github/workflows/ci-triage.yml b/.github/workflows/ci-triage.yml index d465e0281..2dde0837a 100644 --- a/.github/workflows/ci-triage.yml +++ b/.github/workflows/ci-triage.yml @@ -42,7 +42,7 @@ jobs: persist-credentials: false - name: Post triage comment - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const fs = require("fs"); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 682f7129b..404c98a69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,7 +110,7 @@ jobs: uses: ./.github/actions/setup-node-cached - name: Setup Deno - uses: denoland/setup-deno@v2 + uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 with: deno-version: v2.x @@ -258,7 +258,7 @@ jobs: - name: Upload UI regression diagnostics if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ui-regression-diagnostics-${{ github.run_id }} path: | @@ -302,7 +302,7 @@ jobs: - name: Upload quarantine diagnostics if: failure() && steps.quarantine.outputs.present == 'true' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: quarantine-ui-diagnostics-${{ github.run_id }} path: | @@ -321,7 +321,7 @@ jobs: timeout-minutes: 30 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 with: persist-credentials: false @@ -333,7 +333,7 @@ jobs: - name: Upload mockup UI diagnostics if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: mockup-ui-diagnostics-${{ github.run_id }} path: | @@ -354,13 +354,13 @@ jobs: persist-credentials: false - name: Setup Supabase CLI - uses: supabase/setup-cli@v3 + uses: supabase/setup-cli@46f7f98c7f948ad727d22c1e67fab04c223a0520 # v3 with: version: ${{ env.SUPABASE_CLI_VERSION }} - name: Restore Supabase Docker image cache id: supabase-docker-cache - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: /tmp/supabase-docker-cache key: supabase-docker-${{ runner.os }}-cli-${{ env.SUPABASE_CLI_VERSION }}-${{ hashFiles('supabase/config.toml') }} @@ -499,7 +499,7 @@ jobs: - name: Restore browser cache id: pw-cache - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} @@ -519,7 +519,7 @@ jobs: - name: Upload UI diagnostics if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: release-ui-diagnostics-${{ github.run_id }} path: | diff --git a/.github/workflows/dependency-report.yml b/.github/workflows/dependency-report.yml index 16908468b..6ae34417e 100644 --- a/.github/workflows/dependency-report.yml +++ b/.github/workflows/dependency-report.yml @@ -26,12 +26,12 @@ jobs: timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 with: persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version-file: ".nvmrc" cache: npm @@ -45,7 +45,7 @@ jobs: run: node scripts/dependency-report.mjs --out dependency-report.md - name: Publish to rolling issue - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: # Pass step outputs via env (not inline template expansion) to avoid the # GitHub Actions template-injection pattern, even though both values are diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 0a7e1e865..450c7da02 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -60,10 +60,10 @@ jobs: persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Build app image (no push) - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . file: Dockerfile @@ -85,10 +85,10 @@ jobs: persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Build worker image (no push) - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . file: Dockerfile.worker diff --git a/.github/workflows/eval-canary.yml b/.github/workflows/eval-canary.yml index ded63eb29..7253081dd 100644 --- a/.github/workflows/eval-canary.yml +++ b/.github/workflows/eval-canary.yml @@ -75,7 +75,7 @@ jobs: fi - name: Setup Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version-file: ".nvmrc" cache: npm @@ -105,7 +105,7 @@ jobs: - name: Open or update canary failure issue if: failure() && github.event_name == 'schedule' - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const label = "eval-canary"; diff --git a/.github/workflows/ingestion-autopilot.yml b/.github/workflows/ingestion-autopilot.yml index 54fc635d1..6715da82e 100644 --- a/.github/workflows/ingestion-autopilot.yml +++ b/.github/workflows/ingestion-autopilot.yml @@ -53,7 +53,7 @@ jobs: fi - name: Setup Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version-file: ".nvmrc" cache: npm @@ -82,7 +82,7 @@ jobs: - name: Open or update alert issue if: failure() && github.event_name == 'schedule' - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const label = "ingestion-autopilot"; diff --git a/.github/workflows/live-drift.yml b/.github/workflows/live-drift.yml index ccc75fb90..e20e2bd5b 100644 --- a/.github/workflows/live-drift.yml +++ b/.github/workflows/live-drift.yml @@ -43,7 +43,7 @@ jobs: fi - name: Setup Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version-file: ".nvmrc" cache: npm diff --git a/.github/workflows/ops-digest.yml b/.github/workflows/ops-digest.yml index 81487fd84..f2aaf92ad 100644 --- a/.github/workflows/ops-digest.yml +++ b/.github/workflows/ops-digest.yml @@ -47,7 +47,7 @@ jobs: fi - name: Setup Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version-file: ".nvmrc" @@ -56,7 +56,7 @@ jobs: run: node scripts/ops-digest.mjs --out digest.md - name: Publish to rolling issue - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const fs = require("fs"); diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index 673fe9e80..91ef60df3 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -33,6 +33,6 @@ jobs: # gitleaks/gitleaks-action@v3 does not support the merge_group event; # the scan already ran on pull_request so skipping here is safe. if: github.event_name != 'merge_group' - uses: gitleaks/gitleaks-action@v3 + uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/staging-tenancy.yml b/.github/workflows/staging-tenancy.yml index 087c6d95c..71cb0cdd0 100644 --- a/.github/workflows/staging-tenancy.yml +++ b/.github/workflows/staging-tenancy.yml @@ -19,12 +19,12 @@ jobs: timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 with: persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version-file: ".nvmrc" cache: npm @@ -52,7 +52,7 @@ jobs: - name: Upload tenancy evidence if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: staging-tenancy-evidence-${{ github.run_id }} path: artifacts/staging-tenancy-evidence.json diff --git a/.github/workflows/summary.yml b/.github/workflows/summary.yml index ca3e00810..b7d910c0a 100644 --- a/.github/workflows/summary.yml +++ b/.github/workflows/summary.yml @@ -39,7 +39,7 @@ jobs: - name: Run AI inference id: inference - uses: actions/ai-inference@v2 + uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2 with: prompt: | You are summarizing a GitHub issue. @@ -49,7 +49,7 @@ jobs: - name: Comment with AI summary if: github.event.issue.state == 'open' - uses: peter-evans/create-or-update-comment@v5 + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5 with: token: ${{ secrets.GITHUB_TOKEN }} issue-number: ${{ github.event.issue.number }} diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 576d4914f..5eeb0f47c 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -560,3 +560,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-15 | codex/outstanding-work-cleanup | 0c56f27a37af88a073d2bb695d2cf4c05067ff4f + working-tree diff | repo-wide outstanding-work reconciliation and cleanup | No high-confidence P0/P1 remained in the locally executable scope. Fixed the stale architecture index for DSM/specifier routes, pruned redundant Knip configuration, removed the unused `SignedImage` default export, and reconciled maintained docs so completed/superseded plans no longer present as active work. Retained explicitly blocked work: provider-gated operator actions, the live-eval shadow reindex harness, deep-memory section-ownership design, and RAG follow-ups that require live evidence or product/security decisions. Full Knip findings were triaged rather than mass-deleted; unresolved-import scan is clean. | Offline `npm run verify:cheap` passed with 2,417 tests/1 skipped; docs links 806, script refs 264, codebase index 35/35; Knip unresolved scan clean; typecheck clean; focused Vitest 15/15; env-name parity clean; `git diff --check`. Provider-backed Supabase/OpenAI/GitHub/Railway, dependency audit, browser, build, drift, and release gates not run. | | 2026-07-15 | codex/design-polish-pass | 0c56f27a37af88a073d2bb695d2cf4c05067ff4f + reviewed working diff | full live design, responsive, UX, accessibility, design-system, routing, performance, lint, testing, documentation, and release-readiness review | No P0/P1 reproduced. Fixed the P2 duplicate phone scroll surface in the shared standalone shell and added a regression test; fixed mode-menu Tab dismissal; explicitly hid decorative dynamic icons; restricted press scaling to motion-safe environments; moved sheet backdrops and forced-colour glass/backdrop behavior onto design tokens. External target fidelity remains blocked because no independent Figma/mockup source was supplied. | Live 30-route desktop + 21-route phone sweep; targeted 320/390/639/768/1440/1920 proofs; `npm run verify:cheap` (2417 passed/1 skipped); `npm run verify:ui` (175/175); focused keyboard 1/1; accessibility media/axe 5/5; scoped Prettier, ESLint, TypeScript, type-scale, and icon-scale passed. Provider-backed checks not run. | | 2026-07-15 | codex/documents-closed-default | 49f63791bced2b1764a11ab723aea94b45b026b6 | documents viewer disclosure defaults and related defect hunt | Fixed the inconsistent default-open document viewer sections by making indexed text, high-yield summary, tables/diagrams, and indexing details a native mutually exclusive closed disclosure group. The section navigation opens its requested disclosure and deep-linked evidence still reveals its target. The hunt also removed the explicitly open nested table-review queue, preserved printable summary content through the browser print lifecycle, and added cold-server readiness guards to the affected viewer tests. No other high-confidence default-open defect remains in the live Documents scope. | `npm run verify:cheap`; TypeScript; focused ESLint/Prettier; clean-worktree mocked Chromium coverage for deep-linked evidence, structured summary, closed/mutually-exclusive disclosures, navigation opening, and print state restore; `git diff --check`. Turbopack could not run through the local external `node_modules` junction, so clean browser verification used Next's supported Webpack dev mode. No Supabase/OpenAI/live-provider checks run. | +| 2026-07-15 | HEAD / main snapshot (detached review worktree) | 0c56f27a37af88a073d2bb695d2cf4c05067ff4f | comprehensive repository review | Changes requested: two P1 defects (high-risk clinical claim support can accept a different trigger condition on lexical overlap; document-mode URL auto-run loops on navigation and leaves search loading indefinitely), one P2 supply-chain guard gap (the action-pin checker accepts mutable major tags and ignores SHA-pinned major versions), and one P3 orientation-doc gap (DSM and legacy specifier routes are absent from the codebase index). No P0 found. | Node 24/npm 11 and `npm ls --depth=0`; format, runtime, lint, TypeScript, sitemap/brand/icon/type-scale, docs links/scripts, CI/action/Codex guards; coverage 259 files passed/1 skipped and 2,417 tests passed/1 skipped; offline RAG 36 fixtures plus 282 tests; production build/client-secret scan; deployment boot smoke; critical Chromium 8/10 with two reproducible document-search failures; accessibility 5/5; viewport/focus checks through 1920x1080. Provider-backed governance, quality, drift, tenancy, hosted CI, and the cross-browser matrix were blocked or skipped by policy/targeted failures. | diff --git a/scripts/check-github-action-pins.mjs b/scripts/check-github-action-pins.mjs index 5d3de4631..6a82fae7b 100644 --- a/scripts/check-github-action-pins.mjs +++ b/scripts/check-github-action-pins.mjs @@ -1,38 +1,10 @@ import { readdirSync, readFileSync } from "node:fs"; import path from "node:path"; +import { validateActionReference } from "./github-action-pins.mjs"; import { yamlBlock } from "./yaml-contract.mjs"; const workflowDir = path.join(process.cwd(), ".github", "workflows"); -const supportedMajorRanges = new Map([ - [ - "actions/checkout", - { - min: 4, - max: 6, - reason: - "v6 is the currently supported and documented major that this repo has vetted; upgrading to v7 should be a deliberate, reviewed decision.", - }, - ], - [ - "peter-evans/create-or-update-comment", - { - min: 5, - max: 5, - reason: "create-or-update-comment v6 is not a documented published major for this workflow.", - }, - ], - [ - "actions/github-script", - { - min: 8, - max: 9, - reason: "v7 uses the end-of-life Node 20 action runtime; use a Node 24 based release.", - }, - ], -]); - -const usesPattern = /^\s*uses:\s*([^@\s]+)@v(\d+)\s*(?:#.*)?$/; const runsOnLatestPattern = /^\s*runs-on:\s*ubuntu-latest\s*(?:#.*)?$/; const failures = []; const expectedSupabaseCliVersion = "2.108.0"; @@ -51,19 +23,8 @@ for (const fileName of readdirSync(workflowDir) ); } - const match = line.match(usesPattern); - if (!match) return; - - const [, action, rawMajor] = match; - const range = supportedMajorRanges.get(action); - if (!range) return; - - const major = Number(rawMajor); - if (!Number.isInteger(major) || major < range.min || major > range.max) { - failures.push( - `${fileName}:${index + 1}: ${action}@v${rawMajor} is outside supported range v${range.min}-v${range.max}. ${range.reason}`, - ); - } + const actionFailure = validateActionReference(line); + if (actionFailure) failures.push(`${fileName}:${index + 1}: ${actionFailure}`); }); } diff --git a/scripts/github-action-pins.mjs b/scripts/github-action-pins.mjs new file mode 100644 index 000000000..02240377a --- /dev/null +++ b/scripts/github-action-pins.mjs @@ -0,0 +1,47 @@ +const reviewedActionPins = new Map([ + [ + "actions/checkout", + new Map([ + ["9f698171ed81b15d1823a05fc7211befd50c8ae0", "v6.0.3"], + ["9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", "v7.0.0"], + ]), + ], + ["actions/setup-node", new Map([["a0853c24544627f65ddf259abe73b1d18a591444", "v5.0.0"]])], + ["actions/github-script", new Map([["3a2844b7e9c422d3c10d287c895573f7108da1b3", "v9.0.0"]])], + ["actions/cache", new Map([["55cc8345863c7cc4c66a329aec7e433d2d1c52a9", "v6"]])], + ["actions/upload-artifact", new Map([["043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", "v7"]])], + ["denoland/setup-deno", new Map([["22d081ff2d3a40755e97629de92e3bcbfa7cf2ed", "v2.0.5"]])], + ["supabase/setup-cli", new Map([["46f7f98c7f948ad727d22c1e67fab04c223a0520", "v3"]])], + ["gitleaks/gitleaks-action", new Map([["e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e", "v3"]])], + ["actions/ai-inference", new Map([["a7805884c80886efc241e94a5351df715968a0ad", "v2"]])], + ["peter-evans/create-or-update-comment", new Map([["e8674b075228eee787fea43ef493e45ece1004c9", "v5"]])], + ["docker/setup-buildx-action", new Map([["bb05f3f5519dd87d3ba754cc423b652a5edd6d2c", "v4"]])], + ["docker/build-push-action", new Map([["53b7df96c91f9c12dcc8a07bcb9ccacbed38856a", "v7"]])], +]); + +const usesPattern = /^\s*(?:-\s*)?uses:\s*([^@\s]+)@([^\s#]+)(?:\s+#\s*(\S.*?))?\s*$/; +const immutableCommitSha = /^[0-9a-f]{40}$/; + +export function validateActionReference(line) { + const match = line.match(usesPattern); + if (!match) return null; + + const [, action, ref, versionComment] = match; + if (action.startsWith("./")) return null; + if (!immutableCommitSha.test(ref)) { + return `${action}@${ref} is mutable. Pin external actions to a reviewed 40-character commit SHA.`; + } + + const reviewedPins = reviewedActionPins.get(action); + if (!reviewedPins) { + return `${action}@${ref} is not in the reviewed action allowlist.`; + } + const expectedVersion = reviewedPins.get(ref); + if (!expectedVersion) { + return `${action}@${ref} is not a reviewed commit SHA for this action.`; + } + if (versionComment !== expectedVersion) { + return `${action}@${ref} must retain the exact reviewed release comment '# ${expectedVersion}'.`; + } + return null; +} diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index a3be64c25..d8e286ad9 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -176,10 +176,12 @@ import { } from "@/lib/app-modes"; import { documentsSearchHref } from "@/lib/document-flow-routes"; import { + privateScopeReadyForRoute, readSearchNavigationContext, routedSubmissionContextChanged, searchNavigationContextSignature, searchSubmissionSignature, + type PrivateScopeRestorationStatus, type SearchNavigationContext, } from "@/lib/search-navigation-context"; import { persistPrivateSearchScope, restorePrivateSearchScope } from "@/lib/private-search-scope"; @@ -513,9 +515,10 @@ export function ClinicalDashboard({ const routedSearchContext = useMemo(() => readSearchNavigationContext(searchParams), [searchParams]); const routedSearchContextSignature = searchNavigationContextSignature(routedSearchContext); - const [privateScopeStatus, setPrivateScopeStatus] = useState<"none" | "restoring" | "restored" | "unavailable">( + const [privateScopeStatus, setPrivateScopeStatus] = useState( initialSearchNavigationContext.scopeRef ? "restoring" : "none", ); + const [restoredPrivateScopeRef, setRestoredPrivateScopeRef] = useState(null); // Record matches come from the owner-scoped registry API (mock fixtures in // demo mode); ranking stays client-side so live-typing behaviour is @@ -693,25 +696,30 @@ export function ClinicalDashboard({ if (cancelled) return; const scopeRef = routedSearchContext.scopeRef; if (!scopeRef) { + setRestoredPrivateScopeRef(null); setPrivateScopeStatus("none"); return; } if (authStatus === "loading") { + setRestoredPrivateScopeRef(null); setPrivateScopeStatus("restoring"); return; } const ownerId = auth.session?.user.id; if (authStatus !== "authenticated" || !ownerId) { setSelectedDocumentIds([]); + setRestoredPrivateScopeRef(null); setPrivateScopeStatus("unavailable"); return; } const restored = restorePrivateSearchScope(window.sessionStorage, scopeRef, ownerId); if (restored.kind === "restored") { setSelectedDocumentIds(restored.documentIds); + setRestoredPrivateScopeRef(scopeRef); setPrivateScopeStatus("restored"); } else { setSelectedDocumentIds([]); + setRestoredPrivateScopeRef(null); setPrivateScopeStatus("unavailable"); } }); @@ -1601,6 +1609,7 @@ export function ClinicalDashboard({ useEffect(() => { if (urlDocumentSearchBootstrappedRef.current) return; + if (authStatus === "loading") return; const params = new URLSearchParams(window.location.search); const mode = params.get("mode"); const searchText = params.get("q")?.trim(); @@ -1624,10 +1633,20 @@ export function ClinicalDashboard({ if (!shouldRun) return; const isRegistryOnlyMode = mode === "services" || mode === "forms"; if (modeSearch.kind !== "tools" && modeSearch.kind !== "favourites" && !isRegistryOnlyMode && !canRunSearch) return; + const initialContext = readSearchNavigationContext(params); + if (!privateScopeReadyForRoute(initialContext.scopeRef, privateScopeStatus, restoredPrivateScopeRef)) return; urlDocumentSearchBootstrappedRef.current = true; - void executeSearchRef.current(searchText, mode, scopeFiltersRef.current); + autoRunSearchSignatureRef.current = searchSubmissionSignature(mode, searchText, initialContext); + void executeSearchRef.current( + searchText, + mode, + initialContext.scopeFilters, + initialContext.queryMode, + false, + initialContext.scopeRef, + ); // URL search intentionally runs once when the selected mode can execute. - }, [canRunSearch, answerThreadBootstrapped]); + }, [authStatus, canRunSearch, answerThreadBootstrapped, privateScopeStatus, restoredPrivateScopeRef]); useEffect(() => { const updateHash = () => { @@ -2210,16 +2229,30 @@ export function ClinicalDashboard({ : undefined); if (searchMode === "documents" && trimmedQuery) { rememberRecentQuery(trimmedQuery); - router.push( + const navigationContext = { + queryMode: effectiveQueryMode, + scopeFilters: effectiveScopeFilters, + scopeRef: privateScopeRef, + }; + autoRunSearchSignatureRef.current = searchSubmissionSignature(searchMode, trimmedQuery, navigationContext); + window.history.pushState( + null, + "", documentsSearchHref({ query: trimmedQuery, focus: true, run: true, - queryMode: effectiveQueryMode, - scopeFilters: effectiveScopeFilters, - scopeRef: privateScopeRef, + ...navigationContext, }), ); + await executeSearch( + trimmedQuery, + searchMode, + effectiveScopeFilters, + effectiveQueryMode, + replaceExistingAnswer, + privateScopeRef, + ); return; } if (searchMode === "prescribing") { @@ -2243,7 +2276,8 @@ export function ClinicalDashboard({ const submittedSearchText = searchMode === "answer" && submittedUrlQuery ? submittedUrlQuery : trimmedQuery; const canAutoRunMode = searchMode === "documents" || searchMode === "prescribing" || canRunSearch; if (!autoRunSearch || !submittedSearchText || !canAutoRunMode || loading) return; - if (routedSearchContext.scopeRef && privateScopeStatus !== "restored") return; + if (authStatus === "loading") return; + if (!privateScopeReadyForRoute(routedSearchContext.scopeRef, privateScopeStatus, restoredPrivateScopeRef)) return; if (searchMode === "answer" && !answerThreadBootstrapped) return; const previousSignature = autoRunSearchSignatureRef.current; const signature = searchSubmissionSignature(searchMode, submittedSearchText, routedSearchContext); @@ -2265,9 +2299,21 @@ export function ClinicalDashboard({ } if (autoRunSearchSignatureRef.current === signature) return; autoRunSearchSignatureRef.current = signature; + if (searchMode === "documents") { + void executeSearchRef.current( + submittedSearchText, + searchMode, + routedSearchContext.scopeFilters, + routedSearchContext.queryMode, + routedContextChanged, + routedSearchContext.scopeRef, + ); + return; + } void askRef.current(submittedSearchText, routedSearchContext, routedContextChanged); }, [ autoRunSearch, + authStatus, canRunSearch, loading, query, @@ -2279,6 +2325,7 @@ export function ClinicalDashboard({ routedSearchContext, routedSearchContextSignature, privateScopeStatus, + restoredPrivateScopeRef, ]); function pickRecentQuery(recentQuery: string) { diff --git a/src/lib/rag-claim-support.ts b/src/lib/rag-claim-support.ts index 7ced082bd..3303a4f53 100644 --- a/src/lib/rag-claim-support.ts +++ b/src/lib/rag-claim-support.ts @@ -45,6 +45,32 @@ const topicStopwords = new Set([ "source", ]); +const triggerTopicStopwords = new Set([ + "clinical", + "clinically", + "develop", + "developed", + "developing", + "develops", + "indicated", + "indication", + "necessary", + "needed", + "occur", + "occurred", + "occurring", + "occurs", + "patient", + "patients", + "present", + "presenting", + "required", + "sign", + "signs", + "symptom", + "symptoms", +]); + function cleanText(value: string) { return value .replace(/[*_`#>]/g, "") @@ -101,6 +127,30 @@ function topicTokens(value: string) { ); } +function highRiskTriggerTokens(value: string) { + const tokens = new Set(); + const triggerPatterns = [ + /\b(?:when|if|unless|during|after|before)\b\s*([^,;.!?]+?)(?=\s*,|\s+\b(?:administer|avoid|cease|continue|discontinue|escalate|give|prescribe|start|stop|use|withhold)\b|[;.!?]|$)/gi, + /\b(?:administer|avoid|cease|continue|discontinue|escalate|give|prescribe|start|stop|use|withhold)\b[^,;.!?]{0,80}?\bfor\b\s*([^,;.!?]+?)(?=\s+\bfor\b|\s*,|[;.!?]|$)/gi, + ]; + for (const pattern of triggerPatterns) { + for (const match of value.matchAll(pattern)) { + for (const token of topicTokens(match[1] ?? "")) { + if (!triggerTopicStopwords.has(token)) tokens.add(token); + } + } + } + return tokens; +} + +function compatibleHighRiskTrigger(claim: string, evidence: string) { + if (!isHighRiskClaim(claim)) return true; + const triggerTokens = highRiskTriggerTokens(claim); + if (triggerTokens.size === 0) return true; + const evidenceTopics = topicTokens(evidence); + return [...triggerTokens].every((token) => evidenceTopics.has(token)); +} + function isHighRiskClaim(value: string) { return highRiskPattern.test(value) || extractClinicalValueAtoms(value).length > 0; } @@ -191,6 +241,7 @@ function sourceSupportsClaim(claim: string, source: SearchResult) { if (claimEntities.size > 0 && [...claimEntities].some((entity) => !evidenceEntities.has(entity))) return false; if (!compatiblePolarity(claim, evidence)) return false; if (!compatibleSafetyDimensions(claim, evidence)) return false; + if (!compatibleHighRiskTrigger(claim, evidence)) return false; const evidenceAtoms = new Set(extractClinicalValueAtoms(evidence).map(atomKey)); if (extractClinicalValueAtoms(claim).some((atom) => !evidenceAtoms.has(atomKey(atom)))) return false; diff --git a/src/lib/search-navigation-context.ts b/src/lib/search-navigation-context.ts index 5d4628866..e28784709 100644 --- a/src/lib/search-navigation-context.ts +++ b/src/lib/search-navigation-context.ts @@ -17,6 +17,7 @@ export type ResolvedSearchNavigationContext = { scopeFilters: SearchScopeFilters; scopeRef?: string; }; +export type PrivateScopeRestorationStatus = "none" | "restoring" | "restored" | "unavailable"; type ReadableSearchParams = Pick; type FreeTextScopeKey = @@ -181,6 +182,14 @@ export function routedSubmissionContextChanged( return previousSignature?.startsWith(signaturePrefix) === true && previousSignature !== nextSignature; } +export function privateScopeReadyForRoute( + routedScopeRef: string | undefined, + restorationStatus: PrivateScopeRestorationStatus, + restoredScopeRef: string | null, +) { + return !routedScopeRef || (restorationStatus === "restored" && restoredScopeRef === routedScopeRef); +} + export function readSearchNavigationContext(params: ReadableSearchParams): ResolvedSearchNavigationContext { const rawQueryMode = params.get(queryModeParam); const queryMode = diff --git a/tests/github-action-pins.test.ts b/tests/github-action-pins.test.ts new file mode 100644 index 000000000..588fcb689 --- /dev/null +++ b/tests/github-action-pins.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { validateActionReference } from "../scripts/github-action-pins.mjs"; + +describe("GitHub Action pin validation", () => { + it("accepts a reviewed immutable action reference with its exact version comment", () => { + expect( + validateActionReference(" uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0"), + ).toBeNull(); + }); + + it("rejects mutable major tags", () => { + expect(validateActionReference(" uses: actions/setup-node@v5")).toContain("is mutable"); + }); + + it("validates inline list-style action steps", () => { + expect(validateActionReference(" - uses: actions/setup-node@v5")).toContain("is mutable"); + expect( + validateActionReference(" - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0"), + ).toBeNull(); + }); + + it("rejects an unreviewed commit SHA", () => { + expect( + validateActionReference(" uses: actions/setup-node@1111111111111111111111111111111111111111 # v5.0.0"), + ).toContain("is not a reviewed commit SHA"); + }); + + it("rejects a misleading or unsupported version annotation", () => { + expect( + validateActionReference(" uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6.0.3"), + ).toContain("# v7.0.0"); + }); + + it("allows repository-local actions", () => { + expect(validateActionReference(" uses: ./.github/actions/example@v1")).toBeNull(); + }); +}); diff --git a/tests/rag-claim-support.test.ts b/tests/rag-claim-support.test.ts index 4d09a8291..40ce41f48 100644 --- a/tests/rag-claim-support.test.ts +++ b/tests/rag-claim-support.test.ts @@ -73,11 +73,42 @@ describe("deterministic claim support", () => { ["Avoid valproate in hepatic impairment.", "Valproate monitoring includes liver function tests."], ["Escalate urgently for myocarditis symptoms.", "Review myocarditis symptoms at the next appointment."], ["Stop clozapine below ANC 1.0 x10^9/L.", "Stop lithium below a level of 1.0 x10^9/L."], + ["Stop clozapine when fever develops.", "Stop clozapine when myocarditis develops."], + ["Give lithium 300 mg for bipolar disorder.", "Give lithium 300 mg for major depression."], + ["Cease clozapine for neutropenia.", "Cease clozapine for myocarditis."], + ["Discontinue clozapine for neutropenia.", "Discontinue clozapine for myocarditis."], + ["Escalate urgently for neutropenia.", "Escalate urgently for myocarditis."], ])("does not directly support %s from mismatched evidence", (claim, evidence) => { const cited = source("c1", evidence); expect(assessClaimSupport(answer(claim, [cited])).claims[0]?.supportStatus).not.toBe("direct"); }); + it("fails closed when a high-risk action cites a different trigger condition", () => { + const cited = source("c1", "Stop clozapine when myocarditis develops."); + const result = assessAndEnforceClaimSupport(answer("Stop clozapine when fever develops.", [cited])); + + expect(result).toMatchObject({ grounded: false, confidence: "unsupported", responseMode: "evidence_gap" }); + expect(result.supportedClaims?.[0]).toMatchObject({ riskClass: "high_risk", supportStatus: "partial" }); + expect(result.citations).toEqual([]); + }); + + it("supports equivalent condition-first wording without treating the action clause as the trigger", () => { + const cited = source("c1", "Stop clozapine when fever develops."); + + expect(assessClaimSupport(answer("If fever develops, discontinue clozapine.", [cited])).claims[0]).toMatchObject({ + riskClass: "high_risk", + supportStatus: "direct", + }); + }); + + it("uses the first indication span instead of a later duration clause", () => { + const cited = source("c1", "Give lithium 300 mg for bipolar disorder as ongoing therapy."); + + expect( + assessClaimSupport(answer("Give lithium 300 mg for bipolar disorder for ongoing care.", [cited])).claims[0], + ).toMatchObject({ riskClass: "high_risk", supportStatus: "direct" }); + }); + it("evaluates section prose only against that section's citations", () => { const wrongSection = source("wrong", "Stop clozapine below ANC 1.0 x10^9/L."); const citedSection = source("cited", "Clozapine monitoring includes regular blood counts."); diff --git a/tests/search-navigation-context.test.ts b/tests/search-navigation-context.test.ts index 1d428fe06..e26732d3f 100644 --- a/tests/search-navigation-context.test.ts +++ b/tests/search-navigation-context.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { appendSearchNavigationContext, + privateScopeReadyForRoute, readSearchNavigationContext, routedSubmissionContextChanged, searchNavigationContextSignature, @@ -119,4 +120,15 @@ describe("search navigation context", () => { readSearchNavigationContext(new URLSearchParams("scopeRef=private-document-title")).scopeRef, ).toBeUndefined(); }); + + it("waits for the exact routed private scope on initial load and history changes", () => { + const firstScope = "22222222-2222-4222-8222-222222222222"; + const secondScope = "33333333-3333-4333-8333-333333333333"; + + expect(privateScopeReadyForRoute(firstScope, "restoring", null)).toBe(false); + expect(privateScopeReadyForRoute(firstScope, "restored", firstScope)).toBe(true); + expect(privateScopeReadyForRoute(secondScope, "restored", firstScope)).toBe(false); + expect(privateScopeReadyForRoute(secondScope, "restored", secondScope)).toBe(true); + expect(privateScopeReadyForRoute(undefined, "none", null)).toBe(true); + }); }); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index b6253e4c3..95359b48e 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2757,6 +2757,10 @@ test.describe("Clinical KB UI smoke coverage", () => { ); await expect(page.getByRole("complementary", { name: "Selected document evidence" })).toBeVisible(); await expectNoPageHorizontalOverflow(page); + + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(documentResults).toBeVisible(); + await expect(documentResults).toContainText("Best match"); }); test("tools mode searches the existing applications registry inside the dashboard", async ({ page }) => {