diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1f0103..34700eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,7 @@ jobs: - name: Install frontend dependencies working-directory: frontend - run: bun install --frozen-lockfile + run: bun install --frozen-lockfile --ignore-scripts - name: Licence header check continue-on-error: ${{ github.repository != 'hyperpolymath/MetaManifold-WebUI' }} @@ -173,7 +173,7 @@ jobs: # this resolves rather than merely requesting the newest. - name: Set up R run: | - wget -qO- https://cloud.r-project.org/bin/linux/ubuntu/marutter_pubkey.asc \ + wget --https-only -qO- https://cloud.r-project.org/bin/linux/ubuntu/marutter_pubkey.asc \ | sudo gpg --dearmor -o /usr/share/keyrings/r-project.gpg echo "deb [signed-by=/usr/share/keyrings/r-project.gpg] https://cloud.r-project.org/bin/linux/ubuntu $(lsb_release -cs)-cran40/" \ | sudo tee /etc/apt/sources.list.d/r-project.list @@ -200,12 +200,72 @@ jobs: # .Rprofile, and with it the renv autoloader, which would otherwise rebind # .Library to its own sandbox under root's cache; the flag is used rather than # the environment variable above because sudo resets the environment first. + + # renv keeps a content-addressed cache: a package already in it is linked into + # the library instead of being downloaded and compiled again. Persisting that + # cache across runs is what makes a failed restore RESUMABLE. Without it all 79 + # packages are rebuilt from source on every run -- measured at 10m08s and 13m21s + # on two consecutive runs, the largest single cost in this workflow -- and a + # restore that dies at package 60 throws away all 60. + # + # Reordering the packages is not an alternative: renv derives install order from + # the dependency graph, so a package cannot be pulled to the front of the queue + # ahead of the packages it links against. + # + # The cache path is pinned rather than left at the default ~/.cache/R/renv, + # because the restore runs under sudo where ~ is root's home, not the runner's. + - name: Restore the renv cache + id: renv-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: /opt/renv-cache + # The run id keeps every key unique so each attempt writes a NEW entry and + # progress accumulates; restore-keys then picks the most recent entry that + # matches the prefix. A changed renv.lock still falls through to the second + # key and re-uses every package whose version did not move. + key: renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}-${{ hashFiles('renv.lock') }}-${{ github.run_id }} + restore-keys: | + renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}-${{ hashFiles('renv.lock') }}- + renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}- + - name: Install R packages + env: + RENV_PATHS_CACHE: /opt/renv-cache + # renv draws its download counter by hiding the cursor (ESC[?25l), rewriting + # the line in place, then showing it again (ESC[?25h) -- the "25l25h" residue + # that litters the log. A captured CI log is not a terminal, so the rewrite + # never lands and the counter appears frozen at (0/79) for the whole ten + # minutes while the download is in fact progressing. Disabling cli's dynamic + # output makes each update print on its own line, so the log shows real + # progress and a genuine hang becomes distinguishable from a working step. + R_CLI_DYNAMIC: "false" + TERM: dumb run: | - sudo Rscript --no-init-file -e 'install.packages(c("renv", "BiocManager"), repos="https://cloud.r-project.org", lib=.Library)' - sudo Rscript --no-init-file -e 'renv::restore(project=".", library=.Library, prompt=FALSE)' + sudo mkdir -p "$RENV_PATHS_CACHE" + sudo chmod 777 "$RENV_PATHS_CACHE" + sudo env R_CLI_DYNAMIC=false TERM=dumb Rscript --no-init-file -e 'install.packages(c("renv", "BiocManager"), repos="https://cloud.r-project.org", lib=.Library)' + # `sudo env VAR=...` rather than `sudo -E` or a bare VAR=value prefix: it sets + # the variable for Rscript directly and so does not depend on the runner's + # sudoers env_reset policy, which the comment above already notes resets it. + sudo env RENV_PATHS_CACHE="$RENV_PATHS_CACHE" R_CLI_DYNAMIC=false TERM=dumb Rscript --no-init-file -e 'renv::restore(project=".", library=.Library, prompt=FALSE)' Rscript --no-init-file -e 'for (pkg in c("dada2","Biostrings","ShortRead","vegan","dplyr")) if (!require(pkg,character.only=TRUE,quietly=TRUE)) stop(pkg, " failed to install")' + # Both of the next two steps run on failure ON PURPOSE. actions/cache saves only + # when the job succeeds, which would discard exactly the partial progress this + # cache exists to preserve: the packages that DID build before the restore died + # are the ones the next attempt must not build again. The cache is written by + # root, so it is made readable before it is packed. + - name: Make the renv cache readable + if: always() + run: sudo chmod -R a+rX /opt/renv-cache || true + + - name: Save the renv cache + if: always() + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: /opt/renv-cache + key: renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}-${{ hashFiles('renv.lock') }}-${{ github.run_id }} + - name: Install cutadapt run: pip install "$CUTADAPT_SPEC" @@ -252,7 +312,7 @@ jobs: - name: Install frontend dependencies working-directory: frontend - run: bun install --frozen-lockfile + run: bun install --frozen-lockfile --ignore-scripts # Explicit strict-typecheck gate (strict foundation; see # docs/types/strict-mode-status.md). Runs before the (heavier) build so @@ -485,7 +545,7 @@ jobs: git clone --quiet https://github.com/hyperpolymath/cicd-squabbler.git /tmp/squabbler git -C /tmp/squabbler checkout --quiet "$SQUABBLER_SHA" echo "building cicd-squabbler at $(git -C /tmp/squabbler rev-parse HEAD)" - cargo build --release --quiet --manifest-path /tmp/squabbler/Cargo.toml -p squabble-cli + cargo build --release --locked --quiet --manifest-path /tmp/squabbler/Cargo.toml -p squabble-cli "$SQUABBLE" --version # Proves the engine itself works before we trust its verdict on this repo. diff --git a/R/_renv_dependencies.R b/R/_renv_dependencies.R index ba70041..f16c04a 100644 --- a/R/_renv_dependencies.R +++ b/R/_renv_dependencies.R @@ -1,7 +1,19 @@ # SPDX-License-Identifier: AGPL-3.0-only # Renv dependency discovery file. - -if (FALSE) { +# +# renv finds dependencies by parsing every .R file and collecting the +# library()/require() calls it sees, so these four names must appear literally +# in a file that is never actually executed for its effect. +# +# The conventional idiom for that is `if (FALSE) { ... }`, but a condition that +# is a constant is dead code to every static analyser (SonarCloud rdre:S1145), +# and silencing the rule would be hiding a true observation. A function that is +# defined and never called says the same thing without the dead branch: renv +# parses the whole file either way. +# +# Verified 2026-09-21 with renv::dependencies() against both forms: each returns +# exactly dada2, dplyr, tibble, vegan. +.renv_dependencies <- function() { library(dada2) library(vegan) library(dplyr) diff --git a/frontend/bench/index.ts b/frontend/bench/index.ts index 20fe18d..cd95dc3 100644 --- a/frontend/bench/index.ts +++ b/frontend/bench/index.ts @@ -23,9 +23,8 @@ // gate (per the infrastructure prompt; gating is a later-prompt // decision). -import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' -import { dirname } from 'node:path' -import { execSync } from 'node:child_process' +import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs' +import { dirname, join, resolve, isAbsolute } from 'node:path' import { applyColourOverrides } from '../src/api/figureColours' const REPS = 5 @@ -202,10 +201,88 @@ function wTreeRendering(iters: number): BenchmarkResult { // --------------------------------------------------------------------------- +/** Walk up from `startDir` for the nearest `.git`; '' when there is none. */ +function findGitPath(startDir: string): string { + // Walk up for the checkout root rather than trusting cwd: the harness is run + // from frontend/ by `just bench` and from the repo root by CI. + let dir = startDir + for (;;) { + const candidate = join(dir, '.git') + if (existsSync(candidate)) return candidate + const parent = dirname(dir) + if (parent === dir) return '' + dir = parent + } +} + +/** A linked worktree's `.git` is a FILE: "gitdir: /abs/or/rel/path". */ +function resolveGitDir(gitPath: string): string { + if (!statSync(gitPath).isFile()) return gitPath + const pointer = readFileSync(gitPath, 'utf8').trim() + if (!pointer.startsWith('gitdir:')) return '' + const target = pointer.slice('gitdir:'.length).trim() + return isAbsolute(target) ? target : resolve(dirname(gitPath), target) +} + +/** Refs of a linked worktree live in the common dir, not beside its own HEAD. */ +function resolveCommonDir(gitDir: string): string { + const commonFile = join(gitDir, 'commondir') + if (!existsSync(commonFile)) return gitDir + const rel = readFileSync(commonFile, 'utf8').trim() + return isAbsolute(rel) ? rel : resolve(gitDir, rel) +} + +/** Resolve a ref name to its sha: the loose file first, then `packed-refs`. */ +function resolveRef(commonDir: string, ref: string): string { + const loose = join(commonDir, ref) + if (existsSync(loose)) return readFileSync(loose, 'utf8').trim() + + // Fresh clones pack their refs, so the loose file may simply not exist. + const packed = join(commonDir, 'packed-refs') + if (!existsSync(packed)) return 'unknown' + for (const line of readFileSync(packed, 'utf8').split('\n')) { + if (line.startsWith('#') || line.startsWith('^')) continue + const [sha, name] = line.trim().split(' ') + if (name === ref && sha) return sha + } + return 'unknown' +} + +/** + * Resolve the checkout's HEAD commit by reading git's own files. + * + * This used to shell out to `git rev-parse --short HEAD`. That resolved the + * `git` binary through PATH, so whatever `git` happened to be first on PATH ran + * with this process's privileges -- and a benchmark harness has no need of a + * subprocess at all. Reading the plaintext files git already maintains is both + * safer and faster, and it works with no git installed. + * + * The four shapes HEAD can take -- a detached SHA, a symbolic ref to a loose + * ref file, a symbolic ref that is only in `packed-refs`, and a linked worktree + * -- are handled by the four helpers above. They were inlined here until + * SonarCloud measured this function's cognitive complexity at 26 against a + * limit of 15; splitting on the seams the doc comment already described costs + * nothing and makes each shape separately readable. + */ +function headCommit(startDir: string): string { + const gitPath = findGitPath(startDir) + if (!gitPath) return 'unknown' + const gitDir = resolveGitDir(gitPath) + if (!gitDir) return 'unknown' + + const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim() + if (/^[0-9a-f]{40}$/.test(head)) return head // detached + if (!head.startsWith('ref:')) return 'unknown' + + return resolveRef(resolveCommonDir(gitDir), head.slice(4).trim()) +} + function environment(): BenchRun['environment'] { let commit = 'unknown' try { - commit = execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() + // 7 hex is git's own default abbreviation; `rev-parse --short` would widen + // it only in a repository large enough to collide, which this is not. + commit = headCommit(import.meta.dir).slice(0, 7) || 'unknown' } catch { /* outside a git checkout — artifacts still carry every other field */ } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 39b9654..a1dec00 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -23,13 +23,48 @@ import type { // Set "apiBase" in config.json (e.g. "https://bioserver:8080") for split deployments. let _apiBase = '' +/** + * Reduce an apiBase from config.json to an origin plus optional path prefix. + * + * config.json is fetched at runtime and is not part of the build, so whatever it + * contains reaches every later fetch() and EventSource as the start of the URL. + * A split deployment genuinely needs a cross-origin base, so this cannot be + * restricted to same-origin; what it can do is refuse anything that is not http + * or https -- javascript:, data: and blob: are the dangerous ones -- and rebuild + * the value from parsed components rather than passing the string through. + * + * Rebuilding is the substantive part: it drops embedded credentials + * (https://user:pass@host), query and fragment, and normalises any traversal in + * the path prefix. Anything unparseable falls back to same-origin, which is the + * same outcome as a missing config.json. + */ +export function sanitiseApiBase(raw: unknown): string { + if (typeof raw !== 'string' || raw.trim() === '') return '' + const origin = typeof location !== 'undefined' ? location.origin : 'http://localhost' + let url: URL + try { + url = new URL(raw, origin) + } catch { + return '' + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return '' + // Trailing slashes are stripped with a loop, not /\/+$/. That pattern + // backtracks super-linearly on a long run of slashes (SonarCloud + // typescript:S8786), and a long run of slashes is exactly what a hostile + // config.json would supply to the one function written to bound it. + const path = url.pathname + let end = path.length + while (end > 0 && path.codePointAt(end - 1) === 47 /* '/' */) end-- + return `${url.protocol}//${url.host}${path.slice(0, end)}` +} + /** Called once at startup from main.tsx to load runtime config. */ export async function loadConfig(): Promise { try { const res = await fetch('/config.json') if (res.ok) { const cfg = await res.json() - _apiBase = (cfg.apiBase as string ?? '').replace(/\/+$/, '') + _apiBase = sanitiseApiBase(cfg.apiBase) } } catch { // Missing or malformed config.json - default to same-origin diff --git a/frontend/src/components/AnnotationPanelControls.tsx b/frontend/src/components/AnnotationPanelControls.tsx index 07cb541..64f702b 100644 --- a/frontend/src/components/AnnotationPanelControls.tsx +++ b/frontend/src/components/AnnotationPanelControls.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // © 2026 Joshua Benjamin Jewell. All rights reserved. // Licensed under the GNU Affero General Public License version 3 (AGPLv3). -import { useState } from 'react' +import { useEffect, useState } from 'react' import { api } from '../api/client' import { errorMessage } from '../api/errorMessage' import { useToast } from './Toast' @@ -88,8 +88,17 @@ export function AddFuncdbModal({ } } + // Escape is the keyboard equivalent of clicking the backdrop. Without it this + // dialog can be opened but not dismissed without a pointer. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [onClose]) + return (
-
setExpanded(isExpanded ? null : stage)} > {isExpanded ? 'v' : '>'} {STAGE_LABELS[stage]} -
+ {isExpanded && ( handleSort(c)} + onClick={e => { + // The filter dropdown renders inside this , so a click + // in it would otherwise bubble up and re-sort the column. + // Guarding here rather than calling stopPropagation() in the + // dropdown keeps that non-interactive wrapper free of a click + // handler -- and so free of the ARIA role S6819 objects to. + if ((e.target as HTMLElement).closest('[data-dropdown]')) return + handleSort(c) + }} style={stickyStyle}> {c}{sortIndicator(c)} @@ -656,7 +664,7 @@ function ColumnDropdown({ column, distinctFetcher, activeFilters, keywordFilter, }, [onClose]) return ( -
e.stopPropagation()}> +