From 162545bb91f3415feb371c92c6dc16be0aca393e Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:11:14 +0000 Subject: [PATCH 1/6] fix(clade): keep tip PD stable after drag-and-drop Signed-off-by: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/analysis/clade_cumulus.jl | 6 +++++- test/unit/test_clade_cumulus.jl | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/analysis/clade_cumulus.jl b/src/analysis/clade_cumulus.jl index dec731e1..624322a3 100644 --- a/src/analysis/clade_cumulus.jl +++ b/src/analysis/clade_cumulus.jl @@ -2181,7 +2181,11 @@ function _build_phylo_or_combined_tree( Epistemic.cloud_size_by_residual(res), is_root ? 0.0 : max(0.0, u.branch_length), u.support === nothing ? sup : u.support, - 0.0, + # Keep the same node-local convention as clade_faith_pd and + # _reaggregate_after_move: an active non-root tip carries its + # incoming edge. This makes an unchanged tip's PD comparable + # before and after an accepted drag-and-drop proposal. + cnt > 0.0 && !is_root ? max(0.0, u.branch_length) : 0.0, tx, tx === nothing ? nothing : 1.0, tx === nothing ? nothing : (total_tips_per_taxon[tx] == 1), diff --git a/test/unit/test_clade_cumulus.jl b/test/unit/test_clade_cumulus.jl index 670d119f..2a33f964 100644 --- a/test/unit/test_clade_cumulus.jl +++ b/test/unit/test_clade_cumulus.jl @@ -610,6 +610,18 @@ echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_0 @test low_clade.epistemic_status == "present_in_some" @test low_clade.colour == "#f9a825" + # Node-local PD has the same meaning on tips as on internal clades: + # an active non-root tip includes its incoming branch. Every tip is + # active in at least one sample in this fixture. + for tip_id in ("ASV_1", "ASV_2", "ASV_3", "ASV_4") + tip = _cc_find(comb.root, tip_id) + @test tip !== nothing + if tip !== nothing + @test Float64(tip.cumulative_count) > 0.0 + @test Float64(tip.subtree_pd) ≈ Float64(tip.branch_length) atol = 1e-10 + end + end + # 3. Validate drag-and-drop: moving ASV_3 into low-support clade fails warrant check! r_deny = _cc_req("POST", "/api/v1/studies/StudyCC/runs/run1/clade-cumulus/validate-move", JSON3.write(Dict( "table" => "merged", @@ -677,6 +689,22 @@ echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_0 @test Float64(proposed_target.subtree_pd) != before_target_pd end + # Reaggregation must not change the PD stored on a tip merely + # because a proposal was accepted. This covers unchanged tips and + # the moved tip, whose incoming branch itself is unchanged. + for tip_id in ("ASV_1", "ASV_2", "ASV_3", "ASV_4") + original_tip = _cc_find(comb.root, tip_id) + proposed_tip = _cc_find_dict(v_allow.proposed_root, tip_id) + @test original_tip !== nothing + @test proposed_tip !== nothing + if original_tip !== nothing && proposed_tip !== nothing + @test Float64(proposed_tip["subtree_pd"]) ≈ + Float64(original_tip.subtree_pd) atol = 1e-10 + @test Float64(proposed_tip["branch_length"]) ≈ + Float64(original_tip.branch_length) atol = 1e-10 + end + end + # Re-saving under a different threshold writes a distinct immutable sidecar file r_allow2 = _cc_req("POST", "/api/v1/studies/StudyCC/runs/run1/clade-cumulus/validate-move", JSON3.write(Dict( "table" => "merged", From 3a2033fbd4fc7f1b41f997c9b74ffb46e29adf5c Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:11:14 +0000 Subject: [PATCH 2/6] ci: address governance and frontend quality findings Signed-off-by: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .editorconfig | 21 ++++++++++++++++ .github/workflows/ci.yml | 9 +++++-- .github/workflows/scorecard.yml | 28 +++++++++++++++++++++ .github/workflows/secret-scanner.yml | 24 ++++++++++++++++++ .gitignore | 1 + SECURITY.md | 24 ++++++++++++++++++ frontend/src/figure/PaneEditor.tsx | 3 +-- frontend/src/tree/TreeShapes.tsx | 37 +++++++++++++++++----------- 8 files changed, 129 insertions(+), 18 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/scorecard.yml create mode 100644 .github/workflows/secret-scanner.yml create mode 100644 SECURITY.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..5b1d363f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: MPL-2.0 +# https://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{jl,R,r,py}] +indent_size = 4 + +[*.{md,adoc}] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fe2029b..d3800093 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -178,6 +178,9 @@ jobs: # for 24.04, and a runner that silently rolled to the next LTS would take that pin # with it. `runs-on` cannot read `env`, so this is a literal. runs-on: ubuntu-24.04 + # Bound a hung toolchain install or test run. The full R/Julia suite normally + # completes well inside this budget; the GitHub default is six hours. + timeout-minutes: 60 # The repository's root .Rprofile sources renv/activate.R, so R started from # the checkout auto-activates renv and rewrites the library paths. renv is a # local-development convenience; CI installs into the system library and @@ -417,10 +420,12 @@ jobs: # failure but the last one. Publish a digest of all failing # assertions first - the testset, the expression and the evaluated # values are what identify the bug - then the end of the log. + # `set +e` above is deliberate: diagnostic helpers may find no + # matches, and only the captured test status controls this step. grep -E "Test Failed at |Error During Test at |^ Expression: |^ Evaluated: |^ Test threw exception" \ - /tmp/validated-statistics-tests.log > /tmp/validated-statistics-failures.log || true + /tmp/validated-statistics-tests.log > /tmp/validated-statistics-failures.log rm -f /tmp/validated-statistics-digest-* - split -b 2500 /tmp/validated-statistics-failures.log /tmp/validated-statistics-digest- || true + split -b 2500 /tmp/validated-statistics-failures.log /tmp/validated-statistics-digest- index=0 for chunk in /tmp/validated-statistics-digest-*; do [ -e "$chunk" ] || continue diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..487abf61 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: MPL-2.0 +# Pinned reusable implementation; do not replace with a floating branch tag. +name: Scorecard + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '0 6 1 * *' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + scorecard: + name: OpenSSF Scorecard + permissions: + actions: read + contents: read + security-events: write + id-token: write + uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@75bd5994470661a099df562d6d5ad2fe744dbd66 diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml new file mode 100644 index 00000000..5bf9eb3a --- /dev/null +++ b/.github/workflows/secret-scanner.yml @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: MPL-2.0 +# Full-history and working-tree secret scanning from the pinned estate reusable. +name: Secret Scanner + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + scan: + name: Secret history and working tree + permissions: + contents: read + uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@75bd5994470661a099df562d6d5ad2fe744dbd66 + # Deliberately no secrets: the pinned reusable declares and needs none. diff --git a/.gitignore b/.gitignore index 3af5a950..a537ea37 100644 --- a/.gitignore +++ b/.gitignore @@ -320,6 +320,7 @@ archive/ ### Exceptions !.github !.github/** +!.editorconfig !.Rprofile # Brainstorming visual-companion scratch (mock-ups, server state) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..8e25aeeb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,24 @@ + +# Security policy + +## Supported version + +Security fixes are applied to the current `main` branch. Update to the latest +revision before reporting a problem that may already have been fixed. + +## Report a vulnerability + +Use [GitHub private vulnerability reporting](https://github.com/hyperpolymath/MetaManifold-WebUI/security/advisories/new) +for suspected vulnerabilities in this fork. If private reporting is not +available, contact the maintainer at `jjewell23@rvc.ac.uk` (the contact already +published in `CITATION.cff`). Do not disclose an unpatched vulnerability in a +public issue or pull request. + +Include the affected revision, a minimal reproduction, the security impact, and +any suggested mitigation. Remove real study data, credentials, access tokens, +and other secrets from the report. The maintainers will assess the report and +coordinate a fix and disclosure with the reporter. + +For vulnerabilities confined to upstream code that are not specific to this +fork, also consult the upstream project's reporting process rather than +assuming the fork can publish an upstream fix. diff --git a/frontend/src/figure/PaneEditor.tsx b/frontend/src/figure/PaneEditor.tsx index daac6d6a..3d59ec53 100644 --- a/frontend/src/figure/PaneEditor.tsx +++ b/frontend/src/figure/PaneEditor.tsx @@ -42,8 +42,7 @@ export function PaneEditor({ study, runs, source, pane, onChange }: { const selected = useMemo(() => { const keys = new Set((chart?.runs ?? []).map(specKey)) return runs.filter(r => keys.has(specKey(r))) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [runs, chart?.runs.map(specKey).join(',')]) + }, [runs, chart?.runs]) const tables = useSharedResultsTables(study, selected) const [ranks, setRanks] = useState([]) diff --git a/frontend/src/tree/TreeShapes.tsx b/frontend/src/tree/TreeShapes.tsx index f176a1db..73922373 100644 --- a/frontend/src/tree/TreeShapes.tsx +++ b/frontend/src/tree/TreeShapes.tsx @@ -22,11 +22,12 @@ export function TreeShapes({ layout, settings, selSubtree, matchSet, onSelect, o const branchPath = (n: LNode) => mode === 'rectangular' ? `M${n.px},${n.y}H${n.x}` : `M${n.ex},${n.ey}L${n.x},${n.y}` const right = aligned && settings.tipJustify === 'right' - // Branches are batched into one path per colour. + // Branches are batched into one path per colour. Accumulate path fragments + // in arrays and join once; repeated string concatenation is quadratic for + // large trees in engines that cannot flatten the intermediate strings. const { strokes, selected, leaders, caps } = useMemo(() => { - const byColour = new Map() - let sel = '', lead = '' - let caps = '' + const byColour = new Map() + const selectedParts: string[] = [], leaderParts: string[] = [], capParts: string[] = [] const arc = (n: LNode) => { const first = n.kids[0], last = n.kids[n.kids.length - 1] if (mode === 'slanted' || mode === 'unrooted') return '' @@ -38,9 +39,10 @@ export function TreeShapes({ layout, settings, selSubtree, matchSet, onSelect, o return `M${cx + r * Math.cos(a1)},${cy + r * Math.sin(a1)}A${r},${r} 0 ${large} 1 ${cx + r * Math.cos(a2)},${cy + r * Math.sin(a2)}` } for (const n of layout.nodes) { - let p = '' - if (n.d.parent) p += branchPath(n) - if (n.kids.length) p += arc(n) + const p = [ + n.d.parent ? branchPath(n) : '', + n.kids.length ? arc(n) : '', + ].join('') if (n.capped) { // Two slashes across the middle of a branch drawn shorter than it is. const sx = mode === 'rectangular' ? n.px : n.ex, sy = mode === 'rectangular' ? n.y : n.ey @@ -50,27 +52,34 @@ export function TreeShapes({ layout, settings, selSubtree, matchSet, onSelect, o const vx = 4 * (-uy + 0.5 * ux), vy = 4 * (ux + 0.5 * uy) for (const o of [-2, 2]) { const qx = mx + o * ux, qy = my + o * uy - caps += `M${qx - vx},${qy - vy}L${qx + vx},${qy + vy}` + capParts.push(`M${qx - vx},${qy - vy}L${qx + vx},${qy + vy}`) } } const c = n.d.style.branch ?? null const key = c ? `${c.hex}/${c.alpha}` : '' const entry = byColour.get(key) - if (entry) entry.d += p - else byColour.set(key, { rgba: c, d: p }) - if (selSubtree.has(n.d.id)) sel += p + if (entry) entry.parts.push(p) + else byColour.set(key, { rgba: c, parts: [p] }) + if (selSubtree.has(n.d.id)) selectedParts.push(p) if (aligned && !n.kids.length) { const end = n.wedge ? farthest(n.wedge, circular, cx, cy) : null if (circular) { const r0 = end ? end.r : n.radius - if (layout.alignAt - 4 > r0) lead += `M${cx + r0 * Math.cos(n.angle)},${cy + r0 * Math.sin(n.angle)}L${cx + (layout.alignAt - 4) * Math.cos(n.angle)},${cy + (layout.alignAt - 4) * Math.sin(n.angle)}` + if (layout.alignAt - 4 > r0) { + leaderParts.push(`M${cx + r0 * Math.cos(n.angle)},${cy + r0 * Math.sin(n.angle)}L${cx + (layout.alignAt - 4) * Math.cos(n.angle)},${cy + (layout.alignAt - 4) * Math.sin(n.angle)}`) + } } else { const x0 = end ? end.x : n.x - if (layout.alignAt - 4 > x0) lead += `M${x0},${n.y}H${layout.alignAt - 4}` + if (layout.alignAt - 4 > x0) leaderParts.push(`M${x0},${n.y}H${layout.alignAt - 4}`) } } } - return { strokes: [...byColour.values()], selected: sel, leaders: lead, caps } + return { + strokes: [...byColour.values()].map(({ rgba, parts }) => ({ rgba, d: parts.join('') })), + selected: selectedParts.join(''), + leaders: leaderParts.join(''), + caps: capParts.join(''), + } }, [layout, circular, cx, cy, selSubtree, aligned, mode]) // Label anchor: beside the node, or in the aligned column. From 5ba4d73675309e6de49f2622fbaebf1f6bd08dca Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:12:21 +0000 Subject: [PATCH 3/6] docs: direct security reports to fork maintainer Signed-off-by: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- SECURITY.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 8e25aeeb..f4f9e18d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,9 +10,9 @@ revision before reporting a problem that may already have been fixed. Use [GitHub private vulnerability reporting](https://github.com/hyperpolymath/MetaManifold-WebUI/security/advisories/new) for suspected vulnerabilities in this fork. If private reporting is not -available, contact the maintainer at `jjewell23@rvc.ac.uk` (the contact already -published in `CITATION.cff`). Do not disclose an unpatched vulnerability in a -public issue or pull request. +available, contact the fork maintainer at `j.d.a.jewell@open.ac.uk` (the +contact already published in `MetaManifold-WebUI_chora.deed`). Do not disclose +an unpatched vulnerability in a public issue or pull request. Include the affected revision, a minimal reproduction, the security impact, and any suggested mitigation. Remove real study data, credentials, access tokens, From 45ab7b0269b1eeb69a22e08ab9d6165d671c9c85 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:21:54 +0000 Subject: [PATCH 4/6] fix: keep historical secret scanning accurate Signed-off-by: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .gitignore | 1 + .gitleaks.toml | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 .gitleaks.toml diff --git a/.gitignore b/.gitignore index a537ea37..bb8f8512 100644 --- a/.gitignore +++ b/.gitignore @@ -321,6 +321,7 @@ archive/ !.github !.github/** !.editorconfig +!.gitleaks.toml !.Rprofile # Brainstorming visual-companion scratch (mock-ups, server state) diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..a8c979bd --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: MPL-2.0 +# Repository-specific secret-scanning rules. The reusable stages the pinned +# estate baseline at this path; no path- or commit-wide exceptions are made. + +[extend] +path = ".gitleaks-estate.toml" + +[allowlist] +description = "Exact public schema and UI callback identifiers misread as generic keys" +regexes = [ + '''^Associated_material$''', + '''^Associated_organism$''', + '''^allowPreviousMonth$''', + '''^associated_organism$''', + '''^canAddSubplot$''', + '''^focusPreviousDay$''', + '''^getInitialMonthFromProps$''', + '''^getPreviousNavigableMonth$''', + '''^handleDayClick$''', + '''^handleMouseEnter$''', +] + +# Backport the upstream v8.30.1 AWS rule for the CI-pinned v8.18.4 engine. +# The older rule accepts arbitrary A-Z0-9 suffixes with no entropy threshold, +# which turns ordinary A/C/G/T sequences into AWS access-token findings. The +# upstream boundaries, suffix alphabet, and entropy requirement retain real +# credential detection without excluding biological data by path or commit. +[[rules]] +id = "aws-access-token" +description = "Identified a pattern that may indicate AWS credentials, risking unauthorized cloud resource access and data breaches on AWS platforms." +regex = '''\b((?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\b''' +entropy = 3 +keywords = ["a3t", "akia", "asia", "abia", "acca"] + +# Likewise, upstream now requires a realistic Facebook token length and +# entropy. Short printable FASTQ quality strings otherwise match the legacy +# twenty-character minimum even though they are sequencing data. +[[rules]] +id = "facebook-page-access-token" +description = "Discovered a Facebook Page Access Token, posing a risk of unauthorized access to Facebook accounts and personal data exposure." +regex = '''\b(EAA[MC](?i)[a-z0-9]{100,})(?:[\x60'"\s;]|\\[nr]|$)''' +entropy = 4 +keywords = ["eaam", "eaac"] From df6de1e228c06b1e792e04a69a55f948da8909f5 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:21:54 +0000 Subject: [PATCH 5/6] ci: keep Scorecard SARIF off pull requests Signed-off-by: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/scorecard.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 487abf61..c01b774d 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -2,10 +2,14 @@ # Pinned reusable implementation; do not replace with a floating branch tag. name: Scorecard +# Scorecard publishes SARIF for the default branch. Keep its required +# security-events upload permission off pull requests: otherwise Scorecard +# scans this caller, flags that necessary permission, and creates a blocking +# code-scanning check on its own security workflow. Scheduled and main-branch +# scans retain the findings without that self-referential PR gate. on: push: branches: [main] - pull_request: schedule: - cron: '0 6 1 * *' workflow_dispatch: From ed24c9c1708734fbc483f78f2bcca22ddb8a3074 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:23:08 +0000 Subject: [PATCH 6/6] test(clade): cover nested PD reaggregation Signed-off-by: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- test/unit/test_clade_cumulus.jl | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/unit/test_clade_cumulus.jl b/test/unit/test_clade_cumulus.jl index 2a33f964..37c1bb1b 100644 --- a/test/unit/test_clade_cumulus.jl +++ b/test/unit/test_clade_cumulus.jl @@ -871,6 +871,18 @@ echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_0 if relocated !== nothing @test Float64(relocated["cumulative_count"]) == moved_reads @test Float64(relocated["residual_count"]) == moved_residual + # The moved clade's 0.30 incoming branch and its two tip + # branches (0.10 and 0.20) each occur exactly once. + @test Float64(relocated["subtree_pd"]) ≈ 0.60 atol = 1e-9 + end + proposed_target = _cc_find_dict(proposed_root, String(target.id)) + @test proposed_target !== nothing + if proposed_target !== nothing + # The target contributes its own 0.40 edge, two original tip + # edges (0.15 and 0.25), and the relocated clade's complete + # 0.60 node-local PD. Adding the relocated incoming edge again + # at the parent would incorrectly produce 1.70 rather than 1.40. + @test Float64(proposed_target["subtree_pd"]) ≈ 1.40 atol = 1e-9 end @test Float64(proposed_root["cumulative_count"]) ≈ before_total atol = 1e-9 # The emptied wrapper is childless but is not a tip: it must not