diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c58475e10..bfd57f199 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -22,9 +22,8 @@ /src/instrumentation.ts @BigSimmo /src/instrumentation-client.ts @BigSimmo -# RAG / retrieval / search / ranking -/src/lib/rag.ts @BigSimmo -/src/lib/rag-*.ts @BigSimmo +# RAG / retrieval / search / ranking (the stack moved to src/lib/rag/ in #994) +/src/lib/rag/ @BigSimmo /src/lib/retrieval-*.ts @BigSimmo /src/lib/*search*.ts @BigSimmo /src/lib/privacy.ts @BigSimmo diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dcfc55fbe..0a50d3c10 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -31,11 +31,19 @@ updates: # not a function"). Same shape as the TypeScript hold — the lint toolchain is # not yet ESLint-10-ready. Remove once eslint-config-next supports ESLint 10. # Context: closed #1022. + # + # Hold the @types/node major: the types must track engines.node (24.x — + # .nvmrc, both Dockerfiles and the Railway images all run Node 24), or tsc + # accepts an API the deployed runtime does not have. Dependabot #549 is how + # the current 26.x line arrived; the pin back to 24.x is tracked in + # docs/framework-dependency-modernization-checklist.md (audit L20). ignore: - dependency-name: "typescript" update-types: ["version-update:semver-major"] - dependency-name: "eslint" update-types: ["version-update:semver-major"] + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] - package-ecosystem: "github-actions" directory: "/" @@ -63,3 +71,36 @@ updates: groups: docker-images: patterns: ["*"] + + # The ingestion worker parses attacker-supplied PDFs and images with + # PyMuPDF, Pillow, pytesseract and docling. Without these two entries nothing + # in the repo reports a published CVE against a pinned parser (audit M20). + - package-ecosystem: "pip" + directory: "/worker/python" + # Hash-pinned by pip-compile: a bump must regenerate the hashes with + # `npm run generate:worker-python-lock`, then pass + # `npm run check:worker-python-locks:static`, rather than edit a version. + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "Australia/Perth" + open-pull-requests-limit: 2 + groups: + worker-python: + patterns: ["*"] + + - package-ecosystem: "pip" + directory: "/eval/docling" + # Hash-pinned Gate B lab lock that Dockerfile.worker builds the docling venv + # from; regenerate per docs/worker-deploy-runbook.md and confirm with + # `npm run check:worker-python-locks:static` before merging a bump. + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "Australia/Perth" + open-pull-requests-limit: 2 + groups: + docling-python: + patterns: ["*"] diff --git a/.github/workflows/claude-backlink.yml b/.github/workflows/claude-backlink.yml index 223041abc..ceb6c2b29 100644 --- a/.github/workflows/claude-backlink.yml +++ b/.github/workflows/claude-backlink.yml @@ -24,17 +24,21 @@ jobs: name: Reply with a Claude Code link runs-on: ubuntu-24.04 # Only when a human (never a bot — including this workflow's own reply and - # Claude's) mentions @claude, so the backlink never loops or spams automated - # comments. + # Claude's) who is an owner, member or collaborator mentions @claude, so + # the backlink never loops, spams automated comments, or answers accounts + # outside the repository's trust boundary (audit L38). if: > (github.event_name == 'issue_comment' && github.event.comment.user.type != 'Bot' && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review_comment' && github.event.comment.user.type != 'Bot' && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review' && github.event.review.user.type != 'Bot' && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.review.author_association) && github.event.review.body != null && contains(github.event.review.body, '@claude')) permissions: diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index cc85e8426..377c68b24 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -1,7 +1,9 @@ name: Claude -# Runs a Claude Code session when a collaborator mentions "@claude" on a pull -# request comment or review. Claude reads the triggering comment, works on the +# Runs a Claude Code session when an owner, member or collaborator of this +# repository mentions "@claude" on a pull request comment or review. The +# author_association gate in the job's `if:` is what enforces that boundary; +# the bot check alone would admit any account able to comment (audit L38). Claude reads the triggering comment, works on the # PR's current head, pushes a scoped fix to the PR branch, and replies in the # thread. Unlike a subscribed chat session, this fires on the webhook every # time, so it keeps working after the session that opened the PR has ended. @@ -22,18 +24,23 @@ jobs: claude: name: Respond to @claude runs-on: ubuntu-24.04 - # Only run when a human (not a bot) mentions @claude on a pull request, so - # ordinary comments — and Claude's own replies — never spin up a runner. + # Only run when a human (not a bot) with owner, member or collaborator + # association mentions @claude on a pull request, so ordinary comments — + # Claude's own replies, and accounts outside the maintainer's trust + # boundary — never spin up a runner that holds write and id-token scopes. if: > (github.event_name == 'issue_comment' && github.event.issue.pull_request != null && github.event.comment.user.type != 'Bot' && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review_comment' && github.event.comment.user.type != 'Bot' && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) && contains(github.event.comment.body, '@claude')) || (github.event_name == 'pull_request_review' && github.event.review.user.type != 'Bot' && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.review.author_association) && github.event.review.body != null && contains(github.event.review.body, '@claude')) permissions: diff --git a/.github/workflows/codex-autofix-review-comments.yml b/.github/workflows/codex-autofix-review-comments.yml index d9a832305..db9d88663 100644 --- a/.github/workflows/codex-autofix-review-comments.yml +++ b/.github/workflows/codex-autofix-review-comments.yml @@ -69,7 +69,7 @@ jobs: /^\.github\/(?:actions|workflows)\//, /^(?:package|package-lock)\.json$/, /^(?:next|playwright|vitest)(?:\..+)?\.config\.[cm]?[jt]s$/, - /^(?:Dockerfile|railway\.json|nixpacks\.toml)$/, + /^(?:Dockerfile(?:\.worker)?|railway\.(?:app|worker)\.json)$/, ]; const sourcePathPattern = /^(?:src|scripts|supabase|\.github\/(?:actions|workflows))\//; const sourceExtensionPattern = /\.(?:[cm]?[jt]sx?|sql|css|scss|json|ya?ml)$/i; diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 5be6d1d10..1cd33f6f2 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -116,8 +116,47 @@ jobs: - name: Vulnerability scan (HIGH,CRITICAL) # Non-blocking by design — findings are reported, not merge-gating. + # The summary lines are kept so the next step can surface them. continue-on-error: true run: | docker builder prune -af || true - node scripts/trivy-image-scan.mjs clinical-kb-app:ci - node scripts/trivy-image-scan.mjs clinical-kb-worker:ci + : > trivy-scan-summary.txt + node scripts/trivy-image-scan.mjs clinical-kb-app:ci | tee -a trivy-scan-summary.txt + node scripts/trivy-image-scan.mjs clinical-kb-worker:ci | tee -a trivy-scan-summary.txt + + - name: Fail on HIGH/CRITICAL image findings outside pull requests + # Audit M20: the worker image carries the Python parsers (PyMuPDF, + # Pillow, pytesseract, docling) that read attacker-supplied uploads, and + # until now a HIGH/CRITICAL finding reached only this run's log. Pull + # request runs stay advisory (a base-image CVE is not the PR author's + # change), but the weekly schedule, main pushes and manual dispatches + # now fail on any HIGH/CRITICAL so notify-ci-failure.yml — which already + # watches "Docker image build" — delivers it to chat. A scanner that + # could not run (disk pressure, docker save) is still only a warning. + if: always() + run: | + set -euo pipefail + if [ ! -s trivy-scan-summary.txt ]; then + echo "::warning::Trivy produced no scan summary; image vulnerabilities were not assessed this run." + exit 0 + fi + { + echo "## Trivy image scan (HIGH,CRITICAL)" + echo + echo '```' + cat trivy-scan-summary.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + high="$(grep -oE 'HIGH=[0-9]+' trivy-scan-summary.txt | cut -d= -f2 | paste -sd+ - | bc || echo 0)" + critical="$(grep -oE 'CRITICAL=[0-9]+' trivy-scan-summary.txt | cut -d= -f2 | paste -sd+ - | bc || echo 0)" + high="${high:-0}" + critical="${critical:-0}" + echo "HIGH=${high} CRITICAL=${critical}" + if [ "${high}" -eq 0 ] && [ "${critical}" -eq 0 ]; then + exit 0 + fi + if [ "${{ github.event_name != 'pull_request' && github.event_name != 'merge_group' }}" = "true" ]; then + echo "::error::Trivy found HIGH=${high} CRITICAL=${critical} in the production images; see the job summary." + exit 1 + fi + echo "::warning::Trivy found HIGH=${high} CRITICAL=${critical} in the production images (advisory on pull requests)." diff --git a/.github/workflows/live-web-vitals.yml b/.github/workflows/live-web-vitals.yml index a1a07924e..54ce52362 100644 --- a/.github/workflows/live-web-vitals.yml +++ b/.github/workflows/live-web-vitals.yml @@ -27,7 +27,9 @@ name: Live Web Vitals baseline # any breach # -> only the breaching route's findings become actionable, ranked by # measured contribution -# Record the verdict in docs/outstanding-issues.md against #017 either way. +# #017 was CLOSED on 2026-07-31, so do not record against it. Record the +# verdict on the open Web-Vitals row in docs/outstanding-issues.md through +# `npm run issues:update` (never a hand edit) either way. on: workflow_dispatch: @@ -35,11 +37,16 @@ on: routes: description: "Comma-separated routes to measure" required: false - # Every entry must be a real page route. `/documents` is not one — the - # documents segment holds only `search`, `source` and `[id]` with no - # `page.tsx` (see docs/site-map.md), so it would have measured the 404 - # document. `/documents/search` is the canonical documents-mode route. - default: "/,/therapy-compass,/documents/search,/dsm,/forms" + # Every entry must be a real page route that renders in place. The + # summariser rejects a report whose final URL differs from the requested + # one, so a redirecting entry can never be graded: `/therapy-compass`, + # `/dsm` and `/forms` became 307 redirects onto `/?mode=` (#2157, + # #2308) and are measured through their `/search` result routes + # instead — the same reasoning as `$routes` in lighthouse-budget.json. + # A bare `/documents` would have measured the 404 document; the + # canonical documents-mode route is `/documents/search`. Guarded by + # tests/ci-audit-contracts.test.ts (audit M29). + default: "/,/therapy-compass/search,/documents/search,/dsm/search,/forms/search" samples: description: "Lighthouse runs per route/strategy (median is graded)" required: false diff --git a/.github/workflows/notify-ci-failure.yml b/.github/workflows/notify-ci-failure.yml index c1a52e221..a4da5d663 100644 --- a/.github/workflows/notify-ci-failure.yml +++ b/.github/workflows/notify-ci-failure.yml @@ -23,6 +23,9 @@ on: - Eval Canary - Ingestion Autopilot - Docker image build + # Daily owner-boundary detector; without this entry its failure reached + # only GitHub's default e-mail (audit M25). + - Staging tenancy isolation types: - completed diff --git a/.github/workflows/sast.yml b/.github/workflows/sast.yml index c51001a62..7b66ed62e 100644 --- a/.github/workflows/sast.yml +++ b/.github/workflows/sast.yml @@ -27,7 +27,12 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 container: - image: semgrep/semgrep:1.168.0 + # Same immutable digest as the blocking ingestion gate in ci.yml (the + # triage-verified 1.168.0 image, docs/maturity-backlog-workorders.md X4): + # a bare tag can be re-pushed upstream, and this job runs third-party + # code with read access to the private source tree (audit L36). Bump + # both references together. + image: semgrep/semgrep:1.168.0@sha256:59fbed6127ea7c5dde3ba6a85142733bb20ea9aaa36120c953904f1539aaf66e steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index bcee01852..0c91f7677 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -12,10 +12,12 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# Least privilege: scripts/run-gitleaks-pinned.mjs only reads the checkout. +# The pull-requests/security-events grants were a leftover from +# gitleaks-action@v3's SARIF upload, which this workflow no longer performs +# (audit L37). permissions: contents: read - pull-requests: read - security-events: write env: # Match the version gitleaks-action@v3 installs by default. diff --git a/docs/framework-dependency-modernization-checklist.md b/docs/framework-dependency-modernization-checklist.md index d4265b59d..df13b7bc8 100644 --- a/docs/framework-dependency-modernization-checklist.md +++ b/docs/framework-dependency-modernization-checklist.md @@ -248,6 +248,27 @@ responseHeaders)` and copy all supplied headers onto every rebuilt merge. Re-run only a failed or stale lane; do not bypass it. - [ ] After merge, fetch `origin/main` and prove the reviewed content is present. +## Overrides rationale + +`package.json` cannot carry comments, so the reason each `overrides` entry exists is +recorded here (audit L129). Remove an entry when its reason no longer holds; never add +one without a row. `tests/ci-audit-contracts.test.ts` fails when an override is missing +from this list or when a major-scoped override matches nothing in the lock. + +| Override | Kind | Why it exists | When it can go | +| ----------------------------------------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `postcss` `^8.5.18` | floor | Transitive audit finding on the Next and ExcelJS paths (`docs/archive/project-alignment-cleanup.md`). | When every dependant declares the fixed floor itself. | +| `tmp` `^0.2.7` | floor | Same cleanup: transitive audit finding. | As above. | +| `uuid` `^11.1.1` | floor | Same cleanup; ExcelJS round-trip verified with the override in place. | As above. | +| `esbuild` `0.28.2` | exact pin | The worker image is an esbuild bundle (#638) and tsx/vite each pull their own esbuild, so one exact version keeps every copy in the tree identical to the pinned devDependency and to the `allowScripts` key, which matches by exact `name@version`. Dependabot moves all three. | If the worker stops bundling with esbuild, or `allowScripts` gains range matching. | +| `sharp` `0.35.3` | exact pin | Bumped 0.34.5 → 0.35.3 in #1053 and frozen exact with the matching `allowScripts` key; no advisory was recorded. It equals Next's own `^0.35.3` floor, so a Next patch that raises the floor conflicts at install time. | Relax to `^0.35.3` once an image build and `npm run check:npm-ci-dry-run` prove the range. | +| `brace-expansion@1` `^1.1.18` | floor | GHSA for brace-expansion 1.x (ReDoS); the lock still resolves 1.1.18 under minimatch 3. | When no dependant resolves a 1.x brace-expansion. | +| `brace-expansion@2` `^2.1.4` | pre-pin | The lock currently holds no 2.x copy, so this matches nothing today. It is kept deliberately: `tests/installed-lock-parity.test.ts` pins all three brace-expansion majors to CVE-2026-14257-patched maintenance releases, and dropping the entry would let a future transitive bump reintroduce an unpatched 2.x silently. | When the CVE-2026-14257 guard in `tests/installed-lock-parity.test.ts` no longer pins the 2.x line. | +| `brace-expansion@5` `^5.0.9` | floor | Same advisory on the 5.x line used by eslint-config-next, glob and readdir-glob. | When no dependant resolves a 5.x below the floor. | +| `fast-uri` `^3.1.7` | floor | Four advisories against 3.0.0–3.1.5 (host confusion via skipped IDN canonicalization and via percent-encoded scheme normalization; SSRF via malformed IPv6 normalization and via repeated hostname percent-decoding). Both copies come from `ajv` under `ajv-formats` and `schema-utils`, which declare `^3.0.1`, so the floor stays inside the same major. `npm audit --omit=dev --audit-level=high` — the CI safety gate's exact command — was failing on this one before the floor. | When every `ajv` copy in the lock declares a patched floor itself. | +| `@xmldom/xmldom` `^0.8.15` | floor | GHSA-6gmq-8vp8-gcm6 (moderate): XML fragment injection via an invalid `EntityReference.nodeName` during `requireWellFormed` serialization, affecting `<= 0.8.14`. The single copy is `mammoth`'s DOCX parser, which declares `^0.8.6`, so 0.8.15 is a patch inside the declared range rather than the 0.9 major. | When `mammoth` declares a patched floor, or moves to the 0.9 line. | +| `exceljs` → `archiver` `^8.0.0`, `unzipper` `^0.12.5` | nested floor | ExcelJS 4.4.0 declares older majors with open advisories; the nested override lifts only ExcelJS's copies and the XLSX round-trip test covers the result. | When ExcelJS publishes a release declaring these floors. | + ## Automation boundary | Safe to automate with review | Must be handled as a manual rewrite | diff --git a/package.json b/package.json index 890f442b0..4d7bddedf 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "test:coverage": "node scripts/run-vitest.mjs run --coverage", "test:coverage:node": "node scripts/run-vitest.mjs run --project=node --coverage", "test:coverage:ui": "node scripts/run-vitest.mjs run --project=jsdom --coverage", - "test:ci-workflows": "node scripts/run-vitest.mjs run tests/ci-cache-safety.test.ts tests/branch-review-index.test.ts tests/authenticated-live-workflow.test.ts tests/browser-test-plan.test.ts tests/chain-mirror-parity.test.ts tests/codex-autofix-workflow.test.ts tests/codex-run-pr-operator-workflow.test.ts tests/eval-canary-workflow.test.ts tests/live-drift-workflow.test.ts tests/live-domain-monitor-workflow.test.ts tests/ops-digest.test.ts tests/container-ci-contract.test.ts tests/test-runner-safety.test.ts tests/installed-lock-parity.test.ts tests/railway-config.test.ts tests/ingestion-autopilot.test.ts tests/ingestion-autopilot-workflow.test.ts tests/reindex-reaper-workflow.test.ts tests/check-lighthouse-budget.test.ts tests/live-web-vitals-inputs.test.ts tests/offline-release-profile.test.ts tests/bundle-budget-refresh-workflow.test.ts", + "test:ci-workflows": "node scripts/run-vitest.mjs run tests/ci-cache-safety.test.ts tests/ci-audit-contracts.test.ts tests/branch-review-index.test.ts tests/authenticated-live-workflow.test.ts tests/browser-test-plan.test.ts tests/chain-mirror-parity.test.ts tests/codex-autofix-workflow.test.ts tests/codex-run-pr-operator-workflow.test.ts tests/eval-canary-workflow.test.ts tests/live-drift-workflow.test.ts tests/live-domain-monitor-workflow.test.ts tests/ops-digest.test.ts tests/container-ci-contract.test.ts tests/test-runner-safety.test.ts tests/installed-lock-parity.test.ts tests/railway-config.test.ts tests/ingestion-autopilot.test.ts tests/ingestion-autopilot-workflow.test.ts tests/reindex-reaper-workflow.test.ts tests/check-lighthouse-budget.test.ts tests/live-web-vitals-inputs.test.ts tests/offline-release-profile.test.ts tests/bundle-budget-refresh-workflow.test.ts", "test:cc-guards": "node scripts/run-vitest.mjs run --reporter=dot tests/caring-contacts-plan-draft.dom.test.tsx tests/caring-contacts-plan-patient-detail.test.ts tests/caring-contacts-plan-activation.test.ts tests/caring-contacts-plan-wizard.dom.test.tsx tests/caring-contacts-schedule.test.ts tests/caring-contacts-schedule-view.test.ts tests/caring-contacts-schedule-route.test.ts tests/caring-contacts-schedule-screen.dom.test.tsx tests/caring-contacts-schedule-page.dom.test.tsx tests/caring-contacts-clock.test.ts tests/caring-contacts-new-plan-page.dom.test.tsx tests/caring-contacts-explained-automation.dom.test.tsx tests/caring-contacts-workspace-shell.dom.test.tsx tests/caring-contacts-patients-directory.dom.test.tsx tests/caring-contacts-patient-overview.dom.test.tsx tests/caring-contacts-patients-page.dom.test.tsx tests/caring-contacts-domain-isolation.test.ts tests/caring-contacts-interface-vocabulary.test.ts tests/caring-contacts-retention.test.ts tests/caring-contacts-repository.test.ts tests/caring-contacts-overlay-definitions.test.ts tests/caring-contacts-overlay-trigger-inventory.test.ts tests/caring-contacts-workspace-screens.test.ts tests/route-reachability.test.ts tests/design-system-adoption.test.ts tests/caring-contacts-contact-time-adjustment.dom.test.tsx tests/caring-contacts-contact-route.test.ts tests/caring-contacts-overlay-trigger.dom.test.tsx tests/caring-contacts-overlay-host.dom.test.tsx tests/source-control-bytes.test.ts tests/caring-contacts-demo-seed.test.ts tests/caring-contacts-pathway-versions.test.ts tests/caring-contacts-templates-library.dom.test.tsx tests/caring-contacts-templates-page.dom.test.tsx tests/caring-contacts-template-detail.dom.test.tsx tests/caring-contacts-template-detail-page.dom.test.tsx tests/caring-contacts-reporting.test.ts tests/caring-contacts-guidance-reports-pages.dom.test.tsx tests/caring-contacts-team-workload.test.ts tests/caring-contacts-team-route.test.ts tests/caring-contacts-team-roster.dom.test.tsx tests/caring-contacts-team-page.dom.test.tsx", "test:e2e": "node scripts/run-playwright.mjs", "test:e2e:all": "node scripts/run-playwright.mjs", @@ -339,6 +339,8 @@ "brace-expansion@1": "^1.1.18", "brace-expansion@2": "^2.1.4", "brace-expansion@5": "^5.0.9", + "fast-uri": "^3.1.7", + "@xmldom/xmldom": "^0.8.15", "exceljs": { "archiver": "^8.0.0", "unzipper": "^0.12.5" @@ -375,7 +377,8 @@ "vitest": "^4.1.10" }, "allowScripts": { - "esbuild@0.28.1": true, + "esbuild@0.28.2": true, + "@sentry/cli@2.58.6": true, "sharp@0.35.3": true, "unrs-resolver@1.12.2": true, "@eslint/eslintrc@3.3.6": true, diff --git a/railway.app.json b/railway.app.json index 4c2367b9f..4a299f959 100644 --- a/railway.app.json +++ b/railway.app.json @@ -18,6 +18,7 @@ "/public/**", "/src/**", "/scripts/check-client-bundle-secrets.mjs", + "/scripts/check-installed-lock-parity.mjs", "/scripts/check-node-engine.cjs", "/scripts/check-upload-limit-parity.mjs", "/scripts/guard-next-build.mjs", diff --git a/railway.worker.json b/railway.worker.json index c4263639e..1634d3a6c 100644 --- a/railway.worker.json +++ b/railway.worker.json @@ -18,6 +18,7 @@ "/worker/**", "/eval/docling/requirements.txt", "/scripts/build-worker.mjs", + "/scripts/check-installed-lock-parity.mjs", "/scripts/check-node-engine.cjs", "/scripts/enable-server-only-stub.mjs", "/scripts/install-git-hooks.mjs", diff --git a/tests/ci-audit-contracts.test.ts b/tests/ci-audit-contracts.test.ts new file mode 100644 index 000000000..5946596f0 --- /dev/null +++ b/tests/ci-audit-contracts.test.ts @@ -0,0 +1,347 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +// Contracts pinned by the 2026-09-02 repository audit (package p8a: CI +// workflows and supply chain). Each block names the finding it closes so a +// later edit that reopens the gap fails with the audit's own reasoning. + +const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); +const exists = (path: string) => existsSync(new URL(`../${path}`, import.meta.url)); + +/** The `- package-ecosystem: ""` blocks of dependabot.yml, keyed by ecosystem + directory. */ +function dependabotEntries(source: string) { + const entries: Array<{ ecosystem: string; directory: string; body: string }> = []; + const blocks = source.split(/\n(?= - package-ecosystem:)/); + for (const block of blocks) { + const ecosystem = block.match(/package-ecosystem:\s*"([^"]+)"/)?.[1]; + const directory = block.match(/directory:\s*"([^"]+)"/)?.[1]; + if (ecosystem && directory) entries.push({ ecosystem, directory, body: block }); + } + return entries; +} + +describe("M20: the worker's Python parsing stack has a vulnerability signal", () => { + const dependabot = read(".github/dependabot.yml"); + const entries = dependabotEntries(dependabot); + + it.each(["/worker/python", "/eval/docling"])("Dependabot watches the pip lock in %s", (directory) => { + const entry = entries.find((candidate) => candidate.ecosystem === "pip" && candidate.directory === directory); + expect(entry, `no pip ecosystem entry for ${directory}`).toBeDefined(); + // Both locks are hash-pinned by pip-compile; the entry must say how the + // hashes are regenerated so a bump is not merged with a stale lock. + expect(entry!.body).toMatch(/generate:worker-python-lock|check:worker-python-locks/); + }); + + it("keeps a hashed requirements lock at every pip directory Dependabot watches", () => { + for (const entry of entries.filter((candidate) => candidate.ecosystem === "pip")) { + const lock = `${entry.directory.replace(/^\//, "")}/requirements.txt`; + expect(exists(lock), `${lock} missing`).toBe(true); + expect(read(lock)).toContain("--hash=sha256:"); + } + }); + + it("reports HIGH/CRITICAL image findings where a person sees them", () => { + const workflow = read(".github/workflows/docker-image.yml"); + const scanIndex = workflow.indexOf("Vulnerability scan (HIGH,CRITICAL)"); + expect(scanIndex).toBeGreaterThan(-1); + const afterScan = workflow.slice(scanIndex); + // The scan output is kept, summarised into the job summary, and the + // follow-up step exits non-zero on HIGH/CRITICAL outside pull-request + // runs so the scheduled/main run fails and notify-ci-failure.yml (which + // already watches "Docker image build") delivers it to chat. + expect(afterScan).toContain("GITHUB_STEP_SUMMARY"); + expect(afterScan).toMatch(/Fail on HIGH\/CRITICAL image findings/); + expect(afterScan).toMatch(/github\.event_name != 'pull_request'/); + expect(afterScan).toMatch(/github\.event_name != 'merge_group'/); + expect(afterScan).toMatch(/exit 1/); + }); +}); + +describe("M25: the daily staging tenancy harness has a failure reporting path", () => { + it("is watched by notify-ci-failure.yml under its exact workflow name", () => { + const name = read(".github/workflows/staging-tenancy.yml") + .match(/^name:\s*(.+)$/m)?.[1] + ?.trim(); + expect(name).toBe("Staging tenancy isolation"); + const notify = read(".github/workflows/notify-ci-failure.yml"); + const watched = notify.match(/workflows:\n((?:\s+(?:- |#).*\n)+)/)?.[1] ?? ""; + const names = watched + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("- ")) + .map((line) => line.slice(2).trim()); + expect(names).toContain(name); + }); +}); + +describe("M29: the live Web-Vitals default routes can produce a verdict", () => { + const workflow = read(".github/workflows/live-web-vitals.yml"); + const defaultRoutes = workflow.match(/^\s+default:\s*"((?:\/[^"]*)?)"\s*$/m)?.[1]; + + it("declares a default route list", () => { + expect(defaultRoutes).toBeDefined(); + expect(defaultRoutes!.split(",").length).toBeGreaterThan(0); + }); + + // The summariser deliberately rejects a report whose final URL differs from + // the requested URL, so a default entry that server-redirects (the retired + // `/dsm`, `/forms`, `/therapy-compass` mode homes since #2157/#2308) can + // never be graded. Every default route must therefore resolve to a page.tsx + // that renders in place. `/` is the one route whose `redirect(` is guarded + // by legacy query parameters (docs/site-map.md: "Main PsychSift shell"), so + // its plain GET renders and it is exempt from the redirect check. + it.each((defaultRoutes ?? "").split(","))("default route %s renders in place rather than redirecting", (route) => { + const segment = route === "/" ? "" : route.replace(/^\//, ""); + const candidates = [`src/app/(search-app)/${segment}/page.tsx`, `src/app/${segment}/page.tsx`].map((path) => + path.replace("//", "/"), + ); + const page = candidates.find((candidate) => exists(candidate)); + expect(page, `no page.tsx for ${route} (tried ${candidates.join(", ")})`).toBeDefined(); + if (route !== "/") { + expect(read(page!)).not.toMatch(/\bredirect\(/); + } + }); + + it("keeps the summariser's test fixture in step with the workflow default", () => { + const fixture = read("tests/summarise-web-vitals.test.ts").match(/const DEFAULT_ROUTES = "([^"]+)"/)?.[1]; + expect(fixture).toBe(defaultRoutes); + }); + + it("no longer instructs recording the verdict against the closed #017 row", () => { + expect(workflow).not.toMatch(/Record the verdict in docs\/outstanding-issues\.md against #017/); + }); +}); + +describe("L36: the advisory SAST workflow runs the same immutable Semgrep image as the blocking gate", () => { + it("pins sast.yml's container to the digest ci.yml's ingestion gate uses", () => { + const gateDigest = read(".github/workflows/ci.yml").match(/image: semgrep\/semgrep@(sha256:[0-9a-f]{64})/)?.[1]; + expect(gateDigest).toBeDefined(); + const advisory = read(".github/workflows/sast.yml").match(/^\s+image:\s*(\S+)\s*$/m)?.[1]; + expect(advisory).toBeDefined(); + // `tag@digest` keeps the human-readable version while the digest wins at + // pull time; a bare mutable tag can be re-pushed upstream. + expect(advisory).toMatch(/^semgrep\/semgrep(?::[0-9.]+)?@sha256:[0-9a-f]{64}$/); + expect(advisory!.split("@")[1]).toBe(gateDigest); + }); +}); + +describe("L37: the Secret Scan workflow holds only the permission it uses", () => { + it("grants contents: read and nothing else", () => { + const workflow = read(".github/workflows/secret-scan.yml"); + const block = workflow.match(/^permissions:\n((?: \S.*\n)+)/m)?.[1] ?? ""; + const grants = block + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + // scripts/run-gitleaks-pinned.mjs never uploads SARIF or reads the PR API; + // the wider grant was a leftover from gitleaks-action@v3. + expect(grants).toEqual(["contents: read"]); + expect(read("scripts/run-gitleaks-pinned.mjs")).not.toMatch(/--report-format|sarif/i); + }); +}); + +describe("L38: the @claude workflows enforce the collaborator boundary they describe", () => { + const trusted = `contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)`; + const trustedReview = `contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.review.author_association)`; + + it.each([".github/workflows/claude.yml", ".github/workflows/claude-backlink.yml"])( + "%s admits only owner, member or collaborator authors", + (path) => { + const workflow = read(path); + const condition = workflow.match(/^ if: >\n((?: .*\n)+)/m)?.[1] ?? ""; + expect(condition).toContain("issue_comment"); + // Every trigger arm carries its own association gate, so a read-only or + // outside account that can comment cannot start a run that holds + // write/id-token permissions. + const arms = condition.split(/\)\s*\|\|\s*\n/); + expect(arms.length).toBeGreaterThanOrEqual(3); + for (const arm of arms) { + expect(arm, `arm without an author_association gate:\n${arm}`).toMatch( + arm.includes("pull_request_review'") && !arm.includes("review_comment") + ? new RegExp(trustedReview.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + : new RegExp(trusted.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), + ); + } + }, + ); + + it("describes the enforced gate in the claude.yml header", () => { + expect(read(".github/workflows/claude.yml")).toMatch(/owner, member or collaborator/i); + }); +}); + +describe("L54: every script a Dockerfile copies is a Railway watch pattern for that service", () => { + const copiedScripts = (dockerfile: string) => + Array.from(new Set(Array.from(read(dockerfile).matchAll(/^COPY (scripts\/[\w.-]+) /gm), (match) => match[1]))); + + it.each([ + ["Dockerfile", "railway.app.json"], + ["Dockerfile.worker", "railway.worker.json"], + ])("%s scripts are watched by %s", (dockerfile, railwayConfig) => { + const watch = (JSON.parse(read(railwayConfig)) as { build: { watchPatterns: string[] } }).build.watchPatterns; + const scripts = copiedScripts(dockerfile); + expect(scripts.length).toBeGreaterThan(0); + // A change to an image-build-time script alone must rebuild the image, + // otherwise the deployed image runs a script version main no longer has. + for (const script of scripts) { + expect(watch, `${railwayConfig} does not watch ${script}`).toContain(`/${script}`); + } + }); +}); + +describe("L55: allowScripts describes the install scripts the lock actually runs", () => { + type LockPackage = { version?: string; hasInstallScript?: boolean; optional?: boolean }; + const lock = JSON.parse(read("package-lock.json")) as { packages: Record }; + const manifest = JSON.parse(read("package.json")) as { allowScripts?: Record }; + const allowScripts = manifest.allowScripts ?? {}; + const lockVersions = new Map>(); + for (const [path, entry] of Object.entries(lock.packages)) { + const name = path.replace(/^.*node_modules\//, ""); + if (!path || !entry.version) continue; + if (!lockVersions.has(name)) lockVersions.set(name, new Set()); + lockVersions.get(name)!.add(entry.version); + } + + it("names only name@version pairs that exist in package-lock.json", () => { + for (const key of Object.keys(allowScripts)) { + const at = key.lastIndexOf("@"); + const name = key.slice(0, at); + const version = key.slice(at + 1); + expect(lockVersions.get(name), `${key}: ${name} is not in the lock`).toBeDefined(); + expect(Array.from(lockVersions.get(name)!), `${key}: lock has a different version`).toContain(version); + } + }); + + it("covers every non-optional package whose install script npm will run", () => { + // Optional packages are platform-gated (fsevents on darwin) and never + // install on the Linux CI and Railway paths this policy protects. + const uncovered = Object.entries(lock.packages) + .filter(([path, entry]) => path !== "" && entry.hasInstallScript && !entry.optional) + .map(([path, entry]) => `${path.replace(/^.*node_modules\//, "")}@${entry.version}`) + .filter((key) => allowScripts[key] !== true); + expect(uncovered).toEqual([]); + }); +}); + +describe("L91: every CODEOWNERS pattern matches something in the tree", () => { + const patterns = read(".github/CODEOWNERS") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")) + .map((line) => line.split(/\s+/)[0]) + .filter((pattern) => pattern !== "*"); + + function matches(pattern: string) { + const relative = pattern.replace(/^\//, ""); + if (relative.endsWith("/")) return existsSync(new URL(`../${relative}`, import.meta.url)); + if (!relative.includes("*")) return exists(relative); + const slash = relative.lastIndexOf("/"); + const dir = relative.slice(0, slash); + const glob = new RegExp( + `^${relative + .slice(slash + 1) + .replace(/[.]/g, "\\.") + .replace(/\*/g, ".*")}$`, + ); + if (!existsSync(new URL(`../${dir}`, import.meta.url))) return false; + return readdirSync(new URL(`../${dir}/`, import.meta.url)).some((name) => glob.test(name)); + } + + it.each(patterns)("%s names an existing surface", (pattern) => { + expect(matches(pattern), `${pattern} matches no file — review routing would silently fall to the catch-all`).toBe( + true, + ); + }); + + it("routes review on the RAG directory, not the pre-#994 flat files", () => { + expect(patterns).toContain("/src/lib/rag/"); + expect(patterns).not.toContain("/src/lib/rag.ts"); + expect(patterns).not.toContain("/src/lib/rag-*.ts"); + }); +}); + +describe("L92: the Codex auto-resolve high-risk deployment-file list names files that exist", () => { + const workflow = read(".github/workflows/codex-autofix-review-comments.yml"); + const line = workflow.split("\n").find((candidate) => /Dockerfile/.test(candidate) && /railway/.test(candidate)); + const source = line?.match(/\/(\^.*\$)\/,?\s*$/)?.[1]; + const pattern = source ? new RegExp(source) : null; + + it("declares a deployment-file pattern", () => { + expect(pattern).not.toBeNull(); + }); + + it.each(["Dockerfile", "Dockerfile.worker", "railway.app.json", "railway.worker.json"])( + "classifies %s as high risk", + (file) => { + expect(exists(file)).toBe(true); + expect(pattern!.test(file)).toBe(true); + }, + ); + + it.each(["railway.json", "nixpacks.toml"])("does not keep naming the absent %s", (file) => { + expect(exists(file)).toBe(false); + expect(source).not.toContain(file.replace(".", "\\.")); + }); +}); + +describe("L129: every override in package.json matches a lock entry and has a recorded rationale", () => { + type LockPackage = { version?: string }; + const lock = JSON.parse(read("package-lock.json")) as { packages: Record }; + const overrides = (JSON.parse(read("package.json")) as { overrides: Record }).overrides; + const majorsOf = (name: string) => + new Set( + Object.entries(lock.packages) + .filter(([path]) => path === `node_modules/${name}` || path.endsWith(`/node_modules/${name}`)) + .map(([, entry]) => entry.version?.split(".")[0]), + ); + const rationaleSection = () => { + const doc = read("docs/framework-dependency-modernization-checklist.md"); + return doc.slice(doc.indexOf("## Overrides")); + }; + // An override may deliberately outlive its lock match: a defensive pre-pin keeps a + // major on a patched release for the day a transitive bump reintroduces it. Deleting + // one silently drops that protection, so a pre-pin is exempt from the dead-override + // rule only while its row in the rationale table is marked `pre-pin` and says why. + const prePinned = () => + new Set([...rationaleSection().matchAll(/^\|\s*`([^`]+)`[^|]*\|\s*pre-pin\s*\|/gm)].map((match) => match[1])); + + it("keeps no major-scoped override that matches nothing in the lock and is not a recorded pre-pin", () => { + const exempt = prePinned(); + const dead = Object.keys(overrides) + .filter((key) => /^[^@].*@\d+$/.test(key)) + .filter((key) => !exempt.has(key)) + .filter((key) => { + const [name, major] = key.split("@"); + return !majorsOf(name).has(major); + }); + expect(dead).toEqual([]); + }); + + it("lets a pre-pin claim the exemption only for an override that actually exists", () => { + const stray = [...prePinned()].filter((key) => !(key in overrides)); + expect(stray, "a pre-pin row names an override package.json no longer carries").toEqual([]); + }); + + it("records why each override exists in the dependency checklist", () => { + const doc = read("docs/framework-dependency-modernization-checklist.md"); + const section = doc.slice(doc.indexOf("## Overrides")); + expect(section.length).toBeGreaterThan(0); + for (const key of Object.keys(overrides)) { + const name = key.replace(/@\d+$/, ""); + const recorded = section.includes(`\`${key}\``) || section.includes(`\`${name}\``); + expect(recorded, `no rationale recorded for override ${key}`).toBe(true); + } + }); +}); + +describe("L20: Dependabot cannot move @types/node across a major on its own", () => { + it("ignores semver-major updates for @types/node in the npm ecosystem", () => { + const npm = dependabotEntries(read(".github/dependabot.yml")).find((entry) => entry.ecosystem === "npm"); + expect(npm).toBeDefined(); + const ignore = npm!.body.slice(npm!.body.indexOf("ignore:")); + // The types line must track engines.node (24.x); a major bump from + // Dependabot #549 is how the current 26.x mismatch arrived. + expect(ignore).toMatch(/dependency-name:\s*"@types\/node"\n\s+update-types:\s*\["version-update:semver-major"\]/); + }); +}); diff --git a/tests/summarise-web-vitals.test.ts b/tests/summarise-web-vitals.test.ts index a661255fe..5a36a6559 100644 --- a/tests/summarise-web-vitals.test.ts +++ b/tests/summarise-web-vitals.test.ts @@ -24,17 +24,20 @@ import { summariseReport, } from "../scripts/summarise-web-vitals.mjs"; -// Kept in step with the workflow's `routes` dispatch default. Every entry must -// be a real page route in docs/site-map.md: a bare `/documents` has no -// `page.tsx`, so measuring it would have profiled the 404 document. -const DEFAULT_ROUTES = "/,/therapy-compass,/documents/search,/dsm,/forms"; +// Kept in step with the workflow's `routes` dispatch default (guarded by +// tests/ci-audit-contracts.test.ts). Every entry must be a real page route in +// docs/site-map.md that renders in place: a bare `/documents` has no +// `page.tsx`, so measuring it would have profiled the 404 document, and the +// bare `/dsm`, `/forms` and `/therapy-compass` mode homes now 307-redirect, so +// `measuredRequestedPage` would reject every one of their reports. +const DEFAULT_ROUTES = "/,/therapy-compass/search,/documents/search,/dsm/search,/forms/search"; /** All `WEB_VITALS_SAMPLES` reports for one cell, every sample identical. */ function cell(name: string, lcpMs: number | null, cls: number | null) { return Array.from({ length: WEB_VITALS_SAMPLES }, (_, index) => row(`${name}-${index + 1}`, lcpMs, cls)); } -/** The report names a cell expands to: "mobile-dsm" -> mobile-dsm-1..3. */ +/** The report names a cell expands to: "mobile-dsm-search" -> mobile-dsm-1..3. */ function samplesOf(...names: string[]) { return names.flatMap((name) => Array.from({ length: WEB_VITALS_SAMPLES }, (_, i) => `${name}-${i + 1}`)); } @@ -74,10 +77,10 @@ describe("expectedMobileCells", () => { it("derives one mobile run per requested route", () => { expect(expectedMobileCells(DEFAULT_ROUTES)).toEqual([ "mobile-root", - "mobile-therapy-compass", + "mobile-therapy-compass-search", "mobile-documents-search", - "mobile-dsm", - "mobile-forms", + "mobile-dsm-search", + "mobile-forms-search", ]); }); }); @@ -90,17 +93,17 @@ describe("mobileBreaches", () => { it("treats a missing metric as a breach", () => { const rows = expectedMobileCells(DEFAULT_ROUTES).map((run) => - cellOf(run) === "mobile-dsm" ? row(run, null, 0.01) : row(run, 1200, 0.01), + cellOf(run) === "mobile-dsm-search" ? row(run, null, 0.01) : row(run, 1200, 0.01), ); const breaches = mobileBreaches(rows, DEFAULT_ROUTES); - expect(breaches.map((breach) => breach.run)).toEqual(["mobile-dsm"]); + expect(breaches.map((breach) => breach.run)).toEqual(["mobile-dsm-search"]); }); it("treats an over-threshold metric as a breach", () => { const rows = expectedMobileCells(DEFAULT_ROUTES).map((run) => - cellOf(run) === "mobile-forms" ? row(run, WEB_VITALS_THRESHOLDS.lcpMs + 1, 0.01) : row(run, 1200, 0.01), + cellOf(run) === "mobile-forms-search" ? row(run, WEB_VITALS_THRESHOLDS.lcpMs + 1, 0.01) : row(run, 1200, 0.01), ); - expect(mobileBreaches(rows, DEFAULT_ROUTES).map((breach) => breach.run)).toEqual(["mobile-forms"]); + expect(mobileBreaches(rows, DEFAULT_ROUTES).map((breach) => breach.run)).toEqual(["mobile-forms-search"]); }); // The regression this file exists for: the workflow downgrades a per-route @@ -138,32 +141,38 @@ describe("incompleteEvidence", () => { const mobileOnly = expectedMobileCells(DEFAULT_ROUTES).flatMap((name) => cell(name, 1200, 0.01)); expect(mobileBreaches(mobileOnly, DEFAULT_ROUTES)).toEqual([]); // thresholds all pass expect(missingRuns(mobileOnly, DEFAULT_ROUTES)).toEqual( - samplesOf("desktop-root", "desktop-therapy-compass", "desktop-documents-search", "desktop-dsm", "desktop-forms"), + samplesOf( + "desktop-root", + "desktop-therapy-compass-search", + "desktop-documents-search", + "desktop-dsm-search", + "desktop-forms-search", + ), ); expect(incompleteEvidence(mobileOnly, DEFAULT_ROUTES)).toHaveLength(5 * WEB_VITALS_SAMPLES); expect(renderTable(mobileOnly, DEFAULT_ROUTES)).toContain("NOT an #017 verdict"); }); it("treats a present report with no LCP/CLS number as incomplete, not merely a breach", () => { - const rows = allRuns().map((r) => (cellOf(r.run) === "mobile-dsm" ? { ...r, lcpMs: null } : r)); - expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual(samplesOf("mobile-dsm")); + const rows = allRuns().map((r) => (cellOf(r.run) === "mobile-dsm-search" ? { ...r, lcpMs: null } : r)); + expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual(samplesOf("mobile-dsm-search")); }); it("does not treat an over-threshold measurement as incomplete evidence", () => { const rows = allRuns().map((r) => - cellOf(r.run) === "mobile-forms" ? { ...r, lcpMs: WEB_VITALS_THRESHOLDS.lcpMs + 1 } : r, + cellOf(r.run) === "mobile-forms-search" ? { ...r, lcpMs: WEB_VITALS_THRESHOLDS.lcpMs + 1 } : r, ); - expect(mobileBreaches(rows, DEFAULT_ROUTES).map((b) => b.run)).toEqual(["mobile-forms"]); + expect(mobileBreaches(rows, DEFAULT_ROUTES).map((b) => b.run)).toEqual(["mobile-forms-search"]); expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual([]); }); // Completeness is a property of the whole matrix. Checking metric validity // only on mobile left a desktop report with null metrics reading as evidence. it("rejects a desktop report that exists but carries no LCP/CLS number", () => { - const rows = allRuns().map((r) => (cellOf(r.run) === "desktop-forms" ? { ...r, cls: null } : r)); + const rows = allRuns().map((r) => (cellOf(r.run) === "desktop-forms-search" ? { ...r, cls: null } : r)); expect(mobileBreaches(rows, DEFAULT_ROUTES)).toEqual([]); // mobile verdict is clean expect(missingRuns(rows, DEFAULT_ROUTES)).toEqual([]); // the file is present - expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual(samplesOf("desktop-forms")); + expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual(samplesOf("desktop-forms-search")); }); // `/a/b` and `/a-b` both slug to `a-b`, so the second Lighthouse run @@ -187,16 +196,18 @@ describe("incompleteEvidence", () => { // filename cannot reveal it. it("rejects a run that redirected to a different same-origin path", () => { const rows = allRuns().map((r) => - cellOf(r.run) === "mobile-dsm" ? { ...r, url: "https://psychiatry.tools/login" } : r, + cellOf(r.run) === "mobile-dsm-search" ? { ...r, url: "https://psychiatry.tools/login" } : r, ); expect(isProductionVerdict(rows)).toBe(true); // origin is still canonical - expect(measuredRequestedPage(rows.find((r) => cellOf(r.run) === "mobile-dsm"))).toBe(false); - expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual(samplesOf("mobile-dsm")); + expect(measuredRequestedPage(rows.find((r) => cellOf(r.run) === "mobile-dsm-search"))).toBe(false); + expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual(samplesOf("mobile-dsm-search")); }); it("rejects a run that reported a Lighthouse runtime error", () => { - const rows = allRuns().map((r) => (cellOf(r.run) === "desktop-forms" ? { ...r, runtimeError: "NO_FCP" } : r)); - expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual(samplesOf("desktop-forms")); + const rows = allRuns().map((r) => + cellOf(r.run) === "desktop-forms-search" ? { ...r, runtimeError: "NO_FCP" } : r, + ); + expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual(samplesOf("desktop-forms-search")); }); // /documents/search?q=depression and /documents/search are different pages to @@ -213,11 +224,11 @@ describe("incompleteEvidence", () => { it("accepts the same query with params in a different order", () => { const rows = allRuns().map((r) => - cellOf(r.run) === "mobile-dsm" + cellOf(r.run) === "mobile-dsm-search" ? { ...r, - requestedUrl: "https://psychiatry.tools/dsm?a=1&b=2", - url: "https://psychiatry.tools/dsm?b=2&a=1", + requestedUrl: "https://psychiatry.tools/dsm/search?a=1&b=2", + url: "https://psychiatry.tools/dsm/search?b=2&a=1", } : r, ); @@ -228,29 +239,31 @@ describe("incompleteEvidence", () => { // separators with a multi-param query. Encode the components so a rewrite // from one page to another cannot pass as the requested route. it("rejects a rewrite that splits an encoded separator into extra params", () => { - const requested = "https://psychiatry.tools/forms?q=alpha%26run%3D1"; - const rewritten = "https://psychiatry.tools/forms?q=alpha&run=1"; + const requested = "https://psychiatry.tools/forms/search?q=alpha%26run%3D1"; + const rewritten = "https://psychiatry.tools/forms/search?q=alpha&run=1"; expect( measuredRequestedPage({ - ...row("mobile-forms", 1200, 0.01), + ...row("mobile-forms-search", 1200, 0.01), requestedUrl: requested, url: rewritten, }), ).toBe(false); const rows = allRuns().map((r) => - cellOf(r.run) === "mobile-forms" ? { ...r, requestedUrl: requested, url: rewritten } : r, + cellOf(r.run) === "mobile-forms-search" ? { ...r, requestedUrl: requested, url: rewritten } : r, ); - expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual(samplesOf("mobile-forms")); + expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual(samplesOf("mobile-forms-search")); }); it("accepts the same encoded query value on both requested and final URLs", () => { - const url = "https://psychiatry.tools/forms?q=alpha%26run%3D1"; - const rows = allRuns().map((r) => (cellOf(r.run) === "mobile-forms" ? { ...r, requestedUrl: url, url } : r)); + const url = "https://psychiatry.tools/forms/search?q=alpha%26run%3D1"; + const rows = allRuns().map((r) => (cellOf(r.run) === "mobile-forms-search" ? { ...r, requestedUrl: url, url } : r)); expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual([]); }); it("tolerates a trailing-slash difference between requested and final URL", () => { - const rows = allRuns().map((r) => (cellOf(r.run) === "mobile-forms" ? { ...r, url: `${r.requestedUrl}/` } : r)); + const rows = allRuns().map((r) => + cellOf(r.run) === "mobile-forms-search" ? { ...r, url: `${r.requestedUrl}/` } : r, + ); expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual([]); }); }); @@ -323,17 +336,17 @@ describe("verdict prose is gated on the run being able to produce a verdict", () it("suppresses the threshold claim when a run measured the wrong page", () => { const rows = expectedRuns(DEFAULT_ROUTES).map((run) => - cellOf(run) === "mobile-dsm" + cellOf(run) === "mobile-dsm-search" ? { ...row(run, 1200, 0.01), url: "https://psychiatry.tools/login" } : row(run, 1200, 0.01), ); // The redirected run still carries passing numbers, so nothing breaches. expect(mobileBreaches(rows, DEFAULT_ROUTES)).toEqual([]); - expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual(samplesOf("mobile-dsm")); + expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual(samplesOf("mobile-dsm-search")); const table = renderTable(rows, DEFAULT_ROUTES); expect(table).toContain("NOT an #017 verdict"); - expect(table).toContain("mobile-dsm"); + expect(table).toContain("mobile-dsm-search"); // The two claims that must not survive a disqualified run. expect(table).not.toContain("Every mobile route is within"); expect(table).not.toContain("NOT yet an #017 closure"); @@ -342,7 +355,9 @@ describe("verdict prose is gated on the run being able to produce a verdict", () }); it("does not call a staging breach actionable", () => { - const rows = expectedRuns(DEFAULT_ROUTES).map((run) => staging(run, cellOf(run) === "mobile-forms" ? 3000 : 1200)); + const rows = expectedRuns(DEFAULT_ROUTES).map((run) => + staging(run, cellOf(run) === "mobile-forms-search" ? 3000 : 1200), + ); expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual([]); // evidence is complete… expect(isProductionVerdict(rows)).toBe(false); // …but not from production expect(mobileBreaches(rows, DEFAULT_ROUTES)).toHaveLength(1); @@ -352,7 +367,7 @@ describe("verdict prose is gated on the run being able to produce a verdict", () expect(table).toContain("https://staging.psychiatry.tools"); expect(table).not.toContain("become actionable, ranked by measured contribution"); // The breach is still visible, as a measurement rather than a verdict. - expect(table).toContain("mobile-forms"); + expect(table).toContain("mobile-forms-search"); expect(table).toContain("This is a measurement, not a verdict"); }); @@ -410,19 +425,19 @@ describe("sampling", () => { // 9000ms in one of three samples is a cold cache, not a regression. const rows = expectedCells(DEFAULT_ROUTES).flatMap((name) => - name === "mobile-dsm" ? samples(name, [1200, 9000, 1300]) : cell(name, 1200, 0.01), + name === "mobile-dsm-search" ? samples(name, [1200, 9000, 1300]) : cell(name, 1200, 0.01), ); expect(mobileBreaches(rows, DEFAULT_ROUTES)).toEqual([]); - expect(aggregateCells(rows).get("mobile-dsm")?.lcpMs).toBe(1300); + expect(aggregateCells(rows).get("mobile-dsm-search")?.lcpMs).toBe(1300); }); it("refuses to grade a cell whose samples straddle the threshold", () => { // 2400 / 2600 around a 2500ms line: the median says pass, but a rerun would // say breach. That is measured noise, not a verdict. const rows = expectedCells(DEFAULT_ROUTES).flatMap((name) => - name === "mobile-forms" ? samples(name, [2400, 2600, 2450]) : cell(name, 1200, 0.01), + name === "mobile-forms-search" ? samples(name, [2400, 2600, 2450]) : cell(name, 1200, 0.01), ); - const summary = aggregateCells(rows).get("mobile-forms"); + const summary = aggregateCells(rows).get("mobile-forms-search"); expect(summary?.lcpMs).toBe(2450); // median is under the line… expect(straddlesThreshold(summary)).toBe(true); // …but the spread crosses it @@ -430,7 +445,7 @@ describe("sampling", () => { expect(mobileBreaches(rows, DEFAULT_ROUTES)).toEqual([]); const incomplete = incompleteEvidence(rows, DEFAULT_ROUTES); expect(incomplete).toHaveLength(1); - expect(incomplete[0]).toContain("mobile-forms"); + expect(incomplete[0]).toContain("mobile-forms-search"); expect(incomplete[0]).toContain("too noisy to grade"); const table = renderTable(rows, DEFAULT_ROUTES); @@ -450,9 +465,9 @@ describe("sampling", () => { // Spread well clear of the line on one side is a stable measurement, and a // guard that fired here would make every run unverdictable. const rows = expectedCells(DEFAULT_ROUTES).flatMap((name) => - name === "mobile-dsm" ? samples(name, [1100, 1400, 1250]) : cell(name, 1200, 0.01), + name === "mobile-dsm-search" ? samples(name, [1100, 1400, 1250]) : cell(name, 1200, 0.01), ); - expect(straddlesThreshold(aggregateCells(rows).get("mobile-dsm"))).toBe(false); + expect(straddlesThreshold(aggregateCells(rows).get("mobile-dsm-search"))).toBe(false); expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual([]); expect(renderTable(rows, DEFAULT_ROUTES)).toContain("NOT yet an #017 closure"); }); @@ -460,30 +475,30 @@ describe("sampling", () => { it("still breaches when the whole spread is over the line", () => { // Consistently slow is a real, reportable verdict — not noise. const rows = expectedCells(DEFAULT_ROUTES).flatMap((name) => - name === "mobile-forms" ? samples(name, [3000, 3200, 3100]) : cell(name, 1200, 0.01), + name === "mobile-forms-search" ? samples(name, [3000, 3200, 3100]) : cell(name, 1200, 0.01), ); - expect(straddlesThreshold(aggregateCells(rows).get("mobile-forms"))).toBe(false); + expect(straddlesThreshold(aggregateCells(rows).get("mobile-forms-search"))).toBe(false); expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual([]); const breaches = mobileBreaches(rows, DEFAULT_ROUTES); - expect(breaches.map((breach) => breach.run)).toEqual(["mobile-forms"]); + expect(breaches.map((breach) => breach.run)).toEqual(["mobile-forms-search"]); expect(breaches[0]?.reason).toContain("3000"); }); it("treats a missing sample as incomplete even when the others are fine", () => { const rows = expectedRuns(DEFAULT_ROUTES) - .filter((run) => run !== "mobile-dsm-2") + .filter((run) => run !== "mobile-dsm-search-2") .map((run) => row(run, 1200, 0.01)); - expect(missingRuns(rows, DEFAULT_ROUTES)).toEqual(["mobile-dsm-2"]); - expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual(["mobile-dsm-2"]); + expect(missingRuns(rows, DEFAULT_ROUTES)).toEqual(["mobile-dsm-search-2"]); + expect(incompleteEvidence(rows, DEFAULT_ROUTES)).toEqual(["mobile-dsm-search-2"]); }); it("shows the median and the spread it came from", () => { const rows = expectedCells(DEFAULT_ROUTES).flatMap((name) => - name === "mobile-dsm" ? samples(name, [1100, 1400, 1250]) : cell(name, 1200, 0.01), + name === "mobile-dsm-search" ? samples(name, [1100, 1400, 1250]) : cell(name, 1200, 0.01), ); const table = renderTable(rows, DEFAULT_ROUTES); // One row per cell with its sample count, not one row per report. - expect(table).toContain("| mobile-dsm | 3/3 |"); + expect(table).toContain("| mobile-dsm-search | 3/3 |"); expect(table).toContain("1250"); expect(table).toContain("1100–1400"); }); @@ -499,7 +514,7 @@ describe("Chrome build drift", () => { it("disqualifies the verdict when the runner changed browser mid-run", () => { const clean = expectedCells(DEFAULT_ROUTES).flatMap((name) => cell(name, 1200, 0.01)); const rows = withBuild(clean, "Chrome/141").map((entry) => - entry.run === "mobile-dsm-3" ? { ...entry, chromeVersion: "Chrome/142" } : entry, + entry.run === "mobile-dsm-search-3" ? { ...entry, chromeVersion: "Chrome/142" } : entry, ); expect(chromeBuilds(rows)).toEqual(["Chrome/141", "Chrome/142"]); @@ -576,7 +591,7 @@ describe("a sample count too small to grade", () => { it("still renders the measurements, explicitly subordinated", () => { const rows = expectedCells(DEFAULT_ROUTES).map((name) => row(`${name}-1`, 1200, 0.01)); const table = renderTable(rows, DEFAULT_ROUTES, 1); - expect(table).toContain("mobile-dsm"); + expect(table).toContain("mobile-dsm-search"); expect(table).toContain("This is a measurement, not a verdict"); }); });