Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -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
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# SPDX-License-Identifier: MPL-2.0
# 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]
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
Comment thread
hyperpolymath marked this conversation as resolved.
id-token: write
uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@75bd5994470661a099df562d6d5ad2fe744dbd66
24 changes: 24 additions & 0 deletions .github/workflows/secret-scanner.yml
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,8 @@ archive/
### Exceptions
!.github
!.github/**
!.editorconfig
!.gitleaks.toml
!.Rprofile

# Brainstorming visual-companion scratch (mock-ups, server state)
Expand Down
43 changes: 43 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -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"]
24 changes: 24 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
# 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 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,
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.
3 changes: 1 addition & 2 deletions frontend/src/figure/PaneEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>([])
Expand Down
37 changes: 23 additions & 14 deletions frontend/src/tree/TreeShapes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { rgba: Rgba | null; d: string }>()
let sel = '', lead = ''
let caps = ''
const byColour = new Map<string, { rgba: Rgba | null; parts: string[] }>()
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 ''
Expand All @@ -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
Expand All @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion src/analysis/clade_cumulus.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
40 changes: 40 additions & 0 deletions test/unit/test_clade_cumulus.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -843,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
Expand Down
Loading