From b650bbaaf92c8344cba1aaa0b161c057c6d211c4 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:31:13 +0000 Subject: [PATCH 1/4] fix: harden Hypatia filter paths and gate contract Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- scripts/check-governance-contract.sh | 6 +++ scripts/filter-hypatia-inherited-language.sh | 25 ++++++---- scripts/tests/test-governance-contract.sh | 6 ++- ...st-hypatia-inherited-language-exception.sh | 46 ++++++++++++++++++- 4 files changed, 71 insertions(+), 12 deletions(-) diff --git a/scripts/check-governance-contract.sh b/scripts/check-governance-contract.sh index 639bc94..bf502a2 100755 --- a/scripts/check-governance-contract.sh +++ b/scripts/check-governance-contract.sh @@ -237,6 +237,12 @@ fi if gate="$(step_block "$REUSABLE" 'Gate on baseline (blocking when a baseline is committed)')"; then contains "$gate" 'hypatia-findings.effective.json' \ 'the baseline gate consumes exception-aware findings' + contains "$gate" '!inputs.block-on-high' \ + 'the advisory baseline gate remains disabled for blocking-mode callers' + contains "$gate" 'steps.inherited-language.outputs.findings_count > 0' \ + 'the advisory baseline gate runs when effective findings are present' + contains "$gate" "hashFiles(''.hypatia-baseline.json'')" \ + 'the advisory baseline gate still validates a committed baseline when findings are zero' else fail 'the baseline gate step exists' fi diff --git a/scripts/filter-hypatia-inherited-language.sh b/scripts/filter-hypatia-inherited-language.sh index f6758c2..a06b10a 100755 --- a/scripts/filter-hypatia-inherited-language.sh +++ b/scripts/filter-hypatia-inherited-language.sh @@ -29,20 +29,27 @@ for input in "$raw_findings" "$raw_sarif" "$allowlist"; do fi done -# Resolve relative components and symlinks even when output directories do not -# exist yet. Neither output may overwrite either raw audit artifact. -raw_findings=$(realpath -m -- "$raw_findings") -raw_sarif=$(realpath -m -- "$raw_sarif") -effective_findings=$(realpath -m -- "$effective_findings") -effective_sarif=$(realpath -m -- "$effective_sarif") +# Resolve relative components and symlinks portably, including output paths +# whose parent directories do not exist yet. Python's standard library avoids +# relying on GNU-only `realpath -m`. Neither output may overwrite any input or +# the other output. +canonicalize_path() { + python3 -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' "$1" +} +raw_findings=$(canonicalize_path "$raw_findings") +raw_sarif=$(canonicalize_path "$raw_sarif") +allowlist=$(canonicalize_path "$allowlist") +effective_findings=$(canonicalize_path "$effective_findings") +effective_sarif=$(canonicalize_path "$effective_sarif") for output in "$effective_findings" "$effective_sarif"; do - if [[ "$output" == "$raw_findings" || "$output" == "$raw_sarif" || "$output" -ef "$raw_findings" || "$output" -ef "$raw_sarif" ]]; then - echo "Raw inputs and effective outputs must use distinct paths." >&2 + if [[ "$output" == "$raw_findings" || "$output" == "$raw_sarif" || "$output" == "$allowlist" \ + || "$output" -ef "$raw_findings" || "$output" -ef "$raw_sarif" || "$output" -ef "$allowlist" ]]; then + echo "Input, allowlist, and output paths must resolve to distinct files." >&2 exit 1 fi done if [[ "$effective_findings" == "$effective_sarif" || "$effective_findings" -ef "$effective_sarif" ]]; then - echo "Raw inputs and effective outputs must use distinct paths." >&2 + echo "Input, allowlist, and output paths must resolve to distinct files." >&2 exit 1 fi diff --git a/scripts/tests/test-governance-contract.sh b/scripts/tests/test-governance-contract.sh index 7c80eed..7846c12 100644 --- a/scripts/tests/test-governance-contract.sh +++ b/scripts/tests/test-governance-contract.sh @@ -14,10 +14,11 @@ reset_fixture() { } reset_fixture bash "$fixture/scripts/check-governance-contract.sh" > "$fixture/log" 2>&1 -for mutation in allowlist-addition sarif upstream-exception effective-grid jcs-manifest jcs-canonicalizer raw-ijson k9-pin k9-require k9-indeterminate k9-verdict scanner-advisory scanner-secrets scanner-trigger scorecard-trigger; do +for mutation in allowlist-addition baseline-zero-gate sarif upstream-exception effective-grid jcs-manifest jcs-canonicalizer raw-ijson k9-pin k9-require k9-indeterminate k9-verdict scanner-advisory scanner-secrets scanner-trigger scorecard-trigger; do reset_fixture case "$mutation" in allowlist-addition) echo 'frontend/src/fork-only.ts' >> "$fixture/config/hypatia/upstream-typescript-allowlist.txt" ;; + baseline-zero-gate) sed -i "/- name: Gate on baseline/,/^[[:space:]]*run:/ s@^ if: .*@ if: '!inputs.block-on-high \\&\\& steps.inherited-language.outputs.findings_count > 0'@" "$fixture/.github/workflows/hypatia-scan-reusable.yml" ;; sarif) sed -i "/if: github.event_name != 'pull_request'/d" "$fixture/.github/workflows/hypatia-scan-reusable.yml" ;; upstream-exception) sed -i 's@bash scripts/filter-hypatia-inherited-language.sh@bash scripts/no-op-filter.sh@' "$fixture/.github/workflows/hypatia-scan-reusable.yml" ;; effective-grid) sed -i '/- name: Render the verdict grid/,/- name: Upload the verdict grid/s/hypatia-findings.effective.json/hypatia-findings.relativized.json/g' "$fixture/.github/workflows/hypatia-scan-reusable.yml" ;; @@ -42,5 +43,8 @@ for mutation in allowlist-addition sarif upstream-exception effective-grid jcs-m if [[ "$mutation" == allowlist-addition ]]; then grep -Fq 'ERROR: the complete allowlist must match the pinned upstream TypeScript snapshot' "$fixture/log" fi + if [[ "$mutation" == baseline-zero-gate ]]; then + grep -Fq 'ERROR: the advisory baseline gate still validates a committed baseline when findings are zero' "$fixture/log" + fi echo "ok - rejected $mutation" done diff --git a/scripts/tests/test-hypatia-inherited-language-exception.sh b/scripts/tests/test-hypatia-inherited-language-exception.sh index f556bd3..9538ef8 100755 --- a/scripts/tests/test-hypatia-inherited-language-exception.sh +++ b/scripts/tests/test-hypatia-inherited-language-exception.sh @@ -18,6 +18,7 @@ cat > "$tmpdir/allowlist.txt" <<'EOF' frontend/src/App.tsx frontend/src/api/client.ts EOF +cp "$tmpdir/allowlist.txt" "$tmpdir/allowlist.original.txt" cat > "$tmpdir/raw-findings.json" <<'EOF' [ @@ -126,7 +127,7 @@ for raw in raw-findings.json raw.sarif; do echo "ERROR: accepted $output_kind output colliding with $alias" >&2 exit 1 fi - grep -Fq 'Raw inputs and effective outputs must use distinct paths.' "$tmpdir/collision.log" + grep -Fq 'Input, allowlist, and output paths must resolve to distinct files.' "$tmpdir/collision.log" cmp "$tmpdir/raw-findings.json" "$tmpdir/raw-findings.original.json" cmp "$tmpdir/raw.sarif" "$tmpdir/raw.original.sarif" cmp "$tmpdir/companion" "$tmpdir/companion.original" @@ -134,6 +135,31 @@ for raw in raw-findings.json raw.sarif; do done done +# The allowlist is a protected input just like the raw scanner artifacts. +ln -s "$tmpdir/allowlist.txt" "$tmpdir/symlink-allowlist.txt" +ln "$tmpdir/allowlist.txt" "$tmpdir/hardlink-allowlist.txt" +for alias in "$tmpdir/allowlist.txt" "$tmpdir/./allowlist.txt" \ + "$tmpdir/symlink-allowlist.txt" "$tmpdir/hardlink-allowlist.txt"; do + for output_kind in findings sarif; do + printf 'untouched companion output\n' > "$tmpdir/companion" + cp "$tmpdir/companion" "$tmpdir/companion.original" + outputs=("$alias" "$tmpdir/companion") + if [[ "$output_kind" == sarif ]]; then + outputs=("$tmpdir/companion" "$alias") + fi + if bash "$FILTER" "$tmpdir/raw-findings.json" "$tmpdir/raw.sarif" \ + "$tmpdir/allowlist.txt" "${outputs[@]}" "$canonicalizer_arg" > "$tmpdir/collision.log" 2>&1; then + echo "ERROR: accepted $output_kind output colliding with allowlist alias $alias" >&2 + exit 1 + fi + grep -Fq 'Input, allowlist, and output paths must resolve to distinct files.' "$tmpdir/collision.log" + cmp "$tmpdir/allowlist.txt" "$tmpdir/allowlist.original.txt" + cmp "$tmpdir/raw-findings.json" "$tmpdir/raw-findings.original.json" + cmp "$tmpdir/raw.sarif" "$tmpdir/raw.original.sarif" + cmp "$tmpdir/companion" "$tmpdir/companion.original" + done +done + # Canonicalize raw input aliases too, and reject aliased effective outputs. for collision in raw-alias output-alias; do inputs=("$tmpdir/raw-findings.json" "$tmpdir/raw.sarif") @@ -147,7 +173,7 @@ for collision in raw-alias output-alias; do echo "ERROR: accepted $collision collision" >&2 exit 1 fi - grep -Fq 'Raw inputs and effective outputs must use distinct paths.' "$tmpdir/collision.log" + grep -Fq 'Input, allowlist, and output paths must resolve to distinct files.' "$tmpdir/collision.log" cmp "$tmpdir/raw-findings.json" "$tmpdir/raw-findings.original.json" cmp "$tmpdir/raw.sarif" "$tmpdir/raw.original.sarif" cmp "$tmpdir/companion" "$tmpdir/companion.original" @@ -161,6 +187,22 @@ cmp "$tmpdir/effective-findings.json" "$tmpdir/new/findings/effective.json" cmp "$tmpdir/effective.sarif" "$tmpdir/new/sarif/effective.sarif" echo 'PASS: path collisions are rejected before writes; distinct new output paths remain supported.' +# Simulate macOS/BSD environments where GNU `realpath -m` is unavailable. +mkdir -p "$tmpdir/no-realpath-bin" +cat > "$tmpdir/no-realpath-bin/realpath" <<'EOF' +#!/bin/sh +echo 'ERROR: GNU realpath must not be required' >&2 +exit 127 +EOF +chmod +x "$tmpdir/no-realpath-bin/realpath" +PATH="$tmpdir/no-realpath-bin:$PATH" bash "$FILTER" \ + "$tmpdir/raw-findings.json" "$tmpdir/raw.sarif" "$tmpdir/allowlist.txt" \ + "$tmpdir/portable/findings/effective.json" "$tmpdir/portable/sarif/effective.sarif" \ + "$canonicalizer_arg" +cmp "$tmpdir/effective-findings.json" "$tmpdir/portable/findings/effective.json" +cmp "$tmpdir/effective.sarif" "$tmpdir/portable/sarif/effective.sarif" +echo 'PASS: path resolution works without a GNU realpath executable.' + if [[ -n "$canonicalizer_arg" ]]; then echo "PASS: inherited TypeScript paths are narrowly filtered; raw, fork-only, and unrelated findings remain; effective JSON is I-JSON/JCS checked." else From 4ec2c1836a1e1ff46693b984f2c18eff0cbf52e1 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:40:48 +0000 Subject: [PATCH 2/4] fix: preserve trailing newlines in Hypatia paths and enforce baseline gate logic --- scripts/check-governance-contract.sh | 11 ++++---- scripts/filter-hypatia-inherited-language.sh | 10 +++++-- scripts/tests/test-governance-contract.sh | 8 ++++-- ...st-hypatia-inherited-language-exception.sh | 28 +++++++++++++++++++ 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/scripts/check-governance-contract.sh b/scripts/check-governance-contract.sh index bf502a2..0361a03 100755 --- a/scripts/check-governance-contract.sh +++ b/scripts/check-governance-contract.sh @@ -237,12 +237,11 @@ fi if gate="$(step_block "$REUSABLE" 'Gate on baseline (blocking when a baseline is committed)')"; then contains "$gate" 'hypatia-findings.effective.json' \ 'the baseline gate consumes exception-aware findings' - contains "$gate" '!inputs.block-on-high' \ - 'the advisory baseline gate remains disabled for blocking-mode callers' - contains "$gate" 'steps.inherited-language.outputs.findings_count > 0' \ - 'the advisory baseline gate runs when effective findings are present' - contains "$gate" "hashFiles(''.hypatia-baseline.json'')" \ - 'the advisory baseline gate still validates a committed baseline when findings are zero' + if grep -Fxq " if: '!inputs.block-on-high && (steps.inherited-language.outputs.findings_count > 0 || hashFiles(''.hypatia-baseline.json'') != '''')'" <<<"$gate"; then + pass 'the advisory baseline gate requires nonblocking mode and either findings or a committed baseline' + else + fail 'the advisory baseline gate requires nonblocking mode and either findings or a committed baseline' + fi else fail 'the baseline gate step exists' fi diff --git a/scripts/filter-hypatia-inherited-language.sh b/scripts/filter-hypatia-inherited-language.sh index a06b10a..0bf8c1e 100755 --- a/scripts/filter-hypatia-inherited-language.sh +++ b/scripts/filter-hypatia-inherited-language.sh @@ -34,13 +34,19 @@ done # relying on GNU-only `realpath -m`. Neither output may overwrite any input or # the other output. canonicalize_path() { - python3 -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' "$1" + # A non-newline sentinel prevents command substitution from trimming the path. + python3 -c 'import os, sys; sys.stdout.write(os.path.realpath(sys.argv[1]) + ".")' "$1" } raw_findings=$(canonicalize_path "$raw_findings") +raw_findings=${raw_findings%.} raw_sarif=$(canonicalize_path "$raw_sarif") +raw_sarif=${raw_sarif%.} allowlist=$(canonicalize_path "$allowlist") +allowlist=${allowlist%.} effective_findings=$(canonicalize_path "$effective_findings") +effective_findings=${effective_findings%.} effective_sarif=$(canonicalize_path "$effective_sarif") +effective_sarif=${effective_sarif%.} for output in "$effective_findings" "$effective_sarif"; do if [[ "$output" == "$raw_findings" || "$output" == "$raw_sarif" || "$output" == "$allowlist" \ || "$output" -ef "$raw_findings" || "$output" -ef "$raw_sarif" || "$output" -ef "$allowlist" ]]; then @@ -120,7 +126,7 @@ if [[ -n "$canonicalizer" ]]; then mv "$tmpdir/effective-findings.jcs.json" "$tmpdir/effective-findings.json" fi -mkdir -p "$(dirname "$effective_findings")" "$(dirname "$effective_sarif")" +mkdir -p "${effective_findings%/*}" "${effective_sarif%/*}" cp "$tmpdir/effective-findings.json" "$effective_findings" cp "$tmpdir/effective.sarif" "$effective_sarif" diff --git a/scripts/tests/test-governance-contract.sh b/scripts/tests/test-governance-contract.sh index 7846c12..9f8df24 100644 --- a/scripts/tests/test-governance-contract.sh +++ b/scripts/tests/test-governance-contract.sh @@ -14,11 +14,13 @@ reset_fixture() { } reset_fixture bash "$fixture/scripts/check-governance-contract.sh" > "$fixture/log" 2>&1 -for mutation in allowlist-addition baseline-zero-gate sarif upstream-exception effective-grid jcs-manifest jcs-canonicalizer raw-ijson k9-pin k9-require k9-indeterminate k9-verdict scanner-advisory scanner-secrets scanner-trigger scorecard-trigger; do +for mutation in allowlist-addition baseline-zero-gate baseline-operator-gate baseline-grouping-gate sarif upstream-exception effective-grid jcs-manifest jcs-canonicalizer raw-ijson k9-pin k9-require k9-indeterminate k9-verdict scanner-advisory scanner-secrets scanner-trigger scorecard-trigger; do reset_fixture case "$mutation" in allowlist-addition) echo 'frontend/src/fork-only.ts' >> "$fixture/config/hypatia/upstream-typescript-allowlist.txt" ;; baseline-zero-gate) sed -i "/- name: Gate on baseline/,/^[[:space:]]*run:/ s@^ if: .*@ if: '!inputs.block-on-high \\&\\& steps.inherited-language.outputs.findings_count > 0'@" "$fixture/.github/workflows/hypatia-scan-reusable.yml" ;; + baseline-operator-gate) sed -i '/- name: Gate on baseline/,/^[[:space:]]*run:/ s/ || / \&\& /' "$fixture/.github/workflows/hypatia-scan-reusable.yml" ;; + baseline-grouping-gate) sed -i '/- name: Gate on baseline/,/^[[:space:]]*run:/ { s/&& (steps/\&\& steps/; s/)\x27$/\x27/; }' "$fixture/.github/workflows/hypatia-scan-reusable.yml" ;; sarif) sed -i "/if: github.event_name != 'pull_request'/d" "$fixture/.github/workflows/hypatia-scan-reusable.yml" ;; upstream-exception) sed -i 's@bash scripts/filter-hypatia-inherited-language.sh@bash scripts/no-op-filter.sh@' "$fixture/.github/workflows/hypatia-scan-reusable.yml" ;; effective-grid) sed -i '/- name: Render the verdict grid/,/- name: Upload the verdict grid/s/hypatia-findings.effective.json/hypatia-findings.relativized.json/g' "$fixture/.github/workflows/hypatia-scan-reusable.yml" ;; @@ -43,8 +45,8 @@ for mutation in allowlist-addition baseline-zero-gate sarif upstream-exception e if [[ "$mutation" == allowlist-addition ]]; then grep -Fq 'ERROR: the complete allowlist must match the pinned upstream TypeScript snapshot' "$fixture/log" fi - if [[ "$mutation" == baseline-zero-gate ]]; then - grep -Fq 'ERROR: the advisory baseline gate still validates a committed baseline when findings are zero' "$fixture/log" + if [[ "$mutation" == baseline-*-gate ]]; then + grep -Fq 'ERROR: the advisory baseline gate requires nonblocking mode and either findings or a committed baseline' "$fixture/log" fi echo "ok - rejected $mutation" done diff --git a/scripts/tests/test-hypatia-inherited-language-exception.sh b/scripts/tests/test-hypatia-inherited-language-exception.sh index 9538ef8..9e9e656 100755 --- a/scripts/tests/test-hypatia-inherited-language-exception.sh +++ b/scripts/tests/test-hypatia-inherited-language-exception.sh @@ -187,6 +187,34 @@ cmp "$tmpdir/effective-findings.json" "$tmpdir/new/findings/effective.json" cmp "$tmpdir/effective.sarif" "$tmpdir/new/sarif/effective.sarif" echo 'PASS: path collisions are rejected before writes; distinct new output paths remain supported.' +# Command substitution must preserve every trailing newline in resolved paths. +for suffix in $'\n' $'\n\n'; do + inputs=("$tmpdir/raw-findings.json$suffix" "$tmpdir/raw.sarif$suffix" "$tmpdir/allowlist.txt$suffix") + cp "$tmpdir/raw-findings.original.json" "${inputs[0]}" + cp "$tmpdir/raw.original.sarif" "${inputs[1]}" + cp "$tmpdir/allowlist.original.txt" "${inputs[2]}" + outputs=("$tmpdir/newline$suffix/effective-findings.json$suffix" "$tmpdir/newline$suffix/effective.sarif$suffix") + bash "$FILTER" "${inputs[@]}" "${outputs[@]}" "$canonicalizer_arg" + cmp "$tmpdir/effective-findings.json" "${outputs[0]}" + cmp "$tmpdir/effective.sarif" "${outputs[1]}" + cmp "$tmpdir/raw-findings.original.json" "${inputs[0]}" + cmp "$tmpdir/raw.original.sarif" "${inputs[1]}" + cmp "$tmpdir/allowlist.original.txt" "${inputs[2]}" + [[ ! -e "${outputs[0]%$suffix}" && ! -e "${outputs[1]%$suffix}" ]] + + # Symlinks to newline-terminated input names must still be protected. + ln -sf "${inputs[0]}" "$tmpdir/newline-alias" + if bash "$FILTER" "${inputs[@]}" "$tmpdir/newline-alias" "${outputs[1]}" \ + "$canonicalizer_arg" > "$tmpdir/collision.log" 2>&1; then + echo 'ERROR: accepted an output aliasing an input with trailing newlines' >&2 + exit 1 + fi + grep -Fq 'Input, allowlist, and output paths must resolve to distinct files.' "$tmpdir/collision.log" + cmp "$tmpdir/raw-findings.original.json" "${inputs[0]}" + cmp "$tmpdir/effective.sarif" "${outputs[1]}" +done +echo 'PASS: trailing newlines in input and output paths are preserved and aliases remain protected.' + # Simulate macOS/BSD environments where GNU `realpath -m` is unavailable. mkdir -p "$tmpdir/no-realpath-bin" cat > "$tmpdir/no-realpath-bin/realpath" <<'EOF' From 60e9d7cfdf074489fd3e10f03ca7940199099b9e Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:41:25 +0000 Subject: [PATCH 3/4] docs: clarify Hypatia path canonicalization behavior and limitations --- scripts/filter-hypatia-inherited-language.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/filter-hypatia-inherited-language.sh b/scripts/filter-hypatia-inherited-language.sh index 0bf8c1e..feee101 100755 --- a/scripts/filter-hypatia-inherited-language.sh +++ b/scripts/filter-hypatia-inherited-language.sh @@ -29,10 +29,12 @@ for input in "$raw_findings" "$raw_sarif" "$allowlist"; do fi done +# Print the absolute path for $1 to standard output, followed by a newline. # Resolve relative components and symlinks portably, including output paths -# whose parent directories do not exist yet. Python's standard library avoids -# relying on GNU-only `realpath -m`. Neither output may overwrite any input or -# the other output. +# whose parent directories do not exist yet, without GNU `realpath -m`. +# Resolution is non-strict: inaccessible components or symlink loops may remain +# unresolved. Hard-link aliases require the caller's file-identity checks. +# Python invocation failures propagate as a non-zero exit status. canonicalize_path() { # A non-newline sentinel prevents command substitution from trimming the path. python3 -c 'import os, sys; sys.stdout.write(os.path.realpath(sys.argv[1]) + ".")' "$1" From aab6d46d899ef6d15d82d984000430daede8f9d2 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:04:49 +0000 Subject: [PATCH 4/4] fix: address scanner review findings Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .../{governance.yml => hypatia-scan.yml} | 0 0-AI-MANIFEST.a2ml | 2 +- MetaManifold-WebUI_chora.deed | 2 +- README.md | 2 +- .../2026-10-23-governance-security.md | 20 ++-- .../2026-10-24-scanner-review.md | 97 +++++++++++++++++++ install.jl | 2 +- scripts/check-governance-contract.sh | 2 +- scripts/formats/descendants.py | 37 +++++-- scripts/formats/process.py | 26 +++-- scripts/tests/test-governance-contract.sh | 6 +- scripts/tests/test_format_readiness.py | 30 +++++- src/pipeline/dada2/context.jl | 50 +++++++--- test/unit/test_dada2_commands.jl | 6 ++ test/unit/test_permanova.jl | 26 +++-- test/unit/test_statistics_properties.jl | 10 +- 16 files changed, 259 insertions(+), 59 deletions(-) rename .github/workflows/{governance.yml => hypatia-scan.yml} (100%) create mode 100644 docs/release-notes/2026-10-24-scanner-review.md diff --git a/.github/workflows/governance.yml b/.github/workflows/hypatia-scan.yml similarity index 100% rename from .github/workflows/governance.yml rename to .github/workflows/hypatia-scan.yml diff --git a/0-AI-MANIFEST.a2ml b/0-AI-MANIFEST.a2ml index 00ae081..aec6df8 100644 --- a/0-AI-MANIFEST.a2ml +++ b/0-AI-MANIFEST.a2ml @@ -25,7 +25,7 @@ (rule "Run tests; report inaccessible services and missing evidence honestly.")) (evidence (application-ci ".github/workflows/ci.yml") - (governance ".github/workflows/governance.yml") + (governance ".github/workflows/hypatia-scan.yml") (history-scan ".github/workflows/secret-scanner.yml") (security-practices ".github/workflows/scorecard.yml") (fleet-enrolment ".gitbot-fleet/enrolment.json") diff --git a/MetaManifold-WebUI_chora.deed b/MetaManifold-WebUI_chora.deed index df9f568..fe520db 100644 --- a/MetaManifold-WebUI_chora.deed +++ b/MetaManifold-WebUI_chora.deed @@ -22,7 +22,7 @@ :contractiles ".machine_readable/contractiles/" :enforcement ".github/workflows/") (status - :evidence-source ".github/workflows/governance.yml" + :evidence-source ".github/workflows/hypatia-scan.yml" :evidence-status "Fork governance lane: DEED syntax, K9 L0-L2 contract conformance (must, trust, intend at Yard tier; L3 signatures not verified in CI), and the Hypatia verdict grid with gitbot-fleet report-backs. Upstream owns the application code and its CI; this deed describes the fork.") (policies (licensing diff --git a/README.md b/README.md index 3e0f17a..b1d152a 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![codecov](https://codecov.io/gh/JoshuaJewell/MetaManifold-WebUI/graph/badge.svg?token=20F1VLF590)](https://codecov.io/gh/JoshuaJewell/MetaManifold-WebUI) Fork governance (hyperpolymath/MetaManifold-WebUI only; nothing here is part of upstream): -[![Governance (fork)](https://github.com/hyperpolymath/MetaManifold-WebUI/actions/workflows/governance.yml/badge.svg)](https://github.com/hyperpolymath/MetaManifold-WebUI/actions/workflows/governance.yml) +[![Governance (fork)](https://github.com/hyperpolymath/MetaManifold-WebUI/actions/workflows/hypatia-scan.yml/badge.svg)](https://github.com/hyperpolymath/MetaManifold-WebUI/actions/workflows/hypatia-scan.yml) [![DEED syntax](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fhyperpolymath%2FMetaManifold-WebUI%2Fgovernance-badges%2Fdeed.json)](MetaManifold-WebUI_chora.deed) [![K9 contracts](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fhyperpolymath%2FMetaManifold-WebUI%2Fgovernance-badges%2Fk9-grid.json)](.machine_readable/contractiles/) [![Hypatia grid](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fhyperpolymath%2FMetaManifold-WebUI%2Fgovernance-badges%2Fhypatia-grid.json)](https://github.com/hyperpolymath/standards/blob/main/docs/VERDICT-GRID-SPEC.adoc) diff --git a/docs/release-notes/2026-10-23-governance-security.md b/docs/release-notes/2026-10-23-governance-security.md index 01e7948..388af4f 100644 --- a/docs/release-notes/2026-10-23-governance-security.md +++ b/docs/release-notes/2026-10-23-governance-security.md @@ -133,11 +133,12 @@ It is historical evidence, not a freshly executed scan of this change. Refresh the reviewed path snapshot deliberately when syncing upstream. The pinned scanner does not honor `.hypatia-ignore` for this rule, so that file would not implement this exception. -- **Scanner discovery:** Hypatia is called by `governance.yml`, not the - heuristic's expected `hypatia-scan.yml`. CodeQL is already supplied by the - GitHub App (Actions, JS/TS and Python checks on #227); do not run a second - scan just to satisfy filename detection. No mirror target is configured; - adding a no-op `mirror.yml` would manufacture coverage. +- **Scanner discovery (at this audit's snapshot):** Hypatia was called by + `governance.yml`, not the heuristic's expected `hypatia-scan.yml`. PR #265 + now places that same active governance workflow at `hypatia-scan.yml`, rather + than adding a duplicate scan. CodeQL is already supplied by the GitHub App + (Actions, JS/TS and Python checks on #227); no second scan was added. No + mirror target is configured; an empty `mirror.yml` would manufacture coverage. - **HTTP identifiers:** W3C SVG/XLink namespace URIs in `figure/export.ts` and `TreeView.tsx` are identifiers, not insecure network requests. Replacing their `http` scheme breaks namespace identity. @@ -146,9 +147,12 @@ It is historical evidence, not a freshly executed scan of this change. fails the job; the logging regression tests guard this boundary. - **Licensing:** four upstream source files lack SPDX identifiers. Existing headers are protected by `CONTRIBUTING.md`; no licence text was changed. -- **Further review:** outbound-egress monitoring on secret-bearing jobs, - panic-attack's dynamic R evaluation/FFI/input-boundary heuristics and pons's - loop/unreachable heuristics still need scoped review, not blanket dismissal. +- **Further review (at this audit's snapshot):** outbound-egress monitoring + on secret-bearing jobs and the scanner findings still needed scoped review, + not blanket dismissal. PR #265's R-evaluation/FFI and pons process-cleanup + review is recorded in [`scanner-review-2026-10-24.md`](2026-10-24-scanner-review.md); + the remaining heuristic findings and unrelated CI concerns are not declared + clean by that review. - **Fleet:** seven enrolled bots had no report-back. They remain `not_run`; no synthetic reports or success records have been created. - **Alert permissions:** the connected GitHub integration returns HTTP 403 for diff --git a/docs/release-notes/2026-10-24-scanner-review.md b/docs/release-notes/2026-10-24-scanner-review.md new file mode 100644 index 0000000..1d418e6 --- /dev/null +++ b/docs/release-notes/2026-10-24-scanner-review.md @@ -0,0 +1,97 @@ +# Scanner finding review — 2026-10-24 + +This is a code review of the Hypatia, panic-attack and pons findings reported on +PR #265. It records evidence and code changes; it is **not** a scanner baseline, +suppression list or claim that every finding is clean. Raw scanner reports stay +available to CI. + +## Changes made from the reported findings + +- The format-probe child supervisor now uses an explicit monotonic deadline + instead of `while True`. Process-disappearance races are handled as explicit + `continue` cases. Cleanup also checks whether a direct child is still alive + when its process group has disappeared, and kills or reports it instead of + silently discarding that condition. +- The active DADA2 command logger remains because pipeline stages rely on its + one-string/one-record provenance. Its R-literal encoder now escapes quotes, + backslashes, line breaks, tabs and control characters, and rejects NUL (which + R strings cannot represent). Regression tests cover those boundaries. +- The PERMANOVA reference test now binds permutation-block labels as R data. + It no longer interpolates an R expression string into the test's R program. +- The high-precision property test now requires the **returned candidate** to + be a `BigFloat` as part of the numerical comparison. The previous assertion + checked only the independent reference value's type; it did not establish the + result's type or accuracy. +- The installer now calls Julia's `Base.Libc.geteuid()` abstraction rather than + making a direct `ccall` for the root check. + +## R execution findings: remaining boundary, reviewed + +`RCall.reval` executes R source and is treated as an execution boundary. The +pinned panic-attack Julia detector is a content-substring heuristic: it reports +`eval(` or `Meta.parse(` without distinguishing RCall's `reval`, fixed R source, +or parse-only syntax validation. See the pinned detector implementation at +[panic-attack `b689a74`](https://github.com/hyperpolymath/panic-attack/blob/b689a74d2762a1c19839ae3fbd59b7fe5ef920ea/src/assail/analyzer.rs#L4735-L4756). +No inline `panic-attack: accepted` marker was added; these findings remain +visible pending a more precise detector or a separately approved refactor. + +The current boundaries were reviewed individually: + +| Location | Boundary and evidence | +|---|---| +| `src/analysis/analysis.jl` | R snippets are application-owned source. Matrices, labels and seeds are bound as R variables. The one formula assembled from metadata uses `_permanova_formula_rhs`, which accepts only plain, non-reserved ASCII R identifiers; expression-shaped names are rejected and tested in `test/unit/test_permanova.jl`. | +| `src/pipeline/dada2/context.jl` | `_r_run_logged` is active and therefore retained. Stage call templates are fixed in source; every config-derived string/number is rendered by `_r_lit`. The serializer now escapes quote/backslash and line/control characters and refuses NUL; `test/unit/test_dada2_commands.jl` covers these cases. | +| `src/analysis/differential.jl` | Model programs are source constants. Counts, labels, offsets and validated method selections cross through RCall bindings, not source interpolation. Cleanup expressions use fixed variable names. | +| `src/analysis/ordination.jl` | R receives `ParsedFormula.canonical_rhs`, rebuilt from a strict Julia parser that admits identifiers, `+`, two-way `:`/`*`, and `Condition(...)` only. The parser rejects arbitrary calls and is covered by ordination formula tests. | +| `src/core/provenance.jl` | The package name interpolated into a query is restricted to `[A-Za-z][A-Za-z0-9.]*`; quotes, separators and control characters are rejected before source construction. | +| `scripts/check-julia-syntax.jl` | `Meta.parse` constructs syntax trees only. The script does not evaluate parsed expressions; it explicitly rejects parser errors, incomplete expressions and top-level stray `end`. | +| `test/unit/test_permanova.jl` | The former interpolated permutation expression has been replaced by a bound vector and fixed R control flow. Remaining R calls run the test's fixed reference program. | + +The remaining `RCall.reval` sites are required to run the existing pinned R +analysis code. They are not suppressed or claimed to be harmless by virtue of +using R; the input-to-source boundaries above are the reviewed controls. + +## FFI finding: intentional low-level measurement boundary + +`install.jl` no longer contains a direct foreign call. The three direct libc +calls in `src/core/memory_probe.jl` remain: `open`, `read` and `close` read +`/proc//status` into preallocated buffers so the opt-in occupancy probe +does not add Julia allocations to the measurement. The path is built from an +integer child PID, is NUL-terminated, and is kept alive with `GC.@preserve`; no +user-supplied pointer or function name reaches the calls. Read failures are +reported as a missing process sample, and the opened descriptor is closed. + +This is a real, deliberately narrow FFI boundary, not a false positive or a +waiver. The panic-attack finding remains reportable; replacing it with allocating +Julia IO would change the measurement the probe exists to make. + +## Pons findings: verified cleanup races + +The pons findings were on handlers for `ProcessLookupError` and +`ChildProcessError`. These are TOCTOU races between reading `/proc`, signaling, +and reaping a child. The handlers now state the race outcome with explicit +control flow; the direct-child cleanup path fails closed if a child is still +live. The supervisor's cleanup deadline is now visible in the loop condition. +The pinned pons rule only reports empty or Python-`pass`-only handlers; its +[implementation at `32e5567`](https://github.com/hyperpolymath/pons-asinorum/blob/32e55677d8fd1f3010848dee22e822c2f385c2a0/crates/pons-rules/src/t0/swallowed_error.rs) +was reviewed. No pons suppression comments were added. + +## Hypatia findings not waived + +- The seven reported TypeScript paths were checked against the exact upstream + snapshot used by `config/hypatia/upstream-typescript-allowlist.txt` and are + absent from that snapshot. They remain reportable; the upstream exception was + not broadened. +- The Python files are independent statistical references and format-readiness + test/probe harnesses. Their language-policy findings are not safety bugs, but + they are not allowlisted or suppressed. Rewriting the independent oracles + solely to satisfy the scanner would remove their cross-implementation value. +- The workflow-name finding is addressed by moving the **existing, active** fork + governance workflow to `.github/workflows/hypatia-scan.yml`; it still runs the + same governance jobs and scanner, so this does not add a duplicate or no-op + scan. CodeQL is already run by the configured GitHub App; no second CodeQL + workflow was added. There is no configured mirror target, so no empty + `mirror.yml` was added. +- Findings tied to stored code-scanning alerts and any findings in the + unavailable detailed artifact are not represented as fixed here. The scan + artifact remains the authoritative evidence for those items. diff --git a/install.jl b/install.jl index 6e9c708..1f49caf 100644 --- a/install.jl +++ b/install.jl @@ -1073,7 +1073,7 @@ end ## Preflight checks """Return true if the current process is running as root.""" -has_root() = try; ccall(:geteuid, Cuint, ()) == 0; catch; false; end +has_root() = try; Base.Libc.geteuid() == 0; catch; false; end """Return true if sudo is available and can run non-interactively.""" function has_passwordless_sudo()::Bool diff --git a/scripts/check-governance-contract.sh b/scripts/check-governance-contract.sh index 0361a03..93609ee 100755 --- a/scripts/check-governance-contract.sh +++ b/scripts/check-governance-contract.sh @@ -10,7 +10,7 @@ set -euo pipefail ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" -CALLER=.github/workflows/governance.yml +CALLER=.github/workflows/hypatia-scan.yml REUSABLE=.github/workflows/hypatia-scan-reusable.yml ALLOWLIST=config/hypatia/upstream-typescript-allowlist.txt FORMAT_TOOLS=config/format-tools.toml diff --git a/scripts/formats/descendants.py b/scripts/formats/descendants.py index 6cdab69..a49b9f4 100644 --- a/scripts/formats/descendants.py +++ b/scripts/formats/descendants.py @@ -45,15 +45,17 @@ def _kill_tree(pid): current = pending.pop() try: os.kill(current, signal.SIGSTOP) - stopped.append(current) - pending.extend(_children(current)) except ProcessLookupError: - pass + # The process exited after its parent list was read; it has no live descendants to freeze. + continue + stopped.append(current) + pending.extend(_children(current)) for current in reversed(stopped): try: os.kill(current, signal.SIGKILL) except ProcessLookupError: - pass + # A frozen process can still disappear if it was reaped concurrently. + continue class Supervise: @@ -75,7 +77,7 @@ def _set(self, value): def _drain(self): deadline = time.monotonic() + 0.5 - while True: + while time.monotonic() < deadline: children = _children(os.getpid()) if not children: return @@ -84,10 +86,27 @@ def _drain(self): try: os.waitpid(pid, os.WNOHANG) except ChildProcessError: - pass - if time.monotonic() >= deadline: - raise SupervisionError("descendants did not exit within cleanup budget") - time.sleep(0.005) # Bounded cleanup; also reap newly adopted orphans. + # Popen.wait or an earlier nonblocking wait already reaped this child. + continue + remaining = deadline - time.monotonic() + if remaining > 0: + # Bounded cleanup; also reap newly adopted orphans. + time.sleep(min(0.005, remaining)) + + # A final census lets a child that exited at the deadline be reaped, + # and catches anything newly adopted by the subreaper during cleanup. + children = _children(os.getpid()) + if not children: + return + for pid in children: + _kill_tree(pid) + try: + os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + # Another wait already reaped it during the final census. + continue + if _children(os.getpid()): + raise SupervisionError("descendants did not exit within cleanup budget") def __exit__(self, *_): try: diff --git a/scripts/formats/process.py b/scripts/formats/process.py index f67cf99..afbf44a 100644 --- a/scripts/formats/process.py +++ b/scripts/formats/process.py @@ -123,13 +123,25 @@ def _remaining(deadline): def _cleanup(child): # Fast group cleanup; Supervise also adopts/kills detached descendants. try: - os.killpg(child.pid, signal.SIGKILL) - except ProcessLookupError: - pass - try: - child.wait(timeout=0.5) - except subprocess.TimeoutExpired as error: - raise ToolError("tool did not exit within cleanup budget") from error + try: + os.killpg(child.pid, signal.SIGKILL) + except ProcessLookupError: + # A tool can leave its session; do not mistake a missing process + # group for a stopped direct child. + if child.poll() is None: + try: + child.kill() + except ProcessLookupError as error: + # The child may exit between poll() and kill(); verify that + # race instead of silently discarding an unexpected live PID. + if child.poll() is None: + raise ToolError( + "tool escaped its process group and could not be terminated" + ) from error + try: + child.wait(timeout=0.5) + except subprocess.TimeoutExpired as error: + raise ToolError("tool did not exit within cleanup budget") from error finally: for stream in (child.stdin, child.stdout, child.stderr): if not stream.closed: diff --git a/scripts/tests/test-governance-contract.sh b/scripts/tests/test-governance-contract.sh index 9f8df24..4d86936 100644 --- a/scripts/tests/test-governance-contract.sh +++ b/scripts/tests/test-governance-contract.sh @@ -28,9 +28,9 @@ for mutation in allowlist-addition baseline-zero-gate baseline-operator-gate bas jcs-canonicalizer) sed -i '/- name: Apply upstream-inherited TypeScript exception/,/- name: Restore panic-attack build cache/s@"$HOME/.cargo/bin/ijson-jcs"@@' "$fixture/.github/workflows/hypatia-scan-reusable.yml" ;; raw-ijson) sed -i '/- name: Validate findings and count severities/,/- name: Relativize finding paths/s@"$HOME/.cargo/bin/ijson-jcs" canon < hypatia-findings.json > /dev/null@@' "$fixture/.github/workflows/hypatia-scan-reusable.yml" ;; k9-pin) sed -i 's/ref: c6d78606f3928c22c3b0bdc6f5f5b47b96e05a5d/ref: main/' "$fixture/.github/workflows/k9-conformance-reusable.yml" ;; - k9-require) sed -i 's/require-files: true/require-files: false/g' "$fixture/.github/workflows/governance.yml" ;; - k9-indeterminate) sed -i 's/fail-on-indeterminate: true/fail-on-indeterminate: false/' "$fixture/.github/workflows/governance.yml" ;; - k9-verdict) sed -i 's/${K9_VERDICT:-orange}/${K9_VERDICT:-green}/' "$fixture/.github/workflows/governance.yml" ;; + k9-require) sed -i 's/require-files: true/require-files: false/g' "$fixture/.github/workflows/hypatia-scan.yml" ;; + k9-indeterminate) sed -i 's/fail-on-indeterminate: true/fail-on-indeterminate: false/' "$fixture/.github/workflows/hypatia-scan.yml" ;; + k9-verdict) sed -i 's/${K9_VERDICT:-orange}/${K9_VERDICT:-green}/' "$fixture/.github/workflows/hypatia-scan.yml" ;; scanner-advisory) echo ' continue-on-error: true' >> "$fixture/.github/workflows/secret-scanner.yml" ;; scanner-secrets) echo ' secrets: inherit' >> "$fixture/.github/workflows/secret-scanner.yml" ;; scanner-trigger) sed -i 's/^ pull_request:/ pull_request_target:/' "$fixture/.github/workflows/secret-scanner.yml" ;; diff --git a/scripts/tests/test_format_readiness.py b/scripts/tests/test_format_readiness.py index f689121..48f3dd9 100644 --- a/scripts/tests/test_format_readiness.py +++ b/scripts/tests/test_format_readiness.py @@ -8,13 +8,13 @@ import sys import tempfile import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "scripts/formats")) from conformance import check, load_cases, main as conformance_main from inventory import inventory -from process import ToolError, run +from process import ToolError, _cleanup, run from probe_kyq import main as probe_main @@ -50,6 +50,32 @@ def test_output_budget_covers_both_streams(self): with self.assertRaisesRegex(ToolError, "output budget"): run_python("import sys; sys.stderr.write('x' * 10000)", limit=100) + def test_cleanup_kills_direct_child_when_its_group_is_gone(self): + child = Mock() + child.pid = 12345 + streams = [io.BytesIO(), io.BytesIO(), io.BytesIO()] + child.stdin, child.stdout, child.stderr = streams + child.poll.return_value = None + child.wait.return_value = -9 + with patch("process.os.killpg", side_effect=ProcessLookupError): + _cleanup(child) + child.kill.assert_called_once_with() + child.wait.assert_called_once_with(timeout=0.5) + self.assertTrue(all(stream.closed for stream in streams)) + + def test_cleanup_reports_a_live_child_that_cannot_be_killed(self): + child = Mock() + child.pid = 12345 + streams = [io.BytesIO(), io.BytesIO(), io.BytesIO()] + child.stdin, child.stdout, child.stderr = streams + child.poll.side_effect = [None, None] + child.kill.side_effect = ProcessLookupError + with patch("process.os.killpg", side_effect=ProcessLookupError): + with self.assertRaisesRegex(ToolError, "escaped its process group"): + _cleanup(child) + child.wait.assert_not_called() + self.assertTrue(all(stream.closed for stream in streams)) + def test_input_and_deadline_budgets(self): for kwargs in ({"timeout": 0}, {"timeout": float("nan")}, {"timeout": float("inf")}, {"limit": 0}, {"data": b"xx", "limit": 1}): with self.assertRaises(ToolError): diff --git a/src/pipeline/dada2/context.jl b/src/pipeline/dada2/context.jl index 3ddde35..5c53357 100644 --- a/src/pipeline/dada2/context.jl +++ b/src/pipeline/dada2/context.jl @@ -89,22 +89,50 @@ end ## R command provenance - # Render a Julia value as R source text, so a command string assembled here - # parses and evaluates in R exactly as written. This is what lets a single - # string be both logged and run: the record cannot drift from what executed, - # because there is only one string. + # Stage commands are fixed call templates; every configuration-derived value + # is inserted through _r_lit before this sole logged-and-executed string. _r_lit(x::Bool) = x ? "TRUE" : "FALSE" _r_lit(x::Integer) = string(x) _r_lit(x::Real) = string(x) - _r_lit(x::AbstractString) = '"' * replace(replace(x, "\\" => "\\\\"), "\"" => "\\\"") * '"' - _r_lit(x::AbstractVector) = "c(" * join(_r_lit.(x), ", ") * ")" _r_lit(::Nothing) = "NULL" + _r_lit(x::AbstractVector) = "c(" * join(_r_lit.(x), ", ") * ")" + + function _r_lit(x::AbstractString) + occursin('\0', x) && throw(ArgumentError("R character literals cannot contain NUL")) + io = IOBuffer() + write(io, '"') + for c in x + if c == '\\' + write(io, "\\\\") + elseif c == '"' + write(io, "\\\"") + elseif c == '\n' + write(io, "\\n") + elseif c == '\r' + write(io, "\\r") + elseif c == '\t' + write(io, "\\t") + elseif c == '\b' + write(io, "\\b") + elseif c == '\f' + write(io, "\\f") + else + codepoint = UInt32(c) + if codepoint < 0x20 || UInt32(0x7f) <= codepoint <= UInt32(0x9f) || + codepoint in (UInt32(0x2028), UInt32(0x2029)) + print(io, "\\u", uppercase(string(codepoint; base=16, pad=4))) + else + write(io, c) + end + end + end + write(io, '"') + String(take!(io)) + end - # Emit the resolved R command behind the standard marker, then evaluate that - # very string. Call only from within a stage's sink block: the marker `cat` - # goes to the sinked connection, so R remains the sole writer to the log and - # no second file handle can interleave. `grep '^\[MetaManifold\] cmd: '` - # recovers these beside the shell tools' commands. + # The R command is logged and executed as one string so the provenance record + # cannot drift from what ran. Call only with the stage templates above, whose + # data literals have already passed through _r_lit. function _r_run_logged(cmd::AbstractString) marker = PipelineLog.CMD_MARKER * " cmd: R> " * cmd * "\n" R"cat($marker)" diff --git a/test/unit/test_dada2_commands.jl b/test/unit/test_dada2_commands.jl index b8a1739..aa10591 100644 --- a/test/unit/test_dada2_commands.jl +++ b/test/unit/test_dada2_commands.jl @@ -29,6 +29,12 @@ const _D = MetaManifold.DADA2 # A path with a quote or backslash stays a valid single R string literal. @test _D._r_lit("a\"b") == "\"a\\\"b\"" @test _D._r_lit("a\\b") == "\"a\\\\b\"" + @test _D._r_lit("a\nb") == "\"a\\nb\"" + @test _D._r_lit("a\rb") == "\"a\\rb\"" + @test _D._r_lit("a\tb") == "\"a\\tb\"" + @test _D._r_lit("a\u0001b") == "\"a\\u0001b\"" + @test _D._r_lit("a\u2028b") == "\"a\\u2028b\"" + @test_throws ArgumentError _D._r_lit("a\0b") end @testset "a rendered filterAndTrim command parses and matches the call" begin diff --git a/test/unit/test_permanova.jl b/test/unit/test_permanova.jl index 1b11827..e6b0ce6 100644 --- a/test/unit/test_permanova.jl +++ b/test/unit/test_permanova.jl @@ -17,28 +17,35 @@ const _PERM_R = MetaManifold.Server.r_available() _PERM_R || @info "R/vegan unavailable - the PERMANOVA reference tests are SKIPPED" """ - _adonis2_reference(mat, meta, perm_expr) -> DataFrame + _adonis2_reference(mat, meta; blocks=nothing) -> DataFrame Run vegan::adonis2 directly on Bray-Curtis distances of `mat`, with -`group + run` terms tested sequentially and permutations given by the R -expression `perm_expr` (which may refer to `ref_meta`), seeded as -`run_permanova` seeds it. Returns the term rows: term, r2, f, p. +`group + run` terms tested sequentially. `blocks` is either `nothing` for +unrestricted permutations or one label per sample; it is bound as data rather +than interpolated into R source. The seed matches `run_permanova`. Returns the +term rows: term, r2, f, p. """ -function _adonis2_reference(mat, meta, perm_expr) +function _adonis2_reference(mat, meta; blocks=nothing) MetaManifold.RRuntime.with_r_lock() do RCall.globalEnv[:ref_mat] = mat RCall.globalEnv[:ref_meta] = meta + RCall.globalEnv[:ref_perm_blocks] = isnothing(blocks) ? String[] : String.(blocks) RCall.reval(""" set.seed(123, kind = "Mersenne-Twister", normal.kind = "Inversion", sample.kind = "Rejection") ref_d <- vegdist(ref_mat, method = "bray") + ref_permutations <- if (length(ref_perm_blocks) > 0) { + permute::how(blocks = factor(ref_perm_blocks), nperm = 999) + } else { + 999 + } ref_res <- adonis2(ref_d ~ group + run, data = ref_meta, - permutations = $perm_expr, by = "terms", parallel = 1) + permutations = ref_permutations, by = "terms", parallel = 1) ref_rows <- which(!(rownames(ref_res) %in% c("Residual", "Total"))) ref_out <- data.frame(term = rownames(ref_res)[ref_rows], r2 = ref_res\$R2[ref_rows], f = ref_res\$F[ref_rows], p = ref_res[["Pr(>F)"]][ref_rows]) """) out = rcopy(RCall.reval("ref_out")) - RCall.reval("rm(ref_mat, ref_meta, ref_d, ref_res, ref_rows, ref_out)") + RCall.reval("rm(ref_mat, ref_meta, ref_perm_blocks, ref_d, ref_permutations, ref_res, ref_rows, ref_out)") out end end @@ -77,7 +84,7 @@ end @testset "each term is tested on its own, as vegan by = \"terms\"" begin res = Analysis.run_permanova(mat, meta; seed=123) - ref = _adonis2_reference(mat, meta, "999") + ref = _adonis2_reference(mat, meta) @test [t.term for t in res.terms] == ["group", "run"] == ref.term @test [t.r2 for t in res.terms] ≈ ref.r2 @test [t.f_statistic for t in res.terms] ≈ ref.f @@ -92,8 +99,7 @@ end @testset "within-individual blocks: permutation count, floor and refusal" begin res = Analysis.run_permanova(mat, meta; seed=123, blocks=individuals) - ref = _adonis2_reference(mat, meta, - "permute::how(blocks = factor(rep(paste0('i', 1:6), each = 2)), nperm = 999)") + ref = _adonis2_reference(mat, meta; blocks=individuals) @test res.blocked # Six blocks of two admit 2^6 orderings, the observed one excluded. @test res.permutations == 63 diff --git a/test/unit/test_statistics_properties.jl b/test/unit/test_statistics_properties.jl index 4a3cd20..546eeb6 100644 --- a/test/unit/test_statistics_properties.jl +++ b/test/unit/test_statistics_properties.jl @@ -41,8 +41,9 @@ function _reference_simpson(column) first(computed) end -_close(reference, candidate) = +_close(reference::BigFloat, candidate::BigFloat) = abs(candidate - reference) <= _PROP_TOLERANCE * max(big(1), abs(reference)) +_close(::BigFloat, candidate) = false function _prop_matrix(rng, features, samples) matrix = Matrix{BigInt}(undef, features, samples) @@ -96,8 +97,8 @@ end stored = summary_to_storage(summary) for (j, entry) in pairs(stored["samples"]) total_text = entry["total"] - @test (total_text isa AbstractString ? parse_exact_count(total_text) : - BigInt(total_text)) == summary.samples[j].total + parsed_total = total_text isa AbstractString ? parse_exact_count(total_text) : BigInt(total_text) + @test parsed_total == summary.samples[j].total for (i, text) in pairs(entry["counts"]) # Small exact integers remain JSON numbers; big ones are strings. parsed = text isa AbstractString ? parse_exact_count(text) : BigInt(text) @@ -133,7 +134,8 @@ end end shannon = _reference_shannon(column) simpson = _reference_simpson(column) - @test shannon isa BigFloat + # `_close` only accepts BigFloat candidates, so the numerical + # comparison also verifies the returned precision type. @test _close(shannon, sample.shannon) @test _close(simpson, sample.gini_simpson) @test sample.shannon >= 0